diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60c29be7dc..efca8434d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,40 @@ permissions: contents: read jobs: + # ── CLI: independent tests ───────────────────────────────────────── + cli-test: + name: CLI Tests (ubuntu-latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Linux system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + build-essential \ + libssl-dev \ + libxcb1-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev + + - uses: dtolnay/rust-toolchain@stable + + - name: Generate dependency lockfile + run: cargo generate-lockfile + + - uses: swatinem/rust-cache@v2 + with: + shared-key: "cli-ci-v2-ubuntu" + cache-bin: false + save-if: ${{ github.event_name != 'pull_request' }} + + - name: Run CLI tests + run: cargo test --locked -p bitfun-cli + # ── Rust: build check ───────────────────────────────────────────── rust-build-check: name: Rust Build Check (${{ matrix.os }}) @@ -84,6 +118,9 @@ jobs: - uses: dtolnay/rust-toolchain@stable + - name: Generate dependency lockfile + run: cargo generate-lockfile + - uses: swatinem/rust-cache@v2 with: shared-key: "ci-check-v2-${{ runner.os }}-no-cargo-bin-v1" @@ -92,7 +129,7 @@ jobs: save-if: ${{ github.event_name != 'pull_request' }} - name: Check compilation - run: cargo check --workspace --exclude bitfun-cli + run: cargo check --locked --workspace - name: Run core and desktop Rust tests run: cargo test --locked -p bitfun-core -p bitfun-desktop diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index f1c9538633..c244688fa9 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -691,9 +691,11 @@ pub struct HarnessExecutionContext { `src/crates/assembly/core` 仍承担 `bitfun-core` 兼容组装。现有 `ProductAssembler` 是具体结构体, 通过 `assemble(ProductAssemblyInput)` 产生 `ProductRuntimeParts`,本文件不再为它定义第二套目标接口。 -当前边界仍未完成:CLI、Desktop 和 ACP 入口继续直接依赖 `bitfun-core` 的 `product-full`,Server -仅提供健康检查、信息与 ping 路由,其他交付 profile 尚无生产消费方。因此 profile、枚举分支和单元测试 -只能证明契约存在,不能证明对应产品形态已接入。 +当前边界仍未完成:CLI 的 `doctor` 命令已选择 `DeliveryProfile::Cli`,并读取 `ProductAssemblyPlan` 的 +静态 profile;它明确说明未评估运行时 readiness,不把静态服务需求投影为实际 availability, +也不构造 `ProductRuntimeParts` 或注册占位 provider。Chat、Exec、ACP 与其他管理路径仍依赖 `bitfun-core/product-full`。 +Desktop 和 ACP 尚未接入组装结果,Server 仅提供健康检查、信息与 ping 路由。除上述 CLI 诊断切片外, +profile、枚举分支和单元测试仍不能证明对应产品形态已接入。 职责: @@ -707,13 +709,13 @@ pub struct HarnessExecutionContext { | 阶段 | 约束 | |---|---| | 当前 | 维持现有 `ProductAssembler` API 和 `product-full` 兼容门面,不扩张字段或再造描述符 | -| 迁移 | 先消除 `assembly/core -> apps/relay-server` 反向依赖,再按 CLI、ACP、Desktop 接入真实入口 | +| 迁移 | CLI 可先经现有兼容门面消费只读静态计划;迁移执行 owner 或继续接入 ACP、Desktop 前,必须消除 `assembly/core -> apps/relay-server` 反向依赖 | | 完成 | 每个声称支持的 profile 都由生产入口消费组装结果,并有最小入口验证;无消费方的 profile 不对外宣称可用 | 产品定义、品牌资源和界面布局的长期边界以 [`product-customization-blueprint.md`](product-customization-blueprint.md) 为准;CLI 配置层级和 TUI 消费方式以 -[`cli-product-line-design.md`](cli-product-line-design.md) 为准。在出现真实组装消费方和验证路径前,不向当前 -Rust API 增加这些字段。 +[`cli-product-line-design.md`](cli-product-line-design.md) 为准。产品身份、品牌资源和界面布局等字段只有在出现 +对应生产消费方和验证路径后才能进入当前 Rust API。 当前组装路径: @@ -988,11 +990,14 @@ Product 测试: - `bitfun-runtime-services` 提供类型化服务注入;工具 contracts、provider groups 与 execution 已分层。 - `bitfun-harness` 已提供类型化工作流描述与注册能力。 - `bitfun-core` 可继续作为 `product-full` 兼容门面,避免迁移期间一次性重写入口。 +- CLI 已有首个生产入口计划投影:`doctor` 选择 `DeliveryProfile::Cli`,显示静态 profile, + 并明确说明未评估运行时 readiness;它尚未构造运行时组装结果。 仍需完成: - 消除 `assembly/core -> apps/relay-server` 的反向依赖,并用通用边界检查固定依赖方向。 -- 让 CLI、ACP、Desktop 依次消费 `DeliveryProfile` 和 `ProductRuntimeParts`,证明组装结果真正进入生产路径。 +- 为 CLI 接入 owner-owned Runtime Services 与调用级权限、权威事件路径,再让执行路径消费 + `ProductRuntimeParts`;之后让 ACP、Desktop 依次接入,并为每条路径证明行为等价。 - 为 Agent Runtime SDK 增加至少一个非 `bitfun-core` 的真实嵌入方;预览 facade 和单元测试不等于外部可用 SDK。 - 仅在真实端到端切片中接入插件主机;外部插件先转换为类型化工具、Hook、事件、权限请求或诊断, 不把生态对象带入 Agent Runtime。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 3dcb7e0083..8cf2618288 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -84,22 +84,23 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop - `exec` 的 stdin、`text/json/stream-json`、会话恢复/分叉和 Patch 输出。 - Agent、模型、MCP、会话、用量、诊断、ACP 外部 Agent 和插件来源管理命令。 - BitFun 原生插件目录的发现、内容校验、来源确认,以及 OpenCode custom tool 静态名称预览。 -- `DeliveryProfile::Cli`、Product Capability、Runtime Services 与 Plugin Runtime Host 的底层契约和测试。 -- 独立 CLI 打包工作流;主 CI 的 workspace check 仍明确排除 `bitfun-cli`。 +- CLI 的 `doctor` 生产命令选择 `DeliveryProfile::Cli` 并投影 `ProductAssemblyPlan`;它展示静态 profile, + 明确说明未评估运行时 readiness,不构造 Runtime Parts 或注册占位 provider。 +- 独立 CLI 测试与打包工作流;主 CI 的三平台 workspace check 同时覆盖 `bitfun-cli` 编译。 -这些基础不等于生产入口已经完成独立组装。当前 CLI crate 仍直接依赖 `bitfun-core` 的 `product-full`, -生产代码尚未消费 `ProductAssembler` 或 `DeliveryProfile::Cli`;插件命令也仍以来源管理和静态预览为主。 +首个只读计划诊断切片不等于 CLI 已完成独立产品化。当前 CLI crate 仍直接依赖 `bitfun-core` 的 +`product-full`,生产代码尚未消费 `ProductAssembler` 或 Runtime Parts;插件命令也仍以来源管理和静态预览为主。 目标态仍存在以下结构缺口: | 缺口 | 影响 | 本设计的处理 | |---|---|---| -| CLI 生产入口仍直接依赖 `bitfun-core/product-full` 和部分具体管理器 | CLI 能力难以独立裁剪,入口继续承担组装和全局状态职责 | 逐步改为 `DeliveryProfile::Cli` 的显式产品组装和统一能力状态接口;迁移期间保留兼容门面。 | +| CLI 只有 `doctor` 消费 `DeliveryProfile::Cli` 的静态计划;所有执行路径仍直接依赖 `bitfun-core/product-full` 和部分具体管理器 | CLI 能力仍难以独立裁剪,执行入口继续承担全局状态职责 | 接入 owner-owned Runtime Services、调用级权限和权威事件路径后,按行为等价测试逐条迁移到 Runtime Parts;迁移期间保留兼容门面。 | | TUI 编排、输入、命令、副作用和渲染仍有大文件聚集 | 交互回归难以隔离,终端状态与业务状态容易耦合 | 在现有模块上增量收敛为事件、状态归约、副作用和渲染四个边界,不重写全部 TUI。 | | CLI 配置只覆盖入口本地选项,缺少统一层级、来源解释和兼容导入 | 用户无法安全迁移其他 CLI 资产,也难以解释最终配置来源 | 建立 BitFun Canonical Config、来源视图和一次性导入报告。 | | OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 直接发现 OpenCode 来源,后台准备真实执行版本;状态明确区分预览、准备、可用和降级。 | | Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | -| 主 CI 仍排除 CLI,当前保护集中在局部测试和打包工作流 | CLI 常规改动缺少稳定的快速门禁 | 增加独立 CLI check/test/协议契约任务;不强制把终端依赖重新塞回通用 workspace job。 | +| CLI 已有独立 Linux 测试,三平台编译由通用 workspace check 覆盖,但结构化协议和常规打包 smoke 尚未进入同一快速门禁 | 协议或产物回归仍可能晚于常规 PR 发现 | 在对应协议和产品构建切片中补 focused contract 与 smoke;避免为同一依赖图重复建立三平台编译矩阵。 | ## 3. 分阶段产品需求 @@ -107,8 +108,9 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop CLI-P0 的目标是建立后续功能补齐所需的稳定边界,不改变现有用户主路径。 -CLI-P0 不是一个统一重构 PR。首个切片只让生产入口提交 `DeliveryProfile::Cli`,消费 Capability Plan、服务与 -扩展可用性,并为一条用户可见路径补行为等价和独立 CLI check/test。旧门面只在该切片等价成立后退出。 +CLI-P0 不是一个统一重构 PR。首个切片只让生产入口选择 `DeliveryProfile::Cli`,读取静态 profile, +不把服务需求或扩展计划解释为运行时 availability,并为一条用户可见诊断路径补入口验证和独立 CLI 测试。旧门面仅在后续执行切片 +行为等价成立后退出。 其余工作独立立项,不能与 profile 迁移互相充当完成条件: @@ -504,8 +506,8 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes | Product build | 当前产品组装结果/TUI 布局生成 CLI smoke artifact;通用 GUI/TUI、品牌、内置扩展和发行验证按产品定制文档执行 | | 平台 | Windows、macOS、Linux 的 build/smoke;Windows 单独覆盖 ConPTY、Ctrl+C、路径和进程树清理 | -CLI 可以继续从通用 `cargo check --workspace` 中排除终端依赖,但必须有等价的独立 CI 任务运行 -`cargo check -p bitfun-cli`、`cargo test -p bitfun-cli`、结构化协议测试和打包 smoke test。 +通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行 +`cargo test --locked -p bitfun-cli`。结构化协议测试和打包 smoke test 在对应切片落地后进入门禁。 ### 10.2 阶段退出条件 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index b16d01dadb..2facbd2942 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -236,7 +236,7 @@ flowchart LR | 产品形态 | 当前 P0 插件能力 | 入口行为 | |---|---|---| | Desktop / product-full | 生产入口仍直接依赖 `bitfun-core/product-full`;当前没有 managed-plugin 管理或 OpenCode 静态预览的生产 UI/调用方 | 共享代码可编译不等于 Desktop 已消费插件能力 | -| CLI | 生产入口仍直接依赖 `bitfun-core/product-full`;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | `DeliveryProfile::Cli` 已有计划与测试,但入口尚未消费;不执行 OpenCode 插件代码 | +| CLI | 所有执行路径仍依赖 `bitfun-core/product-full`;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | `doctor` 选择 `DeliveryProfile::Cli` 并展示静态 profile,但不评估运行时 readiness;尚未构造 Runtime Parts,不代表执行路径已隔离,也不执行 OpenCode 插件代码 | | ACP | 生产入口仍直接依赖 `bitfun-core/product-full` | `DeliveryProfile::Acp` 尚未进入入口组装;不得把测试中的 profile 解释为生产隔离 | | Server / Remote | 当前生产路由没有插件状态消费闭环;Remote 插件执行未实现 | 不在本地替远端项目发现、准备或执行插件;未接入时返回明确不支持 | | Web / Mobile Web | 依赖现有后端入口,不持有插件执行单元 | 对应 profile 当前为空计划或未接入生产,不能据枚举值宣称独立产品能力 | diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 62b14e2e41..bdfcdea796 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -71,6 +71,7 @@ tracing-subscriber = { workspace = true } tempfile = "3" sha2 = { workspace = true } hex = { workspace = true } +bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } [features] default = [] diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 84e681678e..c9ea73d012 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -3,6 +3,7 @@ /// CLI uses core's GlobalConfig system directly. /// Only CLI-specific configuration is kept here (UI, shortcuts, etc.) use anyhow::Result; +use bitfun_core::infrastructure::try_get_path_manager_arc; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -115,20 +116,31 @@ impl Default for ShortcutsConfig { } impl CliConfig { - /// Get configuration file path - pub(crate) fn config_path() -> Result { - let config_dir = if cfg!(target_os = "windows") { + fn resolve_config_dir() -> Result { + let e2e_storage_guard = matches!( + std::env::var("BITFUN_E2E_STORAGE_GUARD").ok().as_deref(), + Some("1") | Some("true") | Some("TRUE") + ); + if e2e_storage_guard { + let path_manager = + try_get_path_manager_arc().map_err(|error| anyhow::anyhow!(error.to_string()))?; + return Ok(path_manager.user_root_dir().to_path_buf()); + } + + if cfg!(target_os = "windows") { dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("Cannot find config directory"))? - .join("bitfun") + .ok_or_else(|| anyhow::anyhow!("Cannot find config directory")) + .map(|path| path.join("bitfun")) } else { dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))? - .join(".config") - .join("bitfun") - }; + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory")) + .map(|path| path.join(".config").join("bitfun")) + } + } - Ok(config_dir.join("config.toml")) + /// Get configuration file path + pub(crate) fn config_path() -> Result { + Ok(Self::resolve_config_dir()?.join("config.toml")) } /// Load configuration @@ -164,16 +176,7 @@ impl CliConfig { /// Get configuration directory pub(crate) fn config_dir() -> Result { - let config_dir = if cfg!(target_os = "windows") { - dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("Cannot find config directory"))? - .join("bitfun") - } else { - dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))? - .join(".config") - .join("bitfun") - }; + let config_dir = Self::resolve_config_dir()?; fs::create_dir_all(&config_dir)?; Ok(config_dir) diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 5bfd10b519..2658193738 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -18,6 +18,7 @@ mod management; mod modes; mod peer_host; mod plugin_diagnostics; +mod product_assembly; mod prompts; mod root_handlers; mod ui; @@ -726,7 +727,8 @@ async fn run_cli() -> Result<()> { } Some(Commands::Doctor) => { - if !management::print_doctor().await? { + let product_plan = product_assembly::cli_product_assembly_plan(); + if !management::print_doctor(&product_plan).await? { std::process::exit(1); } } diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 3c0b7fb7e5..4a6a13c27d 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -14,6 +14,7 @@ use bitfun_core::plugin_source::{ ManagedPluginSourceIssue, ManagedPluginSourceSnapshot, ManagedPluginTrustDecision, ManagedPluginTrustLevel, }; +use bitfun_core::product_assembly::ProductAssemblyPlan; use bitfun_core::service::config::initialize_global_config; use bitfun_core::service::session_usage::{ generate_session_usage_report, render_usage_report_markdown, SessionUsageReportRequest, @@ -609,7 +610,7 @@ pub(crate) async fn print_mcp_config_summary() -> Result<()> { Ok(()) } -pub(crate) async fn print_doctor() -> Result { +pub(crate) async fn print_doctor(product_plan: &ProductAssemblyPlan) -> Result { let workspace = std::env::current_dir().context("Failed to resolve current directory")?; let config_dir = crate::config::CliConfig::config_dir()?; let config_service = ensure_global_config_service().await?; @@ -650,6 +651,10 @@ pub(crate) async fn print_doctor() -> Result { println!("BitFun CLI doctor"); println!(); + println!( + "[info] Product profile: {} (static plan only; runtime readiness not evaluated)", + product_plan.profile().id() + ); println!("[ok] Workspace: {}", workspace.display()); println!("[ok] Config directory: {}", config_dir.display()); println!("[ok] Agent modes: {}", modes.len()); diff --git a/src/apps/cli/src/product_assembly.rs b/src/apps/cli/src/product_assembly.rs new file mode 100644 index 0000000000..878b8bf253 --- /dev/null +++ b/src/apps/cli/src/product_assembly.rs @@ -0,0 +1,49 @@ +use bitfun_core::product_assembly::{ + product_assembly_plan_for_profile, DeliveryProfile, ProductAssemblyPlan, +}; + +pub(crate) fn cli_product_assembly_plan() -> ProductAssemblyPlan { + product_assembly_plan_for_profile(DeliveryProfile::Cli) +} + +#[cfg(test)] +mod tests { + use super::cli_product_assembly_plan; + use bitfun_core::product_assembly::DeliveryProfile; + use bitfun_runtime_ports::{ + PluginRuntimeAvailability, PluginRuntimeUnavailableReason, RuntimeServiceCapability, + }; + + #[test] + fn cli_product_plan_declares_required_services_without_constructing_runtime_parts() { + let plan = cli_product_assembly_plan(); + + assert_eq!(plan.profile(), DeliveryProfile::Cli); + assert!(!plan.capability_assembly().service_requirements().is_empty()); + for capability in [ + RuntimeServiceCapability::FileSystem, + RuntimeServiceCapability::Workspace, + RuntimeServiceCapability::SessionStore, + RuntimeServiceCapability::Permission, + RuntimeServiceCapability::Events, + RuntimeServiceCapability::Clock, + RuntimeServiceCapability::Terminal, + RuntimeServiceCapability::Git, + RuntimeServiceCapability::Network, + ] { + assert!( + plan.capability_assembly() + .service_requirements() + .iter() + .any(|entry| entry.service_capability() == capability), + "CLI profile should require {capability}" + ); + } + assert_eq!( + plan.extension_capabilities().plugin_runtime(), + PluginRuntimeAvailability::Disabled { + reason: PluginRuntimeUnavailableReason::NotBuilt, + } + ); + } +} diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs new file mode 100644 index 0000000000..48c6e397aa --- /dev/null +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -0,0 +1,95 @@ +use std::process::Command; + +#[test] +fn doctor_reports_the_cli_product_plan_without_claiming_runtime_availability() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let user_root = temp.path().join("user-root"); + let home_root = temp.path().join("home-root"); + let config_root = temp.path().join("host-config"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let output = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + .arg("doctor") + .current_dir(&workspace) + .env_remove("BITFUN_USER_ROOT") + .env_remove("BITFUN_HOME") + .env("BITFUN_E2E_STORAGE_GUARD", "1") + .env("BITFUN_E2E_USER_ROOT", &user_root) + .env("BITFUN_E2E_HOME", &home_root) + .env("APPDATA", &config_root) + .env("XDG_CONFIG_HOME", &config_root) + .env("HOME", &home_root) + .output() + .expect("run bitfun-cli doctor"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stderr}"); + assert!( + stdout.contains( + "[info] Product profile: cli (static plan only; runtime readiness not evaluated)" + ), + "{stdout}" + ); + for internal_state in [ + "Product assembly requirements:", + "runtime services not connected", + "Plugin runtime plan:", + "not_built", + "projection_only", + ] { + assert!(!stdout.contains(internal_state), "{stdout}"); + } + assert!( + stdout.contains(&format!("[ok] Config directory: {}", user_root.display())), + "{stdout}" + ); +} + +#[test] +fn doctor_rejects_incomplete_e2e_storage_roots() { + for (case_name, provide_user_root, provide_home_root) in + [("missing-user", false, true), ("missing-home", true, false)] + { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let user_root = temp.path().join("user-root"); + let home_root = temp.path().join("home-root"); + let config_root = temp.path().join("host-config"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let mut command = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")); + command + .arg("doctor") + .current_dir(&workspace) + .env_remove("BITFUN_USER_ROOT") + .env_remove("BITFUN_E2E_USER_ROOT") + .env_remove("BITFUN_HOME") + .env_remove("BITFUN_E2E_HOME") + .env("BITFUN_E2E_STORAGE_GUARD", "1") + .env("APPDATA", &config_root) + .env("XDG_CONFIG_HOME", &config_root) + .env("HOME", &home_root); + if provide_user_root { + command.env("BITFUN_E2E_USER_ROOT", &user_root); + } + if provide_home_root { + command.env("BITFUN_E2E_HOME", &home_root); + } + + let output = command.output().expect("run bitfun-cli doctor"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "{case_name}: {stderr}"); + assert!( + stderr.contains("BITFUN_E2E_STORAGE_GUARD requires isolated") + && stderr.contains("BITFUN_E2E_USER_ROOT") + && stderr.contains("BITFUN_E2E_HOME"), + "{case_name}: {stderr}" + ); + assert!( + !user_root.join("config.toml").exists(), + "{case_name}: config should not be written before guard validation" + ); + } +} diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 95c9333ff2..0650afedba 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -210,6 +210,11 @@ impl PathManager { false } + /// Get the root directory for user-scoped BitFun storage. + pub fn user_root_dir(&self) -> &Path { + &self.user_root + } + /// Get user config directory: ~/.config/bitfun/config/ pub fn user_config_dir(&self) -> PathBuf { self.user_root.join("config")