refactor(icp): move identity loading and manifest parsing into icp-cli - #712
refactor(icp): move identity loading and manifest parsing into icp-cli#712raymondk wants to merge 9 commits into
Conversation
`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.
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 '<path>'`. `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.
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<dyn identity::Load>` 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`.
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.
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.
The three selection enums described themselves as "similar to IdentitySelection", a type that now lives in icp-cli.
…odule `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.
2077be8 to
945a90b
Compare
|
I'm not sure I agree with this refactor, which shifts the |
Intent
Move identity loading and manifest parsing out of the
icplibrary crate and intoicp-cli.The architecture this serves:
icp-cliis the frontend/UX for the CLI;icpis the library, and itworks with an agent. Loading an identity is a CLI concern, parsing a manifest is a CLI concern,
and the library should touch the filesystem only for building and deploying. The library does not
stop using the filesystem by pretending — it uses the port/abstraction pattern already established
in
icp::context::Context, which is entirelyArc<dyn …>today.Four required pieces:
crates/icp/src/identity(~3,347 LOC) intoicp-cli. Outside the module and theContextwiring there were exactly two genuine couplings —
telemetry_data.rs's use ofidentity::manifest::IdentitySpecanddirectories.rs's use ofidentity::{IdentityDirectories, IdentityPaths}— and a home had to be chosen for those twotypes and justified. Three other apparent references were false positives to verify, not touch:
network/managed/run.rsandnetwork/access.rsuseic_agent's ownAnonymousIdentity, andnetwork/custom_domains.rsmerely names the Internet Identity canister ID. The library shouldend up receiving a constructed
Agent.icp-cli.Projectand theProjectLoadtrait stay inicp— withdeploy in the library, a consumer must still be able to receive a project; a library that can
deploy but cannot represent a project is the failure mode to avoid. What moves is the native,
filesystem-touching loading implementation behind the existing port.
Context.password_funcentirely (26 of its 37 references were insidecrates/icp/src/identity). Explicitly noPromptport: that was considered and ruled out,because with identity in the CLI it is unnecessary abstraction.
Context.debug— a presentation flag has no place in a library.Out of scope by instruction: moving
operations/, starting the larger crate split, and adding anywasm target, cfg gate or wasm CI job.
What Changed
Identity →
icp-cli(crates/icp-cli/src/identity/, moved withgit mv; the module bodies areunchanged apart from
crate::→icp::path rewrites). With it went the twoContextfields thatonly served it, and the selection-resolving helpers built on them:
password_funcis deleted from the library. The CLI's context owns the password reader now(
commands/identity/reauth.rsis its one non-loader consumer).identity: Arc<dyn identity::Load>is deleted, along withget_identity,get_agent, andget_agent_for_{env,network,url}. Those take anIdentitySelection— a CLI concept — and mustinterleave identity loading with root-key resolution, because a delegated identity is validated
against the network's root key. They now live on the CLI's context. What stays in the library is
Context::create_agent: identity + resolved network →Agent. Theagent::Createport has tostay regardless, since
network::Accessorbuilds an anonymous agent to fetch root keys.Context.debugis deleted;--debugis presentation (tracing layer, progress-bar hiding).New
icp_cli::context::Contextwraps the library context andDerefs to it, carrying thefrontend-only state (
debug,password_func, the identity loader). Because it derefs, every libraryport (
dirs,ids,project,network, …) is still reached straight throughctx,ctx.debugand
ctx.get_agent(...)call sites are unchanged, andoperations/keeps taking&icp::context::Contextvia deref coercion — which also kept this out of the way of the concurrentoperations/work. The identity loader is a single shared instance, so its per-selection cache stillunlocks an encrypted identity (and asks for its password) at most once per invocation.
The two couplings, decided:
IdentityStorageTypestays inicp::telemetry_data— telemetry is a library-side data baghanging off
Context, written by subsystems. ItsFrom<&IdentitySpec>conversion travels withIdentitySpecintoicp-cli(orphan rule is satisfied:&IdentitySpecis a local, fundamentaltype).
IdentityPaths/IdentityDirectoriestravel with identity, anddirectories::Access::identity()becomesidentity_dir() -> PathBuf. The genericdirectory-lock machinery (
icp::fs::lock) stays put — it is filesystem infrastructure, notidentity, and settings and the package cache use it the same way. The CLI re-locks the directory
through
Context::identity_dirs().Manifest parsing →
icp-cli:ProjectRootLocateImpland the YAML loader(
crates/icp-cli/src/manifest.rs), consolidation plusProjectLoadImplandLazy(
crates/icp-cli/src/project.rs).Project,ProjectLoad,ProjectRootLocateand the manifestshape types stay in
icp: the shapes are plain serde with no filesystem in them,BuildStepsandRootKeySpecare part of theProjectmodel the builder consumes, andschema-gengenerates thepublished JSON schemas from them. Their modules widened from
pub(crate)topubso the loader canreach the same types it always did (not a semver concern — the crate is
publish = false).ProjectLoadError's two loader-specific variants collapse into a single boxed#[snafu(transparent)] Loadvariant, so the port does not name any one loader's error type; the CLIloader keeps variants with the same messages.
Locatestays a real variant because callers match onit to detect "no project here". Error output is unchanged — see Testing.
icp::context::initializenow receives the directories, the root locator and the project loaderinstead of constructing them (the
$PWD-vs-getcwd()resolution that feeds the locator moved to theCLI with it), and since it no longer locks an identity directory it can no longer fail: it returns a
ContextandContextInitErrorleft the library.DEFAULT_LOCAL_NETWORK_{BIND,PORT}moved toicp::network, where the default network they describe is defined.Tests split along the same line: of the 31
Contexttests, 14 stayed inicpand 17 moved tocrates/icp-cli/src/context/tests.rs. So the CLI can build a mocked library context,icp's portmocks are now compiled under
#[cfg(any(test, feature = "mocks"))]andicp-clienables thatfeature as a dev-dependency (resolver 3 keeps it out of
cargo build --bin icp).Four items that were already dead surfaced once the module landed in a binary crate, where
pubnolonger suppresses dead-code analysis, and are removed:
load_identity_in_context,IdentityPaths::ensure_identity_{defaults,list}_path,WriteIdentityError::CreateDirectoryError,MockIdentityLoader::with_default, plus the never-constructedConsolidateManifestError::Locate.Agent-facing docs record the boundary: a new "Crate Boundary" section in
.claude/architecture.md,plus updates to
.claude/CLAUDE.mdand.claude/testing.md.Measured payoff
crates/icp/srcicpdependenciesDependencies that left
icpentirely:bip32,crypto-bigint,elliptic-curve,hmac,ic-ed25519,ic-identity-hsm,k256,keyring,p256,pem,pkcs8,rand,scrypt,sec1,tiny-bip39,zeroize.pkcs8,pem,sec1,bip32andhmachad zero users outside identity asexpected.
keyringhad one:context/init.rs'sICP_CLI_KEYRING_MOCK_DIRhook, which moved to theCLI's
initializealongside the loader it configures — sokeyringleaves too.Risk Assessment
✅ Low. This is a mechanical relocation: the moved module bodies and the moved consolidation logic are
unchanged apart from import paths, and no
Project, manifest or identity semantics were touched. Thetwo behavioral surfaces that could have drifted silently were checked directly: the password prompt
(the loader's caching is what keeps it to one prompt per invocation) and the manifest error chain
(where a new boxed transparent variant sits in the path). Both are covered below, the latter now by a
new test. The one identity form not exercised locally is HSM linking — SoftHSM2 is not installed on
the dev machine — and CI provisions SoftHSM2 on all three platforms, so the five unconditional
identity_link_hsm*tests cover it here.Testing
cargo build,cargo fmt --all -- --check,cargo clippy --tests --benches -- -D warningsandcargo clippy --all-targets --all-featuresall clean. Both doc-drift generators(
./scripts/generate-cli-docs.sh,./scripts/generate-config-schemas.sh) leave the worktreebyte-identical, so the
cli-refandicp-yaml-schemaCI jobs have nothing to report.Automated:
cargo test -p icp --lib173 pass;cargo test -p icp-cli --bin icp148 pass, includingthe 17 relocated identity/agent context tests; whole-workspace
cargo test --no-fail-fastpassesevery target. Three initially-failing tests were environmental and each was chased down:
canister_install_large_wasm_chunkedneededwasm-tools(installed → passes), and twonetwork_testsport-conflict tests hit an unauthenticated GitHub API 403 resolving the launcherversion (pass with
ICP_CLI_GITHUB_TOKEN, as CI sets). The remaining local failures are the fiveidentity_link_hsm*tests (no SoftHSM2) and twocanister_snapshot_*_resumetests (no mitmproxy) —both provisioned in CI.
Identity behavior specifically:
identity_tests31 pass, includingidentity_storage_forms,pem_session_delegation_avoids_second_password_prompt,pem_explicit_login_creates_sessionandpem_session_migrated_on_rename. Beyond that, the validation pipeline drove the interactiveprompt over a real pty (correct password prints the PEM; a wrong one gives
failed to decrypt PEM file) and confirmed session reuse by passing an empty password file on a second call, plus a fullicp identity new/list/default/principal/account-id/rename/exporttranscript against an isolatedICP_HOME.Manifest errors: compared by hand and now locked in by a new test,
malformed_manifest_reports_the_whole_load_chainincrates/icp-cli/tests/project_tests.rs, whichruns a real command against a malformed
icp.yamland asserts stderr carries both levels the newindirection could have swallowed:
and, for the
Locatepath (already covered bynetwork_ping_tests/network_status_tests):The error chain is preserved by construction, not by luck: snafu-derive 0.9.1's
transparentdelegates
Displayto the source and returnssource.source()fromError::source(), so the boxedProjectLoadError::Load(NativeProjectLoadError::ProjectManifest)renders exactly the two levels theold
ProjectManifestvariant did.Pipeline
Validated through
no-mistakes(review → test → document → lint → push). Review closed with nofindings after one fix round; that round added the manifest-error-chain test above, which the
original change was missing. Two informational lint items are recorded as out of scope: 12
pre-existing rustdoc diagnostics in files this change does not touch (CI does not run rustdoc), and
taplo fmt --checknot being runnable locally — the four changed TOML files were hand-checkedagainst
taplo.toml, and thetoml-fmtCI job is the real gate.🤖 Generated with Claude Code