From a620c810a41fcd8f6cd4c06bcef40bbd9f602434 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 08:44:18 +0000 Subject: [PATCH 1/9] refactor(cli): own the CLI execution context in icp-cli `icp::context::Context` is a bag of ports the library uses to build and deploy. `debug` was neither: it is a presentation flag that only the frontend reads (tracing layer selection, progress-bar hiding), so a library that never prints has no use for it. Introduce `icp_cli::context::Context`, which wraps the library context and carries the CLI-only state. It derefs to the library context, so every port (`dirs`, `ids`, `project`, `network`, ...) is still reached directly through `ctx`, and `operations/` keeps taking `&icp::context::Context` via deref coercion. `ContextInitError` is now re-exported from `icp::context` so callers can name the error `initialize` returns. --- crates/icp-cli/src/commands/build.rs | 3 +- crates/icp-cli/src/commands/canister/call.rs | 2 +- .../icp-cli/src/commands/canister/create.rs | 3 +- .../icp-cli/src/commands/canister/delete.rs | 3 +- .../icp-cli/src/commands/canister/install.rs | 3 +- crates/icp-cli/src/commands/canister/link.rs | 3 +- crates/icp-cli/src/commands/canister/list.rs | 2 +- crates/icp-cli/src/commands/canister/logs.rs | 2 +- .../icp-cli/src/commands/canister/metadata.rs | 2 +- .../src/commands/canister/migrate_id.rs | 2 +- .../src/commands/canister/settings/show.rs | 2 +- .../src/commands/canister/settings/sync.rs | 3 +- .../src/commands/canister/settings/update.rs | 3 +- .../src/commands/canister/snapshot/create.rs | 2 +- .../src/commands/canister/snapshot/delete.rs | 2 +- .../commands/canister/snapshot/download.rs | 2 +- .../src/commands/canister/snapshot/list.rs | 2 +- .../src/commands/canister/snapshot/restore.rs | 2 +- .../src/commands/canister/snapshot/upload.rs | 2 +- crates/icp-cli/src/commands/canister/start.rs | 2 +- .../icp-cli/src/commands/canister/status.rs | 3 +- crates/icp-cli/src/commands/canister/stop.rs | 2 +- .../icp-cli/src/commands/canister/top_up.rs | 2 +- crates/icp-cli/src/commands/cycles/balance.rs | 2 +- crates/icp-cli/src/commands/cycles/mint.rs | 2 +- .../icp-cli/src/commands/cycles/transfer.rs | 2 +- crates/icp-cli/src/commands/deploy.rs | 3 +- .../icp-cli/src/commands/environment/list.rs | 2 +- .../src/commands/identity/account_id.rs | 2 +- .../icp-cli/src/commands/identity/default.rs | 2 +- .../commands/identity/delegation/request.rs | 3 +- .../src/commands/identity/delegation/sign.rs | 3 +- .../src/commands/identity/delegation/use.rs | 2 +- .../icp-cli/src/commands/identity/delete.rs | 2 +- .../icp-cli/src/commands/identity/export.rs | 2 +- .../icp-cli/src/commands/identity/import.rs | 2 +- .../icp-cli/src/commands/identity/link/hsm.rs | 2 +- .../icp-cli/src/commands/identity/link/web.rs | 2 +- crates/icp-cli/src/commands/identity/list.rs | 2 +- crates/icp-cli/src/commands/identity/new.rs | 2 +- .../src/commands/identity/principal.rs | 2 +- .../icp-cli/src/commands/identity/reauth.rs | 2 +- .../icp-cli/src/commands/identity/rename.rs | 2 +- crates/icp-cli/src/commands/network/list.rs | 2 +- crates/icp-cli/src/commands/network/ping.rs | 3 +- crates/icp-cli/src/commands/network/start.rs | 2 +- crates/icp-cli/src/commands/network/status.rs | 6 +-- crates/icp-cli/src/commands/network/stop.rs | 2 +- crates/icp-cli/src/commands/network/update.rs | 3 +- crates/icp-cli/src/commands/new.rs | 2 +- crates/icp-cli/src/commands/project/bundle.rs | 2 +- crates/icp-cli/src/commands/project/show.rs | 2 +- crates/icp-cli/src/commands/settings.rs | 6 +-- crates/icp-cli/src/commands/sync.rs | 3 +- .../icp-cli/src/commands/token/allowance.rs | 2 +- crates/icp-cli/src/commands/token/approve.rs | 2 +- crates/icp-cli/src/commands/token/balance.rs | 2 +- crates/icp-cli/src/commands/token/transfer.rs | 2 +- crates/icp-cli/src/context.rs | 43 +++++++++++++++++++ crates/icp-cli/src/main.rs | 5 ++- crates/icp/src/context/init.rs | 2 - crates/icp/src/context/mod.rs | 6 +-- 62 files changed, 121 insertions(+), 73 deletions(-) create mode 100644 crates/icp-cli/src/context.rs diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index d462f75f2..37413134e 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -1,7 +1,8 @@ +use crate::context::Context; use clap::Args; use clap_complete::ArgValueCandidates; use futures::future::try_join_all; -use icp::context::{Context, EnvironmentSelection}; +use icp::context::EnvironmentSelection; use tracing::info; diff --git a/crates/icp-cli/src/commands/canister/call.rs b/crates/icp-cli/src/commands/canister/call.rs index 17df2d102..868832723 100644 --- a/crates/icp-cli/src/commands/canister/call.rs +++ b/crates/icp-cli/src/commands/canister/call.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use anyhow::{Context as _, anyhow, bail}; use candid::types::{Type, TypeInner}; use candid::{IDLArgs, Principal, TypeEnv, types::Function}; @@ -7,7 +8,6 @@ use candid_parser::utils::CandidSource; use clap::{Args, ValueEnum, ValueHint}; use dialoguer::console::Term; use ic_agent::Agent; -use icp::context::Context; use icp::manifest::ArgsFormat; use icp::parsers::CyclesAmount; use icp::prelude::*; diff --git a/crates/icp-cli/src/commands/canister/create.rs b/crates/icp-cli/src/commands/canister/create.rs index 67d72b822..52015604f 100644 --- a/crates/icp-cli/src/commands/canister/create.rs +++ b/crates/icp-cli/src/commands/canister/create.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use std::io::stdout; use anyhow::anyhow; @@ -6,7 +7,7 @@ use candid::{Nat, Principal}; use clap::{ArgGroup, Args, Parser}; use ic_management_canister_types::CanisterSettings as MgmtCanisterSettings; use icp::canister::resolve_controllers; -use icp::context::{Context, EnvironmentSelection, NetworkSelection}; +use icp::context::{EnvironmentSelection, NetworkSelection}; use icp::identity::IdentitySelection; use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount, parse_token_amount}; use icp::store_id::IdMapping; diff --git a/crates/icp-cli/src/commands/canister/delete.rs b/crates/icp-cli/src/commands/canister/delete.rs index 53243e15b..fc9b76dff 100644 --- a/crates/icp-cli/src/commands/canister/delete.rs +++ b/crates/icp-cli/src/commands/canister/delete.rs @@ -1,8 +1,9 @@ +use crate::context::Context; use anyhow::anyhow; use candid::Principal; use clap::Args; use ic_management_canister_types::CanisterIdRecord; -use icp::context::{CanisterSelection, Context}; +use icp::context::CanisterSelection; use crate::{ commands::args, diff --git a/crates/icp-cli/src/commands/canister/install.rs b/crates/icp-cli/src/commands/canister/install.rs index a55fe5451..4ad10945e 100644 --- a/crates/icp-cli/src/commands/canister/install.rs +++ b/crates/icp-cli/src/commands/canister/install.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use std::io::IsTerminal; use anyhow::{Context as _, anyhow, bail}; @@ -5,7 +6,7 @@ use candid::Principal; use clap::{Args, ValueHint}; use dialoguer::Confirm; use ic_management_canister_types::CanisterInstallMode; -use icp::context::{CanisterSelection, Context}; +use icp::context::CanisterSelection; use icp::fs; use icp::prelude::*; use tracing::{info, warn}; diff --git a/crates/icp-cli/src/commands/canister/link.rs b/crates/icp-cli/src/commands/canister/link.rs index e8c219ad2..8c84d5fb7 100644 --- a/crates/icp-cli/src/commands/canister/link.rs +++ b/crates/icp-cli/src/commands/canister/link.rs @@ -1,8 +1,9 @@ +use crate::context::Context; use anyhow::bail; use candid::Principal; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::context::{Context, EnvironmentSelection}; +use icp::context::EnvironmentSelection; use tracing::info; use crate::options::EnvironmentOpt; diff --git a/crates/icp-cli/src/commands/canister/list.rs b/crates/icp-cli/src/commands/canister/list.rs index e2af589e1..196dadef7 100644 --- a/crates/icp-cli/src/commands/canister/list.rs +++ b/crates/icp-cli/src/commands/canister/list.rs @@ -1,7 +1,7 @@ use std::io::stdout; +use crate::context::Context; use clap::Args; -use icp::context::Context; use serde::Serialize; use crate::options::EnvironmentOpt; diff --git a/crates/icp-cli/src/commands/canister/logs.rs b/crates/icp-cli/src/commands/canister/logs.rs index 02c34565c..d855f7890 100644 --- a/crates/icp-cli/src/commands/canister/logs.rs +++ b/crates/icp-cli/src/commands/canister/logs.rs @@ -1,11 +1,11 @@ use std::io::{ErrorKind, Write as _, stdout}; +use crate::context::Context; use anyhow::{Context as _, anyhow}; use candid::Principal; use clap::Args; use ic_agent::Agent; use ic_management_canister_types::{CanisterLogFilter, CanisterLogRecord, FetchCanisterLogsArgs}; -use icp::context::Context; use icp::signal::stop_signal; use itertools::Itertools; use serde::Serialize; diff --git a/crates/icp-cli/src/commands/canister/metadata.rs b/crates/icp-cli/src/commands/canister/metadata.rs index 32486aaaa..9cbf57cb8 100644 --- a/crates/icp-cli/src/commands/canister/metadata.rs +++ b/crates/icp-cli/src/commands/canister/metadata.rs @@ -1,8 +1,8 @@ use std::io::stdout; +use crate::context::Context; use anyhow::bail; use clap::Args; -use icp::context::Context; use serde::Serialize; use crate::{commands::args, operations::misc::fetch_canister_metadata}; diff --git a/crates/icp-cli/src/commands/canister/migrate_id.rs b/crates/icp-cli/src/commands/canister/migrate_id.rs index 228b535b5..8df44fe72 100644 --- a/crates/icp-cli/src/commands/canister/migrate_id.rs +++ b/crates/icp-cli/src/commands/canister/migrate_id.rs @@ -1,6 +1,7 @@ use std::io::{IsTerminal, stdin}; use std::time::{Duration, Instant}; +use crate::context::Context; use anyhow::bail; use clap::Args; use clap_complete::ArgValueCandidates; @@ -8,7 +9,6 @@ use dialoguer::Confirm; use ic_management_canister_types::{ CanisterIdRecord, CanisterSettings, CanisterStatusType, UpdateSettingsArgs, }; -use icp::context::Context; use icp_canister_interfaces::nns_migration::{MigrationStatus, NNS_MIGRATION_PRINCIPAL}; use indicatif::{ProgressBar, ProgressStyle}; use num_traits::ToPrimitive; diff --git a/crates/icp-cli/src/commands/canister/settings/show.rs b/crates/icp-cli/src/commands/canister/settings/show.rs index c3d7d798c..f514e9c3a 100644 --- a/crates/icp-cli/src/commands/canister/settings/show.rs +++ b/crates/icp-cli/src/commands/canister/settings/show.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use clap::Args; use ic_agent::export::Principal; use ic_management_canister_types::{CanisterIdRecord, DefiniteCanisterSettings, LogVisibility}; -use icp::context::Context; use std::fmt::Write; use crate::{commands::args::CanisterCommandArgs, operations::proxy_management}; diff --git a/crates/icp-cli/src/commands/canister/settings/sync.rs b/crates/icp-cli/src/commands/canister/settings/sync.rs index f34ef291b..413cb9051 100644 --- a/crates/icp-cli/src/commands/canister/settings/sync.rs +++ b/crates/icp-cli/src/commands/canister/settings/sync.rs @@ -1,7 +1,8 @@ +use crate::context::Context; use anyhow::bail; use candid::Principal; use clap::Args; -use icp::context::{CanisterSelection, Context}; +use icp::context::CanisterSelection; use tracing::warn; use crate::commands::args::CanisterCommandArgs; diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index 613a69ea5..720086f0c 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use anyhow::bail; use candid::Nat; use clap::{ArgAction, Args}; @@ -9,7 +10,7 @@ use ic_management_canister_types::{ UpdateSettingsArgs, }; use icp::ProjectLoadError; -use icp::context::{CanisterSelection, Context}; +use icp::context::CanisterSelection; use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount}; use std::collections::{HashMap, HashSet}; use tracing::warn; diff --git a/crates/icp-cli/src/commands/canister/snapshot/create.rs b/crates/icp-cli/src/commands/canister/snapshot/create.rs index 3ea74fa1b..302fa7c72 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/create.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/create.rs @@ -1,5 +1,6 @@ use std::io::stdout; +use crate::context::Context; use anyhow::bail; use byte_unit::{Byte, UnitType}; use candid::Principal; @@ -7,7 +8,6 @@ use clap::Args; use ic_management_canister_types::{ CanisterIdRecord, CanisterStatusType, TakeCanisterSnapshotArgs, }; -use icp::context::Context; use serde::Serialize; use super::SnapshotId; diff --git a/crates/icp-cli/src/commands/canister/snapshot/delete.rs b/crates/icp-cli/src/commands/canister/snapshot/delete.rs index e31ce3201..4e4daf588 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/delete.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/delete.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use candid::Principal; use clap::Args; use ic_management_canister_types::DeleteCanisterSnapshotArgs; -use icp::context::Context; use tracing::info; use super::SnapshotId; diff --git a/crates/icp-cli/src/commands/canister/snapshot/download.rs b/crates/icp-cli/src/commands/canister/snapshot/download.rs index d8b3aa661..1beb6da57 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/download.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/download.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use byte_unit::{Byte, UnitType}; use candid::Principal; use clap::{Args, ValueHint}; -use icp::context::Context; use icp::prelude::*; use tracing::info; diff --git a/crates/icp-cli/src/commands/canister/snapshot/list.rs b/crates/icp-cli/src/commands/canister/snapshot/list.rs index 01c1bad6f..a6c4e6b0f 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/list.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/list.rs @@ -1,10 +1,10 @@ use std::io::stdout; +use crate::context::Context; use byte_unit::{Byte, UnitType}; use candid::Principal; use clap::Args; use ic_management_canister_types::CanisterIdRecord; -use icp::context::Context; use itertools::Itertools; use serde::Serialize; diff --git a/crates/icp-cli/src/commands/canister/snapshot/restore.rs b/crates/icp-cli/src/commands/canister/snapshot/restore.rs index bf6ebb8c8..a1af3f1a8 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/restore.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/restore.rs @@ -1,10 +1,10 @@ +use crate::context::Context; use anyhow::bail; use candid::Principal; use clap::Args; use ic_management_canister_types::{ CanisterIdRecord, CanisterStatusType, LoadCanisterSnapshotArgs, }; -use icp::context::Context; use tracing::info; use super::SnapshotId; diff --git a/crates/icp-cli/src/commands/canister/snapshot/upload.rs b/crates/icp-cli/src/commands/canister/snapshot/upload.rs index a8d97f2c4..495e923ec 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/upload.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/upload.rs @@ -1,9 +1,9 @@ use std::io::stdout; +use crate::context::Context; use byte_unit::{Byte, UnitType}; use candid::Principal; use clap::{Args, ValueHint}; -use icp::context::Context; use icp::prelude::*; use serde::Serialize; use tracing::info; diff --git a/crates/icp-cli/src/commands/canister/start.rs b/crates/icp-cli/src/commands/canister/start.rs index 4278cc7fd..17d4e7caa 100644 --- a/crates/icp-cli/src/commands/canister/start.rs +++ b/crates/icp-cli/src/commands/canister/start.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use candid::Principal; use clap::Args; use ic_management_canister_types::CanisterIdRecord; -use icp::context::Context; use crate::{commands::args, operations::proxy_management}; diff --git a/crates/icp-cli/src/commands/canister/status.rs b/crates/icp-cli/src/commands/canister/status.rs index dd9220687..507e9d592 100644 --- a/crates/icp-cli/src/commands/canister/status.rs +++ b/crates/icp-cli/src/commands/canister/status.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use anyhow::{anyhow, bail}; use clap::Args; use clap_complete::ArgValueCandidates; @@ -6,7 +7,7 @@ use ic_management_canister_types::{ CanisterIdRecord, CanisterStatusResult, EnvironmentVariable, LogVisibility, }; use icp::{ - context::{CanisterSelection, Context, EnvironmentSelection, NetworkSelection}, + context::{CanisterSelection, EnvironmentSelection, NetworkSelection}, identity::IdentitySelection, }; use serde::Serialize; diff --git a/crates/icp-cli/src/commands/canister/stop.rs b/crates/icp-cli/src/commands/canister/stop.rs index 0a10bb0f3..ef516b2d0 100644 --- a/crates/icp-cli/src/commands/canister/stop.rs +++ b/crates/icp-cli/src/commands/canister/stop.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use candid::Principal; use clap::Args; use ic_management_canister_types::CanisterIdRecord; -use icp::context::Context; use crate::{commands::args, operations::proxy_management}; diff --git a/crates/icp-cli/src/commands/canister/top_up.rs b/crates/icp-cli/src/commands/canister/top_up.rs index 11dcd60fa..ac7023db0 100644 --- a/crates/icp-cli/src/commands/canister/top_up.rs +++ b/crates/icp-cli/src/commands/canister/top_up.rs @@ -1,8 +1,8 @@ +use crate::context::Context; use anyhow::{Context as _, bail}; use bigdecimal::BigDecimal; use candid::{Decode, Encode, Nat}; use clap::Args; -use icp::context::Context; use icp::parsers::CyclesAmount; use icp_canister_interfaces::cycles_ledger::{ CYCLES_LEDGER_PRINCIPAL, WithdrawArgs, WithdrawResponse, diff --git a/crates/icp-cli/src/commands/cycles/balance.rs b/crates/icp-cli/src/commands/cycles/balance.rs index 192af682a..299e1b136 100644 --- a/crates/icp-cli/src/commands/cycles/balance.rs +++ b/crates/icp-cli/src/commands/cycles/balance.rs @@ -1,9 +1,9 @@ use std::io::stdout; +use crate::context::Context; use bigdecimal::BigDecimal; use candid::Principal; use clap::Args; -use icp::context::Context; use icp_canister_interfaces::cycles_ledger::CYCLES_LEDGER_PRINCIPAL; use serde::Serialize; diff --git a/crates/icp-cli/src/commands/cycles/mint.rs b/crates/icp-cli/src/commands/cycles/mint.rs index 4af29ddb9..efa2670fc 100644 --- a/crates/icp-cli/src/commands/cycles/mint.rs +++ b/crates/icp-cli/src/commands/cycles/mint.rs @@ -1,9 +1,9 @@ use std::io::stdout; +use crate::context::Context; use anyhow::bail; use bigdecimal::BigDecimal; use clap::Args; -use icp::context::Context; use icp::parsers::{CyclesAmount, parse_token_amount}; use serde::Serialize; diff --git a/crates/icp-cli/src/commands/cycles/transfer.rs b/crates/icp-cli/src/commands/cycles/transfer.rs index 4e31bc6fd..7200cacfa 100644 --- a/crates/icp-cli/src/commands/cycles/transfer.rs +++ b/crates/icp-cli/src/commands/cycles/transfer.rs @@ -1,8 +1,8 @@ use std::io::stdout; +use crate::context::Context; use anyhow::ensure; use clap::Args; -use icp::context::Context; use icp::parsers::CyclesAmount; use icp_canister_interfaces::cycles_ledger::{CYCLES_LEDGER_BLOCK_FEE, CYCLES_LEDGER_PRINCIPAL}; use icrc_ledger_types::icrc1::account::Account; diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 0f38bafa8..d5db4a655 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; @@ -7,7 +8,7 @@ use ic_agent::{Agent, AgentError}; use ic_management_canister_types::{CanisterId, CanisterIdRecord}; use icp::parsers::CyclesAmount; use icp::{ - context::{CanisterSelection, Context, EnvironmentSelection}, + context::{CanisterSelection, EnvironmentSelection}, identity::IdentitySelection, network::Configuration as NetworkConfiguration, }; diff --git a/crates/icp-cli/src/commands/environment/list.rs b/crates/icp-cli/src/commands/environment/list.rs index 816aebdfa..bf721615f 100644 --- a/crates/icp-cli/src/commands/environment/list.rs +++ b/crates/icp-cli/src/commands/environment/list.rs @@ -1,5 +1,5 @@ +use crate::context::Context; use clap::Args; -use icp::context::Context; /// List the environments defined in this project, one per line. /// diff --git a/crates/icp-cli/src/commands/identity/account_id.rs b/crates/icp-cli/src/commands/identity/account_id.rs index e1e0be17d..7ae099891 100644 --- a/crates/icp-cli/src/commands/identity/account_id.rs +++ b/crates/icp-cli/src/commands/identity/account_id.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use candid::Principal; use clap::{Args, ValueEnum}; use ic_ledger_types::{AccountIdentifier, Subaccount}; -use icp::context::Context; use icrc_ledger_types::icrc1::account::Account; use crate::commands::parsers::parse_subaccount; diff --git a/crates/icp-cli/src/commands/identity/default.rs b/crates/icp-cli/src/commands/identity/default.rs index d7235c348..0e35d1e45 100644 --- a/crates/icp-cli/src/commands/identity/default.rs +++ b/crates/icp-cli/src/commands/identity/default.rs @@ -1,6 +1,6 @@ +use crate::context::Context; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::context::Context; use icp::identity::manifest::{IdentityDefaults, IdentityList, change_default_identity}; use tracing::info; diff --git a/crates/icp-cli/src/commands/identity/delegation/request.rs b/crates/icp-cli/src/commands/identity/delegation/request.rs index f095a7e0c..25dd9e7e0 100644 --- a/crates/icp-cli/src/commands/identity/delegation/request.rs +++ b/crates/icp-cli/src/commands/identity/delegation/request.rs @@ -1,7 +1,8 @@ +use crate::context::Context; use clap::{Args, ValueHint}; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; -use icp::{context::Context, fs::read_to_string, identity::key, prelude::*}; +use icp::{fs::read_to_string, identity::key, prelude::*}; use pem::Pem; use snafu::{ResultExt, Snafu}; use tracing::warn; diff --git a/crates/icp-cli/src/commands/identity/delegation/sign.rs b/crates/icp-cli/src/commands/identity/delegation/sign.rs index 6c2c23180..ff3a8b393 100644 --- a/crates/icp-cli/src/commands/identity/delegation/sign.rs +++ b/crates/icp-cli/src/commands/identity/delegation/sign.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use std::{ str::FromStr, time::{SystemTime, UNIX_EPOCH}, @@ -6,7 +7,7 @@ use std::{ use clap::{Args, ValueHint}; use ic_agent::{Identity as _, export::Principal, identity::Delegation as AgentDelegation}; use icp::{ - context::{Context, GetIdentityError}, + context::GetIdentityError, fs::read_to_string, identity::delegation::{ Delegation as WireDelegation, DelegationChain, SignedDelegation as WireSignedDelegation, diff --git a/crates/icp-cli/src/commands/identity/delegation/use.rs b/crates/icp-cli/src/commands/identity/delegation/use.rs index c4a193dfb..b35b58e4c 100644 --- a/crates/icp-cli/src/commands/identity/delegation/use.rs +++ b/crates/icp-cli/src/commands/identity/delegation/use.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; use icp::{ - context::Context, fs::json, identity::{ delegation::DelegationChain, diff --git a/crates/icp-cli/src/commands/identity/delete.rs b/crates/icp-cli/src/commands/identity/delete.rs index 967d0bab0..409620099 100644 --- a/crates/icp-cli/src/commands/identity/delete.rs +++ b/crates/icp-cli/src/commands/identity/delete.rs @@ -1,6 +1,6 @@ +use crate::context::Context; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::context::Context; use icp::identity::key::delete_identity; use tracing::info; diff --git a/crates/icp-cli/src/commands/identity/export.rs b/crates/icp-cli/src/commands/identity/export.rs index cfb56338b..d94613ea0 100644 --- a/crates/icp-cli/src/commands/identity/export.rs +++ b/crates/icp-cli/src/commands/identity/export.rs @@ -1,9 +1,9 @@ +use crate::context::Context; use anyhow::Context as _; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; -use icp::context::Context; use icp::fs::read_to_string; use icp::identity::key::{ExportFormat, export_identity}; use icp::prelude::*; diff --git a/crates/icp-cli/src/commands/identity/import.rs b/crates/icp-cli/src/commands/identity/import.rs index 28dc343ae..bc17ed09c 100644 --- a/crates/icp-cli/src/commands/identity/import.rs +++ b/crates/icp-cli/src/commands/identity/import.rs @@ -24,7 +24,7 @@ use sec1::{EcParameters, EcPrivateKey}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; use tracing::{info, warn}; -use icp::context::Context; +use crate::context::Context; use crate::commands::identity::StorageMode; diff --git a/crates/icp-cli/src/commands/identity/link/hsm.rs b/crates/icp-cli/src/commands/identity/link/hsm.rs index 5b491068e..1bdc72882 100644 --- a/crates/icp-cli/src/commands/identity/link/hsm.rs +++ b/crates/icp-cli/src/commands/identity/link/hsm.rs @@ -1,7 +1,7 @@ +use crate::context::Context; use clap::{Args, ValueHint}; use dialoguer::Password; use icp::{ - context::Context, identity::{key::link_hsm_identity, manifest::IdentityList}, prelude::*, }; diff --git a/crates/icp-cli/src/commands/identity/link/web.rs b/crates/icp-cli/src/commands/identity/link/web.rs index 330fe2fcc..745aa7c6f 100644 --- a/crates/icp-cli/src/commands/identity/link/web.rs +++ b/crates/icp-cli/src/commands/identity/link/web.rs @@ -1,3 +1,4 @@ +use crate::context::Context; use std::{io::IsTerminal, net::SocketAddr, time::Duration}; use anstyle::{AnsiColor, Reset, Style}; @@ -14,7 +15,6 @@ use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity}; use icp::{ - context::Context, fs::read_to_string, identity::{ delegation::DelegationChain, diff --git a/crates/icp-cli/src/commands/identity/list.rs b/crates/icp-cli/src/commands/identity/list.rs index a7ecaa116..9e9d76674 100644 --- a/crates/icp-cli/src/commands/identity/list.rs +++ b/crates/icp-cli/src/commands/identity/list.rs @@ -6,7 +6,7 @@ use icp::identity::manifest::{IdentityDefaults, IdentityList}; use itertools::Itertools; use serde::Serialize; -use icp::context::Context; +use crate::context::Context; /// List the identities #[derive(Debug, Args)] diff --git a/crates/icp-cli/src/commands/identity/new.rs b/crates/icp-cli/src/commands/identity/new.rs index 524e6ce19..f6192bb60 100644 --- a/crates/icp-cli/src/commands/identity/new.rs +++ b/crates/icp-cli/src/commands/identity/new.rs @@ -15,7 +15,7 @@ use icp::{ prelude::*, }; -use icp::context::Context; +use crate::context::Context; use serde::Serialize; use tracing::{info, warn}; diff --git a/crates/icp-cli/src/commands/identity/principal.rs b/crates/icp-cli/src/commands/identity/principal.rs index b276912dc..ea49a5ef6 100644 --- a/crates/icp-cli/src/commands/identity/principal.rs +++ b/crates/icp-cli/src/commands/identity/principal.rs @@ -1,5 +1,5 @@ +use crate::context::Context; use clap::Args; -use icp::context::Context; use crate::options::IdentityOpt; diff --git a/crates/icp-cli/src/commands/identity/reauth.rs b/crates/icp-cli/src/commands/identity/reauth.rs index 937cd1f2c..1a1506616 100644 --- a/crates/icp-cli/src/commands/identity/reauth.rs +++ b/crates/icp-cli/src/commands/identity/reauth.rs @@ -1,9 +1,9 @@ +use crate::context::Context; use std::time::Duration; use clap::Args; use clap_complete::ArgValueCandidates; use icp::{ - context::Context, identity::{ key, manifest::{IdentityList, IdentitySpec, PemFormat}, diff --git a/crates/icp-cli/src/commands/identity/rename.rs b/crates/icp-cli/src/commands/identity/rename.rs index a3b112361..6f6c5ad04 100644 --- a/crates/icp-cli/src/commands/identity/rename.rs +++ b/crates/icp-cli/src/commands/identity/rename.rs @@ -1,6 +1,6 @@ +use crate::context::Context; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::context::Context; use icp::identity::key::rename_identity; use tracing::info; diff --git a/crates/icp-cli/src/commands/network/list.rs b/crates/icp-cli/src/commands/network/list.rs index 9a2a921e0..4c0fc0a54 100644 --- a/crates/icp-cli/src/commands/network/list.rs +++ b/crates/icp-cli/src/commands/network/list.rs @@ -1,5 +1,5 @@ +use crate::context::Context; use clap::Args; -use icp::context::Context; /// List all networks configured in the project #[derive(Args, Debug)] diff --git a/crates/icp-cli/src/commands/network/ping.rs b/crates/icp-cli/src/commands/network/ping.rs index efa4d9836..b58597f71 100644 --- a/crates/icp-cli/src/commands/network/ping.rs +++ b/crates/icp-cli/src/commands/network/ping.rs @@ -1,7 +1,8 @@ +use crate::context::Context; use anyhow::bail; use clap::Args; use ic_agent::{Agent, agent::status::Status}; -use icp::{context::Context, identity::IdentitySelection}; +use icp::identity::IdentitySelection; use std::time::Duration; use tokio::time::sleep; use tracing::info; diff --git a/crates/icp-cli/src/commands/network/start.rs b/crates/icp-cli/src/commands/network/start.rs index f06942f22..dad343939 100644 --- a/crates/icp-cli/src/commands/network/start.rs +++ b/crates/icp-cli/src/commands/network/start.rs @@ -25,7 +25,7 @@ use tracing::{debug, info, warn}; use crate::progress::{ProgressManager, ProgressManagerSettings}; use super::args::NetworkOrEnvironmentArgs; -use icp::context::Context; +use crate::context::Context; /// Run a given network. /// diff --git a/crates/icp-cli/src/commands/network/status.rs b/crates/icp-cli/src/commands/network/status.rs index 84802085c..1739544da 100644 --- a/crates/icp-cli/src/commands/network/status.rs +++ b/crates/icp-cli/src/commands/network/status.rs @@ -1,9 +1,7 @@ +use crate::context::Context; use anyhow::Context as _; use clap::Args; -use icp::{ - context::Context, - network::{Configuration, RootKeySource}, -}; +use icp::network::{Configuration, RootKeySource}; use serde::Serialize; use super::args::NetworkOrEnvironmentArgs; diff --git a/crates/icp-cli/src/commands/network/stop.rs b/crates/icp-cli/src/commands/network/stop.rs index df34d1ce0..6fa3c086a 100644 --- a/crates/icp-cli/src/commands/network/stop.rs +++ b/crates/icp-cli/src/commands/network/stop.rs @@ -7,7 +7,7 @@ use icp::{ use tracing::info; use super::args::NetworkOrEnvironmentArgs; -use icp::context::Context; +use crate::context::Context; /// Stop a background network #[derive(Args, Debug)] diff --git a/crates/icp-cli/src/commands/network/update.rs b/crates/icp-cli/src/commands/network/update.rs index 7ff1c0807..ae9c95a99 100644 --- a/crates/icp-cli/src/commands/network/update.rs +++ b/crates/icp-cli/src/commands/network/update.rs @@ -1,7 +1,8 @@ +use crate::context::Context; use std::sync::{Arc, OnceLock}; use clap::Parser; -use icp::{context::Context, network::managed::cache::download_launcher_version}; +use icp::network::managed::cache::download_launcher_version; use crate::progress::{ProgressManager, ProgressManagerSettings}; diff --git a/crates/icp-cli/src/commands/new.rs b/crates/icp-cli/src/commands/new.rs index 1c27a82bc..f780e4b5f 100644 --- a/crates/icp-cli/src/commands/new.rs +++ b/crates/icp-cli/src/commands/new.rs @@ -197,7 +197,7 @@ fn resolve_name(args: &IcpGenerateArgs) -> Result, anyhow::Error> } pub(crate) async fn exec( - ctx: &icp::context::Context, + ctx: &crate::context::Context, args: &IcpGenerateArgs, ) -> Result<(), anyhow::Error> { // Check for conflicting flags: --quiet and --debug cannot be used together diff --git a/crates/icp-cli/src/commands/project/bundle.rs b/crates/icp-cli/src/commands/project/bundle.rs index b9217c0b8..835e017f1 100644 --- a/crates/icp-cli/src/commands/project/bundle.rs +++ b/crates/icp-cli/src/commands/project/bundle.rs @@ -1,6 +1,6 @@ +use crate::context::Context; use anyhow::Context as _; use clap::{Args, ValueHint}; -use icp::context::Context; use icp::prelude::*; use crate::operations::bundle::create_bundle; diff --git a/crates/icp-cli/src/commands/project/show.rs b/crates/icp-cli/src/commands/project/show.rs index 14998c4bb..ce5365187 100644 --- a/crates/icp-cli/src/commands/project/show.rs +++ b/crates/icp-cli/src/commands/project/show.rs @@ -1,7 +1,7 @@ use anyhow::Context as _; use clap::Args; -use icp::context::Context; +use crate::context::Context; /// Outputs the project's effective yaml configuration. /// diff --git a/crates/icp-cli/src/commands/settings.rs b/crates/icp-cli/src/commands/settings.rs index 147544b70..401a35a27 100644 --- a/crates/icp-cli/src/commands/settings.rs +++ b/crates/icp-cli/src/commands/settings.rs @@ -1,10 +1,8 @@ +use crate::context::Context; use std::{fmt, str::FromStr}; use clap::{Args, Subcommand}; -use icp::{ - context::Context, - settings::{Settings, UpdateCheck}, -}; +use icp::settings::{Settings, UpdateCheck}; use tracing::{info, warn}; use crate::dist::dist_supports_betas; diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index ac2d1e099..b59cb5a78 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -1,10 +1,11 @@ +use crate::context::Context; use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; use clap_complete::ArgValueCandidates; use futures::future::try_join_all; use ic_management_canister_types::{CanisterId, CanisterIdRecord, CanisterStatusType}; -use icp::context::{CanisterSelection, Context, EnvironmentSelection}; +use icp::context::{CanisterSelection, EnvironmentSelection}; use icp::identity::IdentitySelection; use std::collections::BTreeMap; use tracing::info; diff --git a/crates/icp-cli/src/commands/token/allowance.rs b/crates/icp-cli/src/commands/token/allowance.rs index 13dadce90..a2c186fac 100644 --- a/crates/icp-cli/src/commands/token/allowance.rs +++ b/crates/icp-cli/src/commands/token/allowance.rs @@ -1,8 +1,8 @@ use std::io::stdout; +use crate::context::Context; use candid::Principal; use clap::Args; -use icp::context::Context; use icrc_ledger_types::icrc1::account::Account; use serde::Serialize; diff --git a/crates/icp-cli/src/commands/token/approve.rs b/crates/icp-cli/src/commands/token/approve.rs index 72a4fddb3..dde3572bc 100644 --- a/crates/icp-cli/src/commands/token/approve.rs +++ b/crates/icp-cli/src/commands/token/approve.rs @@ -1,10 +1,10 @@ use std::io::stdout; +use crate::context::Context; use anyhow::Context as _; use bigdecimal::BigDecimal; use candid::Principal; use clap::Args; -use icp::context::Context; use icp::parsers::{DurationAmount, parse_token_amount}; use icrc_ledger_types::icrc1::account::Account; use serde::Serialize; diff --git a/crates/icp-cli/src/commands/token/balance.rs b/crates/icp-cli/src/commands/token/balance.rs index 0875239e9..75b57e094 100644 --- a/crates/icp-cli/src/commands/token/balance.rs +++ b/crates/icp-cli/src/commands/token/balance.rs @@ -1,8 +1,8 @@ use std::io::stdout; +use crate::context::Context; use candid::Principal; use clap::Args; -use icp::context::Context; use serde::Serialize; use crate::commands::args::TokenCommandArgs; diff --git a/crates/icp-cli/src/commands/token/transfer.rs b/crates/icp-cli/src/commands/token/transfer.rs index 8edaff185..4d53fb3bf 100644 --- a/crates/icp-cli/src/commands/token/transfer.rs +++ b/crates/icp-cli/src/commands/token/transfer.rs @@ -1,8 +1,8 @@ use std::io::stdout; +use crate::context::Context; use bigdecimal::BigDecimal; use clap::Args; -use icp::context::Context; use icp::parsers::parse_token_amount; use serde::Serialize; diff --git a/crates/icp-cli/src/context.rs b/crates/icp-cli/src/context.rs new file mode 100644 index 000000000..f09037507 --- /dev/null +++ b/crates/icp-cli/src/context.rs @@ -0,0 +1,43 @@ +//! The CLI's execution context. +//! +//! Wraps the library [`icp::context::Context`] — which is a bag of ports for +//! building and deploying — with the frontend-only state the library has no +//! business knowing about, such as presentation flags. Derefs to the library +//! context, so every library port (`dirs`, `ids`, `project`, `network`, …) is +//! reached straight through it. + +use std::{ops::Deref, time::Duration}; + +use icp::{context::ContextInitError, identity::PasswordFunc, prelude::*}; + +/// Execution context for a single CLI invocation. +#[derive(Clone)] +pub struct Context { + /// The library context. + inner: icp::context::Context, + + /// Whether debug output is enabled (`--debug`). Presentation only: it + /// selects the tracing layer and hides progress bars. + pub debug: bool, +} + +impl Deref for Context { + type Target = icp::context::Context; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +/// Builds the context for this CLI invocation. +pub fn initialize( + project_root_override: Option, + debug: bool, + password_func: PasswordFunc, + pem_session_duration: Option, +) -> Result { + let inner = + icp::context::initialize(project_root_override, password_func, pem_session_duration)?; + + Ok(Context { inner, debug }) +} diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index dea176d80..e776f6c86 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -16,6 +16,7 @@ use crate::{ mod artifacts; mod commands; mod complete; +mod context; mod dist; mod events; mod logging; @@ -185,7 +186,7 @@ async fn run() -> Result<(), Error> { .session_length .map(|m| std::time::Duration::from_secs((u64::from(m) + 2) * 60)) }; - let ctx = icp::context::initialize( + let ctx = context::initialize( cli.project_root_override, cli.debug, password_func, @@ -224,7 +225,7 @@ async fn run() -> Result<(), Error> { } /// Dispatch the command to its handler. -async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), Error> { +async fn dispatch(ctx: &crate::context::Context, command: Command) -> Result<(), Error> { match command { // Build Command::Build(args) => commands::build::exec(ctx, &args).await?, diff --git a/crates/icp/src/context/init.rs b/crates/icp/src/context/init.rs index 8a274415b..8831ae2dd 100644 --- a/crates/icp/src/context/init.rs +++ b/crates/icp/src/context/init.rs @@ -37,7 +37,6 @@ pub enum ContextInitError { pub fn initialize( project_root_override: Option, - debug: bool, password_func: PasswordFunc, pem_session_duration: Option, ) -> Result { @@ -149,7 +148,6 @@ pub fn initialize( agent: agent_creator, builder, syncer, - debug, telemetry_data, password_func, }) diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index e05e7fb41..5b5c044eb 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -19,7 +19,7 @@ use snafu::{OptionExt, ResultExt, Snafu}; mod init; -pub use init::initialize; +pub use init::{ContextInitError, initialize}; pub const IC_ROOT_KEY: &[u8; 133] = b"\x30\x81\x82\x30\x1d\x06\x0d\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x01\x02\x01\x06\x0c\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x02\x01\x03\x61\x00\x81\x4c\x0e\x6e\xc7\x1f\xab\x58\x3b\x08\xbd\x81\x37\x3c\x25\x5c\x3c\x37\x1b\x2e\x84\x86\x3c\x98\xa4\xf1\xe0\x8b\x74\x23\x5d\x14\xfb\x5d\x9c\x0c\xd5\x46\xd9\x68\x5f\x91\x3a\x0c\x0b\x2c\xc5\x34\x15\x83\xbf\x4b\x43\x92\xe4\x67\xdb\x96\xd6\x5b\x9b\xb4\xcb\x71\x71\x12\xf8\x47\x2e\x0d\x5a\x4d\x14\x50\x5f\xfd\x74\x84\xb0\x12\x91\x09\x1c\x5f\x87\xb9\x88\x83\x46\x3f\x98\x09\x1a\x0b\xaa\xae"; @@ -99,9 +99,6 @@ pub struct Context { /// Canister synchronizer pub syncer: Arc, - /// Whether debug is enabled - pub debug: bool, - /// Telemetry data collected during command execution pub telemetry_data: Arc, @@ -611,7 +608,6 @@ impl Context { agent: Arc::new(crate::agent::Creator), builder: Arc::new(crate::canister::build::UnimplementedMockBuilder), syncer: Arc::new(crate::canister::sync::UnimplementedMockSyncer), - debug: false, telemetry_data: Arc::new(crate::telemetry_data::TelemetryData::default()), password_func: Arc::new(|| Err("no password available in mock context".to_string())), } From b029568cca078a6a68423b979abe93fd980a9b42 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 08:58:07 +0000 Subject: [PATCH 2/9] refactor(icp): move the native project loader out of the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parsing a manifest is a frontend concern: it means walking the filesystem, reading YAML, globbing canister directories and resolving file references. `Project`, `ProjectLoad` and `ProjectRootLocate` stay in `icp` — a consumer that deploys must still be able to receive a project — but the native, filesystem-touching implementations behind those ports now live in `icp-cli`: - `icp::manifest::{ProjectRootLocateImpl, load_manifest_from_path}` -> `icp_cli::manifest` - `icp::project` (manifest consolidation) and `icp::{ProjectLoadImpl, Lazy}` -> `icp_cli::project` The manifest *shapes* stay in `icp`: they are plain serde types with no filesystem in them, `BuildSteps`/`RootKeySpec` and friends are part of the `Project` model the builder consumes, and `schema-gen` generates the published JSON schemas from them. Their modules are now `pub` so the loader can reach the same types it always did. `ProjectLoadError`'s two loader-specific variants collapse into one boxed `Load` variant, so the library's port does not name any one loader's error type. It is `#[snafu(transparent)]`, and the CLI loader keeps variants with the same messages, so error output is unchanged: `failed to load project` / `failed to load project manifest` / `failed to parse manifest at ''`. `Locate` stays a real variant — callers match on it to detect "no project here". `icp::context::initialize` now receives the directories, the root locator and the project loader instead of constructing them, since the loader (and the `$PWD`-vs-getcwd resolution that feeds it) is the frontend's to build. `DEFAULT_LOCAL_NETWORK_{BIND,PORT}` move to `icp::network`, where the default network they describe is defined. --- Cargo.lock | 3 + crates/icp-cli/Cargo.toml | 3 + crates/icp-cli/src/commands/deploy.rs | 2 +- crates/icp-cli/src/context.rs | 141 ++++++++- crates/icp-cli/src/main.rs | 2 + crates/icp-cli/src/manifest.rs | 398 +++++++++++++++++++++++ crates/icp-cli/src/operations/bundle.rs | 11 +- crates/{icp => icp-cli}/src/project.rs | 350 ++++++++++++++++++-- crates/icp/src/context/init.rs | 147 +-------- crates/icp/src/lib.rs | 315 +----------------- crates/icp/src/manifest/mod.rs | 403 +----------------------- crates/icp/src/network/mod.rs | 7 +- 12 files changed, 918 insertions(+), 864 deletions(-) create mode 100644 crates/icp-cli/src/manifest.rs rename crates/{icp => icp-cli}/src/project.rs (88%) diff --git a/Cargo.lock b/Cargo.lock index 6a086203a..7dfbe2471 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3716,6 +3716,7 @@ dependencies = [ "elliptic-curve", "flate2", "futures", + "glob", "hex", "httptest", "ic-agent", @@ -3727,6 +3728,7 @@ dependencies = [ "icp-canister-interfaces", "icp-events", "icrc-ledger-types", + "indexmap", "indicatif", "indoc", "itertools 0.14.0", @@ -3738,6 +3740,7 @@ dependencies = [ "num-traits", "open", "p256", + "pathdiff", "pem", "phf", "pkcs8", diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 9bf6fae7b..4b7fbe7db 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -34,6 +34,7 @@ dunce.workspace = true elliptic-curve.workspace = true flate2.workspace = true futures.workspace = true +glob.workspace = true tar.workspace = true hex.workspace = true httptest.workspace = true @@ -46,6 +47,7 @@ icp-canister-interfaces.workspace = true icp-events.workspace = true icp = { workspace = true, features = ["clap"] } icrc-ledger-types.workspace = true +indexmap.workspace = true indicatif.workspace = true indoc.workspace = true itertools.workspace = true @@ -56,6 +58,7 @@ num-integer.workspace = true num-traits.workspace = true open.workspace = true p256.workspace = true +pathdiff.workspace = true pem.workspace = true phf.workspace = true pkcs8.workspace = true diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index d5db4a655..05b5a43d4 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -122,7 +122,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: // project load.) let project = ctx.project.load().await?; let member_dir = ctx.project.member_dir(); - match icp::project::member_scoped_canisters(&project.dir, member_dir.as_deref(), &env) { + match crate::project::member_scoped_canisters(&project.dir, member_dir.as_deref(), &env) { Some(scoped) => { member_scoped = true; scoped diff --git a/crates/icp-cli/src/context.rs b/crates/icp-cli/src/context.rs index f09037507..0d1243c02 100644 --- a/crates/icp-cli/src/context.rs +++ b/crates/icp-cli/src/context.rs @@ -6,9 +6,20 @@ //! context, so every library port (`dirs`, `ids`, `project`, `network`, …) is //! reached straight through it. -use std::{ops::Deref, time::Duration}; +use std::{env::current_dir, ops::Deref, sync::Arc, time::Duration}; -use icp::{context::ContextInitError, identity::PasswordFunc, prelude::*}; +use icp::{ + canister::recipe::handlebars::Handlebars, + directories::{Access as _, Directories}, + identity::PasswordFunc, + prelude::*, +}; +use snafu::prelude::*; + +use crate::{ + manifest::ProjectRootLocateImpl, + project::{Lazy, ProjectLoadImpl}, +}; /// Execution context for a single CLI invocation. #[derive(Clone)] @@ -29,6 +40,28 @@ impl Deref for Context { } } +#[derive(Debug, Snafu)] +pub enum ContextInitError { + #[snafu(display("failed to initialize directories"))] + Directories { + source: icp::directories::DirectoriesError, + }, + + #[snafu(display("failed to get current working directory"))] + Cwd { source: std::io::Error }, + + #[snafu(display("failed to convert path to UTF-8"))] + Utf8Path { source: FromPathBufError }, + + #[snafu(display("failed to lock package cache directory"))] + PackageCache { source: icp::fs::lock::LockError }, + + #[snafu(transparent)] + Library { + source: icp::context::ContextInitError, + }, +} + /// Builds the context for this CLI invocation. pub fn initialize( project_root_override: Option, @@ -36,8 +69,108 @@ pub fn initialize( password_func: PasswordFunc, pem_session_duration: Option, ) -> Result { - let inner = - icp::context::initialize(project_root_override, password_func, pem_session_duration)?; + // Setup global directory structure + let dirs = Arc::new(Directories::new().context(DirectoriesSnafu)?); + + // Project root locator + let project_root_locate = Arc::new(ProjectRootLocateImpl::new( + resolve_cwd()?, + project_root_override, + )); + + // Recipes + let recipe = Arc::new(Handlebars { + http_client: reqwest::Client::new(), + pkg_cache: dirs.package_cache().context(PackageCacheSnafu)?, + }); + + // Project loader + let project = Arc::new(Lazy::new(ProjectLoadImpl { + project_root_locate: project_root_locate.clone(), + recipe, + })); + + let inner = icp::context::initialize( + dirs, + project_root_locate, + project, + password_func, + pem_session_duration, + )?; Ok(Context { inner, debug }) } + +/// The directory to start looking for a project in. +/// +/// On Unix, prefer $PWD (the logical path the user cd'd through) over +/// getcwd(3), which resolves symlinks to the physical path and would break +/// upward traversal when the user is inside a symlinked directory whose +/// manifest sits above the symlink's location. +/// +/// Guard with an inode check: if $PWD was inherited from a parent process that +/// used chdir(2) without updating $PWD, the two paths point to different inodes +/// and we fall back to getcwd(). Because `metadata()` follows symlinks, a +/// symlinked $PWD still resolves to the same inode as getcwd(), so the symlink +/// case still works. +#[cfg(unix)] +fn resolve_cwd() -> Result { + let real = PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; + Ok(std::env::var("PWD") + .ok() + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .filter(|p| same_inode(p.as_path(), real.as_path())) + .unwrap_or(real)) +} + +#[cfg(not(unix))] +fn resolve_cwd() -> Result { + PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu) +} + +#[cfg(unix)] +fn same_inode(a: &Path, b: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(), + _ => false, + } +} + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use std::sync::Mutex; + + use camino_tempfile::Utf8TempDir; + + use super::*; + + // Serializes tests that mutate $PWD, since cargo test runs tests in parallel. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + #[test] + fn stale_pwd_is_ignored() { + let _guard = ENV_MUTEX.lock().unwrap(); + + let stale = Utf8TempDir::new().unwrap(); + let real = PathBuf::try_from(std::env::current_dir().unwrap()).unwrap(); + + let old_pwd = std::env::var("PWD").ok(); + // SAFETY: ENV_MUTEX serializes all tests that mutate $PWD. + unsafe { std::env::set_var("PWD", stale.path()) }; + + let resolved = resolve_cwd().unwrap(); + + match old_pwd { + Some(v) => unsafe { std::env::set_var("PWD", v) }, + None => unsafe { std::env::remove_var("PWD") }, + } + + assert_eq!( + resolved, real, + "stale $PWD should be ignored in favour of getcwd()" + ); + } +} diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index e776f6c86..08372d3db 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -20,9 +20,11 @@ mod context; mod dist; mod events; mod logging; +mod manifest; pub(crate) mod operations; mod options; mod progress; +mod project; mod telemetry; mod version; diff --git a/crates/icp-cli/src/manifest.rs b/crates/icp-cli/src/manifest.rs new file mode 100644 index 000000000..ce9a065d0 --- /dev/null +++ b/crates/icp-cli/src/manifest.rs @@ -0,0 +1,398 @@ +//! Filesystem-backed manifest loading. +//! +//! The manifest *shapes* live in [`icp::manifest`]; reading them off disk is a +//! frontend concern, so the native [`ProjectRootLocate`] implementation and the +//! YAML loader live here. + +use std::collections::HashSet; + +use icp::{ + fs, + manifest::{PROJECT_MANIFEST, ProjectRootLocate, ProjectRootLocateError}, + prelude::*, +}; +use serde::Deserialize; +use snafu::prelude::*; + +/// Implementation of [`ProjectRootLocate`]. +pub struct ProjectRootLocateImpl { + /// Current directory to begin search from in case dir is unspecified. + cwd: PathBuf, + + /// Specific directory to be used as project root directly. + dir: Option, +} + +impl ProjectRootLocateImpl { + /// Creates a new instance of `ProjectRootLocateImpl`. + /// + /// - If `dir` is specified, it will be used as Project Root directly. + /// - Otherwise, it will search upwards from `cwd` for the project manifest file (`icp.yaml`). + pub fn new(cwd: PathBuf, dir: Option) -> Self { + Self { cwd, dir } + } +} + +/// The nearest directory at or above `start` that contains a project manifest. +fn nearest_manifest_dir(start: &Path) -> Option { + let mut dir = start.to_owned(); + loop { + if dir.join(PROJECT_MANIFEST).exists() { + return Some(dir); + } + dir = dir.parent()?.to_owned(); + } +} + +/// The nearest directory *strictly above* `dir` that contains a project manifest. +fn next_manifest_dir_above(dir: &Path) -> Option { + let mut cur = dir.parent()?.to_owned(); + loop { + if cur.join(PROJECT_MANIFEST).exists() { + return Some(cur); + } + cur = cur.parent()?.to_owned(); + } +} + +/// Canonicalize a directory (resolving `..` and symlinks) into a UTF-8 path. +/// Returns `None` if the path does not exist or is not valid UTF-8; callers +/// treat that as "cannot establish identity", which is safe for resolution. +fn canonicalize_dir(dir: &Path) -> Option { + let canon = dunce::canonicalize(dir.as_std_path()).ok()?; + PathBuf::try_from(canon).ok() +} + +/// Read only the dependency `path:` entries from a manifest, ignoring every +/// other field. Deliberately lenient: any read/parse failure yields no +/// dependencies, so an unrelated or malformed ancestor manifest is treated as +/// declaring nothing (it will not be adopted as a workspace root). +fn read_dependency_paths(manifest_path: &Path) -> Vec { + #[derive(Deserialize)] + struct DepProbe { + path: String, + } + #[derive(Deserialize)] + struct ManifestProbe { + #[serde(default)] + dependencies: Vec, + } + + let Ok(content) = fs::read(manifest_path) else { + return Vec::new(); + }; + match serde_yaml::from_slice::(&content) { + Ok(p) => p.dependencies.into_iter().map(|d| d.path).collect(), + Err(_) => Vec::new(), + } +} + +/// The set of canonical directories a manifest declares as dependencies, +/// transitively. Each `path:` is resolved relative to the manifest that +/// declares it, then canonicalized so identity is independent of how the path +/// is spelled (matches [`crate::project`] dependency de-duplication). +fn transitive_dep_dirs(manifest_dir: &Path) -> HashSet { + let mut out = HashSet::new(); + let Some(start) = canonicalize_dir(manifest_dir) else { + return out; + }; + let mut visited: HashSet = HashSet::from([start.clone()]); + let mut stack = vec![start]; + while let Some(dir) = stack.pop() { + for rel in read_dependency_paths(&dir.join(PROJECT_MANIFEST)) { + let Some(dep) = canonicalize_dir(&dir.join(&rel)) else { + continue; + }; + out.insert(dep.clone()); + if visited.insert(dep.clone()) { + stack.push(dep); + } + } + } + out +} + +impl ProjectRootLocate for ProjectRootLocateImpl { + fn locate(&self) -> Result { + // Start from the project the command is standing in. An explicit + // override forces member == root (no climb) — see `locate_member`. + let start = self.locate_member()?; + if self.dir.is_some() { + return Ok(start); + } + + // Climb to the workspace root: adopt an ancestor only if its transitive + // dependency closure declares `start`. Early-stop at the first ancestor + // that does not — this never crosses a "gap" and never adopts an + // unrelated ancestor. With no declaring ancestor this + // degenerates to returning `start`, i.e. today's behavior. + let start_canonical = canonicalize_dir(&start).unwrap_or_else(|| start.clone()); + let mut root = start.clone(); + let mut cursor = start; + while let Some(ancestor) = next_manifest_dir_above(&cursor) { + if transitive_dep_dirs(&ancestor).contains(&start_canonical) { + root = ancestor.clone(); + cursor = ancestor; + } else { + break; + } + } + Ok(root) + } + + fn locate_member(&self) -> Result { + // An explicit override (`--project-root-override` / `ICP_PROJECT_ROOT`) + // forces the project directory and skips the upward climb — the escape + // hatch for "operate on exactly this project" (e.g. deploy a vendored + // member as a standalone project). Member and root are then identical. + if let Some(dir) = &self.dir { + if !dir.join(PROJECT_MANIFEST).exists() { + return Err(ProjectRootLocateError::NotFound { + path: dir.to_owned(), + }); + } + + return Ok(dir.to_owned()); + } + + // The project the command is standing in: nearest manifest at/above cwd. + nearest_manifest_dir(&self.cwd).ok_or_else(|| ProjectRootLocateError::NotFound { + path: self.cwd.to_owned(), + }) + } +} + +#[derive(Debug, Snafu)] +pub enum LoadManifestFromPathError { + #[snafu(display("failed to read manifest from path"))] + Read { source: fs::IoError }, + + #[snafu(display("failed to parse manifest at '{path}'"))] + Parse { + source: serde_yaml::Error, + path: PathBuf, + }, +} + +/// Loads a manifest of type `T` from the specified file path. +pub async fn load_manifest_from_path(path: &Path) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let content = fs::read(path).context(ReadSnafu)?; + let m = serde_yaml::from_slice::(&content).context(ParseSnafu { + path: path.to_path_buf(), + })?; + Ok(m) +} + +#[cfg(test)] +mod tests { + use super::*; + use camino_tempfile::Utf8TempDir; + + fn write_manifest(dir: &Path) { + std::fs::write(dir.join(PROJECT_MANIFEST), "").unwrap(); + } + + /// Create `dir` (and parents) and write an `icp.yaml` declaring the given + /// `(alias, path)` dependencies. + fn write_project(dir: &Path, deps: &[(&str, &str)]) { + std::fs::create_dir_all(dir).unwrap(); + let mut body = String::new(); + if !deps.is_empty() { + body.push_str("dependencies:\n"); + for (name, path) in deps { + body.push_str(&format!(" - name: {name}\n path: {path}\n")); + } + } + std::fs::write(dir.join(PROJECT_MANIFEST), body).unwrap(); + } + + // A lone project (no declaring ancestor) is its own root. + #[test] + fn locate_standalone_member_is_its_own_root() { + let tmp = Utf8TempDir::new().unwrap(); + let member = tmp.path().join("openemail"); + write_project(&member, &[]); + + let locator = ProjectRootLocateImpl::new(member.clone(), None); + assert_eq!(locator.locate().unwrap(), member); + } + + // Running inside a member climbs to the parent that declares it. + #[test] + fn locate_climbs_to_declaring_parent() { + let tmp = Utf8TempDir::new().unwrap(); + let openhr = tmp.path().join("openhr"); + write_project(&openhr, &[("openemail", "./openemail")]); + let openemail = openhr.join("openemail"); + write_project(&openemail, &[]); + + let locator = ProjectRootLocateImpl::new(openemail, None); + assert_eq!(locator.locate().unwrap(), openhr); + } + + // A transitive chain climbs all the way to the top-most declaring project, + // from any member in the chain. + #[test] + fn locate_climbs_transitive_chain_to_top() { + let tmp = Utf8TempDir::new().unwrap(); + let app = tmp.path().join("app"); + write_project(&app, &[("openhr", "./openhr")]); + let openhr = app.join("openhr"); + write_project(&openhr, &[("openemail", "./openemail")]); + let openemail = openhr.join("openemail"); + write_project(&openemail, &[]); + + assert_eq!( + ProjectRootLocateImpl::new(openemail, None) + .locate() + .unwrap(), + app + ); + assert_eq!( + ProjectRootLocateImpl::new(openhr, None).locate().unwrap(), + app + ); + } + + // Diamond: the shared member is declared via siblings, not directly by the + // top project, and sits at a hoisted location. Transitive containment still + // resolves the top project as root. + #[test] + fn locate_resolves_diamond_via_transitive_closure() { + let tmp = Utf8TempDir::new().unwrap(); + let app = tmp.path().join("app"); + write_project( + &app, + &[ + ("service_a", "./umbrella/service-a"), + ("service_b", "./umbrella/service-b"), + ], + ); + write_project( + &app.join("umbrella/service-a"), + &[("openemail", "../openemail")], + ); + write_project( + &app.join("umbrella/service-b"), + &[("openemail", "../openemail")], + ); + let openemail = app.join("umbrella/openemail"); + write_project(&openemail, &[]); + + // `umbrella/` has no manifest, so the nearest ancestor above openemail is + // `app`, which declares openemail only transitively (app -> service-a -> + // ../openemail). + assert_eq!( + ProjectRootLocateImpl::new(openemail, None) + .locate() + .unwrap(), + app + ); + } + + // An ancestor that does not declare the project is not adopted as root. + #[test] + fn locate_rejects_unrelated_ancestor() { + let tmp = Utf8TempDir::new().unwrap(); + let outer = tmp.path().join("outer"); + write_project(&outer, &[]); // declares nothing + let app = outer.join("app"); + write_project(&app, &[]); + + let locator = ProjectRootLocateImpl::new(app.clone(), None); + assert_eq!(locator.locate().unwrap(), app); + } + + // Gap: a declaring project sits above a non-declaring manifest. Early-stop + // stops at the contiguous declaring chain and does not cross the gap. + #[test] + fn locate_early_stops_at_gap() { + let tmp = Utf8TempDir::new().unwrap(); + let outer = tmp.path().join("outer"); + // outer declares openhr through the gap directory. + write_project(&outer, &[("openhr", "./legacy/openhr")]); + let legacy = outer.join("legacy"); + write_project(&legacy, &[]); // the gap: declares nothing + let openhr = legacy.join("openhr"); + write_project(&openhr, &[("openemail", "./openemail")]); + let openemail = openhr.join("openemail"); + write_project(&openemail, &[]); + + // Climb stops at openhr because `legacy` (the next ancestor) does not + // declare openemail, even though `outer` above it does. + let locator = ProjectRootLocateImpl::new(openemail, None); + assert_eq!(locator.locate().unwrap(), openhr); + } + + // An explicit override forces that directory as root, with no upward climb. + #[test] + fn locate_override_forces_root_without_climbing() { + let tmp = Utf8TempDir::new().unwrap(); + let openhr = tmp.path().join("openhr"); + write_project(&openhr, &[("openemail", "./openemail")]); + let openemail = openhr.join("openemail"); + write_project(&openemail, &[]); + + // cwd is openemail but override pins openemail itself as the root. + let locator = ProjectRootLocateImpl::new(openemail.clone(), Some(openemail.clone())); + assert_eq!(locator.locate().unwrap(), openemail); + } + + #[test] + fn locate_returns_cwd_when_manifest_present() { + let tmp = Utf8TempDir::new().unwrap(); + write_manifest(tmp.path()); + + let locator = ProjectRootLocateImpl::new(tmp.path().to_path_buf(), None); + assert_eq!(locator.locate().unwrap(), tmp.path()); + } + + #[test] + fn locate_walks_up_to_manifest() { + let tmp = Utf8TempDir::new().unwrap(); + write_manifest(tmp.path()); + + let nested = tmp.path().join("a/b/c"); + std::fs::create_dir_all(&nested).unwrap(); + + let locator = ProjectRootLocateImpl::new(nested, None); + assert_eq!(locator.locate().unwrap(), tmp.path()); + } + + #[test] + fn locate_returns_not_found_when_no_manifest_anywhere() { + let tmp = Utf8TempDir::new().unwrap(); + let nested = tmp.path().join("a/b"); + std::fs::create_dir_all(&nested).unwrap(); + + // Host filesystem contains no icp.yaml above the tempdir (assumed in CI). + let locator = ProjectRootLocateImpl::new(nested, None); + assert!(matches!( + locator.locate(), + Err(ProjectRootLocateError::NotFound { .. }) + )); + } + + // When cwd is a symlinked directory, locate() walks up via the symlink's + // lexical parents + #[cfg(unix)] + #[test] + fn locate_walks_up_through_symlink() { + // target/ has no manifest anywhere above it within the test's scope. + let target = Utf8TempDir::new().unwrap(); + + // project/ contains the manifest; `project/link` is a symlink to target/. + let project = Utf8TempDir::new().unwrap(); + write_manifest(project.path()); + let link = project.path().join("link"); + std::os::unix::fs::symlink(target.path().as_std_path(), link.as_std_path()).unwrap(); + + // cwd is the symlink path; its lexical parent is `project`, + // which contains the manifest. + let locator = ProjectRootLocateImpl::new(link, None); + assert_eq!(locator.locate().unwrap(), project.path()); + } +} diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index 6786fc7d5..a5dc41cfa 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -15,17 +15,20 @@ use icp::{ fs, manifest::{ ArgsFormat, BuildStep, BuildSteps, CanisterManifest, DependencyManifest, - EnvironmentManifest, Instructions, Item, LoadManifestFromPathError, ManagedMode, - ManifestInitArgs, Mode, NetworkManifest, PROJECT_MANIFEST, ProjectManifest, SyncStep, - SyncSteps, load_manifest_from_path, plugin, prebuilt, + EnvironmentManifest, Instructions, Item, ManagedMode, ManifestInitArgs, Mode, + NetworkManifest, PROJECT_MANIFEST, ProjectManifest, SyncStep, SyncSteps, plugin, prebuilt, prebuilt::{LocalSource, SourceField}, }, package::PackageCache, prelude::*, - project::{WorkspaceInstance, WorkspaceInstancesError, workspace_instances}, store_artifact, }; use snafu::{OptionExt, ResultExt, Snafu}; + +use crate::{ + manifest::{LoadManifestFromPathError, load_manifest_from_path}, + project::{WorkspaceInstance, WorkspaceInstancesError, workspace_instances}, +}; use tar::Builder; use crate::operations::build::{BuildManyError, build_many_with_progress_bar}; diff --git a/crates/icp/src/project.rs b/crates/icp-cli/src/project.rs similarity index 88% rename from crates/icp/src/project.rs rename to crates/icp-cli/src/project.rs index c1d7f3fbc..b24bdce03 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp-cli/src/project.rs @@ -1,31 +1,177 @@ -use std::collections::{BTreeMap, HashMap, HashSet, hash_map::Entry}; +use std::{ + collections::{BTreeMap, HashMap, HashSet, hash_map::Entry}, + sync::Arc, +}; +use async_trait::async_trait; use indexmap::{IndexMap, map::Entry as IndexEntry}; - use snafu::prelude::*; - -use crate::{ - Canister, Environment, InitArgs, Network, Project, - canister::{ControllerRef, ManifestEnvVar, ManifestSettings, Settings, recipe}, +use tokio::sync::Mutex; +use tracing::debug; + +use icp::{ + Canister, Environment, InitArgs, Network, Project, ProjectLoad, ProjectLoadError, + canister::{ + ControllerRef, ManifestEnvVar, ManifestSettings, Settings, + recipe::{self, Resolve}, + }, fs, manifest::{ ArgsFormat, CANISTER_MANIFEST, CanisterManifest, DependencyManifest, EnvironmentManifest, - Item, LoadManifestFromPathError, ManifestInitArgs, NetworkManifest, PROJECT_MANIFEST, - ProjectManifest, ProjectRootLocateError, + Item, ManifestInitArgs, NetworkManifest, PROJECT_MANIFEST, ProjectManifest, + ProjectRootLocate, ProjectRootLocateError, canister::{Instructions, SyncSteps}, environment::CanisterSelection, - load_manifest_from_path, network::RootKeySpec, recipe::RecipeType, }, network::{ - Configuration, Connected, Gateway, Managed, ManagedLauncherConfig, ManagedMode, Port, + Configuration, Connected, DEFAULT_LOCAL_NETWORK_BIND, DEFAULT_LOCAL_NETWORK_PORT, Gateway, + Managed, ManagedLauncherConfig, ManagedMode, Port, }, prelude::*, }; -pub const DEFAULT_LOCAL_NETWORK_BIND: &str = "127.0.0.1"; -pub const DEFAULT_LOCAL_NETWORK_PORT: u16 = 8000; +use crate::manifest::{LoadManifestFromPathError, load_manifest_from_path}; + +/// The native, filesystem-backed [`ProjectLoad`] implementation: locate the +/// project root, read its manifest, consolidate it into a [`Project`]. +pub struct ProjectLoadImpl { + pub project_root_locate: Arc, + pub recipe: Arc, +} + +/// Errors from [`ProjectLoadImpl`]. Reported as the cause of +/// [`ProjectLoadError::Load`]. +#[derive(Debug, Snafu)] +pub enum NativeProjectLoadError { + #[snafu(display("failed to load project manifest"))] + ProjectManifest { source: LoadManifestFromPathError }, + + #[snafu(display("failed to load project"))] + Project { source: ConsolidateManifestError }, +} + +/// Wraps a [`NativeProjectLoadError`] into the library's loader-agnostic error. +fn loader_failed(e: NativeProjectLoadError) -> ProjectLoadError { + ProjectLoadError::Load { + source: Box::new(e), + } +} + +/// Ensures the "operating on a workspace root above your sub-project" notice is +/// printed at most once per process (one CLI invocation), no matter how many +/// times the project is loaded. +static WORKSPACE_ROOT_ANNOUNCED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Warn once when the resolved workspace root differs from the sub-project the +/// command is run in, so the upward resolution (§workspace model) is visible for +/// every command, not just deploy. +fn announce_workspace_root_once(member: &Path, root: &Path) { + let differs = match ( + dunce::canonicalize(member.as_std_path()), + dunce::canonicalize(root.as_std_path()), + ) { + (Ok(m), Ok(r)) => m != r, + _ => member != root, + }; + if differs && !WORKSPACE_ROOT_ANNOUNCED.swap(true, std::sync::atomic::Ordering::Relaxed) { + tracing::warn!( + "Running inside sub-project '{member}'; resolved workspace root '{root}'. \ + Commands operate on the workspace root's network, environments, and canister IDs." + ); + } +} + +#[async_trait] +impl ProjectLoad for ProjectLoadImpl { + async fn load(&self) -> Result { + debug!("Loading project"); + // Locate project root + let pdir = self + .project_root_locate + .locate() + .map_err(|source| ProjectLoadError::Locate { source })?; + + debug!("Located icp project in {pdir}"); + + // Announce (once) when we resolved up to a workspace root above the + // sub-project the command is run in, so this is visible for every command. + if let Ok(member) = self.project_root_locate.locate_member() { + announce_workspace_root_once(&member, &pdir); + } + + // Load project manifest + let m = load_manifest_from_path(&pdir.join(PROJECT_MANIFEST)) + .await + .context(ProjectManifestSnafu) + .map_err(loader_failed)?; + + debug!("Loaded project manifest: {m:#?}"); + + // Consolidate manifest into project + let p = consolidate_manifest(&pdir, self.recipe.as_ref(), &m) + .await + .context(ProjectSnafu) + .map_err(loader_failed)?; + + debug!("Rendered project definition: {p:#?}"); + + Ok(p) + } + + async fn exists(&self) -> Result { + match self.project_root_locate.locate() { + Ok(_) => Ok(true), + Err(ProjectRootLocateError::NotFound { .. }) => Ok(false), + } + } + + fn member_dir(&self) -> Option { + self.project_root_locate.locate_member().ok() + } +} + +/// Loads at most once per process, then serves the cached [`Project`]. +pub struct Lazy(T, Arc>>); + +impl Lazy { + pub fn new(v: T) -> Self { + Self(v, Arc::new(Mutex::new(None))) + } +} + +#[async_trait] +impl ProjectLoad for Lazy { + async fn load(&self) -> Result { + if let Some(v) = self.1.lock().await.as_ref() { + return Ok(v.to_owned()); + } + + let v = self.0.load().await?; + + let mut g = self.1.lock().await; + if g.is_none() { + *g = Some(v.to_owned()); + } + + Ok(v) + } + + async fn exists(&self) -> Result { + if self.1.lock().await.as_ref().is_some() { + return Ok(true); + } + + let v = self.0.exists().await?; + Ok(v) + } + + fn member_dir(&self) -> Option { + self.0.member_dir() + } +} #[derive(Debug, Snafu)] pub enum EnvironmentError { @@ -44,9 +190,6 @@ pub enum EnvironmentError { #[derive(Debug, Snafu)] pub enum ConsolidateManifestError { - #[snafu(display("failed to locate project directory"))] - Locate { source: ProjectRootLocateError }, - #[snafu(display("failed to perform glob parsing"))] GlobParse { source: glob::PatternError }, @@ -1443,13 +1586,184 @@ pub async fn consolidate_manifest( }) } +#[cfg(test)] +mod loader_tests { + use super::*; + use camino_tempfile::Utf8TempDir; + use icp::canister::recipe::{RecipeContext, ResolveError}; + use icp::manifest::{canister::BuildSteps, recipe::Recipe}; + use indoc::indoc; + + struct MockProjectRootLocate { + path: PathBuf, + } + + impl MockProjectRootLocate { + fn new(path: PathBuf) -> Self { + Self { path } + } + } + + impl ProjectRootLocate for MockProjectRootLocate { + fn locate(&self) -> Result { + Ok(self.path.clone()) + } + + fn locate_member(&self) -> Result { + Ok(self.path.clone()) + } + } + + struct MockRecipeResolver; + + #[async_trait] + impl Resolve for MockRecipeResolver { + async fn resolve( + &self, + _recipe: &Recipe, + _context: &RecipeContext, + ) -> Result<(BuildSteps, SyncSteps), ResolveError> { + use icp::manifest::adapter::prebuilt::{ + Adapter as PrebuiltAdapter, LocalSource, SourceField, + }; + use icp::manifest::canister::BuildStep; + + // Create a minimal BuildSteps with a dummy prebuilt step + let build_steps = BuildSteps { + steps: vec![BuildStep::Prebuilt(PrebuiltAdapter { + source: SourceField::Local(LocalSource { + path: "dummy.wasm".into(), + }), + sha256: None, + })], + }; + + Ok((build_steps, SyncSteps::default())) + } + } + + #[tokio::test] + async fn test_load_minimal_project() { + // Create temp directory with icp.yaml + let temp_dir = Utf8TempDir::new().unwrap(); + let project_dir = temp_dir.path(); + + // Write a minimal icp.yaml + let manifest_content = indoc! {r#" + canisters: + - name: backend + build: + steps: + - type: pre-built + path: backend.wasm + "#}; + std::fs::write(project_dir.join("icp.yaml"), manifest_content).unwrap(); + + // Create ProjectLoadImpl with mocks + let loader = ProjectLoadImpl { + project_root_locate: Arc::new(MockProjectRootLocate::new(project_dir.to_path_buf())), + recipe: Arc::new(MockRecipeResolver), + }; + + // Call load + let result = loader.load().await; + + // Assert success and check project contents + assert!(result.is_ok()); + let project = result.unwrap(); + assert_eq!(project.dir, project_dir); + assert!( + project.canisters.contains_key("backend"), + "The backend canister was not found" + ); + assert!( + project.environments.contains_key("local"), + "The default `local` environment was not injected" + ); + assert!( + project.environments.contains_key("ic"), + "The default `ic` environment was not injected" + ); + assert!( + project.networks.contains_key("local"), + "The default `local` network was not injected" + ); + assert!( + project.networks.contains_key("ic"), + "The default `ic` network was not injected" + ); + } + + #[tokio::test] + async fn test_load_project_local_override() { + // Create temp directory with icp.yaml + let temp_dir = Utf8TempDir::new().unwrap(); + let project_dir = temp_dir.path(); + + // Write a minimal icp.yaml + let manifest_content = indoc! {r#" + networks: + - name: test-network + mode: connected + url: https://somenetwork.icp + root-key: mainnet + environments: + - name: local + network: test-network + canisters: + - name: backend + build: + steps: + - type: pre-built + path: backend.wasm + "#}; + std::fs::write(project_dir.join("icp.yaml"), manifest_content).unwrap(); + + // Create ProjectLoadImpl with mocks + let loader = ProjectLoadImpl { + project_root_locate: Arc::new(MockProjectRootLocate::new(project_dir.to_path_buf())), + recipe: Arc::new(MockRecipeResolver), + }; + + // Call load + let result = loader.load().await; + + // Assert success and check project contents + assert!(result.is_ok(), "The project did not load: {:?}", result); + let project = result.unwrap(); + assert_eq!(project.dir, project_dir); + assert!( + project.canisters.contains_key("backend"), + "The backend canister was not found" + ); + assert!( + project.environments.contains_key("local"), + "The default `local` environment was not injected" + ); + let e = project.environments.get("local").unwrap(); + assert_eq!(e.network.name, "test-network"); + assert!( + project.environments.contains_key("ic"), + "The default `ic` environment was not injected" + ); + assert!( + project.networks.contains_key("local"), + "The default `local` network was not injected" + ); + assert!( + project.networks.contains_key("ic"), + "The default `ic` network was not injected" + ); + } +} + #[cfg(test)] mod dependency_tests { use super::*; - use crate::canister::recipe::{RecipeContext, Resolve, ResolveError}; - use crate::manifest::canister::{BuildSteps, SyncSteps}; - use crate::manifest::recipe::Recipe; use camino_tempfile::Utf8TempDir; + use icp::canister::recipe::{RecipeContext, Resolve, ResolveError}; + use icp::manifest::canister::{BuildSteps, SyncSteps}; + use icp::manifest::recipe::Recipe; /// Recipes are never used in these tests; every canister is pre-built. struct PanicResolver; diff --git a/crates/icp/src/context/init.rs b/crates/icp/src/context/init.rs index 8831ae2dd..283e59a56 100644 --- a/crates/icp/src/context/init.rs +++ b/crates/icp/src/context/init.rs @@ -1,114 +1,44 @@ -use std::{env::current_dir, sync::Arc}; +use std::{sync::Arc, time::Duration}; use snafu::prelude::*; use crate::canister::build::Builder; -use crate::canister::recipe::handlebars::Handlebars; use crate::canister::sync::Syncer; use crate::context::Context; -use crate::directories::{Access as _, Directories}; -use crate::prelude::*; use crate::store_artifact::ArtifactStore; -use std::time::Duration; use crate::{ - Lazy, ProjectLoadImpl, agent, identity, identity::PasswordFunc, manifest, network, store_id, + ProjectLoad, agent, identity, identity::PasswordFunc, manifest::ProjectRootLocate, network, + store_id, }; #[derive(Debug, Snafu)] pub enum ContextInitError { - #[snafu(display("failed to initialize directories"))] - Directories { - source: crate::directories::DirectoriesError, - }, - - #[snafu(display("failed to get current working directory"))] - Cwd { source: std::io::Error }, - - #[snafu(display("failed to convert path to UTF-8"))] - Utf8Path { source: FromPathBufError }, - #[snafu(display("failed to lock identity directory"))] IdentityDirectory { source: crate::fs::lock::LockError }, - - #[snafu(display("failed to lock package cache directory"))] - PackageCache { source: crate::fs::lock::LockError }, } +/// Assembles the library context from the ports the host provides: where its +/// data lives, how to find a project, and how to load one. pub fn initialize( - project_root_override: Option, + dirs: Arc, + project_root_locate: Arc, + project: Arc, password_func: PasswordFunc, pem_session_duration: Option, ) -> Result { - // Setup global directory structure - let dirs = Arc::new(Directories::new().context(DirectoriesSnafu)?); - - // Project Root. On Unix, prefer $PWD (the logical path the user cd'd - // through) over getcwd(3), which resolves symlinks to the physical path - // and would break upward traversal when the user is inside a symlinked - // directory whose manifest sits above the symlink's location. - // - // Guard with an inode check: if $PWD was inherited from a parent process - // that used chdir(2) without updating $PWD, the two paths point to - // different inodes and we fall back to getcwd(). Because `metadata()` - // follows symlinks, a symlinked $PWD still resolves to the same inode as - // getcwd(), so the symlink case still works. - #[cfg(unix)] - let cwd: PathBuf = { - let real = PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; - match std::env::var("PWD") - .ok() - .map(PathBuf::from) - .filter(|p| p.is_absolute()) - .filter(|p| same_inode(p.as_path(), real.as_path())) - { - Some(logical) => logical, - None => real, - } - }; - - #[cfg(not(unix))] - let cwd: PathBuf = - PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; - - let project_root_locate = Arc::new(manifest::ProjectRootLocateImpl::new( - cwd, - project_root_override, - )); - // Canister ID Store let ids = Arc::new(store_id::AccessImpl::new(project_root_locate.clone())); // Canister Artifact Store (wasm) let artifacts = Arc::new(ArtifactStore::new(project_root_locate.clone())); - // Prepare http client - let http_client = reqwest::Client::new(); - - // Package cache - let pkg_cache = dirs.package_cache().context(PackageCacheSnafu)?; - - // Recipes - let recipe = Arc::new(Handlebars { - http_client, - pkg_cache, - }); - // Canister builder let builder = Arc::new(Builder); // Canister syncer let syncer = Arc::new(Syncer); - // Project loader - let pload = ProjectLoadImpl { - project_root_locate: project_root_locate.clone(), - recipe, - }; - - let pload = Lazy::new(pload); - let pload = Arc::new(pload); - // Telemetry data bag (written by subsystems, read at session finish) let telemetry_data = Arc::new(crate::telemetry_data::TelemetryData::default()); @@ -119,10 +49,11 @@ pub fn initialize( pem_session_duration, telemetry_data.clone(), )); + if let Ok(mockdir) = std::env::var("ICP_CLI_KEYRING_MOCK_DIR") { keyring::set_default_credential_builder(Box::new( crate::identity::keyring_mock::MockKeyring { - dir: PathBuf::from(mockdir), + dir: crate::prelude::PathBuf::from(mockdir), }, )); } @@ -132,7 +63,7 @@ pub fn initialize( // Network accessor let netaccess = Arc::new(network::Accessor { - project_root_locate: project_root_locate.clone(), + project_root_locate, descriptors: dirs.port_descriptor(), agent: agent_creator.clone(), }); @@ -142,7 +73,7 @@ pub fn initialize( dirs, ids, artifacts, - project: pload, + project, identity: idload, network: netaccess, agent: agent_creator, @@ -152,57 +83,3 @@ pub fn initialize( password_func, }) } - -#[cfg(unix)] -fn same_inode(a: &Path, b: &Path) -> bool { - use std::os::unix::fs::MetadataExt; - match (std::fs::metadata(a), std::fs::metadata(b)) { - (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(), - _ => false, - } -} - -#[cfg(test)] -#[cfg(unix)] -mod tests { - use std::sync::Mutex; - - use camino_tempfile::Utf8TempDir; - - use super::*; - - // Serializes tests that mutate $PWD, since cargo test runs tests in parallel. - static ENV_MUTEX: Mutex<()> = Mutex::new(()); - - #[test] - fn stale_pwd_is_ignored() { - let _guard = ENV_MUTEX.lock().unwrap(); - - let stale = Utf8TempDir::new().unwrap(); - let real = PathBuf::try_from(std::env::current_dir().unwrap()).unwrap(); - - let old_pwd = std::env::var("PWD").ok(); - // SAFETY: ENV_MUTEX serializes all tests that mutate $PWD. - unsafe { std::env::set_var("PWD", stale.path()) }; - - let resolved = match std::env::var("PWD") - .ok() - .map(PathBuf::from) - .filter(|p| p.is_absolute()) - .filter(|p| same_inode(p.as_path(), real.as_path())) - { - Some(logical) => logical, - None => real.clone(), - }; - - match old_pwd { - Some(v) => unsafe { std::env::set_var("PWD", v) }, - None => unsafe { std::env::remove_var("PWD") }, - } - - assert_eq!( - resolved, real, - "stale $PWD should be ignored in favour of getcwd()" - ); - } -} diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index b03f808d0..f6c57325b 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -1,24 +1,17 @@ -use std::{ - collections::{BTreeMap, HashMap}, - sync::Arc, -}; +use std::collections::{BTreeMap, HashMap}; use async_trait::async_trait; use indexmap::IndexMap; use serde::Serialize; use snafu::prelude::*; -use tokio::sync::Mutex; -use tracing::debug; use candid_parser::parse_idl_args; use crate::{ - canister::{Settings, recipe::Resolve}, + canister::Settings, manifest::{ - ArgsFormat, LoadManifestFromPathError, PROJECT_MANIFEST, ProjectRootLocate, - ProjectRootLocateError, + ArgsFormat, ProjectRootLocateError, canister::{BuildSteps, SyncSteps}, - load_manifest_from_path, }, network::Configuration, prelude::*, @@ -35,7 +28,6 @@ pub mod network; pub mod package; pub mod parsers; pub mod prelude; -pub mod project; pub mod settings; pub mod signal; pub mod store_artifact; @@ -202,12 +194,13 @@ pub enum ProjectLoadError { #[snafu(display("failed to locate project directory"))] Locate { source: ProjectRootLocateError }, - #[snafu(display("failed to load project manifest"))] - ProjectManifest { source: LoadManifestFromPathError }, - - #[snafu(display("failed to load project"))] - Project { - source: project::ConsolidateManifestError, + /// Anything that went wrong inside a [`ProjectLoad`] implementation, e.g. + /// reading and consolidating manifests in the CLI's native loader. Boxed so + /// that the library does not depend on any one loader's error type; the + /// implementation's own error is the reported cause. + #[snafu(transparent)] + Load { + source: Box, }, } @@ -225,119 +218,6 @@ pub trait ProjectLoad: Sync + Send { } } -pub struct ProjectLoadImpl { - pub project_root_locate: Arc, - pub recipe: Arc, -} - -/// Ensures the "operating on a workspace root above your sub-project" notice is -/// printed at most once per process (one CLI invocation), no matter how many -/// times the project is loaded. -static WORKSPACE_ROOT_ANNOUNCED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -/// Warn once when the resolved workspace root differs from the sub-project the -/// command is run in, so the upward resolution (§workspace model) is visible for -/// every command, not just deploy. -fn announce_workspace_root_once(member: &Path, root: &Path) { - let differs = match ( - dunce::canonicalize(member.as_std_path()), - dunce::canonicalize(root.as_std_path()), - ) { - (Ok(m), Ok(r)) => m != r, - _ => member != root, - }; - if differs && !WORKSPACE_ROOT_ANNOUNCED.swap(true, std::sync::atomic::Ordering::Relaxed) { - tracing::warn!( - "Running inside sub-project '{member}'; resolved workspace root '{root}'. \ - Commands operate on the workspace root's network, environments, and canister IDs." - ); - } -} - -#[async_trait] -impl ProjectLoad for ProjectLoadImpl { - async fn load(&self) -> Result { - debug!("Loading project"); - // Locate project root - let pdir = self.project_root_locate.locate().context(LocateSnafu)?; - - debug!("Located icp project in {pdir}"); - - // Announce (once) when we resolved up to a workspace root above the - // sub-project the command is run in, so this is visible for every command. - if let Ok(member) = self.project_root_locate.locate_member() { - announce_workspace_root_once(&member, &pdir); - } - - // Load project manifest - let m = load_manifest_from_path(&pdir.join(PROJECT_MANIFEST)) - .await - .context(ProjectManifestSnafu)?; - - debug!("Loaded project manifest: {m:#?}"); - - // Consolidate manifest into project - let p = project::consolidate_manifest(&pdir, self.recipe.as_ref(), &m) - .await - .context(ProjectSnafu)?; - - debug!("Rendered project definition: {p:#?}"); - - Ok(p) - } - - async fn exists(&self) -> Result { - match self.project_root_locate.locate() { - Ok(_) => Ok(true), - Err(ProjectRootLocateError::NotFound { .. }) => Ok(false), - } - } - - fn member_dir(&self) -> Option { - self.project_root_locate.locate_member().ok() - } -} - -pub struct Lazy(T, Arc>>); - -impl Lazy { - pub fn new(v: T) -> Self { - Self(v, Arc::new(Mutex::new(None))) - } -} - -#[async_trait] -impl ProjectLoad for Lazy { - async fn load(&self) -> Result { - if let Some(v) = self.1.lock().await.as_ref() { - return Ok(v.to_owned()); - } - - let v = self.0.load().await?; - - let mut g = self.1.lock().await; - if g.is_none() { - *g = Some(v.to_owned()); - } - - Ok(v) - } - - async fn exists(&self) -> Result { - if self.1.lock().await.as_ref().is_some() { - return Ok(true); - } - - let v = self.0.exists().await?; - Ok(v) - } - - fn member_dir(&self) -> Option { - self.0.member_dir() - } -} - #[cfg(test)] /// Mock project loader for testing. /// Returns a pre-configured `Project` when `load()` is called. @@ -691,178 +571,3 @@ impl ProjectLoad for NoProjectLoader { Ok(false) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::canister::recipe::{RecipeContext, Resolve, ResolveError}; - use crate::manifest::{ - ProjectRootLocate, ProjectRootLocateError, - canister::{BuildSteps, SyncSteps}, - recipe::Recipe, - }; - use camino_tempfile::Utf8TempDir; - use indoc::indoc; - - struct MockProjectRootLocate { - path: PathBuf, - } - - impl MockProjectRootLocate { - fn new(path: PathBuf) -> Self { - Self { path } - } - } - - impl ProjectRootLocate for MockProjectRootLocate { - fn locate(&self) -> Result { - Ok(self.path.clone()) - } - - fn locate_member(&self) -> Result { - Ok(self.path.clone()) - } - } - - struct MockRecipeResolver; - - #[async_trait] - impl Resolve for MockRecipeResolver { - async fn resolve( - &self, - _recipe: &Recipe, - _context: &RecipeContext, - ) -> Result<(BuildSteps, SyncSteps), ResolveError> { - use crate::manifest::adapter::prebuilt::{ - Adapter as PrebuiltAdapter, LocalSource, SourceField, - }; - use crate::manifest::canister::BuildStep; - - // Create a minimal BuildSteps with a dummy prebuilt step - let build_steps = BuildSteps { - steps: vec![BuildStep::Prebuilt(PrebuiltAdapter { - source: SourceField::Local(LocalSource { - path: "dummy.wasm".into(), - }), - sha256: None, - })], - }; - - Ok((build_steps, SyncSteps::default())) - } - } - - #[tokio::test] - async fn test_load_minimal_project() { - // Create temp directory with icp.yaml - let temp_dir = Utf8TempDir::new().unwrap(); - let project_dir = temp_dir.path(); - - // Write a minimal icp.yaml - let manifest_content = indoc! {r#" - canisters: - - name: backend - build: - steps: - - type: pre-built - path: backend.wasm - "#}; - std::fs::write(project_dir.join("icp.yaml"), manifest_content).unwrap(); - - // Create ProjectLoadImpl with mocks - let loader = ProjectLoadImpl { - project_root_locate: Arc::new(MockProjectRootLocate::new(project_dir.to_path_buf())), - recipe: Arc::new(MockRecipeResolver), - }; - - // Call load - let result = loader.load().await; - - // Assert success and check project contents - assert!(result.is_ok()); - let project = result.unwrap(); - assert_eq!(project.dir, project_dir); - assert!( - project.canisters.contains_key("backend"), - "The backend canister was not found" - ); - assert!( - project.environments.contains_key("local"), - "The default `local` environment was not injected" - ); - assert!( - project.environments.contains_key("ic"), - "The default `ic` environment was not injected" - ); - assert!( - project.networks.contains_key("local"), - "The default `local` network was not injected" - ); - assert!( - project.networks.contains_key("ic"), - "The default `ic` network was not injected" - ); - } - - #[tokio::test] - async fn test_load_project_local_override() { - // Create temp directory with icp.yaml - let temp_dir = Utf8TempDir::new().unwrap(); - let project_dir = temp_dir.path(); - - // Write a minimal icp.yaml - let manifest_content = indoc! {r#" - networks: - - name: test-network - mode: connected - url: https://somenetwork.icp - root-key: mainnet - environments: - - name: local - network: test-network - canisters: - - name: backend - build: - steps: - - type: pre-built - path: backend.wasm - "#}; - std::fs::write(project_dir.join("icp.yaml"), manifest_content).unwrap(); - - // Create ProjectLoadImpl with mocks - let loader = ProjectLoadImpl { - project_root_locate: Arc::new(MockProjectRootLocate::new(project_dir.to_path_buf())), - recipe: Arc::new(MockRecipeResolver), - }; - - // Call load - let result = loader.load().await; - - // Assert success and check project contents - assert!(result.is_ok(), "The project did not load: {:?}", result); - let project = result.unwrap(); - assert_eq!(project.dir, project_dir); - assert!( - project.canisters.contains_key("backend"), - "The backend canister was not found" - ); - assert!( - project.environments.contains_key("local"), - "The default `local` environment was not injected" - ); - let e = project.environments.get("local").unwrap(); - assert_eq!(e.network.name, "test-network"); - assert!( - project.environments.contains_key("ic"), - "The default `ic` environment was not injected" - ); - assert!( - project.networks.contains_key("local"), - "The default `local` network was not injected" - ); - assert!( - project.networks.contains_key("ic"), - "The default `ic` network was not injected" - ); - } -} diff --git a/crates/icp/src/manifest/mod.rs b/crates/icp/src/manifest/mod.rs index 0a811e358..79bc4c3f8 100644 --- a/crates/icp/src/manifest/mod.rs +++ b/crates/icp/src/manifest/mod.rs @@ -1,20 +1,18 @@ -use std::collections::HashSet; use std::marker::PhantomData; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use snafu::prelude::*; -use crate::fs; use crate::prelude::*; -pub(crate) mod adapter; -pub(crate) mod canister; -pub(crate) mod dependency; -pub(crate) mod environment; -pub(crate) mod network; -pub(crate) mod project; -pub(crate) mod recipe; +pub mod adapter; +pub mod canister; +pub mod dependency; +pub mod environment; +pub mod network; +pub mod project; +pub mod recipe; pub(crate) mod serde_helpers; pub use { @@ -125,390 +123,3 @@ pub trait ProjectRootLocate: Sync + Send { /// Equals [`locate`](Self::locate) at the root or in a standalone project. fn locate_member(&self) -> Result; } - -/// Implementation of [`ProjectRootLocate`]. -pub struct ProjectRootLocateImpl { - /// Current directory to begin search from in case dir is unspecified. - cwd: PathBuf, - - /// Specific directory to be used as project root directly. - dir: Option, -} - -impl ProjectRootLocateImpl { - /// Creates a new instance of `ProjectRootLocateImpl`. - /// - /// - If `dir` is specified, it will be used as Project Root directly. - /// - Otherwise, it will search upwards from `cwd` for the project manifest file (`icp.yaml`). - pub fn new(cwd: PathBuf, dir: Option) -> Self { - Self { cwd, dir } - } -} - -/// The nearest directory at or above `start` that contains a project manifest. -fn nearest_manifest_dir(start: &Path) -> Option { - let mut dir = start.to_owned(); - loop { - if dir.join(PROJECT_MANIFEST).exists() { - return Some(dir); - } - dir = dir.parent()?.to_owned(); - } -} - -/// The nearest directory *strictly above* `dir` that contains a project manifest. -fn next_manifest_dir_above(dir: &Path) -> Option { - let mut cur = dir.parent()?.to_owned(); - loop { - if cur.join(PROJECT_MANIFEST).exists() { - return Some(cur); - } - cur = cur.parent()?.to_owned(); - } -} - -/// Canonicalize a directory (resolving `..` and symlinks) into a UTF-8 path. -/// Returns `None` if the path does not exist or is not valid UTF-8; callers -/// treat that as "cannot establish identity", which is safe for resolution. -fn canonicalize_dir(dir: &Path) -> Option { - let canon = dunce::canonicalize(dir.as_std_path()).ok()?; - PathBuf::try_from(canon).ok() -} - -/// Read only the dependency `path:` entries from a manifest, ignoring every -/// other field. Deliberately lenient: any read/parse failure yields no -/// dependencies, so an unrelated or malformed ancestor manifest is treated as -/// declaring nothing (it will not be adopted as a workspace root). -fn read_dependency_paths(manifest_path: &Path) -> Vec { - #[derive(Deserialize)] - struct DepProbe { - path: String, - } - #[derive(Deserialize)] - struct ManifestProbe { - #[serde(default)] - dependencies: Vec, - } - - let Ok(content) = fs::read(manifest_path) else { - return Vec::new(); - }; - match serde_yaml::from_slice::(&content) { - Ok(p) => p.dependencies.into_iter().map(|d| d.path).collect(), - Err(_) => Vec::new(), - } -} - -/// The set of canonical directories a manifest declares as dependencies, -/// transitively. Each `path:` is resolved relative to the manifest that -/// declares it, then canonicalized so identity is independent of how the path -/// is spelled (matches [`crate::project`] dependency de-duplication). -fn transitive_dep_dirs(manifest_dir: &Path) -> HashSet { - let mut out = HashSet::new(); - let Some(start) = canonicalize_dir(manifest_dir) else { - return out; - }; - let mut visited: HashSet = HashSet::from([start.clone()]); - let mut stack = vec![start]; - while let Some(dir) = stack.pop() { - for rel in read_dependency_paths(&dir.join(PROJECT_MANIFEST)) { - let Some(dep) = canonicalize_dir(&dir.join(&rel)) else { - continue; - }; - out.insert(dep.clone()); - if visited.insert(dep.clone()) { - stack.push(dep); - } - } - } - out -} - -impl ProjectRootLocate for ProjectRootLocateImpl { - fn locate(&self) -> Result { - // Start from the project the command is standing in. An explicit - // override forces member == root (no climb) — see `locate_member`. - let start = self.locate_member()?; - if self.dir.is_some() { - return Ok(start); - } - - // Climb to the workspace root: adopt an ancestor only if its transitive - // dependency closure declares `start`. Early-stop at the first ancestor - // that does not — this never crosses a "gap" and never adopts an - // unrelated ancestor. With no declaring ancestor this - // degenerates to returning `start`, i.e. today's behavior. - let start_canonical = canonicalize_dir(&start).unwrap_or_else(|| start.clone()); - let mut root = start.clone(); - let mut cursor = start; - while let Some(ancestor) = next_manifest_dir_above(&cursor) { - if transitive_dep_dirs(&ancestor).contains(&start_canonical) { - root = ancestor.clone(); - cursor = ancestor; - } else { - break; - } - } - Ok(root) - } - - fn locate_member(&self) -> Result { - // An explicit override (`--project-root-override` / `ICP_PROJECT_ROOT`) - // forces the project directory and skips the upward climb — the escape - // hatch for "operate on exactly this project" (e.g. deploy a vendored - // member as a standalone project). Member and root are then identical. - if let Some(dir) = &self.dir { - if !dir.join(PROJECT_MANIFEST).exists() { - return NotFoundSnafu { - path: dir.to_owned(), - } - .fail(); - } - - return Ok(dir.to_owned()); - } - - // The project the command is standing in: nearest manifest at/above cwd. - nearest_manifest_dir(&self.cwd).ok_or_else(|| { - NotFoundSnafu { - path: self.cwd.to_owned(), - } - .build() - }) - } -} - -#[derive(Debug, Snafu)] -pub enum LoadManifestFromPathError { - #[snafu(display("failed to read manifest from path"))] - Read { source: fs::IoError }, - - #[snafu(display("failed to parse manifest at '{path}'"))] - Parse { - source: serde_yaml::Error, - path: PathBuf, - }, -} - -/// Loads a manifest of type `T` from the specified file path. -pub async fn load_manifest_from_path(path: &Path) -> Result -where - T: for<'de> Deserialize<'de>, -{ - let content = fs::read(path).context(ReadSnafu)?; - let m = serde_yaml::from_slice::(&content).context(ParseSnafu { - path: path.to_path_buf(), - })?; - Ok(m) -} - -#[cfg(test)] -mod tests { - use super::*; - use camino_tempfile::Utf8TempDir; - - fn write_manifest(dir: &Path) { - std::fs::write(dir.join(PROJECT_MANIFEST), "").unwrap(); - } - - /// Create `dir` (and parents) and write an `icp.yaml` declaring the given - /// `(alias, path)` dependencies. - fn write_project(dir: &Path, deps: &[(&str, &str)]) { - std::fs::create_dir_all(dir).unwrap(); - let mut body = String::new(); - if !deps.is_empty() { - body.push_str("dependencies:\n"); - for (name, path) in deps { - body.push_str(&format!(" - name: {name}\n path: {path}\n")); - } - } - std::fs::write(dir.join(PROJECT_MANIFEST), body).unwrap(); - } - - // A lone project (no declaring ancestor) is its own root. - #[test] - fn locate_standalone_member_is_its_own_root() { - let tmp = Utf8TempDir::new().unwrap(); - let member = tmp.path().join("openemail"); - write_project(&member, &[]); - - let locator = ProjectRootLocateImpl::new(member.clone(), None); - assert_eq!(locator.locate().unwrap(), member); - } - - // Running inside a member climbs to the parent that declares it. - #[test] - fn locate_climbs_to_declaring_parent() { - let tmp = Utf8TempDir::new().unwrap(); - let openhr = tmp.path().join("openhr"); - write_project(&openhr, &[("openemail", "./openemail")]); - let openemail = openhr.join("openemail"); - write_project(&openemail, &[]); - - let locator = ProjectRootLocateImpl::new(openemail, None); - assert_eq!(locator.locate().unwrap(), openhr); - } - - // A transitive chain climbs all the way to the top-most declaring project, - // from any member in the chain. - #[test] - fn locate_climbs_transitive_chain_to_top() { - let tmp = Utf8TempDir::new().unwrap(); - let app = tmp.path().join("app"); - write_project(&app, &[("openhr", "./openhr")]); - let openhr = app.join("openhr"); - write_project(&openhr, &[("openemail", "./openemail")]); - let openemail = openhr.join("openemail"); - write_project(&openemail, &[]); - - assert_eq!( - ProjectRootLocateImpl::new(openemail, None) - .locate() - .unwrap(), - app - ); - assert_eq!( - ProjectRootLocateImpl::new(openhr, None).locate().unwrap(), - app - ); - } - - // Diamond: the shared member is declared via siblings, not directly by the - // top project, and sits at a hoisted location. Transitive containment still - // resolves the top project as root. - #[test] - fn locate_resolves_diamond_via_transitive_closure() { - let tmp = Utf8TempDir::new().unwrap(); - let app = tmp.path().join("app"); - write_project( - &app, - &[ - ("service_a", "./umbrella/service-a"), - ("service_b", "./umbrella/service-b"), - ], - ); - write_project( - &app.join("umbrella/service-a"), - &[("openemail", "../openemail")], - ); - write_project( - &app.join("umbrella/service-b"), - &[("openemail", "../openemail")], - ); - let openemail = app.join("umbrella/openemail"); - write_project(&openemail, &[]); - - // `umbrella/` has no manifest, so the nearest ancestor above openemail is - // `app`, which declares openemail only transitively (app -> service-a -> - // ../openemail). - assert_eq!( - ProjectRootLocateImpl::new(openemail, None) - .locate() - .unwrap(), - app - ); - } - - // An ancestor that does not declare the project is not adopted as root. - #[test] - fn locate_rejects_unrelated_ancestor() { - let tmp = Utf8TempDir::new().unwrap(); - let outer = tmp.path().join("outer"); - write_project(&outer, &[]); // declares nothing - let app = outer.join("app"); - write_project(&app, &[]); - - let locator = ProjectRootLocateImpl::new(app.clone(), None); - assert_eq!(locator.locate().unwrap(), app); - } - - // Gap: a declaring project sits above a non-declaring manifest. Early-stop - // stops at the contiguous declaring chain and does not cross the gap. - #[test] - fn locate_early_stops_at_gap() { - let tmp = Utf8TempDir::new().unwrap(); - let outer = tmp.path().join("outer"); - // outer declares openhr through the gap directory. - write_project(&outer, &[("openhr", "./legacy/openhr")]); - let legacy = outer.join("legacy"); - write_project(&legacy, &[]); // the gap: declares nothing - let openhr = legacy.join("openhr"); - write_project(&openhr, &[("openemail", "./openemail")]); - let openemail = openhr.join("openemail"); - write_project(&openemail, &[]); - - // Climb stops at openhr because `legacy` (the next ancestor) does not - // declare openemail, even though `outer` above it does. - let locator = ProjectRootLocateImpl::new(openemail, None); - assert_eq!(locator.locate().unwrap(), openhr); - } - - // An explicit override forces that directory as root, with no upward climb. - #[test] - fn locate_override_forces_root_without_climbing() { - let tmp = Utf8TempDir::new().unwrap(); - let openhr = tmp.path().join("openhr"); - write_project(&openhr, &[("openemail", "./openemail")]); - let openemail = openhr.join("openemail"); - write_project(&openemail, &[]); - - // cwd is openemail but override pins openemail itself as the root. - let locator = ProjectRootLocateImpl::new(openemail.clone(), Some(openemail.clone())); - assert_eq!(locator.locate().unwrap(), openemail); - } - - #[test] - fn locate_returns_cwd_when_manifest_present() { - let tmp = Utf8TempDir::new().unwrap(); - write_manifest(tmp.path()); - - let locator = ProjectRootLocateImpl::new(tmp.path().to_path_buf(), None); - assert_eq!(locator.locate().unwrap(), tmp.path()); - } - - #[test] - fn locate_walks_up_to_manifest() { - let tmp = Utf8TempDir::new().unwrap(); - write_manifest(tmp.path()); - - let nested = tmp.path().join("a/b/c"); - std::fs::create_dir_all(&nested).unwrap(); - - let locator = ProjectRootLocateImpl::new(nested, None); - assert_eq!(locator.locate().unwrap(), tmp.path()); - } - - #[test] - fn locate_returns_not_found_when_no_manifest_anywhere() { - let tmp = Utf8TempDir::new().unwrap(); - let nested = tmp.path().join("a/b"); - std::fs::create_dir_all(&nested).unwrap(); - - // Host filesystem contains no icp.yaml above the tempdir (assumed in CI). - let locator = ProjectRootLocateImpl::new(nested, None); - assert!(matches!( - locator.locate(), - Err(ProjectRootLocateError::NotFound { .. }) - )); - } - - // When cwd is a symlinked directory, locate() walks up via the symlink's - // lexical parents - #[cfg(unix)] - #[test] - fn locate_walks_up_through_symlink() { - // target/ has no manifest anywhere above it within the test's scope. - let target = Utf8TempDir::new().unwrap(); - - // project/ contains the manifest; `project/link` is a symlink to target/. - let project = Utf8TempDir::new().unwrap(); - write_manifest(project.path()); - let link = project.path().join("link"); - std::os::unix::fs::symlink(target.path().as_std_path(), link.as_std_path()).unwrap(); - - // cwd is the symlink path; its lexical parent is `project`, - // which contains the manifest. - let locator = ProjectRootLocateImpl::new(link, None); - assert_eq!(locator.locate().unwrap(), project.path()); - } -} diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 3d025fb40..022ff024c 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -23,9 +23,14 @@ use crate::{ get_managed_network_access, }, prelude::*, - project::DEFAULT_LOCAL_NETWORK_PORT, }; +/// Bind address of the injected default `local` network. +pub const DEFAULT_LOCAL_NETWORK_BIND: &str = "127.0.0.1"; + +/// Port of the injected default `local` network. +pub const DEFAULT_LOCAL_NETWORK_PORT: u16 = 8000; + pub mod access; pub mod config; pub mod custom_domains; From b06b4d4ec74d4249c9e4ebde875c1038ddf842d6 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 09:30:26 +0000 Subject: [PATCH 3/9] refactor(icp): move identity loading into icp-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading an identity means reading PEM files, talking to the OS keyring or an HSM, and — when the key is encrypted — asking the user for a password. None of that belongs in a library whose job is to build and deploy: it should be handed an already-constructed identity or agent. `crates/icp/src/identity/` therefore moves to `crates/icp-cli/src/identity/`, and with it the two pieces of `Context` that only existed to serve it: - `password_func` is deleted from the library. 26 of its 37 references were inside the identity module; the rest were wiring. No `Prompt` port replaces it — with identity in the frontend there is nothing to abstract. - `identity: Arc` is deleted, together with the selection-resolving helpers built on it (`get_identity`, `get_agent`, `get_agent_for_{env,network,url}`). Those take an `IdentitySelection`, a CLI concept, and must interleave identity loading with root-key resolution, so they now live on the CLI's `Context`. What stays in the library is `Context::create_agent`, which turns an identity plus a resolved network into an `Agent`. The two genuine couplings outside the module: - `telemetry_data.rs`: `IdentityStorageType` stays (telemetry is a library-side data bag); its `From<&IdentitySpec>` conversion travels with `IdentitySpec` into the CLI. - `directories.rs`: `Access::identity()` becomes `identity_dir()`, returning a plain path. `IdentityPaths`/`IdentityDirectories` travel with identity; the generic directory-lock machinery (`icp::fs::lock`) stays, exactly as it does for settings and the package cache. `icp::context::initialize` no longer builds an identity loader, so it can no longer fail: it returns a `Context` and `ContextInitError` is gone from the library. The `ICP_CLI_KEYRING_MOCK_DIR` test hook moves to the CLI's `initialize` alongside the loader it configures. Dependencies that leave `icp` entirely (74 -> 58): bip32, crypto-bigint, elliptic-curve, hmac, ic-ed25519, ic-identity-hsm, k256, keyring, p256, pem, pkcs8, rand, scrypt, sec1, tiny-bip39, zeroize. `keyring`'s only non-identity user was the mock hook above, so it leaves too. Filesystem call sites in `crates/icp/src`: 241 -> 137. The `Context` tests split along the same line: identity and agent resolution move to `crates/icp-cli/src/context/tests.rs`, the rest stay. So that the CLI can build a mocked library context, `icp`'s port mocks are now compiled under `#[cfg(any(test, feature = "mocks"))]` and `icp-cli` enables that feature as a dev-dependency. Three dead items surfaced when the module landed in a binary crate, where `pub` no longer suppresses dead-code analysis, and are removed: `load_identity_in_context`, `IdentityPaths::ensure_identity_{defaults,list}_path`, `WriteIdentityError::CreateDirectoryError` and `MockIdentityLoader::with_default`. --- Cargo.lock | 23 +- crates/icp-cli/Cargo.toml | 8 + crates/icp-cli/src/commands/args.rs | 2 +- .../icp-cli/src/commands/canister/create.rs | 2 +- .../icp-cli/src/commands/canister/status.rs | 6 +- crates/icp-cli/src/commands/deploy.rs | 2 +- .../icp-cli/src/commands/identity/default.rs | 4 +- .../commands/identity/delegation/request.rs | 6 +- .../src/commands/identity/delegation/sign.rs | 15 +- .../src/commands/identity/delegation/use.rs | 17 +- .../icp-cli/src/commands/identity/delete.rs | 4 +- .../icp-cli/src/commands/identity/export.rs | 4 +- .../icp-cli/src/commands/identity/import.rs | 16 +- .../icp-cli/src/commands/identity/link/hsm.rs | 17 +- .../icp-cli/src/commands/identity/link/web.rs | 22 +- crates/icp-cli/src/commands/identity/list.rs | 4 +- crates/icp-cli/src/commands/identity/new.rs | 20 +- .../icp-cli/src/commands/identity/reauth.rs | 25 +- .../icp-cli/src/commands/identity/rename.rs | 4 +- crates/icp-cli/src/commands/network/ping.rs | 2 +- crates/icp-cli/src/commands/network/start.rs | 7 +- crates/icp-cli/src/commands/sync.rs | 2 +- crates/icp-cli/src/context.rs | 176 ----- crates/icp-cli/src/context/mod.rs | 363 +++++++++ crates/icp-cli/src/context/tests.rs | 694 ++++++++++++++++++ .../src/identity/delegation.rs | 2 +- crates/{icp => icp-cli}/src/identity/key.rs | 61 +- .../src/identity/keyring_mock.rs | 2 +- .../{icp => icp-cli}/src/identity/manifest.rs | 34 +- crates/{icp => icp-cli}/src/identity/mod.rs | 35 +- .../{icp => icp-cli}/src/identity/seed/mod.rs | 0 .../src/identity/seed/slip10.rs | 0 crates/icp-cli/src/main.rs | 3 +- crates/icp-cli/src/options.rs | 2 +- crates/icp/Cargo.toml | 21 +- crates/icp/src/canister/build/mod.rs | 4 +- crates/icp/src/canister/sync/mod.rs | 4 +- crates/icp/src/context/init.rs | 42 +- crates/icp/src/context/mod.rs | 194 +---- crates/icp/src/context/tests.rs | 617 +--------------- crates/icp/src/directories.rs | 20 +- crates/icp/src/lib.rs | 11 +- crates/icp/src/network/mod.rs | 10 +- crates/icp/src/store_artifact.rs | 14 +- crates/icp/src/store_id.rs | 4 +- crates/icp/src/telemetry_data.rs | 16 - 46 files changed, 1263 insertions(+), 1278 deletions(-) delete mode 100644 crates/icp-cli/src/context.rs create mode 100644 crates/icp-cli/src/context/mod.rs create mode 100644 crates/icp-cli/src/context/tests.rs rename crates/{icp => icp-cli}/src/identity/delegation.rs (99%) rename crates/{icp => icp-cli}/src/identity/key.rs (98%) rename crates/{icp => icp-cli}/src/identity/keyring_mock.rs (99%) rename crates/{icp => icp-cli}/src/identity/manifest.rs (88%) rename crates/{icp => icp-cli}/src/identity/mod.rs (91%) rename crates/{icp => icp-cli}/src/identity/seed/mod.rs (100%) rename crates/{icp => icp-cli}/src/identity/seed/slip10.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 7dfbe2471..20feb0ecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3609,28 +3609,22 @@ dependencies = [ "async-dropper", "async-trait", "bigdecimal", - "bip32", "bollard", "camino", "camino-tempfile", "candid", "candid_parser", "clap", - "crypto-bigint", "directories", "dunce", "ed25519-consensus", - "elliptic-curve", "flate2", "futures", "glob", "handlebars", "hex", - "hmac 0.13.0", "hybrid-array", "ic-agent", - "ic-ed25519", - "ic-identity-hsm", "ic-ledger-types", "ic-management-canister-types 0.8.0", "ic-utils", @@ -3641,21 +3635,13 @@ dependencies = [ "indoc", "itertools 0.14.0", "jsonschema", - "k256", - "keyring", "notify", "num-bigint 0.4.6", "num-integer", "num-traits", - "p256", "pathdiff", - "pem", - "pkcs8", - "rand 0.10.1", "reqwest", "schemars", - "scrypt", - "sec1", "semver", "serde", "serde_json", @@ -3667,14 +3653,12 @@ dependencies = [ "sysinfo", "tar", "time", - "tiny-bip39", "tokio", "tracing", "url", "uuid", "winreg", "wslpath2", - "zeroize", ] [[package]] @@ -3710,6 +3694,7 @@ dependencies = [ "clap", "clap-markdown", "clap_complete", + "crypto-bigint", "cryptoki", "dialoguer 0.12.0", "dunce", @@ -3718,9 +3703,11 @@ dependencies = [ "futures", "glob", "hex", + "hmac 0.13.0", "httptest", "ic-agent", "ic-ed25519", + "ic-identity-hsm", "ic-ledger-types", "ic-management-canister-types 0.8.0", "ic-utils", @@ -3733,6 +3720,7 @@ dependencies = [ "indoc", "itertools 0.14.0", "k256", + "keyring", "lazy_static", "nix 0.31.3", "num-bigint 0.4.6", @@ -3748,6 +3736,7 @@ dependencies = [ "rand 0.10.1", "regex", "reqwest", + "scrypt", "sec1", "semver", "send_ctrlc", @@ -3758,6 +3747,7 @@ dependencies = [ "sha2 0.11.0", "shellwords", "snafu", + "strum 0.28.0", "sysinfo", "tar", "test-tag", @@ -3770,6 +3760,7 @@ dependencies = [ "uuid", "wasmparser 0.255.0", "wslpath2", + "zeroize", ] [[package]] diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 4b7fbe7db..c2d17e833 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -29,6 +29,7 @@ cargo-generate.workspace = true clap-markdown.workspace = true clap.workspace = true clap_complete.workspace = true +crypto-bigint.workspace = true dialoguer.workspace = true dunce.workspace = true elliptic-curve.workspace = true @@ -37,9 +38,11 @@ futures.workspace = true glob.workspace = true tar.workspace = true hex.workspace = true +hmac.workspace = true httptest.workspace = true ic-agent.workspace = true ic-ed25519.workspace = true +ic-identity-hsm.workspace = true ic-ledger-types.workspace = true ic-management-canister-types.workspace = true ic-utils.workspace = true @@ -52,6 +55,7 @@ indicatif.workspace = true indoc.workspace = true itertools.workspace = true k256.workspace = true +keyring.workspace = true lazy_static.workspace = true num-bigint.workspace = true num-integer.workspace = true @@ -65,6 +69,7 @@ pkcs8.workspace = true rand.workspace = true regex.workspace = true reqwest.workspace = true +scrypt.workspace = true sec1.workspace = true semver.workspace = true serde_json.workspace = true @@ -73,6 +78,7 @@ serde.workspace = true sha2.workspace = true shellwords.workspace = true snafu.workspace = true +strum.workspace = true sysinfo.workspace = true tiny-bip39.workspace = true time.workspace = true @@ -83,6 +89,7 @@ url.workspace = true uuid.workspace = true wasmparser.workspace = true wslpath2.workspace = true +zeroize.workspace = true [target.'cfg(unix)'.dependencies] cargo-generate = { workspace = true, features = ["vendored-openssl"] } @@ -91,6 +98,7 @@ cargo-generate = { workspace = true, features = ["vendored-openssl"] } assert_cmd.workspace = true camino-tempfile.workspace = true cryptoki.workspace = true +icp = { workspace = true, features = ["clap", "mocks"] } predicates.workspace = true rand.workspace = true send_ctrlc.workspace = true diff --git a/crates/icp-cli/src/commands/args.rs b/crates/icp-cli/src/commands/args.rs index d161acd8b..17731d28d 100644 --- a/crates/icp-cli/src/commands/args.rs +++ b/crates/icp-cli/src/commands/args.rs @@ -1,13 +1,13 @@ use std::fmt::Display; use std::str::FromStr; +use crate::identity::IdentitySelection; use anyhow::{Context as _, bail}; use candid::Principal; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; 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}; diff --git a/crates/icp-cli/src/commands/canister/create.rs b/crates/icp-cli/src/commands/canister/create.rs index 52015604f..68382e8f3 100644 --- a/crates/icp-cli/src/commands/canister/create.rs +++ b/crates/icp-cli/src/commands/canister/create.rs @@ -1,6 +1,7 @@ use crate::context::Context; use std::io::stdout; +use crate::identity::IdentitySelection; use anyhow::anyhow; use bigdecimal::BigDecimal; use candid::{Nat, Principal}; @@ -8,7 +9,6 @@ use clap::{ArgGroup, Args, Parser}; use ic_management_canister_types::CanisterSettings as MgmtCanisterSettings; use icp::canister::resolve_controllers; use icp::context::{EnvironmentSelection, NetworkSelection}; -use icp::identity::IdentitySelection; use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount, parse_token_amount}; use icp::store_id::IdMapping; use icp::{Canister, context::CanisterSelection, prelude::*}; diff --git a/crates/icp-cli/src/commands/canister/status.rs b/crates/icp-cli/src/commands/canister/status.rs index 507e9d592..e8bb99d2e 100644 --- a/crates/icp-cli/src/commands/canister/status.rs +++ b/crates/icp-cli/src/commands/canister/status.rs @@ -1,4 +1,5 @@ use crate::context::Context; +use crate::identity::IdentitySelection; use anyhow::{anyhow, bail}; use clap::Args; use clap_complete::ArgValueCandidates; @@ -6,10 +7,7 @@ use ic_agent::{Agent, AgentError, export::Principal}; use ic_management_canister_types::{ CanisterIdRecord, CanisterStatusResult, EnvironmentVariable, LogVisibility, }; -use icp::{ - context::{CanisterSelection, EnvironmentSelection, NetworkSelection}, - identity::IdentitySelection, -}; +use icp::context::{CanisterSelection, EnvironmentSelection, NetworkSelection}; use serde::Serialize; use std::fmt::Write; use tracing::debug; diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 05b5a43d4..0ea98c8db 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -1,4 +1,5 @@ use crate::context::Context; +use crate::identity::IdentitySelection; use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; @@ -9,7 +10,6 @@ use ic_management_canister_types::{CanisterId, CanisterIdRecord}; use icp::parsers::CyclesAmount; use icp::{ context::{CanisterSelection, EnvironmentSelection}, - identity::IdentitySelection, network::Configuration as NetworkConfiguration, }; use icp_canister_interfaces::candid_ui::MAINNET_CANDID_UI_CID; diff --git a/crates/icp-cli/src/commands/identity/default.rs b/crates/icp-cli/src/commands/identity/default.rs index 0e35d1e45..c967c0192 100644 --- a/crates/icp-cli/src/commands/identity/default.rs +++ b/crates/icp-cli/src/commands/identity/default.rs @@ -1,7 +1,7 @@ use crate::context::Context; +use crate::identity::manifest::{IdentityDefaults, IdentityList, change_default_identity}; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::identity::manifest::{IdentityDefaults, IdentityList, change_default_identity}; use tracing::info; /// Display or set the currently selected identity @@ -14,7 +14,7 @@ pub(crate) struct DefaultArgs { pub(crate) async fn exec(ctx: &Context, args: &DefaultArgs) -> Result<(), anyhow::Error> { // Load project directories - let dirs = ctx.dirs.identity()?; + let dirs = ctx.identity_dirs()?; match &args.name { Some(name) => { diff --git a/crates/icp-cli/src/commands/identity/delegation/request.rs b/crates/icp-cli/src/commands/identity/delegation/request.rs index 25dd9e7e0..3ce494cd9 100644 --- a/crates/icp-cli/src/commands/identity/delegation/request.rs +++ b/crates/icp-cli/src/commands/identity/delegation/request.rs @@ -1,8 +1,9 @@ use crate::context::Context; +use crate::identity::key; use clap::{Args, ValueHint}; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; -use icp::{fs::read_to_string, identity::key, prelude::*}; +use icp::{fs::read_to_string, prelude::*}; use pem::Pem; use snafu::{ResultExt, Snafu}; use tracing::warn; @@ -52,8 +53,7 @@ pub(crate) async fn exec(ctx: &Context, args: &RequestArgs) -> Result<(), Reques }; let der_public_key = ctx - .dirs - .identity()? + .identity_dirs()? .with_write(async |dirs| key::create_pending_delegation(dirs, &args.name, create_format)) .await? .context(CreateSnafu)?; diff --git a/crates/icp-cli/src/commands/identity/delegation/sign.rs b/crates/icp-cli/src/commands/identity/delegation/sign.rs index ff3a8b393..41346ea27 100644 --- a/crates/icp-cli/src/commands/identity/delegation/sign.rs +++ b/crates/icp-cli/src/commands/identity/delegation/sign.rs @@ -1,4 +1,10 @@ use crate::context::Context; +use crate::{ + context::GetIdentityError, + identity::delegation::{ + Delegation as WireDelegation, DelegationChain, SignedDelegation as WireSignedDelegation, + }, +}; use std::{ str::FromStr, time::{SystemTime, UNIX_EPOCH}, @@ -6,14 +12,7 @@ use std::{ use clap::{Args, ValueHint}; use ic_agent::{Identity as _, export::Principal, identity::Delegation as AgentDelegation}; -use icp::{ - context::GetIdentityError, - fs::read_to_string, - identity::delegation::{ - Delegation as WireDelegation, DelegationChain, SignedDelegation as WireSignedDelegation, - }, - prelude::*, -}; +use icp::{fs::read_to_string, prelude::*}; use pem::Pem; use snafu::{OptionExt, ResultExt, Snafu}; diff --git a/crates/icp-cli/src/commands/identity/delegation/use.rs b/crates/icp-cli/src/commands/identity/delegation/use.rs index b35b58e4c..1a1741b6d 100644 --- a/crates/icp-cli/src/commands/identity/delegation/use.rs +++ b/crates/icp-cli/src/commands/identity/delegation/use.rs @@ -1,14 +1,12 @@ use crate::context::Context; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; -use icp::{ - fs::json, - identity::{ - delegation::DelegationChain, - key, - manifest::{DelegationKeyStorage, PemFormat}, - }, - prelude::*, +use icp::{fs::json, prelude::*}; + +use crate::identity::{ + delegation::DelegationChain, + key, + manifest::{DelegationKeyStorage, PemFormat}, }; use snafu::{ResultExt, Snafu}; use tracing::{info, warn}; @@ -32,8 +30,7 @@ pub(crate) async fn exec(ctx: &Context, args: &UseArgs) -> Result<(), UseError> let chain: DelegationChain = json::load(&args.from_json)?; let storage = ctx - .dirs - .identity()? + .identity_dirs()? .with_write(async |dirs| key::complete_delegation(dirs, &args.name, &chain)) .await? .context(CompleteSnafu)?; diff --git a/crates/icp-cli/src/commands/identity/delete.rs b/crates/icp-cli/src/commands/identity/delete.rs index 409620099..7fa60c709 100644 --- a/crates/icp-cli/src/commands/identity/delete.rs +++ b/crates/icp-cli/src/commands/identity/delete.rs @@ -1,7 +1,7 @@ use crate::context::Context; +use crate::identity::key::delete_identity; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::identity::key::delete_identity; use tracing::info; /// Delete an identity @@ -13,7 +13,7 @@ pub(crate) struct DeleteArgs { } pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow::Error> { - let dirs = ctx.dirs.identity()?; + let dirs = ctx.identity_dirs()?; dirs.with_write(async |dirs| { delete_identity(dirs, &args.name)?; diff --git a/crates/icp-cli/src/commands/identity/export.rs b/crates/icp-cli/src/commands/identity/export.rs index d94613ea0..8966e4935 100644 --- a/crates/icp-cli/src/commands/identity/export.rs +++ b/crates/icp-cli/src/commands/identity/export.rs @@ -1,11 +1,11 @@ use crate::context::Context; +use crate::identity::key::{ExportFormat, export_identity}; use anyhow::Context as _; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; use icp::fs::read_to_string; -use icp::identity::key::{ExportFormat, export_identity}; use icp::prelude::*; /// Print the PEM file for the identity @@ -29,7 +29,7 @@ pub(crate) struct ExportArgs { } pub(crate) async fn exec(ctx: &Context, args: &ExportArgs) -> Result<(), anyhow::Error> { - let dirs = ctx.dirs.identity()?; + let dirs = ctx.identity_dirs()?; // Read password if necessary let export_format = if args.encrypt { diff --git a/crates/icp-cli/src/commands/identity/import.rs b/crates/icp-cli/src/commands/identity/import.rs index bc17ed09c..ac1e01e63 100644 --- a/crates/icp-cli/src/commands/identity/import.rs +++ b/crates/icp-cli/src/commands/identity/import.rs @@ -1,13 +1,13 @@ -use bip39::{Language, Mnemonic}; -use clap::{ArgGroup, Args, ValueHint}; -use dialoguer::Password; -use elliptic_curve::zeroize::Zeroizing; -use icp::identity::{ +use crate::identity::{ delegation::DelegationChain, key::{CreateFormat, CreateIdentityError, IdentityKey, create_identity}, manifest::IdentityKeyAlgorithm, seed::derive_key_from_seed_slip10, }; +use bip39::{Language, Mnemonic}; +use clap::{ArgGroup, Args, ValueHint}; +use dialoguer::Password; +use elliptic_curve::zeroize::Zeroizing; use icp::{ fs::{json, read_to_string}, prelude::*, @@ -215,8 +215,7 @@ async fn import_from_pem( _ => unreachable!(), }; - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_write(async move |dirs| create_identity(dirs, name, key, format, delegation)) .await??; @@ -418,8 +417,7 @@ async fn import_from_seed_phrase( ) -> Result<(), DeriveKeyError> { let mnemonic = Mnemonic::from_phrase(phrase, Language::English).context(ParseMnemonicSnafu)?; let key = derive_key_from_seed_slip10(&mnemonic, &algorithm); - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_write(async move |dirs| create_identity(dirs, name, key, format, delegation)) .await??; Ok(()) diff --git a/crates/icp-cli/src/commands/identity/link/hsm.rs b/crates/icp-cli/src/commands/identity/link/hsm.rs index 1bdc72882..654ed0314 100644 --- a/crates/icp-cli/src/commands/identity/link/hsm.rs +++ b/crates/icp-cli/src/commands/identity/link/hsm.rs @@ -1,10 +1,9 @@ use crate::context::Context; use clap::{Args, ValueHint}; use dialoguer::Password; -use icp::{ - identity::{key::link_hsm_identity, manifest::IdentityList}, - prelude::*, -}; +use icp::prelude::*; + +use crate::identity::{key::link_hsm_identity, manifest::IdentityList}; use snafu::{ResultExt, Snafu, ensure}; use tracing::info; @@ -32,8 +31,7 @@ pub(crate) struct HsmArgs { } pub(crate) async fn exec(ctx: &Context, args: &HsmArgs) -> Result<(), HsmError> { - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_read(async |dirs| -> Result<(), HsmError> { let list = IdentityList::load_from(dirs).context(LoadIdentityListSnafu)?; ensure!( @@ -61,8 +59,7 @@ pub(crate) async fn exec(ctx: &Context, args: &HsmArgs) -> Result<(), HsmError> }), }; - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_write(async |dirs| { link_hsm_identity( dirs, @@ -88,7 +85,7 @@ pub(crate) enum HsmError { #[snafu(display("failed to load identity list"))] LoadIdentityList { - source: icp::identity::manifest::LoadIdentityManifestError, + source: crate::identity::manifest::LoadIdentityManifestError, }, #[snafu(transparent)] @@ -96,6 +93,6 @@ pub(crate) enum HsmError { #[snafu(display("failed to link HSM identity"))] LinkHsm { - source: icp::identity::key::LinkHsmIdentityError, + source: crate::identity::key::LinkHsmIdentityError, }, } diff --git a/crates/icp-cli/src/commands/identity/link/web.rs b/crates/icp-cli/src/commands/identity/link/web.rs index 745aa7c6f..2f69e24ac 100644 --- a/crates/icp-cli/src/commands/identity/link/web.rs +++ b/crates/icp-cli/src/commands/identity/link/web.rs @@ -14,14 +14,12 @@ use clap::{Args, ValueHint}; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; use ic_agent::{Identity as _, export::Principal, identity::BasicIdentity}; -use icp::{ - fs::read_to_string, - identity::{ - delegation::DelegationChain, - key::{self, validate_password}, - manifest::IdentityList, - }, - prelude::*, +use icp::{fs::read_to_string, prelude::*}; + +use crate::identity::{ + delegation::DelegationChain, + key::{self, validate_password}, + manifest::IdentityList, }; use indicatif::{ProgressBar, ProgressStyle}; use rand::RngExt as _; @@ -68,8 +66,7 @@ fn parse_auth(s: &str) -> Result { } pub(crate) async fn exec(ctx: &Context, args: &WebArgs) -> Result<(), WebAuthError> { - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_read(async |dirs| -> Result<(), WebAuthError> { let list = IdentityList::load_from(dirs).context(LoadIdentityListSnafu)?; ensure!( @@ -127,8 +124,7 @@ pub(crate) async fn exec(ctx: &Context, args: &WebArgs) -> Result<(), WebAuthErr let remote_principal = Principal::self_authenticating(&from_key); let auth = args.auth.clone(); - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_write(async |dirs| { key::link_webauth_identity( dirs, @@ -164,7 +160,7 @@ pub(crate) enum WebAuthError { #[snafu(display("failed to load identity list"))] LoadIdentityList { - source: icp::identity::manifest::LoadIdentityManifestError, + source: crate::identity::manifest::LoadIdentityManifestError, }, #[snafu(display("failed to read storage password file"))] diff --git a/crates/icp-cli/src/commands/identity/list.rs b/crates/icp-cli/src/commands/identity/list.rs index 9e9d76674..33c7bb8dd 100644 --- a/crates/icp-cli/src/commands/identity/list.rs +++ b/crates/icp-cli/src/commands/identity/list.rs @@ -1,8 +1,8 @@ use std::io::stdout; +use crate::identity::manifest::{IdentityDefaults, IdentityList}; use candid::Principal; use clap::Args; -use icp::identity::manifest::{IdentityDefaults, IdentityList}; use itertools::Itertools; use serde::Serialize; @@ -21,7 +21,7 @@ pub(crate) struct ListArgs { } pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::Error> { - let dirs = ctx.dirs.identity()?.into_read().await?; + let dirs = ctx.identity_dirs()?.into_read().await?; let list = IdentityList::load_from(dirs.as_ref())?; let defaults = IdentityDefaults::load_from(dirs.as_ref())?; diff --git a/crates/icp-cli/src/commands/identity/new.rs b/crates/icp-cli/src/commands/identity/new.rs index f6192bb60..197f5e62b 100644 --- a/crates/icp-cli/src/commands/identity/new.rs +++ b/crates/icp-cli/src/commands/identity/new.rs @@ -5,14 +5,12 @@ use bip39::{Language, Mnemonic, MnemonicType}; use clap::{Args, ValueHint}; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; -use icp::{ - fs::write_string, - identity::{ - key::{CreateFormat, create_identity, validate_password}, - manifest::{IdentityKeyAlgorithm, IdentityList}, - seed::derive_key_from_seed_slip10, - }, - prelude::*, +use icp::{fs::write_string, prelude::*}; + +use crate::identity::{ + key::{CreateFormat, create_identity, validate_password}, + manifest::{IdentityKeyAlgorithm, IdentityList}, + seed::derive_key_from_seed_slip10, }; use crate::context::Context; @@ -49,8 +47,7 @@ pub(crate) struct NewArgs { } pub(crate) async fn exec(ctx: &Context, args: &NewArgs) -> Result<(), anyhow::Error> { - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_read(async |dirs| -> Result<(), anyhow::Error> { let list = IdentityList::load_from(dirs).context("failed to load identity list")?; anyhow::ensure!( @@ -89,8 +86,7 @@ pub(crate) async fn exec(ctx: &Context, args: &NewArgs) -> Result<(), anyhow::Er } }; - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_write(async |dirs| { create_identity( dirs, diff --git a/crates/icp-cli/src/commands/identity/reauth.rs b/crates/icp-cli/src/commands/identity/reauth.rs index 1a1506616..a959f2081 100644 --- a/crates/icp-cli/src/commands/identity/reauth.rs +++ b/crates/icp-cli/src/commands/identity/reauth.rs @@ -3,12 +3,11 @@ use std::time::Duration; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::{ - identity::{ - key, - manifest::{IdentityList, IdentitySpec, PemFormat}, - }, - settings::Settings, +use icp::settings::Settings; + +use crate::identity::{ + key, + manifest::{IdentityList, IdentitySpec, PemFormat}, }; use snafu::{OptionExt, ResultExt, Snafu}; use tracing::info; @@ -32,8 +31,7 @@ pub(crate) struct ReauthArgs { pub(crate) async fn exec(ctx: &Context, args: &ReauthArgs) -> Result<(), LoginError> { let spec = ctx - .dirs - .identity()? + .identity_dirs()? .with_read(async |dirs| { let list = IdentityList::load_from(dirs)?; list.identities @@ -58,8 +56,7 @@ pub(crate) async fn exec(ctx: &Context, args: &ReauthArgs) -> Result<(), LoginEr let password_func = ctx.password_func.clone(); let der_public_key = ctx - .dirs - .identity()? + .identity_dirs()? .with_read(async |dirs| { key::load_webauth_session_public_key( dirs, @@ -79,8 +76,7 @@ pub(crate) async fn exec(ctx: &Context, args: &ReauthArgs) -> Result<(), LoginEr .await .context(PollSnafu)?; - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_write(async |dirs| key::update_webauth_delegation(dirs, &args.name, &chain)) .await? .context(UpdateDelegationSnafu)?; @@ -109,8 +105,7 @@ pub(crate) async fn exec(ctx: &Context, args: &ReauthArgs) -> Result<(), LoginEr }; let password_func = ctx.password_func.clone(); - ctx.dirs - .identity()? + ctx.identity_dirs()? .with_write(async |dirs| { key::create_explicit_pem_session( dirs, @@ -140,7 +135,7 @@ pub(crate) enum LoginError { #[snafu(transparent)] LoadManifest { - source: icp::identity::manifest::LoadIdentityManifestError, + source: crate::identity::manifest::LoadIdentityManifestError, }, #[snafu(transparent)] diff --git a/crates/icp-cli/src/commands/identity/rename.rs b/crates/icp-cli/src/commands/identity/rename.rs index 6f6c5ad04..6e7aa8601 100644 --- a/crates/icp-cli/src/commands/identity/rename.rs +++ b/crates/icp-cli/src/commands/identity/rename.rs @@ -1,7 +1,7 @@ use crate::context::Context; +use crate::identity::key::rename_identity; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::identity::key::rename_identity; use tracing::info; /// Rename an identity @@ -16,7 +16,7 @@ pub(crate) struct RenameArgs { } pub(crate) async fn exec(ctx: &Context, args: &RenameArgs) -> Result<(), anyhow::Error> { - let dirs = ctx.dirs.identity()?; + let dirs = ctx.identity_dirs()?; dirs.with_write(async |dirs| { rename_identity(dirs, &args.old_name, &args.new_name)?; diff --git a/crates/icp-cli/src/commands/network/ping.rs b/crates/icp-cli/src/commands/network/ping.rs index b58597f71..69298b942 100644 --- a/crates/icp-cli/src/commands/network/ping.rs +++ b/crates/icp-cli/src/commands/network/ping.rs @@ -1,8 +1,8 @@ use crate::context::Context; +use crate::identity::IdentitySelection; use anyhow::bail; use clap::Args; use ic_agent::{Agent, agent::status::Status}; -use icp::identity::IdentitySelection; use std::time::Duration; use tokio::time::sleep; use tracing::info; diff --git a/crates/icp-cli/src/commands/network/start.rs b/crates/icp-cli/src/commands/network/start.rs index dad343939..bed8468e4 100644 --- a/crates/icp-cli/src/commands/network/start.rs +++ b/crates/icp-cli/src/commands/network/start.rs @@ -6,7 +6,6 @@ use clap::Args; use icp::network::ManagedMode; use icp::prelude::*; use icp::{ - identity::manifest::IdentityList, network::{ Configuration, managed::{ @@ -26,6 +25,7 @@ use crate::progress::{ProgressManager, ProgressManagerSettings}; use super::args::NetworkOrEnvironmentArgs; use crate::context::Context; +use crate::identity::manifest::IdentityList; /// Run a given network. /// @@ -131,11 +131,10 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: // Identities let (ids, defaults) = ctx - .dirs - .identity()? + .identity_dirs()? .with_read(async |dirs| { let ids = IdentityList::load_from(dirs)?; - let defaults = icp::identity::manifest::IdentityDefaults::load_from(dirs)?; + let defaults = crate::identity::manifest::IdentityDefaults::load_from(dirs)?; Ok::<_, anyhow::Error>((ids, defaults)) }) .await??; diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index b59cb5a78..b987b90ca 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -1,4 +1,5 @@ use crate::context::Context; +use crate::identity::IdentitySelection; use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; @@ -6,7 +7,6 @@ use clap_complete::ArgValueCandidates; use futures::future::try_join_all; use ic_management_canister_types::{CanisterId, CanisterIdRecord, CanisterStatusType}; use icp::context::{CanisterSelection, EnvironmentSelection}; -use icp::identity::IdentitySelection; use std::collections::BTreeMap; use tracing::info; diff --git a/crates/icp-cli/src/context.rs b/crates/icp-cli/src/context.rs deleted file mode 100644 index 0d1243c02..000000000 --- a/crates/icp-cli/src/context.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! The CLI's execution context. -//! -//! Wraps the library [`icp::context::Context`] — which is a bag of ports for -//! building and deploying — with the frontend-only state the library has no -//! business knowing about, such as presentation flags. Derefs to the library -//! context, so every library port (`dirs`, `ids`, `project`, `network`, …) is -//! reached straight through it. - -use std::{env::current_dir, ops::Deref, sync::Arc, time::Duration}; - -use icp::{ - canister::recipe::handlebars::Handlebars, - directories::{Access as _, Directories}, - identity::PasswordFunc, - prelude::*, -}; -use snafu::prelude::*; - -use crate::{ - manifest::ProjectRootLocateImpl, - project::{Lazy, ProjectLoadImpl}, -}; - -/// Execution context for a single CLI invocation. -#[derive(Clone)] -pub struct Context { - /// The library context. - inner: icp::context::Context, - - /// Whether debug output is enabled (`--debug`). Presentation only: it - /// selects the tracing layer and hides progress bars. - pub debug: bool, -} - -impl Deref for Context { - type Target = icp::context::Context; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -#[derive(Debug, Snafu)] -pub enum ContextInitError { - #[snafu(display("failed to initialize directories"))] - Directories { - source: icp::directories::DirectoriesError, - }, - - #[snafu(display("failed to get current working directory"))] - Cwd { source: std::io::Error }, - - #[snafu(display("failed to convert path to UTF-8"))] - Utf8Path { source: FromPathBufError }, - - #[snafu(display("failed to lock package cache directory"))] - PackageCache { source: icp::fs::lock::LockError }, - - #[snafu(transparent)] - Library { - source: icp::context::ContextInitError, - }, -} - -/// Builds the context for this CLI invocation. -pub fn initialize( - project_root_override: Option, - debug: bool, - password_func: PasswordFunc, - pem_session_duration: Option, -) -> Result { - // Setup global directory structure - let dirs = Arc::new(Directories::new().context(DirectoriesSnafu)?); - - // Project root locator - let project_root_locate = Arc::new(ProjectRootLocateImpl::new( - resolve_cwd()?, - project_root_override, - )); - - // Recipes - let recipe = Arc::new(Handlebars { - http_client: reqwest::Client::new(), - pkg_cache: dirs.package_cache().context(PackageCacheSnafu)?, - }); - - // Project loader - let project = Arc::new(Lazy::new(ProjectLoadImpl { - project_root_locate: project_root_locate.clone(), - recipe, - })); - - let inner = icp::context::initialize( - dirs, - project_root_locate, - project, - password_func, - pem_session_duration, - )?; - - Ok(Context { inner, debug }) -} - -/// The directory to start looking for a project in. -/// -/// On Unix, prefer $PWD (the logical path the user cd'd through) over -/// getcwd(3), which resolves symlinks to the physical path and would break -/// upward traversal when the user is inside a symlinked directory whose -/// manifest sits above the symlink's location. -/// -/// Guard with an inode check: if $PWD was inherited from a parent process that -/// used chdir(2) without updating $PWD, the two paths point to different inodes -/// and we fall back to getcwd(). Because `metadata()` follows symlinks, a -/// symlinked $PWD still resolves to the same inode as getcwd(), so the symlink -/// case still works. -#[cfg(unix)] -fn resolve_cwd() -> Result { - let real = PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; - Ok(std::env::var("PWD") - .ok() - .map(PathBuf::from) - .filter(|p| p.is_absolute()) - .filter(|p| same_inode(p.as_path(), real.as_path())) - .unwrap_or(real)) -} - -#[cfg(not(unix))] -fn resolve_cwd() -> Result { - PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu) -} - -#[cfg(unix)] -fn same_inode(a: &Path, b: &Path) -> bool { - use std::os::unix::fs::MetadataExt; - match (std::fs::metadata(a), std::fs::metadata(b)) { - (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(), - _ => false, - } -} - -#[cfg(test)] -#[cfg(unix)] -mod tests { - use std::sync::Mutex; - - use camino_tempfile::Utf8TempDir; - - use super::*; - - // Serializes tests that mutate $PWD, since cargo test runs tests in parallel. - static ENV_MUTEX: Mutex<()> = Mutex::new(()); - - #[test] - fn stale_pwd_is_ignored() { - let _guard = ENV_MUTEX.lock().unwrap(); - - let stale = Utf8TempDir::new().unwrap(); - let real = PathBuf::try_from(std::env::current_dir().unwrap()).unwrap(); - - let old_pwd = std::env::var("PWD").ok(); - // SAFETY: ENV_MUTEX serializes all tests that mutate $PWD. - unsafe { std::env::set_var("PWD", stale.path()) }; - - let resolved = resolve_cwd().unwrap(); - - match old_pwd { - Some(v) => unsafe { std::env::set_var("PWD", v) }, - None => unsafe { std::env::remove_var("PWD") }, - } - - assert_eq!( - resolved, real, - "stale $PWD should be ignored in favour of getcwd()" - ); - } -} diff --git a/crates/icp-cli/src/context/mod.rs b/crates/icp-cli/src/context/mod.rs new file mode 100644 index 000000000..a2fa28c79 --- /dev/null +++ b/crates/icp-cli/src/context/mod.rs @@ -0,0 +1,363 @@ +//! The CLI's execution context. +//! +//! Wraps the library [`icp::context::Context`] — which is a bag of ports for +//! building and deploying — with the frontend-only state the library has no +//! business knowing about: the presentation flags, the password prompt, and the +//! identity loader. Derefs to the library context, so every library port +//! (`dirs`, `ids`, `project`, `network`, …) is reached straight through it. + +use std::{env::current_dir, ops::Deref, sync::Arc, time::Duration}; + +use ic_agent::{Agent, Identity}; +use icp::{ + ProjectLoadError, + canister::recipe::handlebars::Handlebars, + context::{EnvironmentSelection, NetworkSelection}, + directories::{Access as _, Directories}, + prelude::*, +}; +use snafu::prelude::*; +use url::Url; + +use crate::{ + identity::{IdentityDirectories, IdentityPaths, IdentitySelection, PasswordFunc}, + manifest::ProjectRootLocateImpl, + project::{Lazy, ProjectLoadImpl}, +}; + +/// Execution context for a single CLI invocation. +#[derive(Clone)] +pub struct Context { + /// The library context. + inner: icp::context::Context, + + /// Identity loader. Caches per selection, so an encrypted identity is + /// unlocked (and its password asked for) at most once per invocation. + identity: Arc, + + /// Whether debug output is enabled (`--debug`). Presentation only: it + /// selects the tracing layer and hides progress bars. + pub debug: bool, + + /// Password reader for identity decryption; shared with the identity loader. + pub password_func: PasswordFunc, +} + +impl Deref for Context { + type Target = icp::context::Context; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl Context { + /// The identity directory, under its lock. + pub fn identity_dirs(&self) -> Result { + IdentityPaths::new(self.dirs.identity_dir()) + } + + /// Gets an identity based on the provided identity selection. + pub async fn get_identity( + &self, + identity: &IdentitySelection, + network_root_key: Option>, + ) -> Result, GetIdentityError> { + self.identity + .load(identity.clone(), network_root_key) + .await + .context(IdentityLoadSnafu { + identity: identity.clone(), + }) + } + + /// Creates an agent for a given identity and environment. + pub async fn get_agent_for_env( + &self, + identity: &IdentitySelection, + environment: &EnvironmentSelection, + ) -> Result { + let env = self.get_environment(environment).await?; + // A delegated identity is validated against the network's root key, so + // the network is resolved before the identity is loaded. + let access = self.network.access(&env.network).await?; + let id = self + .get_identity(identity, Some(access.root_key.clone())) + .await?; + Ok(self.create_agent(id, access).await?) + } + + /// Creates an agent for a given identity and network. + pub async fn get_agent_for_network( + &self, + identity: &IdentitySelection, + network_selection: &NetworkSelection, + ) -> Result { + let network = self.get_network(network_selection).await?; + let access = self.network.access(&network).await?; + let id = self + .get_identity(identity, Some(access.root_key.clone())) + .await?; + Ok(self.create_agent(id, access).await?) + } + + /// Creates an agent for a given identity and url. + pub async fn get_agent_for_url( + &self, + identity: &IdentitySelection, + url: &Url, + ) -> Result { + let id = self.get_identity(identity, None).await?; + let agent = self.agent.create(id, url.as_str()).await?; + Ok(agent) + } + + pub async fn get_agent( + &self, + identity: &IdentitySelection, + network: &NetworkSelection, + environment: &EnvironmentSelection, + ) -> Result { + match (environment, network) { + // Error: Both environment and network specified + (EnvironmentSelection::Named(_), NetworkSelection::Named(_)) + | (EnvironmentSelection::Named(_), NetworkSelection::Url(_, _)) => { + Err(GetAgentError::EnvironmentAndNetworkSpecified) + } + + // Default environment + default network + (EnvironmentSelection::Default, NetworkSelection::Default) => { + // Try to get agent from the default environment if project exists + match self.get_agent_for_env(identity, environment).await { + Ok(agent) => Ok(agent), + Err(GetAgentForEnvError::GetEnvironment { + source: + icp::context::GetEnvironmentError::ProjectLoad { + source: ProjectLoadError::Locate { .. }, + }, + }) => Err(GetAgentError::NoProjectOrNetwork), + Err(e) => Err(e.into()), + } + } + + // Environment specified + (EnvironmentSelection::Named(_), NetworkSelection::Default) => { + Ok(self.get_agent_for_env(identity, environment).await?) + } + + // Network specified + (EnvironmentSelection::Default, NetworkSelection::Named(_)) + | (EnvironmentSelection::Default, NetworkSelection::Url(_, _)) => { + Ok(self.get_agent_for_network(identity, network).await?) + } + } + } +} + +#[derive(Debug, Snafu)] +pub enum GetIdentityError { + #[snafu(display("failed to load identity"))] + IdentityLoad { + source: crate::identity::LoadError, + identity: IdentitySelection, + }, +} + +#[derive(Debug, Snafu)] +pub enum GetAgentForEnvError { + #[snafu(transparent)] + GetIdentity { source: GetIdentityError }, + + #[snafu(transparent)] + GetEnvironment { + source: icp::context::GetEnvironmentError, + }, + + #[snafu(transparent)] + NetworkAccess { source: icp::network::AccessError }, + + #[snafu(transparent)] + AgentCreate { + source: icp::agent::CreateAgentError, + }, +} + +#[derive(Debug, Snafu)] +pub enum GetAgentForNetworkError { + #[snafu(transparent)] + GetIdentity { source: GetIdentityError }, + + #[snafu(transparent)] + GetNetwork { + source: icp::context::GetNetworkError, + }, + + #[snafu(transparent)] + NetworkAccess { source: icp::network::AccessError }, + + #[snafu(transparent)] + AgentCreate { + source: icp::agent::CreateAgentError, + }, +} + +#[derive(Debug, Snafu)] +pub enum GetAgentForUrlError { + #[snafu(transparent)] + GetIdentity { source: GetIdentityError }, + + #[snafu(transparent)] + AgentCreate { + source: icp::agent::CreateAgentError, + }, +} + +#[derive(Debug, Snafu)] +pub enum GetAgentError { + #[snafu(transparent)] + ProjectExists { source: ProjectLoadError }, + + #[snafu(display("You can't specify both an environment and a network"))] + EnvironmentAndNetworkSpecified, + + #[snafu(display( + "No project found and no network specified. Either run this command inside a project or specify a network with --network" + ))] + NoProjectOrNetwork, + + #[snafu(transparent)] + GetAgentForEnv { source: GetAgentForEnvError }, + + #[snafu(transparent)] + GetAgentForNetwork { source: GetAgentForNetworkError }, + + #[snafu(transparent)] + GetAgentForUrl { source: GetAgentForUrlError }, +} + +#[derive(Debug, Snafu)] +pub enum ContextInitError { + #[snafu(display("failed to initialize directories"))] + Directories { + source: icp::directories::DirectoriesError, + }, + + #[snafu(display("failed to get current working directory"))] + Cwd { source: std::io::Error }, + + #[snafu(display("failed to convert path to UTF-8"))] + Utf8Path { source: FromPathBufError }, + + #[snafu(display("failed to lock package cache directory"))] + PackageCache { source: icp::fs::lock::LockError }, + + #[snafu(display("failed to lock identity directory"))] + IdentityDirectory { source: icp::fs::lock::LockError }, +} + +/// Builds the context for this CLI invocation. +pub fn initialize( + project_root_override: Option, + debug: bool, + password_func: PasswordFunc, + pem_session_duration: Option, +) -> Result { + // Setup global directory structure + let dirs = Arc::new(Directories::new().context(DirectoriesSnafu)?); + + // Project root locator + let project_root_locate = Arc::new(ProjectRootLocateImpl::new( + resolve_cwd()?, + project_root_override, + )); + + // Recipes + let recipe = Arc::new(Handlebars { + http_client: reqwest::Client::new(), + pkg_cache: dirs.package_cache().context(PackageCacheSnafu)?, + }); + + // Project loader + let project = Arc::new(Lazy::new(ProjectLoadImpl { + project_root_locate: project_root_locate.clone(), + recipe, + })); + + let inner = icp::context::initialize(dirs.clone(), project_root_locate, project); + + // Identity loader + let identity = Arc::new(crate::identity::Loader::new( + IdentityPaths::new(dirs.identity_dir()).context(IdentityDirectorySnafu)?, + password_func.clone(), + pem_session_duration, + inner.telemetry_data.clone(), + )); + if let Ok(mockdir) = std::env::var("ICP_CLI_KEYRING_MOCK_DIR") { + keyring::set_default_credential_builder(Box::new( + crate::identity::keyring_mock::MockKeyring { + dir: PathBuf::from(mockdir), + }, + )); + } + + Ok(Context { + inner, + identity, + debug, + password_func, + }) +} + +/// The directory to start looking for a project in. +/// +/// On Unix, prefer $PWD (the logical path the user cd'd through) over +/// getcwd(3), which resolves symlinks to the physical path and would break +/// upward traversal when the user is inside a symlinked directory whose +/// manifest sits above the symlink's location. +/// +/// Guard with an inode check: if $PWD was inherited from a parent process that +/// used chdir(2) without updating $PWD, the two paths point to different inodes +/// and we fall back to getcwd(). Because `metadata()` follows symlinks, a +/// symlinked $PWD still resolves to the same inode as getcwd(), so the symlink +/// case still works. +#[cfg(unix)] +fn resolve_cwd() -> Result { + let real = PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu)?; + Ok(std::env::var("PWD") + .ok() + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .filter(|p| same_inode(p.as_path(), real.as_path())) + .unwrap_or(real)) +} + +#[cfg(not(unix))] +fn resolve_cwd() -> Result { + PathBuf::try_from(current_dir().context(CwdSnafu)?).context(Utf8PathSnafu) +} + +#[cfg(unix)] +fn same_inode(a: &Path, b: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(), + _ => false, + } +} + +#[cfg(test)] +impl Context { + /// A context whose library ports are all mocks and whose identity loader + /// serves the anonymous identity. + pub fn mocked() -> Context { + Context { + inner: icp::context::Context::mocked(), + identity: Arc::new(crate::identity::MockIdentityLoader::anonymous()), + debug: false, + password_func: Arc::new(|| Err("no password available in mock context".to_string())), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/icp-cli/src/context/tests.rs b/crates/icp-cli/src/context/tests.rs new file mode 100644 index 000000000..de0adfefa --- /dev/null +++ b/crates/icp-cli/src/context/tests.rs @@ -0,0 +1,694 @@ +//! Tests for the identity- and agent-resolution the CLI layers on top of the +//! library context. + +use std::{collections::HashMap, sync::Arc}; + +use ic_agent::Identity; +use icp::{ + Environment, MockProjectLoader, Network, NoProjectLoader, Project, + context::{EnvironmentSelection, NetworkSelection}, + network::{ + Configuration, Gateway, Managed, ManagedLauncherConfig, ManagedMode, MockNetworkAccessor, + Port, access::NetworkAccess, + }, + prelude::*, +}; +use indexmap::IndexMap; +use url::Url; + +use super::*; +use crate::identity::MockIdentityLoader; + +const DEFAULT_LOCAL_NETWORK_URL: &str = "http://localhost:8000"; + +#[tokio::test] +async fn test_get_identity_default() { + let ctx = Context::mocked(); + + let result = ctx.get_identity(&IdentitySelection::Default, None).await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_get_identity_anonymous() { + let ctx = Context::mocked(); + + let result = ctx.get_identity(&IdentitySelection::Anonymous, None).await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_get_identity_named() { + let alice_identity: Arc = Arc::new(ic_agent::identity::AnonymousIdentity); + + let ctx = Context { + identity: Arc::new( + MockIdentityLoader::anonymous().with_identity("alice", Arc::clone(&alice_identity)), + ), + ..Context::mocked() + }; + + let result = ctx + .get_identity(&IdentitySelection::Named("alice".to_string()), None) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_get_identity_named_not_found() { + let ctx = Context::mocked(); + + let result = ctx + .get_identity(&IdentitySelection::Named("nonexistent".to_string()), None) + .await; + + assert!(matches!( + result, + Err(GetIdentityError::IdentityLoad { + identity: IdentitySelection::Named(_), + source: crate::identity::LoadError::LoadIdentity { .. } + }) + )); +} + +#[tokio::test] +async fn test_get_agent_for_env_uses_environment_network() { + let local_root_key = vec![1, 2, 3]; + let staging_root_key = vec![4, 5, 6]; + + // Complex project has "test" environment which uses "staging" network + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new( + MockNetworkAccessor::new() + .with_network( + "local", + NetworkAccess { + root_key: local_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse("http://localhost:8000").unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ) + .with_network( + "staging", + NetworkAccess { + root_key: staging_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse("http://staging:9000").unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ), + ), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let agent = ctx + .get_agent_for_env( + &IdentitySelection::Anonymous, + &EnvironmentSelection::Named("test".to_string()), + ) + .await + .unwrap(); + + assert_eq!(agent.read_root_key(), staging_root_key); +} + +#[tokio::test] +async fn test_get_agent_for_env_environment_not_found() { + let ctx = Context::mocked(); + + let result = ctx + .get_agent_for_env( + &IdentitySelection::Anonymous, + &EnvironmentSelection::Named("nonexistent".to_string()), + ) + .await; + + assert!(matches!( + result, + Err(GetAgentForEnvError::GetEnvironment { + source: icp::context::GetEnvironmentError::EnvironmentNotFound { .. } + }) + )); +} + +#[tokio::test] +async fn test_get_agent_for_env_network_not_configured() { + // Environment "dev" exists in project and uses "local" network, + // but "local" network is not configured in MockNetworkAccessor + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::complex()), + // MockNetworkAccessor has no networks configured + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let result = ctx + .get_agent_for_env( + &IdentitySelection::Anonymous, + &EnvironmentSelection::Named("dev".to_string()), + ) + .await; + + assert!(matches!( + result, + Err(GetAgentForEnvError::NetworkAccess { + source: icp::network::AccessError::GetNetworkAccess { .. } + }) + )); +} + +#[tokio::test] +async fn test_get_agent_for_network_success() { + let root_key = vec![1, 2, 3]; + + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new(MockNetworkAccessor::new().with_network( + "local", + NetworkAccess { + root_key: root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse("http://localhost:8000").unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + )), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let agent = ctx + .get_agent_for_network( + &IdentitySelection::Anonymous, + &NetworkSelection::Named("local".to_string()), + ) + .await + .unwrap(); + + assert_eq!(agent.read_root_key(), root_key); +} + +#[tokio::test] +async fn test_get_agent_for_network_network_not_found() { + let ctx = Context::mocked(); + + let result = ctx + .get_agent_for_network( + &IdentitySelection::Anonymous, + &NetworkSelection::Named("nonexistent".to_string()), + ) + .await; + + assert!(matches!( + result, + Err(GetAgentForNetworkError::GetNetwork { + source: icp::context::GetNetworkError::NetworkNotFound { .. } + }) + )); +} + +#[tokio::test] +async fn test_get_agent_for_network_not_configured() { + // Network "local" exists in project but is not configured in MockNetworkAccessor + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::complex()), + // MockNetworkAccessor has no networks configured + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let result = ctx + .get_agent_for_network( + &IdentitySelection::Anonymous, + &NetworkSelection::Named("local".to_string()), + ) + .await; + + assert!(matches!( + result, + Err(GetAgentForNetworkError::NetworkAccess { + source: icp::network::AccessError::GetNetworkAccess { .. } + }) + )); +} + +#[tokio::test] +async fn test_get_agent_for_url_success() { + let ctx = Context::mocked(); + + let result = ctx + .get_agent_for_url( + &IdentitySelection::Anonymous, + &Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), + ) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_get_agent_defaults_outside_project() { + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(NoProjectLoader), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + // Default environment + default network outside project should error + let error = ctx + .get_agent( + &IdentitySelection::Anonymous, + &NetworkSelection::Default, + &EnvironmentSelection::Default, + ) + .await + .unwrap_err(); + + // Should fail with NoProjectOrNetwork error + assert!(matches!(error, GetAgentError::NoProjectOrNetwork)); +} + +#[tokio::test] +async fn test_get_agent_defaults_inside_project_with_default_local() { + let local_root_key = vec![1, 1, 1]; + + // Create a project with a "local" environment (the default environment name) + let local_network = Network { + name: LOCAL.to_string(), + configuration: Configuration::Managed { + managed: Managed { + mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { + gateway: Gateway { + bind: "127.0.0.1".to_string(), + port: Port::Fixed(8000), + domains: vec![], + }, + artificial_delay_ms: None, + ii: false, + nns: false, + subnets: None, + bitcoind_addr: None, + dogecoind_addr: None, + version: None, + })), + }, + }, + }; + + let mut networks = HashMap::new(); + networks.insert(LOCAL.to_string(), local_network.clone()); + + let local_env = Environment { + name: LOCAL.to_string(), + network: local_network, + canisters: IndexMap::new(), // No canisters needed for get_agent test + }; + + let mut environments = HashMap::new(); + environments.insert(LOCAL.to_string(), local_env); + + let project = Project { + dir: "/project".into(), + canisters: IndexMap::new(), // No canisters needed for get_agent test + networks, + environments, + member_missing_envs: std::collections::HashMap::new(), + }; + + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::new(project)), + network: Arc::new(MockNetworkAccessor::new().with_network( + LOCAL, + NetworkAccess { + root_key: local_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + )), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let agent = ctx + .get_agent( + &IdentitySelection::Anonymous, + &NetworkSelection::Default, + &EnvironmentSelection::Default, + ) + .await + .unwrap(); + + // Should successfully create agent using project's default environment + assert_eq!(agent.read_root_key(), local_root_key); +} + +#[tokio::test] +async fn test_get_agent_defaults_with_overridden_local_network() { + // Create a project where "local" network is overridden to use port 9000 + let custom_local_network = Network { + name: LOCAL.to_string(), + configuration: Configuration::Managed { + managed: Managed { + mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { + gateway: Gateway { + bind: "127.0.0.1".to_string(), + port: Port::Fixed(9000), + domains: vec![], + }, + artificial_delay_ms: None, + ii: false, + nns: false, + subnets: None, + bitcoind_addr: None, + dogecoind_addr: None, + version: None, + })), + }, + }, + }; + + let mut networks = HashMap::new(); + networks.insert(LOCAL.to_string(), custom_local_network.clone()); + + let local_env = Environment { + name: LOCAL.to_string(), + network: custom_local_network, + canisters: IndexMap::new(), // No canisters needed for get_agent test + }; + + let mut environments = HashMap::new(); + environments.insert(LOCAL.to_string(), local_env); + + let project = Project { + dir: "/project".into(), + canisters: IndexMap::new(), // No canisters needed for get_agent test + networks, + environments, + member_missing_envs: std::collections::HashMap::new(), + }; + + let custom_root_key = vec![1, 2, 3, 4]; + + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::new(project)), + network: Arc::new(MockNetworkAccessor::new().with_network( + LOCAL, + NetworkAccess { + root_key: custom_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse("http://localhost:9000").unwrap(), // Custom port + http_gateway_url: None, + use_friendly_domains: false, + }, + )), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let agent = ctx + .get_agent( + &IdentitySelection::Anonymous, + &NetworkSelection::Default, + &EnvironmentSelection::Default, + ) + .await + .unwrap(); + + // Should use the custom network configuration + assert_eq!(agent.read_root_key(), custom_root_key); +} + +#[tokio::test] +async fn test_get_agent_defaults_with_overridden_local_environment() { + // Create project where "local" environment uses a custom network + let default_local_network = Network { + name: LOCAL.to_string(), + configuration: Configuration::Managed { + managed: Managed { + mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { + gateway: Gateway { + bind: "127.0.0.1".to_string(), + port: Port::Fixed(8000), + domains: vec![], + }, + artificial_delay_ms: None, + ii: false, + nns: false, + subnets: None, + bitcoind_addr: None, + dogecoind_addr: None, + version: None, + })), + }, + }, + }; + + let custom_network = Network { + name: "custom".to_string(), + configuration: Configuration::Managed { + managed: Managed { + mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { + gateway: Gateway { + bind: "127.0.0.1".to_string(), + port: Port::Fixed(7000), + domains: vec![], + }, + artificial_delay_ms: None, + ii: false, + nns: false, + subnets: None, + bitcoind_addr: None, + dogecoind_addr: None, + version: None, + })), + }, + }, + }; + + let mut networks = HashMap::new(); + networks.insert(LOCAL.to_string(), default_local_network); + networks.insert("custom".to_string(), custom_network.clone()); + + // "local" environment uses "custom" network + let local_env = Environment { + name: LOCAL.to_string(), + network: custom_network, + canisters: IndexMap::new(), // No canisters needed for get_agent test + }; + + let mut environments = HashMap::new(); + environments.insert(LOCAL.to_string(), local_env); + + let project = Project { + dir: "/project".into(), + canisters: IndexMap::new(), // No canisters needed for get_agent test + networks, + environments, + member_missing_envs: std::collections::HashMap::new(), + }; + + let local_root_key = vec![1, 2, 3, 4]; + let custom_root_key = vec![5, 6, 7, 8]; + + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::new(project)), + network: Arc::new( + MockNetworkAccessor::new() + .with_network( + LOCAL, + NetworkAccess { + root_key: local_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ) + .with_network( + "custom", + NetworkAccess { + root_key: custom_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse("http://localhost:7000").unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ), + ), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let agent = ctx + .get_agent( + &IdentitySelection::Anonymous, + &NetworkSelection::Default, + &EnvironmentSelection::Default, + ) + .await + .unwrap(); + + // Should use the custom network from the overridden environment + assert_eq!(agent.read_root_key(), custom_root_key); +} + +#[tokio::test] +async fn test_get_agent_explicit_network_inside_project() { + let local_root_key = vec![2, 3, 4]; + let staging_root_key = vec![12, 13, 14]; + + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new( + MockNetworkAccessor::new() + .with_network( + LOCAL, + NetworkAccess { + root_key: local_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ) + .with_network( + "staging", + NetworkAccess { + root_key: staging_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse("http://localhost:8001").unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ), + ), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let agent = ctx + .get_agent( + &IdentitySelection::Anonymous, + &NetworkSelection::Named("staging".to_string()), + &EnvironmentSelection::Default, + ) + .await + .unwrap(); + + // Should use the explicitly specified network, regardless of project + assert_eq!(agent.read_root_key(), staging_root_key); +} + +#[tokio::test] +async fn test_get_agent_explicit_environment_inside_project() { + let local_root_key = vec![5, 6, 7]; + let staging_root_key = vec![15, 16, 17]; + + // complex() has "test" environment using "staging" network + let ctx = Context { + inner: icp::context::Context { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new( + MockNetworkAccessor::new() + .with_network( + LOCAL, + NetworkAccess { + root_key: local_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ) + .with_network( + "staging", + NetworkAccess { + root_key: staging_root_key.clone(), + root_key_source: icp::network::RootKeySource::Configured, + api_url: Url::parse("http://localhost:8001").unwrap(), + http_gateway_url: None, + use_friendly_domains: false, + }, + ), + ), + ..icp::context::Context::mocked() + }, + ..Context::mocked() + }; + + let agent = ctx + .get_agent( + &IdentitySelection::Anonymous, + &NetworkSelection::Default, + &EnvironmentSelection::Named("test".to_string()), + ) + .await + .unwrap(); + + // Should use the network from the "test" environment (which is "staging") + assert_eq!(agent.read_root_key(), staging_root_key); +} + +#[cfg(unix)] +mod cwd { + use std::sync::Mutex; + + use camino_tempfile::Utf8TempDir; + + use super::*; + + // Serializes tests that mutate $PWD, since cargo test runs tests in parallel. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + #[test] + fn stale_pwd_is_ignored() { + let _guard = ENV_MUTEX.lock().unwrap(); + + let stale = Utf8TempDir::new().unwrap(); + let real = PathBuf::try_from(std::env::current_dir().unwrap()).unwrap(); + + let old_pwd = std::env::var("PWD").ok(); + // SAFETY: ENV_MUTEX serializes all tests that mutate $PWD. + unsafe { std::env::set_var("PWD", stale.path()) }; + + let resolved = resolve_cwd().unwrap(); + + match old_pwd { + Some(v) => unsafe { std::env::set_var("PWD", v) }, + None => unsafe { std::env::remove_var("PWD") }, + } + + assert_eq!( + resolved, real, + "stale $PWD should be ignored in favour of getcwd()" + ); + } +} diff --git a/crates/icp/src/identity/delegation.rs b/crates/icp-cli/src/identity/delegation.rs similarity index 99% rename from crates/icp/src/identity/delegation.rs rename to crates/icp-cli/src/identity/delegation.rs index 9ac9e68f2..64833624d 100644 --- a/crates/icp/src/identity/delegation.rs +++ b/crates/icp-cli/src/identity/delegation.rs @@ -5,7 +5,7 @@ use ic_agent::export::Principal; use serde::{Deserialize, Serialize}; use snafu::{ResultExt, Snafu}; -use crate::{fs, prelude::*}; +use icp::{fs, prelude::*}; /// Matches the Candid `DelegationChain` record from the cli-backend canister. /// All byte fields are hex-encoded strings on the wire. diff --git a/crates/icp/src/identity/key.rs b/crates/icp-cli/src/identity/key.rs similarity index 98% rename from crates/icp/src/identity/key.rs rename to crates/icp-cli/src/identity/key.rs index 3c28075e0..83597c040 100644 --- a/crates/icp/src/identity/key.rs +++ b/crates/icp-cli/src/identity/key.rs @@ -27,23 +27,24 @@ use tracing::{debug, warn}; use url::Url; use zeroize::Zeroizing; -use crate::{ +use icp::{ context::IC_ROOT_KEY, fs::{ self, lock::{LRead, LWrite}, }, - identity::{ - IdentityPaths, PasswordFunc, - delegation::{self, SignedDelegation}, - manifest::{ - DelegationKeyStorage, IdentityDefaults, IdentityKeyAlgorithm, IdentityList, - IdentitySpec, LoadIdentityManifestError, PemFormat, WriteIdentityManifestError, - }, - }, prelude::*, }; +use crate::identity::{ + IdentityPaths, PasswordFunc, + delegation::{self, SignedDelegation}, + manifest::{ + DelegationKeyStorage, IdentityDefaults, IdentityKeyAlgorithm, IdentityList, IdentitySpec, + LoadIdentityManifestError, PemFormat, WriteIdentityManifestError, + }, +}; + #[derive(Debug, Clone)] pub enum IdentityKey { Secp256k1(k256::SecretKey), @@ -67,7 +68,7 @@ pub enum ExportFormat { #[derive(Debug, Snafu)] pub enum LoadIdentityError { #[snafu(transparent)] - ReadFileError { source: crate::fs::IoError }, + ReadFileError { source: icp::fs::IoError }, #[snafu(display("failed to load PEM from `{origin}`: failed to parse"))] ParsePemError { @@ -100,7 +101,7 @@ pub enum LoadIdentityError { GetPasswordError { message: String }, #[snafu(transparent)] - LockError { source: crate::fs::lock::LockError }, + LockError { source: icp::fs::lock::LockError }, #[snafu(display("failed to load keyring entry"))] LoadEntryError { source: keyring::Error }, @@ -404,7 +405,7 @@ fn try_load_pem_session(dirs: LRead<&IdentityPaths>, name: &str) -> Option, - password_func: PasswordFunc, - pem_session_duration: Option, -) -> Result, LoadIdentityInContextError> { - let identity = load_identity( - dirs, - &IdentityList::load_from(dirs.read())?, - &(IdentityDefaults::load_from(dirs.read())?).default, - password_func, - None, - pem_session_duration, - )?; - - Ok(identity) -} - pub const MIN_IDENTITY_PASSWORD_LEN: usize = 8; pub fn validate_password(password: &str) -> Result<(), String> { @@ -916,7 +900,7 @@ pub enum CreateIdentityError { CreateIdentityDelegationExpired, #[snafu(display("failed to create delegation directory"))] - CreateIdentityDelegationDir { source: crate::fs::IoError }, + CreateIdentityDelegationDir { source: icp::fs::IoError }, #[snafu(display("failed to save delegation chain to `{path}`"))] CreateIdentitySaveDelegation { @@ -1112,13 +1096,10 @@ pub fn create_identity( #[derive(Debug, Snafu)] pub enum WriteIdentityError { #[snafu(display("failed to write file"))] - WriteFileError { source: crate::fs::IoError }, - - #[snafu(display("failed to create directory"))] - CreateDirectoryError { source: crate::fs::IoError }, + WriteFileError { source: icp::fs::IoError }, #[snafu(transparent)] - LockError { source: crate::fs::lock::LockError }, + LockError { source: icp::fs::lock::LockError }, #[snafu(display("failed to create keyring entry"))] CreateEntryError { source: keyring::Error }, @@ -1639,11 +1620,11 @@ pub enum CreatePendingDelegationError { #[snafu(display("failed to write session key PEM file for `{name}`"))] DlgWritePemFile { name: String, - source: crate::fs::IoError, + source: icp::fs::IoError, }, #[snafu(display("failed to create delegation directory"))] - DlgCreateDelegationDir { source: crate::fs::IoError }, + DlgCreateDelegationDir { source: icp::fs::IoError }, #[snafu(display("failed to save delegation chain to `{path}`"))] DlgSaveDelegation { @@ -1820,7 +1801,7 @@ pub enum UpdateWebAuthDelegationError { }, #[snafu(display("failed to create delegation directory"))] - UpdateWebAuthCreateDir { source: crate::fs::IoError }, + UpdateWebAuthCreateDir { source: icp::fs::IoError }, } /// Updates the delegation chain for an existing web-based identity. @@ -1944,7 +1925,7 @@ pub enum CompleteDelegationError { DecodeDelegationChainKey { source: hex::FromHexError }, #[snafu(display("failed to create delegation directory"))] - CreateDelegationChainDir { source: crate::fs::IoError }, + CreateDelegationChainDir { source: icp::fs::IoError }, #[snafu(display("failed to save delegation chain to `{path}`"))] SaveDelegationChain { diff --git a/crates/icp/src/identity/keyring_mock.rs b/crates/icp-cli/src/identity/keyring_mock.rs similarity index 99% rename from crates/icp/src/identity/keyring_mock.rs rename to crates/icp-cli/src/identity/keyring_mock.rs index ed20de944..b8b422853 100644 --- a/crates/icp/src/identity/keyring_mock.rs +++ b/crates/icp-cli/src/identity/keyring_mock.rs @@ -5,7 +5,7 @@ use keyring::{ credential::{CredentialApi, CredentialBuilderApi, CredentialPersistence}, }; -use crate::prelude::*; +use icp::prelude::*; pub struct MockKeyring { pub dir: PathBuf, diff --git a/crates/icp/src/identity/manifest.rs b/crates/icp-cli/src/identity/manifest.rs similarity index 88% rename from crates/icp/src/identity/manifest.rs rename to crates/icp-cli/src/identity/manifest.rs index bb6f6f2a5..ae503662b 100644 --- a/crates/icp/src/identity/manifest.rs +++ b/crates/icp-cli/src/identity/manifest.rs @@ -6,15 +6,17 @@ use snafu::{Snafu, ensure}; use strum::{Display, EnumString}; use url::Url; -use crate::{ +use icp::{ fs::{ json, lock::{LRead, LWrite}, }, - identity::IdentityPaths, prelude::*, + telemetry_data::IdentityStorageType, }; +use crate::identity::IdentityPaths; + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] pub struct IdentityDefaults { @@ -177,6 +179,21 @@ impl IdentitySpec { } } +/// How this identity is reported in telemetry. +impl From<&IdentitySpec> for IdentityStorageType { + fn from(spec: &IdentitySpec) -> Self { + match spec { + IdentitySpec::Pem { .. } => Self::Pem, + IdentitySpec::Keyring { .. } => Self::Keyring, + IdentitySpec::Hsm { .. } => Self::Hsm, + IdentitySpec::Anonymous => Self::Anonymous, + IdentitySpec::WebAuth { .. } => Self::InternetIdentity, + IdentitySpec::PendingDelegation { .. } => Self::PendingDelegation, + IdentitySpec::Delegation { .. } => Self::Delegation, + } + } +} + #[derive(Copy, Clone, Eq, PartialEq, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum PemFormat { @@ -191,16 +208,15 @@ pub enum DelegationKeyStorage { Pem { format: PemFormat }, } -#[derive(Deserialize, Serialize, Clone, Debug, EnumString, Display)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[derive(Deserialize, Serialize, Clone, Debug, EnumString, Display, clap::ValueEnum)] pub enum IdentityKeyAlgorithm { #[serde(rename = "secp256k1", alias = "k256")] #[strum(serialize = "secp256k1", serialize = "k256")] - #[cfg_attr(feature = "clap", value(alias = "k256"))] + #[value(alias = "k256")] Secp256k1, #[serde(rename = "prime256v1", alias = "p256", alias = "secp256r1")] #[strum(serialize = "prime256v1", serialize = "p256", serialize = "secp256r1")] - #[cfg_attr(feature = "clap", value(alias = "p256", alias = "secp256r1"))] + #[value(alias = "p256", alias = "secp256r1")] Prime256v1, #[serde(rename = "ed25519")] #[strum(serialize = "ed25519")] @@ -213,10 +229,10 @@ pub enum WriteIdentityManifestError { WriteJsonError { source: json::Error }, #[snafu(transparent)] - CreateDirectoryError { source: crate::fs::IoError }, + CreateDirectoryError { source: icp::fs::IoError }, #[snafu(transparent)] - DirectoryLockError { source: crate::fs::lock::LockError }, + DirectoryLockError { source: icp::fs::lock::LockError }, } #[derive(Debug, Snafu)] @@ -228,7 +244,7 @@ pub enum LoadIdentityManifestError { BadVersion { path: PathBuf }, #[snafu(transparent)] - DirectoryLockError { source: crate::fs::lock::LockError }, + DirectoryLockError { source: icp::fs::lock::LockError }, } #[derive(Debug, Snafu)] diff --git a/crates/icp/src/identity/mod.rs b/crates/icp-cli/src/identity/mod.rs similarity index 91% rename from crates/icp/src/identity/mod.rs rename to crates/icp-cli/src/identity/mod.rs index 5f4cd21ae..812e26f03 100644 --- a/crates/icp/src/identity/mod.rs +++ b/crates/icp-cli/src/identity/mod.rs @@ -9,16 +9,17 @@ use snafu::prelude::*; use std::collections::HashMap; -use crate::{ +use icp::{ fs::lock::{DirectoryStructureLock, LockError, PathsAccess}, - identity::{ - key::{LoadIdentityError, LoadIdentityInContextError, load_identity}, - manifest::{IdentityList, LoadIdentityManifestError}, - }, prelude::*, telemetry_data::{IdentityStorageType, TelemetryData}, }; +use crate::identity::{ + key::{LoadIdentityError, LoadIdentityInContextError, load_identity}, + manifest::{IdentityList, LoadIdentityManifestError}, +}; + pub mod delegation; pub mod key; pub mod keyring_mock; @@ -44,26 +45,16 @@ impl IdentityPaths { self.dir.join(IDENTITY_DEFAULTS) } - pub fn ensure_identity_defaults_path(&self) -> Result { - crate::fs::create_dir_all(&self.dir)?; - Ok(self.dir.join(IDENTITY_DEFAULTS)) - } - pub fn identity_list_path(&self) -> PathBuf { self.dir.join(IDENTITIES_LIST) } - pub fn ensure_identity_list_path(&self) -> Result { - crate::fs::create_dir_all(&self.dir)?; - Ok(self.dir.join(IDENTITIES_LIST)) - } - pub fn key_pem_path(&self, name: &str) -> PathBuf { self.dir.join(format!("keys/{name}.pem")) } - pub fn ensure_key_pem_path(&self, name: &str) -> Result { - crate::fs::create_dir_all(&self.dir.join("keys"))?; + pub fn ensure_key_pem_path(&self, name: &str) -> Result { + icp::fs::create_dir_all(&self.dir.join("keys"))?; Ok(self.dir.join(format!("keys/{name}.pem"))) } @@ -71,8 +62,8 @@ impl IdentityPaths { self.dir.join(format!("delegations/{name}.json")) } - pub fn ensure_delegation_chain_path(&self, name: &str) -> Result { - crate::fs::create_dir_all(&self.dir.join("delegations"))?; + pub fn ensure_delegation_chain_path(&self, name: &str) -> Result { + icp::fs::create_dir_all(&self.dir.join("delegations"))?; Ok(self.dir.join(format!("delegations/{name}.json"))) } } @@ -266,12 +257,6 @@ impl MockIdentityLoader { self.named.insert(name.into(), identity); self } - - /// Sets the default identity. - pub fn with_default(mut self, identity: Arc) -> Self { - self.default = identity; - self - } } #[cfg(test)] diff --git a/crates/icp/src/identity/seed/mod.rs b/crates/icp-cli/src/identity/seed/mod.rs similarity index 100% rename from crates/icp/src/identity/seed/mod.rs rename to crates/icp-cli/src/identity/seed/mod.rs diff --git a/crates/icp/src/identity/seed/slip10.rs b/crates/icp-cli/src/identity/seed/slip10.rs similarity index 100% rename from crates/icp/src/identity/seed/slip10.rs rename to crates/icp-cli/src/identity/seed/slip10.rs diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 08372d3db..5e34d5698 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -19,6 +19,7 @@ mod complete; mod context; mod dist; mod events; +mod identity; mod logging; mod manifest; pub(crate) mod operations; @@ -165,7 +166,7 @@ async fn run() -> Result<(), Error> { "Starting icp-cli" ); - let password_func: icp::identity::PasswordFunc = match cli.identity_password_file { + let password_func: identity::PasswordFunc = match cli.identity_password_file { Some(path) => Arc::new(move || { icp::fs::read_to_string(&path) .map(|s| s.trim().to_string()) diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index dfe70e416..26bd8c725 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -1,8 +1,8 @@ +use crate::identity::IdentitySelection; use clap::error::ErrorKind; use clap::{ArgGroup, ArgMatches, Args, FromArgMatches}; use clap_complete::ArgValueCandidates; use icp::context::{EnvironmentSelection, NetworkSelection}; -use icp::identity::IdentitySelection; use icp::network::RootKeySpec; use icp::prelude::LOCAL; use url::Url; diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 51c357840..222ac6b86 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -9,28 +9,22 @@ publish.workspace = true async-dropper = { workspace = true } async-trait = { workspace = true } bigdecimal = { workspace = true } -bip32 = { workspace = true } bollard = { workspace = true } camino = { workspace = true } camino-tempfile = { workspace = true } candid = { workspace = true } -crypto-bigint = { workspace = true } candid_parser = { workspace = true } clap = { workspace = true, optional = true } directories = { workspace = true } dunce = { workspace = true } ed25519-consensus = { workspace = true } -elliptic-curve = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } glob = { workspace = true } handlebars = { workspace = true } hex = { workspace = true } -hmac = { workspace = true } hybrid-array = { workspace = true } ic-agent = { workspace = true } -ic-ed25519 = { workspace = true } -ic-identity-hsm = { workspace = true } ic-ledger-types = { workspace = true } ic-management-canister-types = { workspace = true } ic-utils = { workspace = true } @@ -40,22 +34,14 @@ icrc-ledger-types = { workspace = true } indexmap = { workspace = true } indoc = { workspace = true } itertools = { workspace = true } -k256 = { workspace = true } -keyring = { workspace = true } notify = { workspace = true } num-bigint = { workspace = true } num-integer = { workspace = true } num-traits = { workspace = true } -p256 = { workspace = true } pathdiff = { workspace = true } -pem = { workspace = true } -pkcs8 = { workspace = true } -rand = { workspace = true } reqwest = { workspace = true } schemars = { workspace = true } -scrypt = { workspace = true } semver = { workspace = true } -sec1 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } @@ -66,7 +52,6 @@ strum = { workspace = true } sysinfo = { workspace = true } tar = { workspace = true } time = { workspace = true } -tiny-bip39 = { workspace = true } # `io-std` is listed for `tokio::io::stdout`/`stderr`; feature unification with other # crates supplies it anyway, so dropping it would not fail the build. `rt-multi-thread` # is needed by `block_in_place` in `canister::sync::plugin` and arrives via the @@ -76,7 +61,11 @@ tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } wslpath2 = { workspace = true } -zeroize = { workspace = true } + +[features] +# Exposes the in-crate port mocks (`Context::mocked`, `MockProjectLoader`, ...) +# so downstream crates can test against the library's ports. +mocks = [] [target.'cfg(windows)'.dependencies] winreg = { workspace = true } diff --git a/crates/icp/src/canister/build/mod.rs b/crates/icp/src/canister/build/mod.rs index d630d9ee4..be2e91db9 100644 --- a/crates/icp/src/canister/build/mod.rs +++ b/crates/icp/src/canister/build/mod.rs @@ -55,12 +55,12 @@ impl Build for Builder { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] /// Unimplemented mock implementation of `Build`. /// All methods panic with `unimplemented!()` when called. pub struct UnimplementedMockBuilder; -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] #[async_trait] impl Build for UnimplementedMockBuilder { async fn build( diff --git a/crates/icp/src/canister/sync/mod.rs b/crates/icp/src/canister/sync/mod.rs index a09b827b9..a0ba2c1a7 100644 --- a/crates/icp/src/canister/sync/mod.rs +++ b/crates/icp/src/canister/sync/mod.rs @@ -76,12 +76,12 @@ impl Synchronize for Syncer { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] /// Unimplemented mock implementation of `Synchronize`. /// All methods panic with `unimplemented!()` when called. pub struct UnimplementedMockSyncer; -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] #[async_trait] impl Synchronize for UnimplementedMockSyncer { async fn sync( diff --git a/crates/icp/src/context/init.rs b/crates/icp/src/context/init.rs index 283e59a56..1ed0fc032 100644 --- a/crates/icp/src/context/init.rs +++ b/crates/icp/src/context/init.rs @@ -1,22 +1,11 @@ -use std::{sync::Arc, time::Duration}; - -use snafu::prelude::*; +use std::sync::Arc; use crate::canister::build::Builder; use crate::canister::sync::Syncer; use crate::context::Context; use crate::store_artifact::ArtifactStore; -use crate::{ - ProjectLoad, agent, identity, identity::PasswordFunc, manifest::ProjectRootLocate, network, - store_id, -}; - -#[derive(Debug, Snafu)] -pub enum ContextInitError { - #[snafu(display("failed to lock identity directory"))] - IdentityDirectory { source: crate::fs::lock::LockError }, -} +use crate::{ProjectLoad, agent, manifest::ProjectRootLocate, network, store_id}; /// Assembles the library context from the ports the host provides: where its /// data lives, how to find a project, and how to load one. @@ -24,9 +13,7 @@ pub fn initialize( dirs: Arc, project_root_locate: Arc, project: Arc, - password_func: PasswordFunc, - pem_session_duration: Option, -) -> Result { +) -> Context { // Canister ID Store let ids = Arc::new(store_id::AccessImpl::new(project_root_locate.clone())); @@ -42,22 +29,6 @@ pub fn initialize( // Telemetry data bag (written by subsystems, read at session finish) let telemetry_data = Arc::new(crate::telemetry_data::TelemetryData::default()); - // Identity loader - let idload = Arc::new(identity::Loader::new( - dirs.identity().context(IdentityDirectorySnafu)?, - password_func.clone(), - pem_session_duration, - telemetry_data.clone(), - )); - - if let Ok(mockdir) = std::env::var("ICP_CLI_KEYRING_MOCK_DIR") { - keyring::set_default_credential_builder(Box::new( - crate::identity::keyring_mock::MockKeyring { - dir: crate::prelude::PathBuf::from(mockdir), - }, - )); - } - // Agent creator let agent_creator = Arc::new(agent::Creator); @@ -68,18 +39,15 @@ pub fn initialize( agent: agent_creator.clone(), }); - // Setup environment - Ok(Context { + Context { dirs, ids, artifacts, project, - identity: idload, network: netaccess, agent: agent_creator, builder, syncer, telemetry_data, - password_func, - }) + } } diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index 5b5c044eb..e03f99bbc 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -6,7 +6,6 @@ use crate::{ agent::CreateAgentError, canister::{build::Build, sync::Synchronize}, directories, - identity::IdentitySelection, manifest::network::RootKeySpec, network::{Configuration as NetworkConfiguration, access::NetworkAccess}, prelude::*, @@ -19,7 +18,7 @@ use snafu::{OptionExt, ResultExt, Snafu}; mod init; -pub use init::{ContextInitError, initialize}; +pub use init::initialize; pub const IC_ROOT_KEY: &[u8; 133] = b"\x30\x81\x82\x30\x1d\x06\x0d\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x01\x02\x01\x06\x0c\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x02\x01\x03\x61\x00\x81\x4c\x0e\x6e\xc7\x1f\xab\x58\x3b\x08\xbd\x81\x37\x3c\x25\x5c\x3c\x37\x1b\x2e\x84\x86\x3c\x98\xa4\xf1\xe0\x8b\x74\x23\x5d\x14\xfb\x5d\x9c\x0c\xd5\x46\xd9\x68\x5f\x91\x3a\x0c\x0b\x2c\xc5\x34\x15\x83\xbf\x4b\x43\x92\xe4\x67\xdb\x96\xd6\x5b\x9b\xb4\xcb\x71\x71\x12\xf8\x47\x2e\x0d\x5a\x4d\x14\x50\x5f\xfd\x74\x84\xb0\x12\x91\x09\x1c\x5f\x87\xb9\x88\x83\x46\x3f\x98\x09\x1a\x0b\xaa\xae"; @@ -84,14 +83,11 @@ pub struct Context { /// Project loader pub project: Arc, - /// Identity loader - identity: Arc, - /// NetworkAccess loader pub network: Arc, /// Agent creator - agent: Arc, + pub agent: Arc, /// Canister builder pub builder: Arc, @@ -101,27 +97,9 @@ pub struct Context { /// Telemetry data collected during command execution pub telemetry_data: Arc, - - /// Password reader for identity decryption; shared with the identity loader. - pub password_func: Arc Result + Send + Sync>, } impl Context { - /// Gets an identity based on the provided identity selection. - // TODO: refactor the whole codebase to use this method instead of directly accessing `ctx.identity.load()` - pub async fn get_identity( - &self, - identity: &IdentitySelection, - network_root_key: Option>, - ) -> Result, GetIdentityError> { - self.identity - .load(identity.clone(), network_root_key) - .await - .context(IdentityLoadSnafu { - identity: identity.clone(), - }) - } - /// Gets an environment by name from the currently loaded project. /// /// # Errors @@ -372,38 +350,11 @@ impl Context { Ok(()) } - /// Creates an agent for a given identity and environment. - pub async fn get_agent_for_env( - &self, - identity: &IdentitySelection, - environment: &EnvironmentSelection, - ) -> Result { - let env = self.get_environment(environment).await?; - let access = self.network.access(&env.network).await?; - let id = self - .get_identity(identity, Some(access.root_key.clone())) - .await?; - Ok(self.create_agent(id, access).await?) - } - - /// Creates an agent for a given identity and network. - pub async fn get_agent_for_network( - &self, - identity: &IdentitySelection, - network_selection: &NetworkSelection, - ) -> Result { - let network = self.get_network(network_selection).await?; - let access = self.network.access(&network).await?; - let id = self - .get_identity(identity, Some(access.root_key.clone())) - .await?; - Ok(self.create_agent(id, access).await?) - } - - /// Private helper to create an agent given identity and network access. + /// Creates an agent for an identity, bound to an already resolved network. /// - /// Used by [`Self::get_agent_for_env`] and [`Self::get_agent_for_network`]. - async fn create_agent( + /// The caller loads the identity: which identity to use, and how to unlock + /// it, is the frontend's business. + pub async fn create_agent( &self, id: Arc, network_access: NetworkAccess, @@ -416,58 +367,6 @@ impl Context { Ok(agent) } - /// Creates an agent for a given identity and url. - pub async fn get_agent_for_url( - &self, - identity: &IdentitySelection, - url: &Url, - ) -> Result { - let id = self.get_identity(identity, None).await?; - let agent = self.agent.create(id, url.as_str()).await?; - Ok(agent) - } - - pub async fn get_agent( - &self, - identity: &IdentitySelection, - network: &NetworkSelection, - environment: &EnvironmentSelection, - ) -> Result { - match (environment, network) { - // Error: Both environment and network specified - (EnvironmentSelection::Named(_), NetworkSelection::Named(_)) - | (EnvironmentSelection::Named(_), NetworkSelection::Url(_, _)) => { - Err(GetAgentError::EnvironmentAndNetworkSpecified) - } - - // Default environment + default network - (EnvironmentSelection::Default, NetworkSelection::Default) => { - // Try to get agent from the default environment if project exists - match self.get_agent_for_env(identity, environment).await { - Ok(agent) => Ok(agent), - Err(GetAgentForEnvError::GetEnvironment { - source: - GetEnvironmentError::ProjectLoad { - source: crate::ProjectLoadError::Locate { .. }, - }, - }) => Err(GetAgentError::NoProjectOrNetwork), - Err(e) => Err(e.into()), - } - } - - // Environment specified - (EnvironmentSelection::Named(_), NetworkSelection::Default) => { - Ok(self.get_agent_for_env(identity, environment).await?) - } - - // Network specified - (EnvironmentSelection::Default, NetworkSelection::Named(_)) - | (EnvironmentSelection::Default, NetworkSelection::Url(_, _)) => { - Ok(self.get_agent_for_network(identity, network).await?) - } - } - } - pub async fn get_canister_id( &self, canister: &CanisterSelection, @@ -595,7 +494,7 @@ impl Context { } } - #[cfg(test)] + #[cfg(any(test, feature = "mocks"))] /// Creates a test context with all mocks pub fn mocked() -> Context { Context { @@ -603,26 +502,15 @@ impl Context { ids: Arc::new(crate::store_id::mock::MockInMemoryIdStore::new()), artifacts: Arc::new(crate::store_artifact::MockInMemoryArtifactStore::new()), project: Arc::new(crate::MockProjectLoader::minimal()), - identity: Arc::new(crate::identity::MockIdentityLoader::anonymous()), network: Arc::new(crate::network::MockNetworkAccessor::new()), agent: Arc::new(crate::agent::Creator), builder: Arc::new(crate::canister::build::UnimplementedMockBuilder), syncer: Arc::new(crate::canister::sync::UnimplementedMockSyncer), telemetry_data: Arc::new(crate::telemetry_data::TelemetryData::default()), - password_func: Arc::new(|| Err("no password available in mock context".to_string())), } } } -#[derive(Debug, Snafu)] -pub enum GetIdentityError { - #[snafu(display("failed to load identity"))] - IdentityLoad { - source: crate::identity::LoadError, - identity: IdentitySelection, - }, -} - #[derive(Debug, Snafu)] pub enum GetEnvironmentError { #[snafu(transparent)] @@ -735,74 +623,6 @@ pub enum RemoveCanisterIdForEnvError { }, } -#[derive(Debug, Snafu)] -pub enum GetAgentForEnvError { - #[snafu(transparent)] - GetIdentity { source: GetIdentityError }, - - #[snafu(transparent)] - GetEnvironment { source: GetEnvironmentError }, - - #[snafu(transparent)] - NetworkAccess { source: crate::network::AccessError }, - - #[snafu(transparent)] - AgentCreate { - source: crate::agent::CreateAgentError, - }, -} - -#[derive(Debug, Snafu)] -pub enum GetAgentForNetworkError { - #[snafu(transparent)] - GetIdentity { source: GetIdentityError }, - - #[snafu(transparent)] - GetNetwork { source: GetNetworkError }, - - #[snafu(transparent)] - NetworkAccess { source: crate::network::AccessError }, - - #[snafu(transparent)] - AgentCreate { - source: crate::agent::CreateAgentError, - }, -} - -#[derive(Debug, Snafu)] -pub enum GetAgentForUrlError { - #[snafu(transparent)] - GetIdentity { source: GetIdentityError }, - - #[snafu(transparent)] - AgentCreate { - source: crate::agent::CreateAgentError, - }, -} - -#[derive(Debug, Snafu)] -pub enum GetAgentError { - #[snafu(transparent)] - ProjectExists { source: crate::ProjectLoadError }, - - #[snafu(display("You can't specify both an environment and a network"))] - EnvironmentAndNetworkSpecified, - - #[snafu(display( - "No project found and no network specified. Either run this command inside a project or specify a network with --network" - ))] - NoProjectOrNetwork, - - #[snafu(transparent)] - GetAgentForEnv { source: GetAgentForEnvError }, - - #[snafu(transparent)] - GetAgentForNetwork { source: GetAgentForNetworkError }, - - #[snafu(transparent)] - GetAgentForUrl { source: GetAgentForUrlError }, -} - #[derive(Debug, Snafu)] pub enum GetCanisterIdError { #[snafu(display("You can't specify both an environment and a network"))] diff --git a/crates/icp/src/context/tests.rs b/crates/icp/src/context/tests.rs index 4c25ea726..7ea2df57c 100644 --- a/crates/icp/src/context/tests.rs +++ b/crates/icp/src/context/tests.rs @@ -1,71 +1,9 @@ use super::*; use crate::{ - Environment, MockProjectLoader, Network, Project, - identity::MockIdentityLoader, - network::{ - Configuration, Gateway, Managed, ManagedLauncherConfig, ManagedMode, MockNetworkAccessor, - Port, access::NetworkAccess, - }, + MockProjectLoader, store_id::{Access as IdAccess, mock::MockInMemoryIdStore}, }; use candid::Principal; -use indexmap::IndexMap; -use std::collections::HashMap; - -const DEFAULT_LOCAL_NETWORK_URL: &str = "http://localhost:8000"; - -#[tokio::test] -async fn test_get_identity_default() { - let ctx = Context::mocked(); - - let result = ctx.get_identity(&IdentitySelection::Default, None).await; - - assert!(result.is_ok()); -} - -#[tokio::test] -async fn test_get_identity_anonymous() { - let ctx = Context::mocked(); - - let result = ctx.get_identity(&IdentitySelection::Anonymous, None).await; - - assert!(result.is_ok()); -} - -#[tokio::test] -async fn test_get_identity_named() { - let alice_identity: Arc = Arc::new(ic_agent::identity::AnonymousIdentity); - - let ctx = Context { - identity: Arc::new( - MockIdentityLoader::anonymous().with_identity("alice", Arc::clone(&alice_identity)), - ), - ..Context::mocked() - }; - - let result = ctx - .get_identity(&IdentitySelection::Named("alice".to_string()), None) - .await; - - assert!(result.is_ok()); -} - -#[tokio::test] -async fn test_get_identity_named_not_found() { - let ctx = Context::mocked(); - - let result = ctx - .get_identity(&IdentitySelection::Named("nonexistent".to_string()), None) - .await; - - assert!(matches!( - result, - Err(GetIdentityError::IdentityLoad { - identity: IdentitySelection::Named(_), - source: crate::identity::LoadError::LoadIdentity { .. } - }) - )); -} #[tokio::test] async fn test_get_environment_success() { @@ -336,182 +274,6 @@ async fn test_remove_canister_id_for_env_nonexistent_canister() { assert!(result.is_ok()); } -#[tokio::test] -async fn test_get_agent_for_env_uses_environment_network() { - let local_root_key = vec![1, 2, 3]; - let staging_root_key = vec![4, 5, 6]; - - // Complex project has "test" environment which uses "staging" network - let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new( - MockNetworkAccessor::new() - .with_network( - "local", - NetworkAccess { - root_key: local_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse("http://localhost:8000").unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ) - .with_network( - "staging", - NetworkAccess { - root_key: staging_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse("http://staging:9000").unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ), - ), - ..Context::mocked() - }; - - let agent = ctx - .get_agent_for_env( - &IdentitySelection::Anonymous, - &EnvironmentSelection::Named("test".to_string()), - ) - .await - .unwrap(); - - assert_eq!(agent.read_root_key(), staging_root_key); -} - -#[tokio::test] -async fn test_get_agent_for_env_environment_not_found() { - let ctx = Context::mocked(); - - let result = ctx - .get_agent_for_env( - &IdentitySelection::Anonymous, - &EnvironmentSelection::Named("nonexistent".to_string()), - ) - .await; - - assert!(matches!( - result, - Err(GetAgentForEnvError::GetEnvironment { - source: GetEnvironmentError::EnvironmentNotFound { .. } - }) - )); -} - -#[tokio::test] -async fn test_get_agent_for_env_network_not_configured() { - // Environment "dev" exists in project and uses "local" network, - // but "local" network is not configured in MockNetworkAccessor - let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - // MockNetworkAccessor has no networks configured - ..Context::mocked() - }; - - let result = ctx - .get_agent_for_env( - &IdentitySelection::Anonymous, - &EnvironmentSelection::Named("dev".to_string()), - ) - .await; - - assert!(matches!( - result, - Err(GetAgentForEnvError::NetworkAccess { - source: crate::network::AccessError::GetNetworkAccess { .. } - }) - )); -} - -#[tokio::test] -async fn test_get_agent_for_network_success() { - let root_key = vec![1, 2, 3]; - - let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new(MockNetworkAccessor::new().with_network( - "local", - NetworkAccess { - root_key: root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse("http://localhost:8000").unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - )), - ..Context::mocked() - }; - - let agent = ctx - .get_agent_for_network( - &IdentitySelection::Anonymous, - &NetworkSelection::Named("local".to_string()), - ) - .await - .unwrap(); - - assert_eq!(agent.read_root_key(), root_key); -} - -#[tokio::test] -async fn test_get_agent_for_network_network_not_found() { - let ctx = Context::mocked(); - - let result = ctx - .get_agent_for_network( - &IdentitySelection::Anonymous, - &NetworkSelection::Named("nonexistent".to_string()), - ) - .await; - - assert!(matches!( - result, - Err(GetAgentForNetworkError::GetNetwork { - source: GetNetworkError::NetworkNotFound { .. } - }) - )); -} - -#[tokio::test] -async fn test_get_agent_for_network_not_configured() { - // Network "local" exists in project but is not configured in MockNetworkAccessor - let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - // MockNetworkAccessor has no networks configured - ..Context::mocked() - }; - - let result = ctx - .get_agent_for_network( - &IdentitySelection::Anonymous, - &NetworkSelection::Named("local".to_string()), - ) - .await; - - assert!(matches!( - result, - Err(GetAgentForNetworkError::NetworkAccess { - source: crate::network::AccessError::GetNetworkAccess { .. } - }) - )); -} - -#[tokio::test] -async fn test_get_agent_for_url_success() { - let ctx = Context::mocked(); - - let result = ctx - .get_agent_for_url( - &IdentitySelection::Anonymous, - &Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), - ) - .await; - - assert!(result.is_ok()); -} - #[tokio::test] async fn test_get_canister_id_for_env() { let ids_store = Arc::new(MockInMemoryIdStore::new()); @@ -576,380 +338,3 @@ async fn test_ids_by_environment() { assert_eq!(result.get("backend"), Some(&backend_id)); assert_eq!(result.get("frontend"), Some(&frontend_id)); } - -#[tokio::test] -async fn test_get_agent_defaults_outside_project() { - let ctx = Context { - project: Arc::new(crate::NoProjectLoader), - ..Context::mocked() - }; - - // Default environment + default network outside project should error - let error = ctx - .get_agent( - &IdentitySelection::Anonymous, - &NetworkSelection::Default, - &EnvironmentSelection::Default, - ) - .await - .unwrap_err(); - - // Should fail with NoProjectOrNetwork error - assert!(matches!(error, GetAgentError::NoProjectOrNetwork)); -} - -#[tokio::test] -async fn test_get_agent_defaults_inside_project_with_default_local() { - let local_root_key = vec![1, 1, 1]; - - // Create a project with a "local" environment (the default environment name) - let local_network = Network { - name: LOCAL.to_string(), - configuration: Configuration::Managed { - managed: Managed { - mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { - gateway: Gateway { - bind: "127.0.0.1".to_string(), - port: Port::Fixed(8000), - domains: vec![], - }, - artificial_delay_ms: None, - ii: false, - nns: false, - subnets: None, - bitcoind_addr: None, - dogecoind_addr: None, - version: None, - })), - }, - }, - }; - - let mut networks = HashMap::new(); - networks.insert(LOCAL.to_string(), local_network.clone()); - - let local_env = Environment { - name: LOCAL.to_string(), - network: local_network, - canisters: IndexMap::new(), // No canisters needed for get_agent test - }; - - let mut environments = HashMap::new(); - environments.insert(LOCAL.to_string(), local_env); - - let project = Project { - dir: "/project".into(), - canisters: IndexMap::new(), // No canisters needed for get_agent test - networks, - environments, - member_missing_envs: std::collections::HashMap::new(), - }; - - let ctx = Context { - project: Arc::new(crate::MockProjectLoader::new(project)), - network: Arc::new(MockNetworkAccessor::new().with_network( - LOCAL, - NetworkAccess { - root_key: local_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - )), - ..Context::mocked() - }; - - let agent = ctx - .get_agent( - &IdentitySelection::Anonymous, - &NetworkSelection::Default, - &EnvironmentSelection::Default, - ) - .await - .unwrap(); - - // Should successfully create agent using project's default environment - assert_eq!(agent.read_root_key(), local_root_key); -} - -#[tokio::test] -async fn test_get_agent_defaults_with_overridden_local_network() { - // Create a project where "local" network is overridden to use port 9000 - let custom_local_network = Network { - name: LOCAL.to_string(), - configuration: Configuration::Managed { - managed: Managed { - mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { - gateway: Gateway { - bind: "127.0.0.1".to_string(), - port: Port::Fixed(9000), - domains: vec![], - }, - artificial_delay_ms: None, - ii: false, - nns: false, - subnets: None, - bitcoind_addr: None, - dogecoind_addr: None, - version: None, - })), - }, - }, - }; - - let mut networks = HashMap::new(); - networks.insert(LOCAL.to_string(), custom_local_network.clone()); - - let local_env = Environment { - name: LOCAL.to_string(), - network: custom_local_network, - canisters: IndexMap::new(), // No canisters needed for get_agent test - }; - - let mut environments = HashMap::new(); - environments.insert(LOCAL.to_string(), local_env); - - let project = Project { - dir: "/project".into(), - canisters: IndexMap::new(), // No canisters needed for get_agent test - networks, - environments, - member_missing_envs: std::collections::HashMap::new(), - }; - - let custom_root_key = vec![1, 2, 3, 4]; - - let ctx = Context { - project: Arc::new(crate::MockProjectLoader::new(project)), - network: Arc::new(MockNetworkAccessor::new().with_network( - LOCAL, - NetworkAccess { - root_key: custom_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse("http://localhost:9000").unwrap(), // Custom port - http_gateway_url: None, - use_friendly_domains: false, - }, - )), - ..Context::mocked() - }; - - let agent = ctx - .get_agent( - &IdentitySelection::Anonymous, - &NetworkSelection::Default, - &EnvironmentSelection::Default, - ) - .await - .unwrap(); - - // Should use the custom network configuration - assert_eq!(agent.read_root_key(), custom_root_key); -} - -#[tokio::test] -async fn test_get_agent_defaults_with_overridden_local_environment() { - // Create project where "local" environment uses a custom network - let default_local_network = Network { - name: LOCAL.to_string(), - configuration: Configuration::Managed { - managed: Managed { - mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { - gateway: Gateway { - bind: "127.0.0.1".to_string(), - port: Port::Fixed(8000), - domains: vec![], - }, - artificial_delay_ms: None, - ii: false, - nns: false, - subnets: None, - bitcoind_addr: None, - dogecoind_addr: None, - version: None, - })), - }, - }, - }; - - let custom_network = Network { - name: "custom".to_string(), - configuration: Configuration::Managed { - managed: Managed { - mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { - gateway: Gateway { - bind: "127.0.0.1".to_string(), - port: Port::Fixed(7000), - domains: vec![], - }, - artificial_delay_ms: None, - ii: false, - nns: false, - subnets: None, - bitcoind_addr: None, - dogecoind_addr: None, - version: None, - })), - }, - }, - }; - - let mut networks = HashMap::new(); - networks.insert(LOCAL.to_string(), default_local_network); - networks.insert("custom".to_string(), custom_network.clone()); - - // "local" environment uses "custom" network - let local_env = Environment { - name: LOCAL.to_string(), - network: custom_network, - canisters: IndexMap::new(), // No canisters needed for get_agent test - }; - - let mut environments = HashMap::new(); - environments.insert(LOCAL.to_string(), local_env); - - let project = Project { - dir: "/project".into(), - canisters: IndexMap::new(), // No canisters needed for get_agent test - networks, - environments, - member_missing_envs: std::collections::HashMap::new(), - }; - - let local_root_key = vec![1, 2, 3, 4]; - let custom_root_key = vec![5, 6, 7, 8]; - - let ctx = Context { - project: Arc::new(crate::MockProjectLoader::new(project)), - network: Arc::new( - MockNetworkAccessor::new() - .with_network( - LOCAL, - NetworkAccess { - root_key: local_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ) - .with_network( - "custom", - NetworkAccess { - root_key: custom_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse("http://localhost:7000").unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ), - ), - ..Context::mocked() - }; - - let agent = ctx - .get_agent( - &IdentitySelection::Anonymous, - &NetworkSelection::Default, - &EnvironmentSelection::Default, - ) - .await - .unwrap(); - - // Should use the custom network from the overridden environment - assert_eq!(agent.read_root_key(), custom_root_key); -} - -#[tokio::test] -async fn test_get_agent_explicit_network_inside_project() { - let local_root_key = vec![2, 3, 4]; - let staging_root_key = vec![12, 13, 14]; - - let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new( - MockNetworkAccessor::new() - .with_network( - LOCAL, - NetworkAccess { - root_key: local_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ) - .with_network( - "staging", - NetworkAccess { - root_key: staging_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse("http://localhost:8001").unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ), - ), - ..Context::mocked() - }; - - let agent = ctx - .get_agent( - &IdentitySelection::Anonymous, - &NetworkSelection::Named("staging".to_string()), - &EnvironmentSelection::Default, - ) - .await - .unwrap(); - - // Should use the explicitly specified network, regardless of project - assert_eq!(agent.read_root_key(), staging_root_key); -} - -#[tokio::test] -async fn test_get_agent_explicit_environment_inside_project() { - let local_root_key = vec![5, 6, 7]; - let staging_root_key = vec![15, 16, 17]; - - // complex() has "test" environment using "staging" network - let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new( - MockNetworkAccessor::new() - .with_network( - LOCAL, - NetworkAccess { - root_key: local_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ) - .with_network( - "staging", - NetworkAccess { - root_key: staging_root_key.clone(), - root_key_source: crate::network::RootKeySource::Configured, - api_url: Url::parse("http://localhost:8001").unwrap(), - http_gateway_url: None, - use_friendly_domains: false, - }, - ), - ), - ..Context::mocked() - }; - - let agent = ctx - .get_agent( - &IdentitySelection::Anonymous, - &NetworkSelection::Default, - &EnvironmentSelection::Named("test".to_string()), - ) - .await - .unwrap(); - - // Should use the network from the "test" environment (which is "staging") - assert_eq!(agent.read_root_key(), staging_root_key); -} diff --git a/crates/icp/src/directories.rs b/crates/icp/src/directories.rs index 37f9f2724..3369d5e30 100644 --- a/crates/icp/src/directories.rs +++ b/crates/icp/src/directories.rs @@ -6,7 +6,6 @@ use crate::{ fs::lock::LockError, - identity::{IdentityDirectories, IdentityPaths}, package::PackageCache, prelude::*, settings::{SettingsDirectories, SettingsPaths}, @@ -17,7 +16,10 @@ use snafu::prelude::*; /// Trait for accessing global ICP CLI directories. pub trait Access: Sync + Send { /// Returns the path to the identity directory. - fn identity(&self) -> Result; + /// + /// Unlocked: identities are loaded and edited by the frontend, which owns + /// the directory structure inside it (and its lock). + fn identity_dir(&self) -> PathBuf; /// Returns the path to the global port descriptors directory. fn port_descriptor(&self) -> PathBuf; @@ -177,8 +179,8 @@ impl Access for Directories { /// Returns the path to the identity directory. /// /// This directory stores user identity files, keys, and related data. - fn identity(&self) -> Result { - IdentityPaths::new(self.data().join("identity")) + fn identity_dir(&self) -> PathBuf { + self.data().join("identity") } /// Returns the path to the global port descriptors directory. @@ -207,7 +209,7 @@ impl Access for Directories { /// /// This directory stores telemetry events and state files. /// - /// Unlike [`Self::settings`] or [`Self::identity`], this intentionally + /// Unlike [`Self::settings`], this intentionally /// returns a plain path without a directory lock. Telemetry files are /// append-only or write-once, so concurrent access is harmless and /// locking would risk blocking the CLI on a best-effort subsystem. @@ -220,16 +222,16 @@ impl Access for Directories { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] /// Unimplemented mock implementation of `Access`. /// All methods panic with `unimplemented!()` when called. #[derive(Debug, Clone)] pub struct UnimplementedMockDirs; -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] impl Access for UnimplementedMockDirs { - fn identity(&self) -> Result { - unimplemented!("UnimplementedMockDirs::identity") + fn identity_dir(&self) -> PathBuf { + unimplemented!("UnimplementedMockDirs::identity_dir") } fn port_descriptor(&self) -> PathBuf { diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index f6c57325b..1121d1716 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -22,7 +22,6 @@ pub mod canister; pub mod context; pub mod directories; pub mod fs; -pub mod identity; pub mod manifest; pub mod network; pub mod package; @@ -218,14 +217,14 @@ pub trait ProjectLoad: Sync + Send { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] /// Mock project loader for testing. /// Returns a pre-configured `Project` when `load()` is called. pub struct MockProjectLoader { project: Project, } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] impl MockProjectLoader { /// Creates a new mock project loader with the given project. pub fn new(project: Project) -> Self { @@ -539,7 +538,7 @@ impl MockProjectLoader { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] #[async_trait] impl ProjectLoad for MockProjectLoader { async fn load(&self) -> Result { @@ -551,12 +550,12 @@ impl ProjectLoad for MockProjectLoader { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] /// Mock project loader that always fails with a Locate error. /// Useful for testing scenarios where no project exists. pub struct NoProjectLoader; -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] #[async_trait] impl ProjectLoad for NoProjectLoader { async fn load(&self) -> Result { diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 022ff024c..a98975a4e 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -396,16 +396,16 @@ impl Access for Accessor { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] use std::collections::HashMap; -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] pub struct MockNetworkAccessor { /// Network-specific access configurations by network name networks: HashMap, } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] impl MockNetworkAccessor { /// Creates a new empty mock network accessor. pub fn new() -> Self { @@ -421,14 +421,14 @@ impl MockNetworkAccessor { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] impl Default for MockNetworkAccessor { fn default() -> Self { Self::new() } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] #[async_trait] impl Access for MockNetworkAccessor { fn get_network_directory(&self, network: &Network) -> Result { diff --git a/crates/icp/src/store_artifact.rs b/crates/icp/src/store_artifact.rs index 913a59de7..cc5a17326 100644 --- a/crates/icp/src/store_artifact.rs +++ b/crates/icp/src/store_artifact.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] use std::{collections::HashMap, sync::Mutex}; use crate::{ @@ -177,30 +177,30 @@ impl Access for ArtifactStore { } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] /// In-memory mock implementation of `Access`. -pub(crate) struct MockInMemoryArtifactStore { +pub struct MockInMemoryArtifactStore { store: Mutex>>, } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] impl MockInMemoryArtifactStore { /// Creates a new empty in-memory artifact store. - pub(crate) fn new() -> Self { + pub fn new() -> Self { Self { store: Mutex::new(HashMap::new()), } } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] impl Default for MockInMemoryArtifactStore { fn default() -> Self { Self::new() } } -#[cfg(test)] +#[cfg(any(test, feature = "mocks"))] #[async_trait] impl Access for MockInMemoryArtifactStore { async fn save(&self, name: &str, wasm: &[u8]) -> Result<(), SaveError> { diff --git a/crates/icp/src/store_id.rs b/crates/icp/src/store_id.rs index 872cd8e42..bee9ef56c 100644 --- a/crates/icp/src/store_id.rs +++ b/crates/icp/src/store_id.rs @@ -272,8 +272,8 @@ impl AccessImpl { } } -#[cfg(test)] -pub(crate) mod mock { +#[cfg(any(test, feature = "mocks"))] +pub mod mock { use super::*; /// In-memory mock implementation of `Access`. /// diff --git a/crates/icp/src/telemetry_data.rs b/crates/icp/src/telemetry_data.rs index d74a96783..47f85ceaa 100644 --- a/crates/icp/src/telemetry_data.rs +++ b/crates/icp/src/telemetry_data.rs @@ -8,8 +8,6 @@ use std::sync::Mutex; use serde::Serialize; -use crate::identity::manifest::IdentitySpec; - /// Data collected during command execution for telemetry. /// /// Stored in [`crate::context::Context`] so any subsystem with access to @@ -84,17 +82,3 @@ pub enum NetworkType { Managed, Connected, } - -impl From<&IdentitySpec> for IdentityStorageType { - fn from(spec: &IdentitySpec) -> Self { - match spec { - IdentitySpec::Pem { .. } => Self::Pem, - IdentitySpec::Keyring { .. } => Self::Keyring, - IdentitySpec::Hsm { .. } => Self::Hsm, - IdentitySpec::Anonymous => Self::Anonymous, - IdentitySpec::WebAuth { .. } => Self::InternetIdentity, - IdentitySpec::PendingDelegation { .. } => Self::PendingDelegation, - IdentitySpec::Delegation { .. } => Self::Delegation, - } - } -} From 8b89dc944efac505b25cdb6de755dfda18c5d0e5 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 09:30:31 +0000 Subject: [PATCH 4/9] docs: record the icp/icp-cli crate boundary The agent-facing docs still pointed at `crates/icp/src/project.rs` for manifest consolidation and described `icp` as owning manifest loading. State the boundary once, in `.claude/architecture.md`, and point the subsystem sections at their new homes. --- .claude/CLAUDE.md | 14 +++++++++++--- .claude/architecture.md | 17 +++++++++++++++-- .claude/testing.md | 5 +++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7bcdd8150..cf5c39eb2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -20,15 +20,23 @@ cargo fmt && cargo clippy # Run after changes pass tests ### Workspace Structure -- **`crates/icp-cli`**: Main CLI binary (`icp`) with command implementations -- **`crates/icp`**: Core library with project model, manifest loading, canister management, network configuration +- **`crates/icp-cli`**: Main CLI binary (`icp`): the frontend/UX. Command implementations, identity + loading (`src/identity/`) and manifest parsing (`src/manifest.rs`, `src/project.rs`) +- **`crates/icp`**: Core library with the project model, canister management and network + configuration. It works with an **agent**: the frontend loads identities and parses manifests and + hands the library the result through the ports on `Context` - **`crates/icp-canister-interfaces`**: Canister interface definitions for ICP system canisters - **`crates/icp-events`**: Progress and user-facing notices as data (`Event`, `Reporter`, `Task`, `EventSink`), so operations can report without depending on the terminal. serde + futures only - **`crates/schema-gen`**: JSON schema generation for manifest validation ### Command Structure -Commands are in `crates/icp-cli/src/commands/`, each as a module with an `exec()` function receiving a `Context` (from `crates/icp/src/context/`). Dispatched via `clap` in `main.rs`. Traits like `ProjectLoad` and `ProjectRootLocate` enable dependency injection for testing. +Commands are in `crates/icp-cli/src/commands/`, each as a module with an `exec()` function receiving +a `Context` (from `crates/icp-cli/src/context/`, which wraps and derefs to the library's +`icp::context::Context`). Dispatched via `clap` in `main.rs`. Traits like `ProjectLoad` and +`ProjectRootLocate` are ports on the library context: the library declares them, the CLI implements +the filesystem-backed versions, and mocks (behind the `icp` crate's `mocks` feature) stand in for +them in tests. See `.claude/architecture.md` for detailed subsystem documentation (manifests, build adapters, recipes, networks, identity). diff --git a/.claude/architecture.md b/.claude/architecture.md index 7d9a4ca26..985799d2b 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -1,5 +1,14 @@ # Architecture Details +## Crate Boundary + +`icp-cli` is the frontend/UX; `icp` is the library, and it works with an `ic_agent::Agent`. Anything +that reads the user's files or asks the user something belongs to the frontend: identity loading +(`crates/icp-cli/src/identity/`) and manifest parsing (`crates/icp-cli/src/manifest.rs` for the +`ProjectRootLocate` implementation and the YAML loader, `crates/icp-cli/src/project.rs` for manifest +consolidation and the `ProjectLoad` implementation). The library declares those as ports on +`icp::context::Context` (`Arc` throughout) and never prompts. + ## Project Model The project model is built hierarchically through manifest consolidation: @@ -22,11 +31,12 @@ Manifests are YAML files that define project structure. The system supports: - **Path references**: Reference external manifest files - **Glob patterns**: For canisters, use globs like `canisters/*` to auto-discover -The `consolidate_manifest` function in `crates/icp/src/project.rs` transforms raw manifests into the final `Project` structure. The serde structs in the `icp::manifest` module represent the format that the user's YAML files can be written in, while the serde structs with identical meaning outside `icp::manifest` are instead the canonical form, with defaults filled in and normalizations applied. Code should always deal with the canonical form. +The `consolidate_manifest` function in `crates/icp-cli/src/project.rs` transforms raw manifests into the final `Project` structure. The serde structs in the `icp::manifest` module represent the format that the user's YAML files can be written in, while the serde structs with identical meaning outside `icp::manifest` are instead the canonical form, with defaults filled in and normalizations applied. Code should always deal with the canonical form. ## Build Adapters -Canisters are built using adapter pipelines defined in `crates/icp/src/manifest/adapter/`: +Canisters are built using adapter pipelines defined in `crates/icp/src/manifest/adapter/` (the +manifest *shapes* stay in the library; only their loading lives in the CLI): - **Script Adapter**: Runs shell commands with environment variables (e.g., `$ICP_WASM_OUTPUT_PATH`) - **Prebuilt Adapter**: Uses pre-compiled WASM from local files, URLs, or registry @@ -66,6 +76,9 @@ These constants are defined in `crates/icp/src/prelude.rs` as `LOCAL` and `IC` a ## Identity & Canister IDs +Identity loading lives in `crates/icp-cli/src/identity/`; the library only ever receives an +already-constructed identity or agent. + - **Identities**: Stored in platform-specific directories as PEM files (Secp256k1 or Ed25519): - macOS: `~/Library/Application Support/org.dfinity.icp-cli/identity/` - Linux: `~/.local/share/icp-cli/identity/` diff --git a/.claude/testing.md b/.claude/testing.md index 5c0f6ba96..eb1324a1e 100644 --- a/.claude/testing.md +++ b/.claude/testing.md @@ -16,3 +16,8 @@ Tests are split between unit tests (in modules) and integration tests: - `MockProjectLoader::minimal()`: Single canister, network, environment - `MockProjectLoader::complex()`: Multiple canisters, networks, environments - `NoProjectLoader`: Simulates missing project for error cases + +These, along with `Context::mocked()` and the other port mocks (`MockNetworkAccessor`, +`MockInMemoryIdStore`, ...), are compiled under `#[cfg(any(test, feature = "mocks"))]`. `icp-cli` +enables the `icp` crate's `mocks` feature as a dev-dependency so its own tests can build a mocked +context; `crates/icp-cli/src/identity/mod.rs` adds `MockIdentityLoader` on top. From b9746761ccecdf0e92136b3a2663264c7bcfc15d Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 09:37:27 +0000 Subject: [PATCH 5/9] docs: note what the manifest and project modules own after the split --- crates/icp-cli/src/project.rs | 4 ++++ crates/icp/src/manifest/mod.rs | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/crates/icp-cli/src/project.rs b/crates/icp-cli/src/project.rs index b24bdce03..102db1a3e 100644 --- a/crates/icp-cli/src/project.rs +++ b/crates/icp-cli/src/project.rs @@ -1,3 +1,7 @@ +//! Manifest consolidation: turning the manifests a project declares into the +//! library's [`Project`] model, and the [`ProjectLoad`] implementation that +//! drives it. + use std::{ collections::{BTreeMap, HashMap, HashSet, hash_map::Entry}, sync::Arc, diff --git a/crates/icp/src/manifest/mod.rs b/crates/icp/src/manifest/mod.rs index 79bc4c3f8..d30d185c1 100644 --- a/crates/icp/src/manifest/mod.rs +++ b/crates/icp/src/manifest/mod.rs @@ -1,3 +1,9 @@ +//! The manifest format: the serde shapes of `icp.yaml` and `canister.yaml`. +//! +//! These types describe what a user may write; reading them off disk is the +//! frontend's job (see `icp_cli::manifest`). The canonical, defaults-filled +//! forms the rest of the library works with live outside this module. + use std::marker::PhantomData; use schemars::JsonSchema; From 04a7e6eee3962025b76448acabd71545ffaa1d21 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 09:43:12 +0000 Subject: [PATCH 6/9] style(cli): group the new crate-local imports with the others The mechanical rewrite left `use crate::context::Context;` sitting above the external crates in every command module. Move it down to the file's crate-local import group. --- crates/icp-cli/src/commands/build.rs | 2 +- crates/icp-cli/src/commands/canister/call.rs | 3 ++- crates/icp-cli/src/commands/canister/create.rs | 3 ++- crates/icp-cli/src/commands/canister/delete.rs | 3 ++- crates/icp-cli/src/commands/canister/install.rs | 3 ++- crates/icp-cli/src/commands/canister/link.rs | 2 +- crates/icp-cli/src/commands/canister/settings/show.rs | 2 +- crates/icp-cli/src/commands/canister/settings/sync.rs | 2 +- crates/icp-cli/src/commands/canister/settings/update.rs | 2 +- crates/icp-cli/src/commands/canister/snapshot/delete.rs | 2 +- crates/icp-cli/src/commands/canister/snapshot/download.rs | 3 ++- crates/icp-cli/src/commands/canister/snapshot/restore.rs | 2 +- crates/icp-cli/src/commands/canister/start.rs | 2 +- crates/icp-cli/src/commands/canister/status.rs | 5 +++-- crates/icp-cli/src/commands/canister/stop.rs | 2 +- crates/icp-cli/src/commands/canister/top_up.rs | 2 +- crates/icp-cli/src/commands/deploy.rs | 5 +++-- crates/icp-cli/src/commands/environment/list.rs | 3 ++- crates/icp-cli/src/commands/identity/account_id.rs | 2 +- crates/icp-cli/src/commands/identity/default.rs | 5 +++-- crates/icp-cli/src/commands/identity/delegation/request.rs | 4 ++-- crates/icp-cli/src/commands/identity/delegation/sign.rs | 2 +- crates/icp-cli/src/commands/identity/delegation/use.rs | 3 ++- crates/icp-cli/src/commands/identity/delete.rs | 5 +++-- crates/icp-cli/src/commands/identity/export.rs | 5 +++-- crates/icp-cli/src/commands/identity/link/hsm.rs | 3 ++- crates/icp-cli/src/commands/identity/link/web.rs | 2 +- crates/icp-cli/src/commands/identity/principal.rs | 2 +- crates/icp-cli/src/commands/identity/reauth.rs | 2 +- crates/icp-cli/src/commands/identity/rename.rs | 5 +++-- crates/icp-cli/src/commands/network/list.rs | 3 ++- crates/icp-cli/src/commands/network/ping.rs | 4 ++-- crates/icp-cli/src/commands/network/status.rs | 2 +- crates/icp-cli/src/commands/network/update.rs | 2 +- crates/icp-cli/src/commands/project/bundle.rs | 2 +- crates/icp-cli/src/commands/settings.rs | 2 +- crates/icp-cli/src/commands/sync.rs | 5 +++-- crates/icp-cli/src/options.rs | 3 ++- 38 files changed, 64 insertions(+), 47 deletions(-) diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index 37413134e..b086d1a73 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use clap::Args; use clap_complete::ArgValueCandidates; use futures::future::try_join_all; @@ -6,6 +5,7 @@ use icp::context::EnvironmentSelection; use tracing::info; +use crate::context::Context; use crate::{ operations::build::build_many_with_progress_bar, options::{EnvironmentOpt, arg_struct_change_help}, diff --git a/crates/icp-cli/src/commands/canister/call.rs b/crates/icp-cli/src/commands/canister/call.rs index 868832723..8d39138bc 100644 --- a/crates/icp-cli/src/commands/canister/call.rs +++ b/crates/icp-cli/src/commands/canister/call.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use anyhow::{Context as _, anyhow, bail}; use candid::types::{Type, TypeInner}; use candid::{IDLArgs, Principal, TypeEnv, types::Function}; @@ -21,6 +20,8 @@ use crate::{ operations::proxy::update_or_proxy_raw, }; +use crate::context::Context; + /// How to interpret and display the call response blob. #[derive(Debug, Clone, Copy, Default, ValueEnum)] pub(crate) enum CallOutputMode { diff --git a/crates/icp-cli/src/commands/canister/create.rs b/crates/icp-cli/src/commands/canister/create.rs index 68382e8f3..2fb2f7622 100644 --- a/crates/icp-cli/src/commands/canister/create.rs +++ b/crates/icp-cli/src/commands/canister/create.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use std::io::stdout; use crate::identity::IdentitySelection; @@ -20,6 +19,8 @@ use crate::{ operations::create::{CreateFunding, CreateOperation, CreateTarget, shell_quote}, }; +use crate::context::Context; + pub(crate) const DEFAULT_CANISTER_CYCLES: u128 = 2 * TRILLION; #[derive(Clone, Debug, Default, Args)] diff --git a/crates/icp-cli/src/commands/canister/delete.rs b/crates/icp-cli/src/commands/canister/delete.rs index fc9b76dff..8659f3034 100644 --- a/crates/icp-cli/src/commands/canister/delete.rs +++ b/crates/icp-cli/src/commands/canister/delete.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use anyhow::anyhow; use candid::Principal; use clap::Args; @@ -10,6 +9,8 @@ use crate::{ operations::{proxy_management, recover_cycles}, }; +use crate::context::Context; + /// Delete a canister from a network. /// /// Cycles will be sent to the caller via the cycles ledger. diff --git a/crates/icp-cli/src/commands/canister/install.rs b/crates/icp-cli/src/commands/canister/install.rs index 4ad10945e..81d1bbbd5 100644 --- a/crates/icp-cli/src/commands/canister/install.rs +++ b/crates/icp-cli/src/commands/canister/install.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use std::io::IsTerminal; use anyhow::{Context as _, anyhow, bail}; @@ -22,6 +21,8 @@ use crate::{ }, }; +use crate::context::Context; + /// Install a built WASM to a canister on a network #[derive(Debug, Args)] pub(crate) struct InstallArgs { diff --git a/crates/icp-cli/src/commands/canister/link.rs b/crates/icp-cli/src/commands/canister/link.rs index 8c84d5fb7..1efa30acb 100644 --- a/crates/icp-cli/src/commands/canister/link.rs +++ b/crates/icp-cli/src/commands/canister/link.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use anyhow::bail; use candid::Principal; use clap::Args; @@ -6,6 +5,7 @@ use clap_complete::ArgValueCandidates; use icp::context::EnvironmentSelection; use tracing::info; +use crate::context::Context; use crate::options::EnvironmentOpt; /// Link an existing canister to the project by recording its ID in the canister ID store. diff --git a/crates/icp-cli/src/commands/canister/settings/show.rs b/crates/icp-cli/src/commands/canister/settings/show.rs index f514e9c3a..d0d13c7b1 100644 --- a/crates/icp-cli/src/commands/canister/settings/show.rs +++ b/crates/icp-cli/src/commands/canister/settings/show.rs @@ -1,9 +1,9 @@ -use crate::context::Context; use clap::Args; use ic_agent::export::Principal; use ic_management_canister_types::{CanisterIdRecord, DefiniteCanisterSettings, LogVisibility}; use std::fmt::Write; +use crate::context::Context; use crate::{commands::args::CanisterCommandArgs, operations::proxy_management}; /// Show the settings of a canister. diff --git a/crates/icp-cli/src/commands/canister/settings/sync.rs b/crates/icp-cli/src/commands/canister/settings/sync.rs index 413cb9051..c94d41f70 100644 --- a/crates/icp-cli/src/commands/canister/settings/sync.rs +++ b/crates/icp-cli/src/commands/canister/settings/sync.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use anyhow::bail; use candid::Principal; use clap::Args; @@ -6,6 +5,7 @@ use icp::context::CanisterSelection; use tracing::warn; use crate::commands::args::CanisterCommandArgs; +use crate::context::Context; /// Synchronize a canister's settings with those defined in the project #[derive(Debug, Args)] diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index 720086f0c..17c9dfbda 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use anyhow::bail; use candid::Nat; use clap::{ArgAction, Args}; @@ -15,6 +14,7 @@ use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount}; use std::collections::{HashMap, HashSet}; use tracing::warn; +use crate::context::Context; use crate::{commands::args, operations::proxy_management}; #[derive(Clone, Debug, Default, Args)] diff --git a/crates/icp-cli/src/commands/canister/snapshot/delete.rs b/crates/icp-cli/src/commands/canister/snapshot/delete.rs index 4e4daf588..10c09b032 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/delete.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/delete.rs @@ -1,10 +1,10 @@ -use crate::context::Context; use candid::Principal; use clap::Args; use ic_management_canister_types::DeleteCanisterSnapshotArgs; use tracing::info; use super::SnapshotId; +use crate::context::Context; use crate::{commands::args, operations::proxy_management}; /// Delete a canister snapshot diff --git a/crates/icp-cli/src/commands/canister/snapshot/download.rs b/crates/icp-cli/src/commands/canister/snapshot/download.rs index 1beb6da57..1cb6eaa68 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/download.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/download.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use byte_unit::{Byte, UnitType}; use candid::Principal; use clap::{Args, ValueHint}; @@ -14,6 +13,8 @@ use crate::operations::snapshot_transfer::{ load_metadata, read_snapshot_metadata, save_metadata, }; +use crate::context::Context; + /// Download a snapshot to local disk #[derive(Debug, Args)] pub(crate) struct DownloadArgs { diff --git a/crates/icp-cli/src/commands/canister/snapshot/restore.rs b/crates/icp-cli/src/commands/canister/snapshot/restore.rs index a1af3f1a8..f73ec860f 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/restore.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/restore.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use anyhow::bail; use candid::Principal; use clap::Args; @@ -8,6 +7,7 @@ use ic_management_canister_types::{ use tracing::info; use super::SnapshotId; +use crate::context::Context; use crate::{commands::args, operations::proxy_management}; /// Restore a canister from a snapshot diff --git a/crates/icp-cli/src/commands/canister/start.rs b/crates/icp-cli/src/commands/canister/start.rs index 17d4e7caa..4ee71a65f 100644 --- a/crates/icp-cli/src/commands/canister/start.rs +++ b/crates/icp-cli/src/commands/canister/start.rs @@ -1,8 +1,8 @@ -use crate::context::Context; use candid::Principal; use clap::Args; use ic_management_canister_types::CanisterIdRecord; +use crate::context::Context; use crate::{commands::args, operations::proxy_management}; /// Start a canister on a network diff --git a/crates/icp-cli/src/commands/canister/status.rs b/crates/icp-cli/src/commands/canister/status.rs index e8bb99d2e..200d40ec6 100644 --- a/crates/icp-cli/src/commands/canister/status.rs +++ b/crates/icp-cli/src/commands/canister/status.rs @@ -1,5 +1,3 @@ -use crate::context::Context; -use crate::identity::IdentitySelection; use anyhow::{anyhow, bail}; use clap::Args; use clap_complete::ArgValueCandidates; @@ -18,6 +16,9 @@ use crate::{ options, }; +use crate::context::Context; +use crate::identity::IdentitySelection; + /// Error code returned by the replica if the target canister is not found const E_CANISTER_NOT_FOUND: &str = "IC0301"; /// Error code returned by the replica if the caller is not a controller diff --git a/crates/icp-cli/src/commands/canister/stop.rs b/crates/icp-cli/src/commands/canister/stop.rs index ef516b2d0..2c28958fb 100644 --- a/crates/icp-cli/src/commands/canister/stop.rs +++ b/crates/icp-cli/src/commands/canister/stop.rs @@ -1,8 +1,8 @@ -use crate::context::Context; use candid::Principal; use clap::Args; use ic_management_canister_types::CanisterIdRecord; +use crate::context::Context; use crate::{commands::args, operations::proxy_management}; /// Stop a canister on a network diff --git a/crates/icp-cli/src/commands/canister/top_up.rs b/crates/icp-cli/src/commands/canister/top_up.rs index ac7023db0..34b2e8d94 100644 --- a/crates/icp-cli/src/commands/canister/top_up.rs +++ b/crates/icp-cli/src/commands/canister/top_up.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use anyhow::{Context as _, bail}; use bigdecimal::BigDecimal; use candid::{Decode, Encode, Nat}; @@ -10,6 +9,7 @@ use icp_canister_interfaces::cycles_ledger::{ use tracing::info; use crate::commands::args; +use crate::context::Context; use crate::operations::token::TokenAmount; /// Top up a canister with cycles diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 0ea98c8db..b9239fdc3 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -1,5 +1,3 @@ -use crate::context::Context; -use crate::identity::IdentitySelection; use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; @@ -37,6 +35,9 @@ use crate::{ progress::{ProgressManager, ProgressManagerSettings}, }; +use crate::context::Context; +use crate::identity::IdentitySelection; + /// Deploy a project to an environment #[derive(Args, Debug)] #[command(after_long_help = "\ diff --git a/crates/icp-cli/src/commands/environment/list.rs b/crates/icp-cli/src/commands/environment/list.rs index bf721615f..2e9792b57 100644 --- a/crates/icp-cli/src/commands/environment/list.rs +++ b/crates/icp-cli/src/commands/environment/list.rs @@ -1,6 +1,7 @@ -use crate::context::Context; use clap::Args; +use crate::context::Context; + /// List the environments defined in this project, one per line. /// /// Use `icp project show` to see the fully expanded configuration including diff --git a/crates/icp-cli/src/commands/identity/account_id.rs b/crates/icp-cli/src/commands/identity/account_id.rs index 7ae099891..cd42fafaa 100644 --- a/crates/icp-cli/src/commands/identity/account_id.rs +++ b/crates/icp-cli/src/commands/identity/account_id.rs @@ -1,10 +1,10 @@ -use crate::context::Context; use candid::Principal; use clap::{Args, ValueEnum}; use ic_ledger_types::{AccountIdentifier, Subaccount}; use icrc_ledger_types::icrc1::account::Account; use crate::commands::parsers::parse_subaccount; +use crate::context::Context; use crate::options::IdentityOpt; /// The account identifier format to display diff --git a/crates/icp-cli/src/commands/identity/default.rs b/crates/icp-cli/src/commands/identity/default.rs index c967c0192..bf2d1896c 100644 --- a/crates/icp-cli/src/commands/identity/default.rs +++ b/crates/icp-cli/src/commands/identity/default.rs @@ -1,9 +1,10 @@ -use crate::context::Context; -use crate::identity::manifest::{IdentityDefaults, IdentityList, change_default_identity}; use clap::Args; use clap_complete::ArgValueCandidates; use tracing::info; +use crate::context::Context; +use crate::identity::manifest::{IdentityDefaults, IdentityList, change_default_identity}; + /// Display or set the currently selected identity #[derive(Debug, Args)] pub(crate) struct DefaultArgs { diff --git a/crates/icp-cli/src/commands/identity/delegation/request.rs b/crates/icp-cli/src/commands/identity/delegation/request.rs index 3ce494cd9..1493e6d20 100644 --- a/crates/icp-cli/src/commands/identity/delegation/request.rs +++ b/crates/icp-cli/src/commands/identity/delegation/request.rs @@ -1,5 +1,3 @@ -use crate::context::Context; -use crate::identity::key; use clap::{Args, ValueHint}; use dialoguer::Password; use elliptic_curve::zeroize::Zeroizing; @@ -9,6 +7,8 @@ use snafu::{ResultExt, Snafu}; use tracing::warn; use crate::commands::identity::StorageMode; +use crate::context::Context; +use crate::identity::key; /// Create a pending delegation identity with a new P256 session key /// diff --git a/crates/icp-cli/src/commands/identity/delegation/sign.rs b/crates/icp-cli/src/commands/identity/delegation/sign.rs index 41346ea27..e09b1a3df 100644 --- a/crates/icp-cli/src/commands/identity/delegation/sign.rs +++ b/crates/icp-cli/src/commands/identity/delegation/sign.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use crate::{ context::GetIdentityError, identity::delegation::{ @@ -16,6 +15,7 @@ use icp::{fs::read_to_string, prelude::*}; use pem::Pem; use snafu::{OptionExt, ResultExt, Snafu}; +use crate::context::Context; use crate::options::IdentityOpt; /// Sign a delegation from the selected identity to a target key diff --git a/crates/icp-cli/src/commands/identity/delegation/use.rs b/crates/icp-cli/src/commands/identity/delegation/use.rs index 1a1741b6d..221d45e35 100644 --- a/crates/icp-cli/src/commands/identity/delegation/use.rs +++ b/crates/icp-cli/src/commands/identity/delegation/use.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; use icp::{fs::json, prelude::*}; @@ -11,6 +10,8 @@ use crate::identity::{ use snafu::{ResultExt, Snafu}; use tracing::{info, warn}; +use crate::context::Context; + /// Complete a pending delegation identity by providing a signed delegation chain /// /// Reads the JSON output of `icp identity delegation sign` from a file and attaches diff --git a/crates/icp-cli/src/commands/identity/delete.rs b/crates/icp-cli/src/commands/identity/delete.rs index 7fa60c709..b2d740c7d 100644 --- a/crates/icp-cli/src/commands/identity/delete.rs +++ b/crates/icp-cli/src/commands/identity/delete.rs @@ -1,9 +1,10 @@ -use crate::context::Context; -use crate::identity::key::delete_identity; use clap::Args; use clap_complete::ArgValueCandidates; use tracing::info; +use crate::context::Context; +use crate::identity::key::delete_identity; + /// Delete an identity #[derive(Debug, Args)] pub(crate) struct DeleteArgs { diff --git a/crates/icp-cli/src/commands/identity/export.rs b/crates/icp-cli/src/commands/identity/export.rs index 8966e4935..e2931ace5 100644 --- a/crates/icp-cli/src/commands/identity/export.rs +++ b/crates/icp-cli/src/commands/identity/export.rs @@ -1,5 +1,3 @@ -use crate::context::Context; -use crate::identity::key::{ExportFormat, export_identity}; use anyhow::Context as _; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; @@ -8,6 +6,9 @@ use elliptic_curve::zeroize::Zeroizing; use icp::fs::read_to_string; use icp::prelude::*; +use crate::context::Context; +use crate::identity::key::{ExportFormat, export_identity}; + /// Print the PEM file for the identity #[derive(Debug, Args)] pub(crate) struct ExportArgs { diff --git a/crates/icp-cli/src/commands/identity/link/hsm.rs b/crates/icp-cli/src/commands/identity/link/hsm.rs index 654ed0314..6923cf6f8 100644 --- a/crates/icp-cli/src/commands/identity/link/hsm.rs +++ b/crates/icp-cli/src/commands/identity/link/hsm.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use clap::{Args, ValueHint}; use dialoguer::Password; use icp::prelude::*; @@ -7,6 +6,8 @@ use crate::identity::{key::link_hsm_identity, manifest::IdentityList}; use snafu::{ResultExt, Snafu, ensure}; use tracing::info; +use crate::context::Context; + /// Link an HSM key to a new identity #[derive(Debug, Args)] pub(crate) struct HsmArgs { diff --git a/crates/icp-cli/src/commands/identity/link/web.rs b/crates/icp-cli/src/commands/identity/link/web.rs index 2f69e24ac..95ce1d256 100644 --- a/crates/icp-cli/src/commands/identity/link/web.rs +++ b/crates/icp-cli/src/commands/identity/link/web.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use std::{io::IsTerminal, net::SocketAddr, time::Duration}; use anstyle::{AnsiColor, Reset, Style}; @@ -30,6 +29,7 @@ use tracing::{info, warn}; use url::Url; use crate::commands::identity::StorageMode; +use crate::context::Context; /// Link a web-based identity (such as Internet Identity) to a new icp-cli identity #[derive(Debug, Args)] diff --git a/crates/icp-cli/src/commands/identity/principal.rs b/crates/icp-cli/src/commands/identity/principal.rs index ea49a5ef6..11e4ac28f 100644 --- a/crates/icp-cli/src/commands/identity/principal.rs +++ b/crates/icp-cli/src/commands/identity/principal.rs @@ -1,6 +1,6 @@ -use crate::context::Context; use clap::Args; +use crate::context::Context; use crate::options::IdentityOpt; /// Display the principal for the current identity diff --git a/crates/icp-cli/src/commands/identity/reauth.rs b/crates/icp-cli/src/commands/identity/reauth.rs index a959f2081..59b6d6916 100644 --- a/crates/icp-cli/src/commands/identity/reauth.rs +++ b/crates/icp-cli/src/commands/identity/reauth.rs @@ -1,4 +1,3 @@ -use crate::context::Context; use std::time::Duration; use clap::Args; @@ -13,6 +12,7 @@ use snafu::{OptionExt, ResultExt, Snafu}; use tracing::info; use crate::commands::identity::{delegation::sign::DurationArg, link::web}; +use crate::context::Context; /// Re-authenticate an Internet Identity delegation or create a PEM session delegation #[derive(Debug, Args)] diff --git a/crates/icp-cli/src/commands/identity/rename.rs b/crates/icp-cli/src/commands/identity/rename.rs index 6e7aa8601..158216ca4 100644 --- a/crates/icp-cli/src/commands/identity/rename.rs +++ b/crates/icp-cli/src/commands/identity/rename.rs @@ -1,9 +1,10 @@ -use crate::context::Context; -use crate::identity::key::rename_identity; use clap::Args; use clap_complete::ArgValueCandidates; use tracing::info; +use crate::context::Context; +use crate::identity::key::rename_identity; + /// Rename an identity #[derive(Debug, Args)] pub(crate) struct RenameArgs { diff --git a/crates/icp-cli/src/commands/network/list.rs b/crates/icp-cli/src/commands/network/list.rs index 4c0fc0a54..d8588cc53 100644 --- a/crates/icp-cli/src/commands/network/list.rs +++ b/crates/icp-cli/src/commands/network/list.rs @@ -1,6 +1,7 @@ -use crate::context::Context; use clap::Args; +use crate::context::Context; + /// List all networks configured in the project #[derive(Args, Debug)] pub(crate) struct ListArgs; diff --git a/crates/icp-cli/src/commands/network/ping.rs b/crates/icp-cli/src/commands/network/ping.rs index 69298b942..d46cab825 100644 --- a/crates/icp-cli/src/commands/network/ping.rs +++ b/crates/icp-cli/src/commands/network/ping.rs @@ -1,5 +1,3 @@ -use crate::context::Context; -use crate::identity::IdentitySelection; use anyhow::bail; use clap::Args; use ic_agent::{Agent, agent::status::Status}; @@ -9,6 +7,8 @@ use tracing::info; use url::Url; use super::args::NetworkOrEnvironmentArgs; +use crate::context::Context; +use crate::identity::IdentitySelection; /// Try to connect to a network, and print out its status. #[derive(Args, Debug)] diff --git a/crates/icp-cli/src/commands/network/status.rs b/crates/icp-cli/src/commands/network/status.rs index 1739544da..4dc373f93 100644 --- a/crates/icp-cli/src/commands/network/status.rs +++ b/crates/icp-cli/src/commands/network/status.rs @@ -1,10 +1,10 @@ -use crate::context::Context; use anyhow::Context as _; use clap::Args; use icp::network::{Configuration, RootKeySource}; use serde::Serialize; use super::args::NetworkOrEnvironmentArgs; +use crate::context::Context; /// Get status information about a running network #[derive(Args, Debug)] diff --git a/crates/icp-cli/src/commands/network/update.rs b/crates/icp-cli/src/commands/network/update.rs index ae9c95a99..6f12fbe5c 100644 --- a/crates/icp-cli/src/commands/network/update.rs +++ b/crates/icp-cli/src/commands/network/update.rs @@ -1,9 +1,9 @@ -use crate::context::Context; use std::sync::{Arc, OnceLock}; use clap::Parser; use icp::network::managed::cache::download_launcher_version; +use crate::context::Context; use crate::progress::{ProgressManager, ProgressManagerSettings}; /// Update icp-cli-network-launcher to the latest version. diff --git a/crates/icp-cli/src/commands/project/bundle.rs b/crates/icp-cli/src/commands/project/bundle.rs index 835e017f1..12dab8446 100644 --- a/crates/icp-cli/src/commands/project/bundle.rs +++ b/crates/icp-cli/src/commands/project/bundle.rs @@ -1,8 +1,8 @@ -use crate::context::Context; use anyhow::Context as _; use clap::{Args, ValueHint}; use icp::prelude::*; +use crate::context::Context; use crate::operations::bundle::create_bundle; /// Bundle a project into a self-contained deployable archive. diff --git a/crates/icp-cli/src/commands/settings.rs b/crates/icp-cli/src/commands/settings.rs index 401a35a27..356ed5b85 100644 --- a/crates/icp-cli/src/commands/settings.rs +++ b/crates/icp-cli/src/commands/settings.rs @@ -1,10 +1,10 @@ -use crate::context::Context; use std::{fmt, str::FromStr}; use clap::{Args, Subcommand}; use icp::settings::{Settings, UpdateCheck}; use tracing::{info, warn}; +use crate::context::Context; use crate::dist::dist_supports_betas; /// Configure user settings diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index b987b90ca..8a729ca97 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -1,5 +1,3 @@ -use crate::context::Context; -use crate::identity::IdentitySelection; use anyhow::{anyhow, bail}; use candid::Principal; use clap::Args; @@ -15,6 +13,9 @@ use crate::{ options::{EnvironmentOpt, IdentityOpt}, }; +use crate::context::Context; +use crate::identity::IdentitySelection; + /// Synchronize canisters #[derive(Debug, Args)] pub(crate) struct SyncArgs { diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index 26bd8c725..9624d98a3 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -1,4 +1,3 @@ -use crate::identity::IdentitySelection; use clap::error::ErrorKind; use clap::{ArgGroup, ArgMatches, Args, FromArgMatches}; use clap_complete::ArgValueCandidates; @@ -7,6 +6,8 @@ use icp::network::RootKeySpec; use icp::prelude::LOCAL; use url::Url; +use crate::identity::IdentitySelection; + mod heading { pub const NETWORK_PARAMETERS: &str = "Network Selection Parameters"; pub const IDENITTY_PARAMETERS: &str = "Identity Selection Parameters"; From ee19ebb84e8b17de2412285ac02d9719f69a21a3 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 10:05:49 +0000 Subject: [PATCH 7/9] docs(icp): stop pointing at IdentitySelection from the selection types The three selection enums described themselves as "similar to IdentitySelection", a type that now lives in icp-cli. --- crates/icp/src/context/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index e03f99bbc..9b4c4a4f2 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -22,7 +22,7 @@ pub use init::initialize; pub const IC_ROOT_KEY: &[u8; 133] = b"\x30\x81\x82\x30\x1d\x06\x0d\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x01\x02\x01\x06\x0c\x2b\x06\x01\x04\x01\x82\xdc\x7c\x05\x03\x02\x01\x03\x61\x00\x81\x4c\x0e\x6e\xc7\x1f\xab\x58\x3b\x08\xbd\x81\x37\x3c\x25\x5c\x3c\x37\x1b\x2e\x84\x86\x3c\x98\xa4\xf1\xe0\x8b\x74\x23\x5d\x14\xfb\x5d\x9c\x0c\xd5\x46\xd9\x68\x5f\x91\x3a\x0c\x0b\x2c\xc5\x34\x15\x83\xbf\x4b\x43\x92\xe4\x67\xdb\x96\xd6\x5b\x9b\xb4\xcb\x71\x71\x12\xf8\x47\x2e\x0d\x5a\x4d\x14\x50\x5f\xfd\x74\x84\xb0\x12\x91\x09\x1c\x5f\x87\xb9\x88\x83\x46\x3f\x98\x09\x1a\x0b\xaa\xae"; -/// Selection type for networks - similar to IdentitySelection +/// How a command names the network it targets. #[derive(Clone, Debug, PartialEq)] pub enum NetworkSelection { /// Use the network from the environment @@ -33,7 +33,7 @@ pub enum NetworkSelection { Url(Url, RootKeySpec), } -/// Selection type for environments - similar to IdentitySelection +/// How a command names the environment it targets. #[derive(Clone, Debug, PartialEq)] pub enum EnvironmentSelection { /// Use the default environment (local) @@ -60,7 +60,7 @@ pub enum NetworkOrEnvironmentSelection { Environment(String), } -/// Selection type for canisters - similar to IdentitySelection +/// How a command names the canister it targets. #[derive(Clone, Debug, PartialEq)] pub enum CanisterSelection { /// Use a canister by name (requires project context) From 170c82f25df1c53fea7a7586ab4b20d804fe7ce2 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 10:41:03 +0000 Subject: [PATCH 8/9] no-mistakes(review): test(cli): cover the manifest-load error chain end to end --- crates/icp-cli/tests/project_tests.rs | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/icp-cli/tests/project_tests.rs b/crates/icp-cli/tests/project_tests.rs index 7aaee9d5f..b7383b387 100644 --- a/crates/icp-cli/tests/project_tests.rs +++ b/crates/icp-cli/tests/project_tests.rs @@ -293,3 +293,31 @@ fn redefine_ic_network_disallowed() { .failure() .stderr(contains("`ic` is a reserved network name")); } + +#[test] +fn malformed_manifest_reports_the_whole_load_chain() { + let ctx = TestContext::new(); + + // Setup project + let project_dir = ctx.create_project_dir("icp"); + + // Deliberately malformed YAML: the flow sequence is never closed + write_string( + &project_dir.join("icp.yaml"), + indoc! {r#" + canisters: + - name: [oops + "#}, + ) + .expect("failed to write project manifest"); + + // Any command that loads the project should fail, reporting both the loader's + // own level and the parse failure underneath it + ctx.icp() + .current_dir(project_dir) + .args(["project", "show"]) + .assert() + .failure() + .stderr(contains("failed to load project manifest")) + .stderr(contains("failed to parse manifest at")); +} From 945a90b45b436ea4319c09229cb6544a35155ef8 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Thu, 13 Aug 2026 19:09:35 +0000 Subject: [PATCH 9/9] fix(cli): point the completion candidates at the relocated identity module `complete.rs` arrived with shell completions (#697) while this branch was in review, so it still reached for `icp::identity` and built its context through `icp::context::initialize`. Give it the same treatment as every other command module: the CLI's own `Context` (it needs `identity_dirs()`), `crate::identity::manifest::IdentityList`, and `crate::context::initialize`. Its "cannot prompt while completing" password reader is unchanged. --- crates/icp-cli/src/complete.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/icp-cli/src/complete.rs b/crates/icp-cli/src/complete.rs index 66a48cf58..6b3340033 100644 --- a/crates/icp-cli/src/complete.rs +++ b/crates/icp-cli/src/complete.rs @@ -14,12 +14,13 @@ use std::time::Duration; use clap::CommandFactory as _; use clap_complete::CompleteEnv; use clap_complete::engine::CompletionCandidate; -use icp::context::Context; -use icp::identity::manifest::IdentityList; use icp::network::Configuration; use icp::prelude::*; use icp::{Environment, Network, Project}; +use crate::context::Context; +use crate::identity::manifest::IdentityList; + /// Answer a completion request and exit, if this invocation is one. /// /// Must run before anything writes to stdout: the completion protocol is @@ -61,7 +62,7 @@ fn context() -> Option<&'static Context> { CONTEXT .get_or_init(|| { - icp::context::initialize( + crate::context::initialize( std::env::var("ICP_PROJECT_ROOT").ok().map(PathBuf::from), false, Arc::new(|| Err("cannot prompt while completing".to_string())), @@ -86,7 +87,7 @@ fn identities() -> &'static [(String, String)] { static IDENTITIES: OnceLock> = OnceLock::new(); IDENTITIES.get_or_init(|| { - let Some(dirs) = context().and_then(|ctx| ctx.dirs.identity().ok()) else { + let Some(dirs) = context().and_then(|ctx| ctx.identity_dirs().ok()) else { return Vec::new(); }; let Some(Ok(Ok(list))) =