diff --git a/.claude/agents/build-validator.md b/.claude/agents/build-validator.md index 076f1b4..a17269d 100644 --- a/.claude/agents/build-validator.md +++ b/.claude/agents/build-validator.md @@ -27,7 +27,7 @@ cargo check -p edgezero-core --all-features cargo check -p edgezero-adapter-fastly --features cli cargo check -p edgezero-adapter-cloudflare --features cli cargo check -p edgezero-adapter-axum --features axum -cargo check -p edgezero-cli --features dev-example +cargo check -p edgezero-cli --features demo-example ``` ## Demo apps diff --git a/.claude/agents/verify-app.md b/.claude/agents/verify-app.md index e3a7407..36ac6a1 100644 --- a/.claude/agents/verify-app.md +++ b/.claude/agents/verify-app.md @@ -50,7 +50,7 @@ Demo adapters must build for their respective WASM targets. ## 6. Dev server smoke test ``` -cargo run -p edgezero-cli --features dev-example -- dev & +cargo run -p edgezero-cli --features demo-example -- demo & pid=$! trap 'kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true' EXIT sleep 3 diff --git a/.claude/settings.json b/.claude/settings.json index c671bef..e7e9405 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,32 +1,30 @@ { "permissions": { "allow": [ - "Bash(ls:*)", - "Bash(cat:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(wc:*)", - "Bash(tree:*)", - "Bash(which:*)", - "Bash(cargo build:*)", - "Bash(cargo test:*)", "Bash(cargo check:*)", + "Bash(cargo clippy:*)", + "Bash(cargo fmt:*)", "Bash(cargo metadata:*)", "Bash(cargo run -p edgezero-cli:*)", - - "Bash(cargo fmt:*)", - "Bash(cargo clippy:*)", - + "Bash(cargo test:*)", + "Bash(cat:*)", + "Bash(git branch:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git status:*)", + "Bash(head:*)", + "Bash(ls:*)", "Bash(npm ci:*)", "Bash(npm run:*)", - "Bash(rustup target:*)", - - "Bash(git status:*)", - "Bash(git diff:*)", - "Bash(git log:*)", - "Bash(git branch:*)" + "Bash(tail:*)", + "Bash(tree:*)", + "Bash(wc:*)", + "Bash(which:*)" ] + }, + "enabledPlugins": { + "superpowers@claude-plugins-official": true } } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c472760..9a22536 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,9 @@ jobs: - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" + - name: Verify a generated project compiles + run: cargo test -p edgezero-cli --test generated_project_builds -- --ignored + adapter-wasm-tests: name: ${{ matrix.adapter }} wasm tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7dc139c..ce82db2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ target/ # Worktrees .worktrees/ -# Superpowers plans -docs/superpowers/ - # Editors .claude/* !.claude/settings.json diff --git a/CLAUDE.md b/CLAUDE.md index 849b738..7bc481b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ crates/ edgezero-adapter-cloudflare/# Cloudflare Workers bridge (wasm32-unknown-unknown) edgezero-adapter-spin/ # Fermyon Spin bridge (wasm32-wasip1) edgezero-adapter-axum/ # Axum/Tokio bridge (native, dev server) - edgezero-cli/ # CLI: new, build, deploy, dev, serve + edgezero-cli/ # CLI lib + bin: new, build, deploy, demo, serve examples/app-demo/ # Reference app with all 4 adapters (excluded from workspace) docs/ # VitePress documentation site (Node.js) scripts/ # Build/deploy/test helper scripts @@ -55,8 +55,8 @@ cargo check --workspace --all-targets --features "fastly cloudflare spin" # Spin wasm32 compilation check cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin -# Run the demo dev server -cargo run -p edgezero-cli --features dev-example -- dev +# Run the demo server +cargo run -p edgezero-cli --features demo-example -- demo # Docs site cd docs && npm ci && npm run dev diff --git a/README.md b/README.md index a98f829..629cf7a 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ Production-ready toolkit for portable edge HTTP workloads. Write once, deploy to cargo install --path crates/edgezero-cli # Create a new project -edgezero-cli new my-app +edgezero new my-app cd my-app -# Start the dev server -edgezero-cli dev +# Run it locally on the Axum adapter +edgezero serve --adapter axum # Test it curl http://127.0.0.1:8787/ diff --git a/TODO.md b/TODO.md index 384a7d6..19bf99e 100644 --- a/TODO.md +++ b/TODO.md @@ -35,7 +35,7 @@ High-level backlog and decisions to drive the next milestones. - [ ] Adapters: assert error-path mapping for Fastly/Cloudflare request conversion and re-enable the ignored Cloudflare response header test. - [ ] CLI: add integration tests for `edgezero new` scaffolding, feature-flag builds, and `dev` fallback app. - [ ] CLI: cover `dev_server`, generator, and template scaffolding flows with tempdir-based integration tests to guard manual HTTP parsing and shell commands. -- [ ] CI: verify feature combinations (without `dev-example`, `json`, `form`) compile and run basic smoke tests. +- [ ] CI: verify feature combinations (without `demo-example`, `json`, `form`) compile and run basic smoke tests. - [ ] Macros: add trybuild coverage for `app!` manifest expansion (route/middleware generation and error surfacing). - [x] Core: unit-test `App::build_app`/`Hooks` wiring and `PathParams::deserialize` edge cases beyond indirect coverage. _(Added targeted unit tests in `crates/edgezero-core/src/app.rs` and `crates/edgezero-core/src/params.rs`.)_ - [x] Coverage hygiene: consolidate duplicate router/extractor request-parsing tests and share adapter contract fixtures to reduce redundant maintenance. _(Router duplicates trimmed; extractor suite now owns request parsing checks.)_ @@ -158,7 +158,7 @@ High-level backlog and decisions to drive the next milestones. ## Review (2025-09-18 03:08 UTC) - Implemented `edgezero build|deploy --adapter fastly` by wiring cargo wasm32 builds and Fastly CLI invocation in the CLI. -- Documented optional `dev-example` dependency in `edgezero-cli/README.md` and added error handling for unsupported adapters. +- Documented optional `demo-example` dependency in `edgezero-cli/README.md` and added error handling for unsupported adapters. - Verified builds with `cargo test -p edgezero-cli`. ## Review (2025-09-18 03:27 UTC) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 1c394a5..b56a01c 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -95,7 +95,7 @@ static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { dev_heading: "{display} (local)", dev_steps: &[ "`cd {crate_dir}`", - "`cargo run` or `edgezero-cli serve --adapter axum`", + "`cargo run` or `edgezero serve --adapter axum`", ], }, run_module: "edgezero_adapter_axum", diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 8fe373d..869abb4 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -5,14 +5,14 @@ use std::env; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; -/// Config store for local dev / Axum. Reads from env vars with manifest +/// Config store for local dev / Axum. Reads from env vars with in-memory /// defaults as fallback. Env vars take precedence over defaults. /// /// # Note on `from_env` /// /// [`AxumConfigStore::from_env`] only reads environment variables for keys -/// declared in `[stores.config.defaults]`. Use an empty-string default when a -/// key should be overrideable from env without carrying a real default value. +/// present in the supplied defaults map. The portable manifest no longer +/// carries config-store defaults, so the dev server passes an empty map. pub struct AxumConfigStore { defaults: HashMap, env: HashMap, diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index d15c7e4..c4dc266 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -1,5 +1,6 @@ use std::env; use std::fs; +use std::iter; use std::net::{SocketAddr, TcpListener as StdTcpListener}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -355,9 +356,10 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { if A::config_store().is_some() && manifest_data.stores.config.is_none() { log::warn!("A::config_store() is set but [stores.config] is missing in the manifest. This override is ignored on Axum."); } - let config_store_handle = manifest_data.stores.config.as_ref().map(|cfg| { - let defaults = cfg.config_store_defaults().clone(); - let store = AxumConfigStore::from_env(defaults); + let config_store_handle = manifest_data.stores.config.as_ref().map(|_cfg| { + // The portable manifest no longer carries `[stores.config.defaults]`; + // the axum config store starts empty and reads from the environment. + let store = AxumConfigStore::from_env(iter::empty()); ConfigStoreHandle::new(Arc::new(store)) }); let secret = has_secret_store.then(|| { log::info!("Secret store: reading from environment variables"); SecretHandle::new(Arc::new( @@ -479,7 +481,7 @@ mod tests { let manifest = ManifestLoader::load_from_str( r#" [stores.kv] -name = "EDGEZERO_KV" +ids = ["EDGEZERO_KV"] "#, ); assert_eq!( @@ -603,7 +605,6 @@ mod integration_tests { use edgezero_core::extractor::Secrets; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle as CoreSecretHandle; - use std::iter; use std::time::{Duration, Instant}; use tokio::task::{spawn_blocking, JoinHandle}; use tokio::time::sleep; diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index 805bded..fa0c371 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -45,7 +45,7 @@ static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { readme: ReadmeInfo { description: "{display} entrypoint.", dev_heading: "{display} (local)", - dev_steps: &["`edgezero-cli serve --adapter cloudflare`"], + dev_steps: &["`edgezero serve --adapter cloudflare`"], }, run_module: "edgezero_adapter_cloudflare", }; diff --git a/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs b/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs index 72d2f59..690b5ac 100644 --- a/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs +++ b/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs @@ -6,5 +6,11 @@ use worker::*; #[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::<{{proj_core_mod}}::App>(req, env, ctx).await + edgezero_adapter_cloudflare::run_app::<{{proj_core_mod}}::App>( + include_str!("../../../edgezero.toml"), + req, + env, + ctx, + ) + .await } diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 529be98..52431fb 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -45,7 +45,7 @@ static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { readme: ReadmeInfo { description: "{display} entrypoint.", dev_heading: "{display} (local)", - dev_steps: &["`cd {crate_dir}`", "`edgezero-cli serve --adapter fastly`"], + dev_steps: &["`cd {crate_dir}`", "`edgezero serve --adapter fastly`"], }, run_module: "edgezero_adapter_fastly", }; diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index c8bebf3..3c56bcc 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -45,7 +45,7 @@ static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { readme: ReadmeInfo { description: "{display} entrypoint.", dev_heading: "{display} (local)", - dev_steps: &["`edgezero-cli serve --adapter spin`"], + dev_steps: &["`edgezero serve --adapter spin`"], }, run_module: "edgezero_adapter_spin", }; diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index a6dbd3a..9637f7e 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -155,22 +155,18 @@ mod tests { } #[test] - fn store_settings_resolve_spin_manifest_overrides() { + fn store_settings_resolve_spin_manifest_declarations() { let settings = resolve_settings( r#" [stores.kv] -name = "GLOBAL_KV" - -[stores.kv.adapters.spin] -name = "SPIN_KV" +ids = ["SPIN_KV", "cache"] +default = "SPIN_KV" [stores.config] +ids = ["app_config"] [stores.secrets] -enabled = false - -[stores.secrets.adapters.spin] -enabled = true +ids = ["default"] "#, false, ); diff --git a/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs b/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs index 1839924..a4db77d 100644 --- a/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs +++ b/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs @@ -1,3 +1,11 @@ +#![cfg_attr( + target_arch = "wasm32", + allow( + unsafe_code, + reason = "spin's #[http_component] macro generates the unsafe wasm export" + ) +)] + #[cfg(target_arch = "wasm32")] use spin_sdk::http::{IncomingRequest, IntoResponse}; #[cfg(target_arch = "wasm32")] diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 801e316..6a4c5d1 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -8,6 +8,10 @@ description = "EdgeZero CLI: build and deploy to multiple edge adapters" [lints] workspace = true +[[bin]] +name = "edgezero" +path = "src/main.rs" + [dependencies] edgezero-core = { workspace = true } edgezero-adapter = { path = "../edgezero-adapter" } @@ -41,4 +45,4 @@ default = [ "edgezero-adapter-spin", ] cli = ["dep:clap"] -dev-example = ["dep:app-demo-core"] +demo-example = ["dep:app-demo-core", "edgezero-adapter-axum"] diff --git a/crates/edgezero-cli/README.md b/crates/edgezero-cli/README.md index 0ea16fb..e9457c8 100644 --- a/crates/edgezero-cli/README.md +++ b/crates/edgezero-cli/README.md @@ -9,7 +9,7 @@ The crate exposes two cargo features: | Feature | Description | Enabled by default | |----------------|----------------------------------------------------------|--------------------| | `cli` | Builds the command-line interface (`edgezero` binary). | ✅ | -| `dev-example` | Pulls in `examples/app-demo/app-demo-core` so `edgezero dev` can boot the bundled demo app. Enable only when you want the sample router available. | ❌ | +| `demo-example` | Pulls in `examples/app-demo/app-demo-core` so `edgezero demo` can boot the bundled example app. Contributor-only; enable when working on the in-repo example. | ❌ | When you just need the CLI functionality (e.g. packaging for distribution), build without the demo feature: @@ -17,10 +17,10 @@ When you just need the CLI functionality (e.g. packaging for distribution), buil cargo build -p edgezero-cli --no-default-features --features cli ``` -For contributors working on the demo, enable the extra feature: +For contributors working on the bundled example, enable the extra feature: ```bash -cargo run -p edgezero-cli --features "cli,dev-example" -- dev +cargo run -p edgezero-cli --features "cli,demo-example" -- demo ``` ## Commands @@ -28,7 +28,8 @@ cargo run -p edgezero-cli --features "cli,dev-example" -- dev _(summaries only; see `edgezero --help` for details)_ - `edgezero new ` – Scaffold a new EdgeZero project (templates still evolving). -- `edgezero dev` – Serve the current project locally (add `--features dev-example` to run the bundled demo). +- `edgezero serve --adapter ` – Run the current project locally on the named adapter. +- `edgezero demo` – Run the bundled `app-demo` example locally (contributor-only; requires `--features demo-example`). - `edgezero build --adapter fastly` – Compile the Fastly crate to `wasm32-wasip1` and drop the artifact in `pkg/`. - `edgezero deploy --adapter fastly` – Invoke the Fastly CLI (`fastly compute deploy`) from the detected Fastly crate. - `edgezero serve --adapter fastly` – Run `fastly compute serve` in the Fastly crate directory for local testing (requires Fastly CLI). diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 9256233..7fd7ee6 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -10,46 +10,80 @@ pub struct Args { #[derive(Subcommand, Debug)] pub enum Command { /// Build the project for a target edge. - Build { - #[arg(long = "adapter", required = true)] - adapter: String, - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - adapter_args: Vec, - }, + Build(BuildArgs), + /// Run the bundled `app-demo` example locally (contributor-only). + #[cfg(feature = "demo-example")] + Demo, /// Deploy to a target edge. - Deploy { - #[arg(long = "adapter", required = true)] - adapter: String, - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - adapter_args: Vec, - }, - /// Run a local simulation (if available). - Dev, + Deploy(DeployArgs), /// Create a new `EdgeZero` app skeleton (multi-crate workspace). New(NewArgs), /// Run a local simulation (adapter-specific). - Serve { - #[arg(long = "adapter", required = true)] - adapter: String, - }, + Serve(ServeArgs), } -#[derive(clap::Args, Debug)] +/// Arguments for the `build` command. +#[derive(clap::Args, Debug, Default)] +#[non_exhaustive] +pub struct BuildArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, + /// Arguments passed through to the adapter build command. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub adapter_args: Vec, +} + +/// Arguments for the `deploy` command. +#[derive(clap::Args, Debug, Default)] +#[non_exhaustive] +pub struct DeployArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, + /// Arguments passed through to the adapter deploy command. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub adapter_args: Vec, +} + +/// Arguments for the `new` command. +#[derive(clap::Args, Debug, Default)] +#[non_exhaustive] pub struct NewArgs { /// Directory to create the app in (default: current dir). #[arg(long)] pub dir: Option, - /// Force using a local path dependency to edgezero-core (if available). - #[arg(long)] - pub local_core: bool, /// App name (e.g., my-edge-app). pub name: String, } +/// Arguments for the `serve` command. +#[derive(clap::Args, Debug, Default)] +#[non_exhaustive] +pub struct ServeArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn build_args_derives_default() { + let args = BuildArgs::default(); + assert!(args.adapter.is_empty()); + assert!(args.adapter_args.is_empty()); + } + + #[test] + fn new_args_derives_default() { + let args = NewArgs::default(); + assert!(args.name.is_empty()); + assert!(args.dir.is_none()); + } + #[test] fn missing_required_adapter_returns_error() { Args::try_parse_from(["edgezero", "build"]).expect_err("missing --adapter"); @@ -67,10 +101,10 @@ mod tests { "value", ]) .expect("parse build"); - let Command::Build { + let Command::Build(BuildArgs { adapter, adapter_args, - } = args.cmd + }) = args.cmd else { panic!("expected Command::Build"); }; @@ -86,6 +120,5 @@ mod tests { }; assert_eq!(new_args.name, "demo-app"); assert!(new_args.dir.is_none()); - assert!(!new_args.local_core); } } diff --git a/crates/edgezero-cli/src/demo_server.rs b/crates/edgezero-cli/src/demo_server.rs new file mode 100644 index 0000000..a1b89b4 --- /dev/null +++ b/crates/edgezero-cli/src/demo_server.rs @@ -0,0 +1,29 @@ +#![cfg(feature = "demo-example")] + +//! The `edgezero demo` subcommand. +//! +//! `demo` runs the bundled `app-demo` example locally — the **same way** +//! `app-demo`'s own axum adapter runs it: via +//! [`edgezero_adapter_axum::dev_server::run_app`], which loads +//! `app-demo`'s `edgezero.toml` and wires the full setup (routing, KV / +//! config / secret stores, logging, host/port). +//! +//! This is a contributor-only convenience: it depends on the in-repo +//! `examples/app-demo` crate, so it is compiled only under the +//! `demo-example` feature and is not part of any shipped CLI. + +/// Run the bundled `app-demo` example on the local axum server. +/// +/// Delegates to `run_app`, so `edgezero demo` behaves identically to +/// `cargo run -p app-demo-adapter-axum`. +/// +/// # Errors +/// +/// Returns an error if the demo server fails to start. +pub fn run_demo() -> Result<(), String> { + use app_demo_core::App; + use edgezero_adapter_axum::dev_server::run_app; + + run_app::(include_str!("../../../examples/app-demo/edgezero.toml")) + .map_err(|err| format!("demo server error: {err}")) +} diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs deleted file mode 100644 index f05125c..0000000 --- a/crates/edgezero-cli/src/dev_server.rs +++ /dev/null @@ -1,124 +0,0 @@ -#![cfg(feature = "edgezero-adapter-axum")] - -use std::env; -use std::io::ErrorKind; -use std::net::SocketAddr; -use std::path::PathBuf; - -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; -use edgezero_core::addr; -use edgezero_core::manifest::ManifestLoader; -use edgezero_core::router::RouterService; - -use crate::adapter; -use crate::adapter::Action; - -#[cfg(not(feature = "dev-example"))] -use edgezero_core::{action, extractor::Path, response::Text}; - -#[cfg(feature = "dev-example")] -use app_demo_core::App; -#[cfg(feature = "dev-example")] -use edgezero_core::app::Hooks as _; - -#[cfg(not(feature = "dev-example"))] -#[derive(serde::Deserialize)] -struct EchoParams { - name: String, -} - -pub fn run_dev() { - match try_run_manifest_axum() { - Ok(true) => return, - Ok(false) => {} - Err(err) => log::error!("[edgezero] dev manifest error: {err}"), - } - - let addr = resolve_dev_addr(); - log::info!( - "[edgezero] dev: starting local server on http://{}:{}", - addr.ip(), - addr.port() - ); - - let router = build_dev_router(); - let config = AxumDevServerConfig { - addr, - ..AxumDevServerConfig::default() - }; - - let server = AxumDevServer::with_config(router, config); - if let Err(err) = server.run() { - log::error!("[edgezero] dev server error: {err}"); - } -} - -fn build_dev_router() -> RouterService { - #[cfg(feature = "dev-example")] - { - let demo_app = App::build_app(); - demo_app.router().clone() - } - - #[cfg(not(feature = "dev-example"))] - { - default_router() - } -} - -#[cfg(not(feature = "dev-example"))] -fn default_router() -> RouterService { - RouterService::builder() - .get("/", dev_root) - .get("/echo/{name}", dev_echo) - .build() -} - -#[cfg(not(feature = "dev-example"))] -#[action] -async fn dev_root() -> Text<&'static str> { - Text::new("EdgeZero dev server") -} - -#[cfg(not(feature = "dev-example"))] -#[action] -async fn dev_echo(Path(params): Path) -> Text { - Text::new(format!("hello {}", params.name)) -} - -/// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` -/// environment variables, falling back to `127.0.0.1:8787`. -fn resolve_dev_addr() -> SocketAddr { - let env_host = env::var("EDGEZERO_HOST").ok(); - let env_port = env::var("EDGEZERO_PORT").ok(); - let resolution = addr::resolve_bind_addr(env_host.as_deref(), env_port.as_deref(), None, None); - for warning in &resolution.warnings { - log::warn!("[edgezero] {warning}"); - } - resolution.addr -} - -fn try_run_manifest_axum() -> Result { - let Some(manifest) = load_manifest_optional()? else { - return Ok(false); - }; - - if manifest.manifest().adapters.contains_key("axum") { - adapter::execute("axum", Action::Serve, Some(&manifest), &[]) - .map_err(|err| format!("serve command failed: {err}"))?; - return Ok(true); - } - - Ok(false) -} - -fn load_manifest_optional() -> Result, String> { - let path = env::var("EDGEZERO_MANIFEST") - .map_or_else(|_| PathBuf::from("edgezero.toml"), PathBuf::from); - - match ManifestLoader::from_path(&path) { - Ok(manifest) => Ok(Some(manifest)), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), - Err(err) => Err(format!("failed to load {}: {err}", path.display())), - } -} diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 6e30ad7..8e87422 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -63,6 +63,8 @@ struct AdapterContext<'blueprint> { } struct ProjectLayout { + cli_dir: PathBuf, + cli_name: String, core_dir: PathBuf, core_mod: String, core_name: String, @@ -92,9 +94,16 @@ impl ProjectLayout { let core_src = core_dir.join("src"); fs::create_dir_all(&core_src).map_err(|err| GeneratorError::io(&core_src, err))?; + let cli_name = format!("{name}-cli"); + let cli_dir = crates_dir.join(&cli_name); + let cli_src = cli_dir.join("src"); + fs::create_dir_all(&cli_src).map_err(|err| GeneratorError::io(&cli_src, err))?; + let project_mod = name.replace('-', "_"); let core_mod = core_name.replace('-', "_"); Ok(ProjectLayout { + cli_dir, + cli_name, core_dir, core_mod, core_name, @@ -115,6 +124,21 @@ struct AdapterArtifacts { workspace_members: Vec, } +/// Locate the edgezero checkout that built this binary. +/// +/// `CARGO_MANIFEST_DIR` is baked in at compile time and points at +/// `crates/edgezero-cli`; its grandparent is the workspace root. Returns +/// `None` when that path no longer holds a checkout (e.g. an installed +/// binary whose source tree was moved or removed), in which case +/// dependency resolution falls back to Git. +fn edgezero_repo_root() -> Option { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = manifest_dir.parent()?.parent()?; + let is_checkout = root.join("crates/edgezero-cli/src/lib.rs").is_file() + && root.join("crates/edgezero-core/src/lib.rs").is_file(); + is_checkout.then(|| root.to_path_buf()) +} + /// # Errors /// Returns [`GeneratorError`] if any filesystem operation, template render, /// or layout invariant fails. @@ -122,14 +146,23 @@ pub fn generate_new(args: &NewArgs) -> Result<(), GeneratorError> { let layout = ProjectLayout::new(args)?; let mut workspace_dependencies = seed_workspace_dependencies(); - let cwd = env::current_dir().map_err(|err| GeneratorError::io(".", err))?; - let core_crate_line = resolve_core_dependency(&layout, &cwd, &mut workspace_dependencies); + // Resolve edgezero dependencies against the checkout that built this + // binary so generated projects use path dependencies wherever they are + // created. Only an installed binary detached from its source tree falls + // back to the current directory (and then, typically, to Git). + let repo_root = match edgezero_repo_root() { + Some(root) => root, + None => env::current_dir().map_err(|err| GeneratorError::io(".", err))?, + }; + let core_crate_line = resolve_core_dependency(&layout, &repo_root, &mut workspace_dependencies); + let cli_crate_line = resolve_cli_dependency(&layout, &repo_root, &mut workspace_dependencies); - let adapter_artifacts = collect_adapter_data(&layout, &cwd, &mut workspace_dependencies)?; + let adapter_artifacts = collect_adapter_data(&layout, &repo_root, &mut workspace_dependencies)?; let mut data_map = build_base_data( &layout, &core_crate_line, + &cli_crate_line, &adapter_artifacts, &workspace_dependencies, ); @@ -163,6 +196,10 @@ fn seed_workspace_dependencies() -> BTreeMap { .to_owned(), ); deps.insert("axum".to_owned(), "axum = \"0.8\"".to_owned()); + deps.insert( + "clap".to_owned(), + "clap = { version = \"4\", features = [\"derive\"] }".to_owned(), + ); deps.insert( "serde".to_owned(), "serde = { version = \"1\", features = [\"derive\"] }".to_owned(), @@ -191,9 +228,38 @@ fn seed_workspace_dependencies() -> BTreeMap { deps } +fn resolve_cli_dependency( + layout: &ProjectLayout, + repo_root: &Path, + workspace_dependencies: &mut BTreeMap, +) -> String { + const CLI_GIT_FALLBACK: &str = "edgezero-cli = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-cli\" }"; + + let ResolvedDependency { + name, + workspace_line, + crate_line, + } = resolve_dep_line( + &layout.out_dir, + repo_root, + "crates/edgezero-cli", + CLI_GIT_FALLBACK, + &[], + ); + + if workspace_line == CLI_GIT_FALLBACK { + log::warn!( + "[edgezero] the generated CLI crate depends on `edgezero-cli` via a Git fallback; it will not build until `edgezero-cli` is available as a library on the referenced remote. Run `edgezero new` from inside an edgezero checkout to use a path dependency instead." + ); + } + + workspace_dependencies.entry(name).or_insert(workspace_line); + crate_line +} + fn resolve_core_dependency( layout: &ProjectLayout, - cwd: &Path, + repo_root: &Path, workspace_dependencies: &mut BTreeMap, ) -> String { let ResolvedDependency { @@ -202,7 +268,7 @@ fn resolve_core_dependency( crate_line, } = resolve_dep_line( &layout.out_dir, - cwd, + repo_root, "crates/edgezero-core", "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", &[], @@ -214,7 +280,7 @@ fn resolve_core_dependency( fn collect_adapter_data( layout: &ProjectLayout, - cwd: &Path, + repo_root: &Path, workspace_dependencies: &mut BTreeMap, ) -> Result { let mut contexts = Vec::new(); @@ -236,7 +302,7 @@ fn collect_adapter_data( let crate_dir_rel = format!("crates/{crate_name}"); let data_entries = blueprint_data_entries( layout, - cwd, + repo_root, blueprint, &crate_name, &crate_dir_rel, @@ -281,7 +347,7 @@ fn collect_adapter_data( /// resolving its dependencies and recording them in `workspace_dependencies`. fn blueprint_data_entries( layout: &ProjectLayout, - cwd: &Path, + repo_root: &Path, blueprint: &'static AdapterBlueprint, crate_name: &str, crate_dir_rel: &str, @@ -301,7 +367,7 @@ fn blueprint_data_entries( crate_line, } = resolve_dep_line( &layout.out_dir, - cwd, + repo_root, dep.repo_crate, dep.fallback, dep.features, @@ -429,12 +495,14 @@ fn append_readme_entries( fn build_base_data( layout: &ProjectLayout, core_crate_line: &str, + cli_crate_line: &str, artifacts: &AdapterArtifacts, workspace_dependencies: &BTreeMap, ) -> Map { let mut data = Map::new(); data.insert("name".into(), Value::String(layout.name.clone())); data.insert("proj_core".into(), Value::String(layout.core_name.clone())); + data.insert("proj_cli".into(), Value::String(layout.cli_name.clone())); data.insert( "proj_core_mod".into(), Value::String(layout.core_mod.clone()), @@ -444,6 +512,10 @@ fn build_base_data( "dep_edgezero_core".into(), Value::String(core_crate_line.to_owned()), ); + data.insert( + "dep_edgezero_cli".into(), + Value::String(cli_crate_line.to_owned()), + ); let adapter_list_str = artifacts .adapter_ids @@ -542,6 +614,20 @@ fn render_templates( &layout.core_dir.join("src/handlers.rs"), )?; + log::info!("[edgezero] writing cli crate {}", layout.cli_name); + write_tmpl( + &hbs, + "cli_Cargo_toml", + data_value, + &layout.cli_dir.join("Cargo.toml"), + )?; + write_tmpl( + &hbs, + "cli_src_main_rs", + data_value, + &layout.cli_dir.join("src/main.rs"), + )?; + for context in adapter_contexts { let crate_dir_name = context .dir @@ -637,57 +723,78 @@ mod tests { .contains("failed to format generator output")); } - #[test] - fn generate_new_scaffolds_workspace_layout() { - let temp = TempDir::new().expect("temp dir"); - let bin_dir = temp.path().join("bin"); - fs::create_dir_all(&bin_dir).expect("bin dir"); + fn write_git_stub(bin_dir: &Path) { + fs::create_dir_all(bin_dir).expect("bin dir"); let git_path = if cfg!(windows) { bin_dir.join("git.cmd") } else { bin_dir.join("git") }; - if cfg!(windows) { fs::write(&git_path, b"@echo off\r\nexit /b 0\r\n").expect("write git stub"); } else { fs::write(&git_path, b"#!/bin/sh\nexit 0\n").expect("write git stub"); } - #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; let mut perms = fs::metadata(&git_path).expect("metadata").permissions(); perms.set_mode(0o755); fs::set_permissions(&git_path, perms).expect("chmod"); - }; - - let _path_guard = PathOverride::prepend(&bin_dir); - - let args = NewArgs { - name: "demo-app".into(), - dir: Some(temp.path().to_string_lossy().into_owned()), - local_core: false, - }; - - generate_new(&args).expect("scaffold succeeds"); + } + } - let project_dir = temp.path().join("demo-app"); + fn assert_scaffold_files(project_dir: &Path) { assert!(project_dir.is_dir(), "project directory created"); assert!(project_dir.join("Cargo.toml").exists()); assert!(project_dir.join("edgezero.toml").exists()); assert!(project_dir.join(".gitignore").exists()); assert!(project_dir.join("README.md").exists()); assert!(project_dir.join("crates/demo-app-core/src/lib.rs").exists()); + assert!( + project_dir.join("crates/demo-app-cli/Cargo.toml").exists(), + "-cli crate Cargo.toml should be scaffolded" + ); + assert!( + project_dir.join("crates/demo-app-cli/src/main.rs").exists(), + "-cli crate main.rs should be scaffolded" + ); + assert!( + project_dir + .join("crates/demo-app-adapter-spin/spin.toml") + .exists(), + "spin.toml should be scaffolded" + ); + } + fn assert_scaffold_workspace(project_dir: &Path) { let cargo_toml = fs::read_to_string(project_dir.join("Cargo.toml")).expect("read Cargo.toml"); - assert!(cargo_toml.contains("crates/demo-app-core")); - assert!(cargo_toml.contains("crates/demo-app-adapter-cloudflare")); - assert!(cargo_toml.contains("crates/demo-app-adapter-fastly")); + for member in [ + "crates/demo-app-core", + "crates/demo-app-cli", + "crates/demo-app-adapter-cloudflare", + "crates/demo-app-adapter-fastly", + "crates/demo-app-adapter-spin", + ] { + assert!( + cargo_toml.contains(member), + "workspace Cargo.toml should include {member}" + ); + } + assert!(cargo_toml.contains("[workspace.lints.clippy]")); + assert!(cargo_toml.contains("blanket_clippy_restriction_lints = \"allow\"")); + + // Generated from a checkout: edgezero crates must resolve to local + // path dependencies, not the Git fallback (whose `edgezero-cli` has + // no library target until this work is published). assert!( - cargo_toml.contains("crates/demo-app-adapter-spin"), - "workspace Cargo.toml should include spin adapter" + cargo_toml.contains("edgezero-cli = { path ="), + "edgezero-cli must resolve to a local path dependency" + ); + assert!( + cargo_toml.contains("edgezero-core = { path ="), + "edgezero-core must resolve to a local path dependency" ); let manifest = @@ -698,25 +805,18 @@ mod tests { manifest.contains("[adapters.spin"), "edgezero.toml should include spin adapter section" ); - assert!( - project_dir - .join("crates/demo-app-adapter-spin/spin.toml") - .exists(), - "spin.toml should be scaffolded" - ); let gitignore = fs::read_to_string(project_dir.join(".gitignore")).expect("read .gitignore"); assert!(gitignore.contains("target/")); - let clippy = fs::read_to_string(project_dir.join("clippy.toml")).expect("read clippy.toml"); assert!(clippy.contains("allow-expect-in-tests = true")); + } - assert!(cargo_toml.contains("[workspace.lints.clippy]")); - assert!(cargo_toml.contains("blanket_clippy_restriction_lints = \"allow\"")); - + fn assert_scaffold_crate_lints(project_dir: &Path) { for crate_dir in [ "crates/demo-app-core", + "crates/demo-app-cli", "crates/demo-app-adapter-axum", "crates/demo-app-adapter-cloudflare", "crates/demo-app-adapter-fastly", @@ -731,7 +831,7 @@ mod tests { ); } - assert_generated_sources_are_lint_clean(&project_dir); + assert_generated_sources_are_lint_clean(project_dir); } /// Regression guard for the generated sources: a freshly scaffolded @@ -771,4 +871,24 @@ mod tests { "adapter attributes must carry a reason for allow_attributes_without_reason", ); } + + #[test] + fn generate_new_scaffolds_workspace_layout() { + let temp = TempDir::new().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + write_git_stub(&bin_dir); + let _path_guard = PathOverride::prepend(&bin_dir); + + let args = NewArgs { + name: "demo-app".into(), + dir: Some(temp.path().to_string_lossy().into_owned()), + }; + + generate_new(&args).expect("scaffold succeeds"); + + let project_dir = temp.path().join("demo-app"); + assert_scaffold_files(&project_dir); + assert_scaffold_workspace(&project_dir); + assert_scaffold_crate_lints(&project_dir); + } } diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs new file mode 100644 index 0000000..2bebc39 --- /dev/null +++ b/crates/edgezero-cli/src/lib.rs @@ -0,0 +1,398 @@ +//! `EdgeZero` CLI library. +//! +//! Exposes the built-in command handlers (`run_build`, `run_deploy`, +//! `run_new`, `run_serve`) and their argument structs so downstream +//! projects can build their own CLI binary that reuses any subset of +//! edgezero's built-in commands. The default `edgezero` binary +//! (`main.rs`) is a thin wrapper over this library. +//! +//! `run_demo` is an additional contributor-only handler, available only +//! under the `demo-example` feature — it runs the in-repo `app-demo` +//! example and is not meant for downstream CLIs. + +#[cfg(feature = "cli")] +mod adapter; +#[cfg(all(feature = "cli", feature = "demo-example"))] +mod demo_server; +#[cfg(feature = "cli")] +mod generator; +#[cfg(feature = "cli")] +mod scaffold; + +/// CLI argument structs (`Args`, `Command`, and the per-command `*Args` +/// types). A `pub mod` so downstream binaries can reuse the built-in +/// command argument types — e.g. `edgezero_cli::args::BuildArgs`. +#[cfg(feature = "cli")] +pub mod args; + +#[cfg(feature = "cli")] +use args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; +#[cfg(feature = "cli")] +use edgezero_core::manifest::ManifestLoader; +#[cfg(feature = "cli")] +use std::env; +#[cfg(feature = "cli")] +use std::io::ErrorKind; +#[cfg(feature = "cli")] +use std::path::PathBuf; + +/// Initialize a CLI logger that prints messages without timestamps or level +/// prefixes — the CLI's output IS the user-facing UX, not a debug log. +#[cfg(feature = "cli")] +#[inline] +pub fn init_cli_logger() { + use log::LevelFilter; + use simple_logger::SimpleLogger; + let _logger_init = SimpleLogger::new() + .with_level(LevelFilter::Info) + .without_timestamps() + .with_module_level("edgezero_cli", LevelFilter::Info) + .init(); +} + +/// Build the project for a target edge adapter. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is not +/// configured, or the adapter build command fails. +#[cfg(feature = "cli")] +#[inline] +pub fn run_build(args: &BuildArgs) -> Result<(), String> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + if let Some(loader) = &manifest { + log_store_bindings(&args.adapter, loader); + } + adapter::execute( + &args.adapter, + adapter::Action::Build, + manifest.as_ref(), + &args.adapter_args, + ) +} + +/// Deploy the project to a target edge adapter. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is not +/// configured, or the adapter deploy command fails. +#[cfg(feature = "cli")] +#[inline] +pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + adapter::execute( + &args.adapter, + adapter::Action::Deploy, + manifest.as_ref(), + &args.adapter_args, + ) +} + +/// Run a local simulation for a target edge adapter. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is not +/// configured, or the adapter serve command fails. +#[cfg(feature = "cli")] +#[inline] +pub fn run_serve(args: &ServeArgs) -> Result<(), String> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + adapter::execute( + &args.adapter, + adapter::Action::Serve, + manifest.as_ref(), + &[], + ) +} + +/// Create a new `EdgeZero` app skeleton. +/// +/// # Errors +/// +/// Returns an error if the project cannot be scaffolded. +#[cfg(feature = "cli")] +#[inline] +pub fn run_new(args: &NewArgs) -> Result<(), String> { + generator::generate_new(args).map_err(|err| err.to_string()) +} + +/// Run the bundled `app-demo` example locally on the axum dev server. +/// +/// Contributor-only: available only under the `demo-example` feature, +/// which pulls in the in-repo `examples/app-demo` crate. +/// +/// # Errors +/// +/// Returns an error if the demo server fails to start. +#[cfg(all(feature = "cli", feature = "demo-example"))] +#[inline] +pub fn run_demo() -> Result<(), String> { + demo_server::run_demo() +} + +#[cfg(feature = "cli")] +fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Option { + let manifest_data = manifest.manifest(); + if !manifest_data.secret_store_enabled(adapter_name) { + return None; + } + + // Note: the configured binding identifier is intentionally NOT included in + // this log line. CodeQL's `rust/cleartext-logging` rule taints any value + // returned by a function whose name contains "secret" (it can't tell + // metadata from secret material), and adapters/operators can read the + // binding name from their own `edgezero.toml` if they need to verify it. + let message = match adapter_name { + "axum" => "[edgezero] secrets enabled for axum -- ensure the required environment variables are set for local runs", + "cloudflare" => "[edgezero] secrets enabled for cloudflare -- ensure the required secret bindings exist in wrangler", + _ => "[edgezero] secrets enabled -- ensure the configured secret store is provisioned on the target platform", + }; + + Some(message.to_owned()) +} + +#[cfg(feature = "cli")] +fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { + if let Some(message) = store_bindings_message(adapter_name, manifest) { + log::info!("{message}"); + } +} + +#[cfg(feature = "cli")] +fn ensure_adapter_defined( + adapter_name: &str, + manifest_loader: Option<&ManifestLoader>, +) -> Result<(), String> { + if let Some(loader) = manifest_loader { + if loader.manifest().adapters.contains_key(adapter_name) { + return Ok(()); + } + let available: Vec = loader.manifest().adapters.keys().cloned().collect(); + if available.is_empty() { + Err(format!( + "adapter `{adapter_name}` is not configured in edgezero.toml (no adapters defined)" + )) + } else { + Err(format!( + "adapter `{}` is not configured in edgezero.toml (available: {})", + adapter_name, + available.join(", ") + )) + } + } else { + Ok(()) + } +} + +#[cfg(feature = "cli")] +fn load_manifest_optional() -> Result, String> { + let path = env::var("EDGEZERO_MANIFEST") + .map_or_else(|_| PathBuf::from("edgezero.toml"), PathBuf::from); + + match ManifestLoader::from_path(&path) { + Ok(loader) => Ok(Some(loader)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(format!("failed to load {}: {err}", path.display())), + } +} + +#[cfg(test)] +#[cfg(feature = "cli")] +mod tests { + use super::*; + use edgezero_core::manifest::ManifestLoader; + use std::fs; + use std::sync::{Mutex, OnceLock}; + use tempfile::TempDir; + + const BASIC_MANIFEST: &str = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "crates/demo-fastly/fastly.toml" + +[adapters.fastly.build] +target = "wasm32-unknown-unknown" +profile = "release" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + + struct EnvOverride { + key: &'static str, + original: Option, + } + + impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(original) = &self.original { + env::set_var(self.key, original); + } else { + env::remove_var(self.key); + } + } + } + + impl EnvOverride { + fn set(key: &'static str, value: &str) -> Self { + let original = env::var(key).ok(); + env::set_var(key, value); + Self { key, original } + } + } + + fn manifest_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) + } + + #[test] + fn load_manifest_optional_returns_none_when_missing() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("missing.toml"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let result = load_manifest_optional().expect("load result"); + assert!(result.is_none()); + } + + #[test] + fn load_manifest_optional_reads_manifest() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let manifest = load_manifest_optional() + .expect("load result") + .expect("manifest present"); + assert!(manifest.manifest().adapters.contains_key("fastly")); + } + + #[test] + fn ensure_adapter_defined_accepts_known_adapter() { + let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); + ensure_adapter_defined("fastly", Some(&loader)).expect("known adapter"); + } + + #[test] + fn ensure_adapter_defined_reports_unknown_adapter() { + let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); + let err = ensure_adapter_defined("cloudflare", Some(&loader)).expect_err("should err"); + assert!(err.contains("available")); + assert!(err.contains("fastly")); + } + + #[test] + fn ensure_adapter_defined_allows_when_manifest_missing() { + ensure_adapter_defined("fastly", None).expect("manifest missing -> permissive"); + } + + #[cfg(not(windows))] + #[test] + fn run_build_executes_manifest_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let args = BuildArgs { + adapter: "fastly".to_owned(), + adapter_args: Vec::new(), + }; + run_build(&args).expect("build command runs"); + } + + #[cfg(not(windows))] + #[test] + fn run_deploy_executes_manifest_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let args = DeployArgs { + adapter: "fastly".to_owned(), + adapter_args: Vec::new(), + }; + run_deploy(&args).expect("deploy command runs"); + } + + #[cfg(not(windows))] + #[test] + fn run_serve_executes_manifest_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let args = ServeArgs { + adapter: "fastly".to_owned(), + }; + run_serve(&args).expect("serve command runs"); + } + + #[test] + fn secret_store_binding_is_readable_from_manifest() { + let manifest_with_secrets = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[stores.secrets] +ids = ["MY_SECRETS"] + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + let loader = ManifestLoader::load_from_str(manifest_with_secrets); + assert_eq!( + loader.manifest().secret_store_binding("fastly"), + "MY_SECRETS" + ); + assert!(loader.manifest().stores.secrets.is_some()); + } + + #[test] + fn store_bindings_message_is_adapter_specific() { + let loader = ManifestLoader::load_from_str( + r#" +[stores.secrets] +ids = ["MY_SECRETS"] +"#, + ); + + let axum = store_bindings_message("axum", &loader).expect("axum message"); + assert!(axum.contains("environment variables")); + + let cloudflare = store_bindings_message("cloudflare", &loader).expect("cloudflare message"); + assert!(cloudflare.contains("wrangler")); + + let fastly = store_bindings_message("fastly", &loader).expect("fastly message"); + assert!(fastly.contains("secrets enabled")); + } + + #[test] + fn store_bindings_message_is_absent_without_secret_store() { + let loader = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert!(store_bindings_message("fastly", &loader).is_none()); + } +} diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index afdde45..f4a095c 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -1,92 +1,23 @@ -//! `EdgeZero` CLI. - -#[cfg(feature = "cli")] -mod adapter; -#[cfg(feature = "cli")] -mod args; -#[cfg(all(feature = "cli", feature = "edgezero-adapter-axum"))] -mod dev_server; -#[cfg(feature = "cli")] -mod generator; -#[cfg(feature = "cli")] -mod scaffold; - -#[cfg(feature = "cli")] -use edgezero_core::manifest::ManifestLoader; -#[cfg(feature = "cli")] -use std::env; -#[cfg(feature = "cli")] -use std::io::ErrorKind; -#[cfg(feature = "cli")] -use std::path::PathBuf; -#[cfg(feature = "cli")] -use std::process; - -/// Initialize a CLI logger that prints messages without timestamps or level -/// prefixes — the CLI's output IS the user-facing UX, not a debug log. -#[cfg(feature = "cli")] -fn init_cli_logger() { - use log::LevelFilter; - use simple_logger::SimpleLogger; - let _logger_init = SimpleLogger::new() - .with_level(LevelFilter::Info) - .without_timestamps() - .with_module_level("edgezero_cli", LevelFilter::Info) - .init(); -} +//! `EdgeZero` CLI binary — a thin wrapper over the `edgezero_cli` library. #[cfg(feature = "cli")] fn main() { - use args::{Args, Command}; use clap::Parser as _; - - init_cli_logger(); - let args = Args::parse(); - match args.cmd { - Command::New(new_args) => { - if let Err(err) = generator::generate_new(&new_args) { - log::error!("[edgezero] new error: {err}"); - process::exit(1); - } - } - Command::Build { - adapter, - adapter_args, - } => { - if let Err(err) = handle_build(&adapter, &adapter_args) { - log::error!("[edgezero] build error: {err}"); - process::exit(1); - } - } - Command::Deploy { - adapter, - adapter_args, - } => { - if let Err(err) = handle_deploy(&adapter, &adapter_args) { - log::error!("[edgezero] deploy error: {err}"); - process::exit(1); - } - } - Command::Serve { adapter } => { - if let Err(err) = handle_serve(&adapter) { - log::error!("[edgezero] serve error: {err}"); - process::exit(1); - } - } - Command::Dev => { - #[cfg(feature = "edgezero-adapter-axum")] - { - dev_server::run_dev(); - } - - #[cfg(not(feature = "edgezero-adapter-axum"))] - { - log::error!( - "edgezero-cli built without `edgezero-adapter-axum`; rebuild with that feature to use `edgezero dev`." - ); - process::exit(1); - } - } + use edgezero_cli::args::{Args, Command}; + use std::process; + + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Command::Build(args) => edgezero_cli::run_build(&args), + Command::Deploy(args) => edgezero_cli::run_deploy(&args), + #[cfg(feature = "demo-example")] + Command::Demo => edgezero_cli::run_demo(), + Command::New(args) => edgezero_cli::run_new(&args), + Command::Serve(args) => edgezero_cli::run_serve(&args), + }; + if let Err(err) = result { + log::error!("[edgezero] {err}"); + process::exit(1); } } @@ -100,295 +31,3 @@ fn main() { .init(); log::error!("edgezero-cli built without `cli` feature. Rebuild with `--features cli`."); } - -#[cfg(feature = "cli")] -fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Option { - let manifest_data = manifest.manifest(); - if !manifest_data.secret_store_enabled(adapter_name) { - return None; - } - - // Note: the configured binding identifier is intentionally NOT included in - // this log line. CodeQL's `rust/cleartext-logging` rule taints any value - // returned by a function whose name contains "secret" (it can't tell - // metadata from secret material), and adapters/operators can read the - // binding name from their own `edgezero.toml` if they need to verify it. - let message = match adapter_name { - "axum" => "[edgezero] secrets enabled for axum -- ensure the required environment variables are set for local runs", - "cloudflare" => "[edgezero] secrets enabled for cloudflare -- ensure the required secret bindings exist in wrangler", - _ => "[edgezero] secrets enabled -- ensure the configured secret store is provisioned on the target platform", - }; - - Some(message.to_owned()) -} - -#[cfg(feature = "cli")] -fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { - if let Some(message) = store_bindings_message(adapter_name, manifest) { - log::info!("{message}"); - } -} - -#[cfg(feature = "cli")] -fn handle_build(adapter_name: &str, adapter_args: &[String]) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(adapter_name, manifest.as_ref())?; - if let Some(loader) = &manifest { - log_store_bindings(adapter_name, loader); - } - adapter::execute( - adapter_name, - adapter::Action::Build, - manifest.as_ref(), - adapter_args, - ) -} - -#[cfg(feature = "cli")] -fn handle_deploy(adapter_name: &str, adapter_args: &[String]) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(adapter_name, manifest.as_ref())?; - adapter::execute( - adapter_name, - adapter::Action::Deploy, - manifest.as_ref(), - adapter_args, - ) -} - -#[cfg(feature = "cli")] -fn handle_serve(adapter_name: &str) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(adapter_name, manifest.as_ref())?; - adapter::execute(adapter_name, adapter::Action::Serve, manifest.as_ref(), &[]) -} - -#[cfg(feature = "cli")] -fn ensure_adapter_defined( - adapter_name: &str, - manifest_loader: Option<&ManifestLoader>, -) -> Result<(), String> { - if let Some(loader) = manifest_loader { - if loader.manifest().adapters.contains_key(adapter_name) { - return Ok(()); - } - let available: Vec = loader.manifest().adapters.keys().cloned().collect(); - if available.is_empty() { - Err(format!( - "adapter `{adapter_name}` is not configured in edgezero.toml (no adapters defined)" - )) - } else { - Err(format!( - "adapter `{}` is not configured in edgezero.toml (available: {})", - adapter_name, - available.join(", ") - )) - } - } else { - Ok(()) - } -} - -#[cfg(feature = "cli")] -fn load_manifest_optional() -> Result, String> { - let path = env::var("EDGEZERO_MANIFEST") - .map_or_else(|_| PathBuf::from("edgezero.toml"), PathBuf::from); - - match ManifestLoader::from_path(&path) { - Ok(loader) => Ok(Some(loader)), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), - Err(err) => Err(format!("failed to load {}: {err}", path.display())), - } -} - -#[cfg(test)] -#[cfg(feature = "cli")] -mod tests { - use super::*; - use edgezero_core::manifest::ManifestLoader; - use std::fs; - use std::sync::{Mutex, OnceLock}; - use tempfile::TempDir; - - const BASIC_MANIFEST: &str = r#" -[app] -name = "demo-app" -entry = "crates/demo-core" - -[adapters.fastly.adapter] -crate = "crates/demo-fastly" -manifest = "crates/demo-fastly/fastly.toml" - -[adapters.fastly.build] -target = "wasm32-unknown-unknown" -profile = "release" - -[adapters.fastly.commands] -build = "echo build" -deploy = "echo deploy" -serve = "echo serve" -"#; - - struct EnvOverride { - key: &'static str, - original: Option, - } - - impl Drop for EnvOverride { - fn drop(&mut self) { - if let Some(original) = &self.original { - env::set_var(self.key, original); - } else { - env::remove_var(self.key); - } - } - } - - impl EnvOverride { - fn set(key: &'static str, value: &str) -> Self { - let original = env::var(key).ok(); - env::set_var(key, value); - Self { key, original } - } - } - - fn manifest_guard() -> &'static Mutex<()> { - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) - } - - #[test] - fn load_manifest_optional_returns_none_when_missing() { - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("missing.toml"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - let result = load_manifest_optional().expect("load result"); - assert!(result.is_none()); - } - - #[test] - fn load_manifest_optional_reads_manifest() { - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - let manifest = load_manifest_optional() - .expect("load result") - .expect("manifest present"); - assert!(manifest.manifest().adapters.contains_key("fastly")); - } - - #[test] - fn ensure_adapter_defined_accepts_known_adapter() { - let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); - ensure_adapter_defined("fastly", Some(&loader)).expect("known adapter"); - } - - #[test] - fn ensure_adapter_defined_reports_unknown_adapter() { - let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); - let err = ensure_adapter_defined("cloudflare", Some(&loader)).expect_err("should err"); - assert!(err.contains("available")); - assert!(err.contains("fastly")); - } - - #[test] - fn ensure_adapter_defined_allows_when_manifest_missing() { - ensure_adapter_defined("fastly", None).expect("manifest missing -> permissive"); - } - - #[cfg(not(windows))] - #[test] - fn handle_build_executes_manifest_command() { - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - let args: Vec = Vec::new(); - handle_build("fastly", &args).expect("build command runs"); - } - - #[cfg(not(windows))] - #[test] - fn handle_deploy_executes_manifest_command() { - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - let args: Vec = Vec::new(); - handle_deploy("fastly", &args).expect("deploy command runs"); - } - - #[cfg(not(windows))] - #[test] - fn handle_serve_executes_manifest_command() { - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - handle_serve("fastly").expect("serve command runs"); - } - - #[test] - fn secret_store_binding_is_readable_from_manifest() { - let manifest_with_secrets = r#" -[app] -name = "demo-app" -entry = "crates/demo-core" - -[stores.secrets] -name = "MY_SECRETS" - -[adapters.fastly.commands] -build = "echo build" -deploy = "echo deploy" -serve = "echo serve" -"#; - let loader = ManifestLoader::load_from_str(manifest_with_secrets); - assert_eq!( - loader.manifest().secret_store_binding("fastly"), - "MY_SECRETS" - ); - assert!(loader.manifest().stores.secrets.is_some()); - } - - #[test] - fn store_bindings_message_is_adapter_specific() { - let loader = ManifestLoader::load_from_str( - r#" -[stores.secrets] -name = "MY_SECRETS" -"#, - ); - - let axum = store_bindings_message("axum", &loader).expect("axum message"); - assert!(axum.contains("environment variables")); - - let cloudflare = store_bindings_message("cloudflare", &loader).expect("cloudflare message"); - assert!(cloudflare.contains("wrangler")); - - let fastly = store_bindings_message("fastly", &loader).expect("fastly message"); - assert!(fastly.contains("secrets enabled")); - } - - #[test] - fn store_bindings_message_respects_secret_store_enabled() { - let loader = ManifestLoader::load_from_str( - " -[stores.secrets] -enabled = false -", - ); - assert!(store_bindings_message("fastly", &loader).is_none()); - } -} diff --git a/crates/edgezero-cli/src/scaffold.rs b/crates/edgezero-cli/src/scaffold.rs index 714ac5e..969ee7d 100644 --- a/crates/edgezero-cli/src/scaffold.rs +++ b/crates/edgezero-cli/src/scaffold.rs @@ -95,6 +95,17 @@ pub fn register_templates(hbs: &mut Handlebars) { include_str!("templates/core/src/handlers.rs.hbs"), ) .expect("compiled-in template is valid"); + // CLI + hbs.register_template_string( + "cli_Cargo_toml", + include_str!("templates/cli/Cargo.toml.hbs"), + ) + .expect("compiled-in template is valid"); + hbs.register_template_string( + "cli_src_main_rs", + include_str!("templates/cli/src/main.rs.hbs"), + ) + .expect("compiled-in template is valid"); // Adapter-specific templates for adapter in scaffold::registered_blueprints() { for template in adapter.template_registrations { @@ -135,6 +146,11 @@ pub fn resolve_dep_line( if let Some(rel) = relative_to(workspace_dir, repo_root) { let dep_path = Path::new(&rel).join(repo_rel_crate); format!("{} = {{ path = \"{}\" }}", crate_name, dep_path.display()) + } else if let Ok(absolute) = fs::canonicalize(&candidate) { + // The output directory is outside the edgezero checkout, so a + // relative path cannot be expressed cleanly. Depend on the local + // crate by absolute path rather than falling back to Git. + format!("{} = {{ path = \"{}\" }}", crate_name, absolute.display()) } else { fallback.to_owned() } @@ -161,21 +177,35 @@ pub fn resolve_dep_line( } } +/// Normalise an arbitrary project name into a valid Cargo package name. +/// +/// ASCII letters are lower-cased (so `MyApp` becomes `myapp`, not the +/// invalid `-y-pp`); `-` and `_` are kept; every other character collapses +/// to a single `-`. Leading separators are dropped and trailing separators +/// trimmed, so the result never starts or ends with `-`/`_`. A digit-leading +/// result is prefixed with `_`, and an empty result falls back to +/// `edgezero-app`. pub fn sanitize_crate_name(input: &str) -> String { let mut out = String::new(); - for (i, ch) in input.chars().enumerate() { - let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_'; - if valid { - if i == 0 && ch.is_ascii_digit() { - out.push('_'); - } - out.push(ch); + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); } else { - out.push('-'); + // `-`, `_`, and every other invalid character collapse to a + // single separator; leading and doubled separators are dropped. + let separator = if ch == '_' { '_' } else { '-' }; + if !out.is_empty() && !out.ends_with(['-', '_']) { + out.push(separator); + } } } + while out.ends_with(['-', '_']) { + out.pop(); + } if out.is_empty() { "edgezero-app".to_owned() + } else if out.starts_with(|ch: char| ch.is_ascii_digit()) { + format!("_{out}") } else { out } @@ -222,6 +252,8 @@ mod tests { "core_Cargo_toml", "core_src_lib_rs", "core_src_handlers_rs", + "cli_Cargo_toml", + "cli_src_main_rs", ] { assert!(hbs.has_template(name), "missing template {name}"); } @@ -236,4 +268,31 @@ mod tests { } } } + + #[test] + fn sanitize_crate_name_lowercases_mixed_case() { + // Regression: uppercase letters were mangled to `-`, producing the + // invalid package name `-y-pp` for `MyApp`. + assert_eq!(sanitize_crate_name("MyApp"), "myapp"); + assert_eq!(sanitize_crate_name("My App"), "my-app"); + } + + #[test] + fn sanitize_crate_name_keeps_valid_separators() { + assert_eq!(sanitize_crate_name("my-edge-app"), "my-edge-app"); + assert_eq!(sanitize_crate_name("my_app"), "my_app"); + } + + #[test] + fn sanitize_crate_name_trims_and_collapses_separators() { + assert_eq!(sanitize_crate_name(" spaced "), "spaced"); + assert_eq!(sanitize_crate_name("a@@@b"), "a-b"); + assert_eq!(sanitize_crate_name("-leading-"), "leading"); + } + + #[test] + fn sanitize_crate_name_handles_digit_leading_and_empty() { + assert_eq!(sanitize_crate_name("123app"), "_123app"); + assert_eq!(sanitize_crate_name("!!!"), "edgezero-app"); + } } diff --git a/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs new file mode 100644 index 0000000..a5112cf --- /dev/null +++ b/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs @@ -0,0 +1,13 @@ +[package] +name = "{{proj_cli}}" +version = "0.1.0" +edition = "2021" +publish = false + +[lints] +workspace = true + +[dependencies] +{{{dep_edgezero_cli}}} +clap = { workspace = true } +log = { workspace = true } diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs new file mode 100644 index 0000000..9278eb2 --- /dev/null +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -0,0 +1,42 @@ +//! {{name}} CLI — built on the `edgezero-cli` library. +//! +//! This binary reuses the built-in `edgezero` commands via the +//! `edgezero_cli` library and is the place to add your own subcommands. + +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; + +#[derive(Parser, Debug)] +#[command(name = "{{proj_cli}}", about = "{{name}} edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Build the project for a target edge. + Build(BuildArgs), + /// Deploy to a target edge. + Deploy(DeployArgs), + /// Create a new `EdgeZero` app skeleton. + New(NewArgs), + /// Run a local simulation (adapter-specific). + Serve(ServeArgs), +} + +fn main() { + use std::process; + + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Build(args) => edgezero_cli::run_build(&args), + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::New(args) => edgezero_cli::run_new(&args), + Cmd::Serve(args) => edgezero_cli::run_serve(&args), + }; + if let Err(err) = result { + log::error!("[{{name}}] {err}"); + process::exit(1); + } +} diff --git a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs index 24bf25c..382a2ad 100644 --- a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs @@ -126,6 +126,7 @@ mod tests { use futures::executor::block_on; use std::collections::HashMap; use std::env; + use std::sync::{Mutex, MutexGuard, OnceLock}; struct TestProxyClient; @@ -138,6 +139,39 @@ mod tests { } } + /// Serializes every test that reads or writes the `API_BASE_URL` + /// process-global env var — concurrent `env::set_var` / `env::var` + /// across threads is unsound, so these tests must not overlap. + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Restores `API_BASE_URL` to its prior value when dropped, so a + /// panicking assertion cannot leak process-global state. + struct EnvVarGuard { + original: Option, + } + + impl EnvVarGuard { + fn set(value: &str) -> Self { + let original = env::var("API_BASE_URL").ok(); + env::set_var("API_BASE_URL", value); + Self { original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var("API_BASE_URL", value), + None => env::remove_var("API_BASE_URL"), + } + } + } + #[test] fn root_returns_static_body() { let ctx = empty_context("/"); @@ -210,28 +244,27 @@ mod tests { #[test] fn build_proxy_target_merges_segments_and_query() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); + let _env = EnvVarGuard::set("https://example.com/api"); let original = Uri::from_static("/proxy/status?foo=bar"); let target = build_proxy_target("status/200", &original).expect("target uri"); assert_eq!( target.to_string(), "https://example.com/api/status/200?foo=bar" ); - env::remove_var("API_BASE_URL"); } #[test] fn proxy_demo_without_handle_returns_placeholder() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - env::remove_var("API_BASE_URL"); } #[test] fn proxy_demo_uses_injected_handle() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); let mut request = request_builder() .method(Method::GET) @@ -248,8 +281,6 @@ mod tests { let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::CREATED); - - env::remove_var("API_BASE_URL"); } fn empty_context(path: &str) -> RequestContext { diff --git a/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs index b8ebff1..a7c02b6 100644 --- a/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs @@ -1,6 +1,7 @@ [workspace] members = [ "crates/{{proj_core}}", + "crates/{{proj_cli}}", {{{workspace_members}}} ] resolver = "2" diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index 48c902d..ee06846 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -52,6 +52,20 @@ methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +# -- Stores ---------------------------------------------------------------- +# +# `[stores.]` declares logical store ids only. `default` is required +# when more than one id is declared; with a single id it resolves to that id. + +[stores.kv] +ids = ["app_kv"] + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] + # [environment] # # [[environment.variables]] diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs new file mode 100644 index 0000000..2e424c8 --- /dev/null +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -0,0 +1,98 @@ +//! Opt-in integration test: a freshly scaffolded project compiles. +//! +//! Ignored by default — it runs `cargo check` on a generated workspace +//! (host plus each adapter's wasm target), which recompiles the edgezero +//! stack and may fetch crates (minutes, not milliseconds). The fast +//! `generator` unit tests assert that the scaffold resolves edgezero crates +//! to local path dependencies; this test additionally proves the generated +//! workspace — the CLI crate that imports `edgezero_cli`, and the +//! target-gated adapter entrypoints — compiles end to end. +//! +//! Run it explicitly (and in CI): +//! +//! ```sh +//! cargo test -p edgezero-cli --test generated_project_builds -- --ignored +//! ``` + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::process::Command; + + /// Targets installed for the toolchain that builds `project`. A wasm + /// check is skipped when its target is absent (e.g. a local run where + /// the project sits outside a checkout that pins the wasm targets); CI + /// installs both wasm targets, so the full set always runs there. + fn installed_targets(project: &Path) -> String { + Command::new("rustup") + .args(["target", "list", "--installed"]) + .current_dir(project) + .output() + .map(|out| String::from_utf8_lossy(&out.stdout).into_owned()) + .unwrap_or_default() + } + + #[test] + #[ignore = "compiles a generated workspace and may fetch crates; run explicitly"] + #[expect( + clippy::print_stderr, + reason = "an opt-in test surfacing a skipped wasm check" + )] + fn generated_workspace_compiles() { + let temp = tempfile::tempdir().expect("temp dir"); + let new_status = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .arg("new") + .arg("scaffold-probe") + .arg("--dir") + .arg(temp.path()) + .status() + .expect("run `edgezero new`"); + assert!(new_status.success(), "`edgezero new` should succeed"); + + let project = temp.path().join("scaffold-probe"); + + // Host target: the whole workspace, including the generated CLI + // crate that imports `edgezero_cli`. + let host = Command::new(env!("CARGO")) + .args(["check", "--workspace"]) + .current_dir(&project) + .status() + .expect("run `cargo check` on the generated workspace"); + assert!( + host.success(), + "generated workspace should compile for the host target", + ); + + // Per-adapter wasm targets: where target-gated template code lives + // (entrypoint signatures, macro-generated unsafe exports). + let targets = installed_targets(&project); + for (adapter, target) in [ + ("cloudflare", "wasm32-unknown-unknown"), + ("fastly", "wasm32-wasip1"), + ("spin", "wasm32-wasip1"), + ] { + if !targets.contains(target) { + eprintln!("skipping {adapter} wasm check: target {target} not installed"); + continue; + } + let crate_name = format!("scaffold-probe-adapter-{adapter}"); + let wasm = Command::new(env!("CARGO")) + .args([ + "check", + "-p", + &crate_name, + "--target", + target, + "--features", + adapter, + ]) + .current_dir(&project) + .status() + .expect("run `cargo check` for a wasm adapter target"); + assert!( + wasm.success(), + "generated {adapter} adapter should compile for {target}", + ); + } + } +} diff --git a/crates/edgezero-cli/tests/lib_consumer.rs b/crates/edgezero-cli/tests/lib_consumer.rs new file mode 100644 index 0000000..d164f91 --- /dev/null +++ b/crates/edgezero-cli/tests/lib_consumer.rs @@ -0,0 +1,68 @@ +//! External-consumer integration test. +//! +//! Exercises the `edgezero_cli` public API exactly as a downstream +//! binary would — proving the library surface (`args::BuildArgs`, +//! `run_build`) is usable from outside the crate. +//! +//! This module deliberately contains exactly one `#[test]`: it mutates +//! the process-global `EDGEZERO_MANIFEST` env var, and a single test +//! means no in-binary parallelism on it. If a second env-touching test +//! is ever added here, gate both with a shared `Mutex` guard. + +#[cfg(test)] +mod tests { + use edgezero_cli::args::BuildArgs; + use edgezero_cli::run_build; + use std::env; + use std::fs; + use tempfile::TempDir; + + const BASIC_MANIFEST: &str = r#" +[app] +name = "consumer-app" +entry = "crates/consumer-core" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + + /// RAII guard that restores `EDGEZERO_MANIFEST` to its prior value on drop. + struct EnvOverride { + original: Option, + } + + impl Drop for EnvOverride { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var("EDGEZERO_MANIFEST", value), + None => env::remove_var("EDGEZERO_MANIFEST"), + } + } + } + + impl EnvOverride { + fn set(value: &str) -> Self { + let original = env::var("EDGEZERO_MANIFEST").ok(); + env::set_var("EDGEZERO_MANIFEST", value); + Self { original } + } + } + + #[cfg(not(windows))] + #[test] + fn external_consumer_can_call_run_build() { + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let _env = EnvOverride::set(&manifest_path.to_string_lossy()); + + // Construct via `Default` + field mutation — the path that works for + // an external crate even though `BuildArgs` is `#[non_exhaustive]`. + let mut args = BuildArgs::default(); + args.adapter = "fastly".to_owned(); + + run_build(&args).expect("external consumer can run_build"); + } +} diff --git a/crates/edgezero-core/src/env_config.rs b/crates/edgezero-core/src/env_config.rs new file mode 100644 index 0000000..c9e1b60 --- /dev/null +++ b/crates/edgezero-core/src/env_config.rs @@ -0,0 +1,220 @@ +//! `EDGEZERO__*` environment-config layer. +//! +//! Adapter-specific runtime config — platform store names, per-store tuning, +//! bind host/port, and logging level — is supplied at runtime through +//! `EDGEZERO__`-prefixed environment variables. `__` (double underscore) +//! separates key-path segments, so `EDGEZERO__STORES__KV__SESSIONS__NAME` +//! parses to the segment path `["stores", "kv", "sessions", "name"]`. +//! +//! Every segment is lower-cased on parse, and lookup arguments are lower-cased +//! before matching — callers pass lower-case logical ids and get a +//! case-insensitive match against the upper-case env-var convention. + +use std::collections::BTreeMap; +use std::env; + +/// The prefix every recognised variable must start with. +const PREFIX: &str = "EDGEZERO__"; +/// The key-path segment separator. +const SEPARATOR: &str = "__"; + +/// Adapter runtime config resolved from `EDGEZERO__*` environment variables. +/// +/// Keys are lower-cased segment paths; values are the raw environment-variable +/// strings. Build one with [`EnvConfig::from_env`] (native targets) or +/// [`EnvConfig::from_vars`] (e.g. Cloudflare Workers, which have no +/// `std::env`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EnvConfig { + entries: BTreeMap, String>, +} + +impl EnvConfig { + /// `EDGEZERO__ADAPTER__HOST`. + #[must_use] + #[inline] + pub fn adapter_host(&self) -> Option<&str> { + self.get(&["adapter", "host"]) + } + + /// `EDGEZERO__ADAPTER__PORT` (raw string — callers parse it). + #[must_use] + #[inline] + pub fn adapter_port(&self) -> Option<&str> { + self.get(&["adapter", "port"]) + } + + /// Read all `EDGEZERO__`-prefixed variables from the process environment + /// (`std::env::vars()`). On targets without a process environment (e.g. + /// `wasm32-unknown-unknown`) this yields an empty config. + #[must_use] + #[inline] + pub fn from_env() -> Self { + Self::from_vars(env::vars()) + } + + /// Build from an explicit `(key, value)` iterator. Cloudflare Workers have + /// no `std::env`; that adapter enumerates its `Env` binding object and + /// calls this instead of [`EnvConfig::from_env`]. + #[must_use] + #[inline] + pub fn from_vars(vars: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: Into, + { + let mut entries = BTreeMap::new(); + for (key, value) in vars { + let Some(rest) = key.as_ref().strip_prefix(PREFIX) else { + continue; + }; + let segments: Vec = + rest.split(SEPARATOR).map(str::to_ascii_lowercase).collect(); + if segments.is_empty() || segments.iter().any(String::is_empty) { + continue; + } + entries.insert(segments, value.into()); + } + Self { entries } + } + + /// Generic lookup by segment path. Segments are matched case-insensitively + /// — they are lower-cased before comparison, matching the lower-cased + /// parsed keys. + #[must_use] + #[inline] + pub fn get(&self, segments: &[&str]) -> Option<&str> { + let path: Vec = segments + .iter() + .map(|seg| seg.to_ascii_lowercase()) + .collect(); + self.entries.get(&path).map(String::as_str) + } + + /// `EDGEZERO__LOGGING__LEVEL`. + #[must_use] + #[inline] + pub fn logging_level(&self) -> Option<&str> { + self.get(&["logging", "level"]) + } + + /// Platform name for a logical store — `EDGEZERO__STORES______NAME` + /// — falling back to `id` itself when the variable is unset. `kind` is + /// `"kv"` / `"config"` / `"secrets"`. + #[must_use] + #[inline] + pub fn store_name(&self, kind: &str, id: &str) -> String { + self.get(&["stores", kind, id, "name"]) + .map_or_else(|| id.to_owned(), str::to_owned) + } + + /// Free-form per-store tuning — `EDGEZERO__STORES______`. + #[must_use] + #[inline] + pub fn store_setting(&self, kind: &str, id: &str, key: &str) -> Option<&str> { + self.get(&["stores", kind, id, key]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> EnvConfig { + EnvConfig::from_vars([ + ("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod-sessions"), + ("EDGEZERO__STORES__KV__SESSIONS__MAX_LIST_KEYS", "500"), + ("EDGEZERO__ADAPTER__HOST", "0.0.0.0"), + ("EDGEZERO__ADAPTER__PORT", "9000"), + ("EDGEZERO__LOGGING__LEVEL", "debug"), + ("PATH", "/usr/bin"), + ]) + } + + #[test] + fn parses_and_lower_cases_segments() { + let cfg = sample(); + assert_eq!( + cfg.get(&["stores", "kv", "sessions", "name"]), + Some("prod-sessions") + ); + } + + #[test] + fn get_is_case_insensitive() { + let cfg = sample(); + assert_eq!( + cfg.get(&["STORES", "KV", "Sessions", "NAME"]), + Some("prod-sessions") + ); + } + + #[test] + fn store_name_hit() { + let cfg = sample(); + assert_eq!(cfg.store_name("kv", "sessions"), "prod-sessions"); + } + + #[test] + fn store_name_falls_back_to_id() { + let cfg = sample(); + assert_eq!(cfg.store_name("kv", "cache"), "cache"); + } + + #[test] + fn store_setting_lookup() { + let cfg = sample(); + assert_eq!( + cfg.store_setting("kv", "sessions", "max_list_keys"), + Some("500") + ); + assert_eq!(cfg.store_setting("kv", "sessions", "ttl"), None); + } + + #[test] + fn adapter_and_logging_accessors() { + let cfg = sample(); + assert_eq!(cfg.adapter_host(), Some("0.0.0.0")); + assert_eq!(cfg.adapter_port(), Some("9000")); + assert_eq!(cfg.logging_level(), Some("debug")); + } + + #[test] + fn empty_config_returns_none_and_fallbacks() { + let empty: [(&str, &str); 0] = []; + let cfg = EnvConfig::from_vars(empty); + assert_eq!(cfg.adapter_host(), None); + assert_eq!(cfg.adapter_port(), None); + assert_eq!(cfg.logging_level(), None); + assert_eq!(cfg.store_setting("kv", "sessions", "name"), None); + assert_eq!(cfg.get(&["stores", "kv", "sessions", "name"]), None); + assert_eq!(cfg.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn non_prefixed_variable_is_ignored() { + let cfg = EnvConfig::from_vars([ + ("PATH", "/usr/bin"), + ("EDGEZERO_HOST", "ignored-no-double-underscore"), + ("EDGEZERO__ADAPTER__HOST", "kept"), + ]); + assert_eq!(cfg.adapter_host(), Some("kept")); + assert_eq!(cfg.get(&["host"]), None); + } + + #[test] + fn malformed_variables_are_skipped() { + // `EDGEZERO__` alone, a trailing `__`, and an interior empty segment + // must all be skipped without panicking. + let cfg = EnvConfig::from_vars([ + ("EDGEZERO__", "empty"), + ("EDGEZERO__ADAPTER__", "trailing"), + ("EDGEZERO__ADAPTER____PORT", "interior-empty"), + ("EDGEZERO__ADAPTER__HOST", "good"), + ]); + assert_eq!(cfg.adapter_host(), Some("good")); + assert_eq!(cfg.adapter_port(), None); + assert_eq!(cfg.get(&["adapter"]), None); + } +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index a37f8d8..2c4e5ee 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod body; pub mod compression; pub mod config_store; pub mod context; +pub mod env_config; pub mod error; pub mod extractor; pub mod handler; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index d2efc14..7a81a00 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1,22 +1,18 @@ use log::LevelFilter; use serde::de::Error as DeError; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{env, fs, io}; use validator::{Validate, ValidationError}; +/// Default config store / binding name used when `[stores.config]` is omitted. pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; /// Default KV store / binding name used when `[stores.kv]` is omitted. pub const DEFAULT_KV_STORE_NAME: &str = "EDGEZERO_KV"; /// Default secret store / binding name used when `[stores.secrets]` is omitted. pub const DEFAULT_SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; -// Spin config values come from Spin component variables (flat namespace); -// there is no runtime store-name concept, so adapter-name overrides for spin -// would be silently ignored. Keep spin out of the allowed set to surface -// misconfiguration at validation time rather than at runtime. -const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly"]; pub struct ManifestLoader { manifest: Arc, @@ -169,25 +165,16 @@ impl Manifest { /// Returns the KV store name for a given adapter. /// - /// Resolution order: - /// 1. Per-adapter override (`[stores.kv.adapters.]`) - /// 2. Global name (`[stores.kv] name = "..."`) - /// 3. Default: `"EDGEZERO_KV"` + /// In the portable model the manifest carries no platform name; the name + /// resolves to the declared default logical id, or `"EDGEZERO_KV"` when + /// `[stores.kv]` is omitted. #[must_use] #[inline] - pub fn kv_store_name(&self, adapter: &str) -> &str { - let Some(kv) = self.stores.kv.as_ref() else { - return DEFAULT_KV_STORE_NAME; - }; - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = kv - .adapters - .iter() - .find(|&(name, _)| name.eq_ignore_ascii_case(&adapter_lower)) - { - return &adapter_cfg.1.name; - } - &kv.name + pub fn kv_store_name(&self, _adapter: &str) -> &str { + self.stores + .kv + .as_ref() + .map_or(DEFAULT_KV_STORE_NAME, StoreDeclaration::default_id) } #[must_use] @@ -210,45 +197,25 @@ impl Manifest { /// Returns the secret store binding identifier for a given adapter. /// - /// Resolution order: - /// 1. Per-adapter override (`[stores.secrets.adapters.]`) - /// 2. Global name (`[stores.secrets] name = "..."`) - /// 3. Default: `"EDGEZERO_SECRETS"` + /// In the portable model the manifest carries no platform name; the name + /// resolves to the declared default logical id, or `"EDGEZERO_SECRETS"` + /// when `[stores.secrets]` is omitted. #[must_use] #[inline] - pub fn secret_store_binding(&self, adapter: &str) -> &str { - let Some(secrets) = self.stores.secrets.as_ref() else { - return DEFAULT_SECRET_STORE_NAME; - }; - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = secrets - .adapters - .iter() - .find(|&(name, _)| name.eq_ignore_ascii_case(&adapter_lower)) - { - if let Some(name) = adapter_cfg.1.name.as_deref() { - return name; - } - } - &secrets.name + pub fn secret_store_binding(&self, _adapter: &str) -> &str { + self.stores + .secrets + .as_ref() + .map_or(DEFAULT_SECRET_STORE_NAME, StoreDeclaration::default_id) } /// Returns whether the secret store should be attached for a given adapter. + /// + /// True whenever a `[stores.secrets]` section is declared. #[must_use] #[inline] - pub fn secret_store_enabled(&self, adapter: &str) -> bool { - let Some(secrets) = self.stores.secrets.as_ref() else { - return false; - }; - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = secrets - .adapters - .iter() - .find(|&(name, _)| name.eq_ignore_ascii_case(&adapter_lower)) - { - return adapter_cfg.1.enabled; - } - secrets.enabled + pub fn secret_store_enabled(&self, _adapter: &str) -> bool { + self.stores.secrets.is_some() } } @@ -456,66 +423,62 @@ pub struct ManifestAdapterCommands { pub struct ManifestStores { #[serde(default)] #[validate(nested)] - pub config: Option, + pub config: Option, #[serde(default)] #[validate(nested)] - pub kv: Option, + pub kv: Option, #[serde(default)] #[validate(nested)] - pub secrets: Option, + pub secrets: Option, } -/// `[stores.config]` section — provider-neutral config store. +/// Portable `[stores.]` declaration. +/// +/// Declares logical store ids only — the portable fact that "this app uses a +/// KV/config/secrets store called ``". No platform names, no per-adapter +/// tuning. Platform-specific runtime config (store names, tuning) is supplied +/// out of band; in this interim model a store's name resolves to its logical +/// [`StoreDeclaration::default_id`]. #[derive(Debug, Deserialize, Validate)] #[non_exhaustive] -pub struct ManifestConfigStoreConfig { - /// Per-adapter name overrides, keyed by supported lowercase adapter name - /// (`axum`, `cloudflare`, or `fastly`). Spin config uses component - /// variables in a flat namespace, so `stores.config.adapters.spin` is - /// rejected during validation. +#[validate(schema(function = "validate_store_declaration"))] +pub struct StoreDeclaration { + /// Logical default store id. Required when `ids.len() > 1`; when there is + /// exactly one id it resolves to `ids[0]`. #[serde(default)] - #[validate(nested)] - #[validate(custom(function = "validate_config_store_adapter_keys"))] - pub adapters: BTreeMap, - /// Optional default values used for local dev (Axum adapter). - #[serde(default)] - pub defaults: BTreeMap, - /// Global store/binding name used when no adapter-specific override is set. + pub default: Option, + /// Logical store ids — non-empty (enforced in validation, not by serde, so + /// a legacy manifest is rejected with the migration-guide message rather + /// than a bare "missing field `ids`" parse error). #[serde(default)] - #[validate(length(min = 1_u64))] - pub name: Option, -} - -/// `[stores.config.adapters.]` override. -#[derive(Debug, Deserialize, Serialize, Validate)] -#[non_exhaustive] -pub struct ManifestConfigAdapterConfig { - #[validate(length(min = 1_u64))] - pub name: String, + pub ids: Vec, + /// Any field other than `ids` / `default` — the pre-rewrite store schema + /// (`name`, `enabled`, `adapters`, `defaults`) lands here and is rejected + /// with a migration-guide message during validation. + #[serde(flatten)] + pub legacy: BTreeMap, } -impl ManifestConfigStoreConfig { - /// Access the default key-value pairs for local dev. +impl StoreDeclaration { + /// Resolve the config store name for a given adapter. + /// + /// In the portable model the manifest carries no platform name; the name + /// resolves to the logical [`StoreDeclaration::default_id`]. #[must_use] #[inline] - pub fn config_store_defaults(&self) -> &BTreeMap { - &self.defaults + pub fn config_store_name(&self, _adapter: &str) -> &str { + self.default_id() } - /// Resolve the config store name for a given adapter. - /// - /// Priority: adapter override → global name → `DEFAULT_CONFIG_STORE_NAME`. + /// Resolve the default logical store id (the explicit `default`, else the + /// first declared id). #[must_use] #[inline] - pub fn config_store_name(&self, adapter: &str) -> &str { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(override_cfg) = self.adapters.get(&adapter_lower) { - return &override_cfg.name; - } - if let Some(name) = self.name.as_deref() { - return name; - } - DEFAULT_CONFIG_STORE_NAME + pub fn default_id(&self) -> &str { + self.default + .as_deref() + .or_else(|| self.ids.first().map(String::as_str)) + .unwrap_or("") } } @@ -583,62 +546,6 @@ impl ManifestLoggingConfig { } } -/// Global KV store configuration. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestKvConfig { - /// Per-adapter name overrides. - #[serde(default)] - #[validate(nested)] - pub adapters: BTreeMap, - - /// Store / binding name (default: `"EDGEZERO_KV"`). - #[serde(default = "default_kv_name")] - #[validate(length(min = 1_u64))] - pub name: String, -} - -/// Per-adapter KV binding / store name override. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestKvAdapterConfig { - #[validate(length(min = 1_u64))] - pub name: String, -} - -/// Global secret store configuration. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestSecretsConfig { - /// Per-adapter name overrides. - #[serde(default)] - #[validate(nested)] - pub adapters: BTreeMap, - - /// Whether the secret store is enabled for adapters without overrides. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// Store / binding name (default: `"EDGEZERO_SECRETS"`). - #[serde(default = "default_secret_name")] - #[validate(length(min = 1_u64))] - pub name: String, -} - -/// Per-adapter secret store name override. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestSecretsAdapterConfig { - /// Whether the secret store is enabled for this adapter. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// Optional per-adapter secret store name override. - #[serde(default)] - #[validate(length(min = 1_u64))] - pub name: Option, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum HttpMethod { @@ -797,18 +704,6 @@ impl<'de> Deserialize<'de> for LogLevel { } } -fn default_enabled() -> bool { - true -} - -fn default_kv_name() -> String { - DEFAULT_KV_STORE_NAME.to_owned() -} - -fn default_secret_name() -> String { - DEFAULT_SECRET_STORE_NAME.to_owned() -} - fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { match path.parent() { Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), @@ -818,45 +713,56 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } } -fn validate_config_store_adapter_keys( - adapters: &BTreeMap, -) -> Result<(), ValidationError> { - let mixed_case_keys = adapters - .keys() - .filter(|key| key.as_str() != key.to_ascii_lowercase()) - .cloned() - .collect::>(); - if !mixed_case_keys.is_empty() { - let mut error = ValidationError::new("config_store_adapter_keys_lowercase"); +/// Validates a single `[stores.]` declaration against the portable +/// schema. +/// +/// Rejects the pre-rewrite store fields (`name`, `enabled`, `adapters`, +/// `defaults`) with an error pointing at the migration guide, and enforces the +/// `ids` / `default` invariants. +fn validate_store_declaration(declaration: &StoreDeclaration) -> Result<(), ValidationError> { + if !declaration.legacy.is_empty() { + let mut keys = declaration.legacy.keys().cloned().collect::>(); + keys.sort(); + let mut error = ValidationError::new("legacy_store_schema"); error.message = Some( format!( - "config store adapter override keys must be lowercase: {}", - mixed_case_keys.join(", ") + "the pre-rewrite `[stores.]` schema is no longer supported \ + (offending field(s): {}); migrate to the portable `ids` / `default` \ + form -- see docs/guide/manifest-store-migration.md", + keys.join(", ") ) .into(), ); return Err(error); } - let unknown_keys = adapters - .keys() - .filter(|key| !SUPPORTED_CONFIG_STORE_ADAPTERS.contains(&key.as_str())) - .cloned() - .collect::>(); - if unknown_keys.is_empty() { - return Ok(()); + if declaration.ids.is_empty() { + let mut error = ValidationError::new("store_ids_empty"); + error.message = + Some("`[stores.].ids` must declare at least one logical store id".into()); + return Err(error); + } + + if declaration.ids.len() > 1 && declaration.default.is_none() { + let mut error = ValidationError::new("store_default_required"); + error.message = Some( + "`default` is required when `[stores.]` declares more than one id \ + -- see docs/guide/manifest-store-migration.md" + .into(), + ); + return Err(error); + } + + if let Some(default) = declaration.default.as_deref() { + if !declaration.ids.iter().any(|id| id == default) { + let mut error = ValidationError::new("store_default_unknown"); + error.message = + Some(format!("`default` (`{default}`) must be one of the declared `ids`").into()); + return Err(error); + } } - let mut error = ValidationError::new("config_store_adapter_keys_known"); - error.message = Some( - format!( - "config store adapter override keys must match supported adapters ({}): {}", - SUPPORTED_CONFIG_STORE_ADAPTERS.join(", "), - unknown_keys.join(", ") - ) - .into(), - ); - Err(error) + Ok(()) } #[cfg(test)] @@ -916,15 +822,15 @@ env = "APP_TOKEN" #[test] fn try_load_from_str_rejects_failed_validation() { - // `[stores.config]` requires a non-empty `name` when set; an empty - // string trips `validator` and surfaces as InvalidData. + // `[stores.config]` requires a non-empty `ids` list; an empty list + // trips `validator` and surfaces as InvalidData. let err = ManifestLoader::try_load_from_str( r#" [app] name = "demo" [stores.config] -name = "" +ids = [] "#, ) .err() @@ -1483,139 +1389,99 @@ manifest = "fastly.toml" assert_eq!(HttpMethod::Head.as_str(), "HEAD"); } - // Config store tests - #[test] - fn config_store_name_falls_back_to_default_constant() { - // [stores.config] present but no name and no adapter overrides: - // config_store_name() must return DEFAULT_CONFIG_STORE_NAME. - let toml = "[stores.config]\n"; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!( - config.config_store_name("fastly"), - DEFAULT_CONFIG_STORE_NAME - ); - assert_eq!( - config.config_store_name("cloudflare"), - DEFAULT_CONFIG_STORE_NAME - ); - assert_eq!(config.config_store_name("axum"), DEFAULT_CONFIG_STORE_NAME); - } - - #[test] - fn config_store_name_defaults_when_omitted() { - // No [stores.config] section at all: callers skip the config store entirely. - let manifest = ManifestLoader::load_from_str(""); - assert!(manifest.manifest().stores.config.is_none()); - } + // -- Portable store declarations --------------------------------------- #[test] - fn config_store_name_uses_global_name() { + fn store_declaration_round_trips() { let toml = r#" +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" + [stores.config] -name = "app_config" +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] "#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("fastly"), "app_config"); - assert_eq!(config.config_store_name("cloudflare"), "app_config"); - assert_eq!(config.config_store_name("axum"), "app_config"); - } + let loader = ManifestLoader::load_from_str(toml); + let stores = &loader.manifest().stores; - #[test] - fn config_store_name_adapter_override() { - let toml = r#" -[stores.config] -name = "global_config" + let kv = stores.kv.as_ref().expect("kv declared"); + assert_eq!(kv.ids, ["sessions", "cache"]); + assert_eq!(kv.default_id(), "sessions"); -[stores.config.adapters.fastly] -name = "my-config-link" + let config = stores.config.as_ref().expect("config declared"); + assert_eq!(config.ids, ["app_config"]); + assert_eq!(config.default_id(), "app_config"); + assert_eq!(config.config_store_name("fastly"), "app_config"); -[stores.config.adapters.cloudflare] -name = "APP_CONFIG_BINDING" -"#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("fastly"), "my-config-link"); - assert_eq!(config.config_store_name("cloudflare"), "APP_CONFIG_BINDING"); - assert_eq!(config.config_store_name("axum"), "global_config"); + let secrets = stores.secrets.as_ref().expect("secrets declared"); + assert_eq!(secrets.default_id(), "default"); } #[test] - fn config_store_name_case_insensitive() { - let toml = r#" -[stores.config.adapters.fastly] -name = "fastly-store" -"#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("FASTLY"), "fastly-store"); - assert_eq!(config.config_store_name("Fastly"), "fastly-store"); - assert_eq!(config.config_store_name("fastly"), "fastly-store"); + fn store_declaration_default_id_falls_back_to_first_id() { + let loader = ManifestLoader::load_from_str("[stores.kv]\nids = [\"only\"]\n"); + let kv = loader.manifest().stores.kv.as_ref().expect("kv declared"); + assert!(kv.default.is_none()); + assert_eq!(kv.default_id(), "only"); } #[test] - fn config_store_mixed_case_adapter_key_fails_validation() { - let src = r#" -[stores.config.adapters.Fastly] -name = "fastly-store" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); + fn store_declaration_empty_ids_fails_validation() { + let manifest: Manifest = toml::from_str("[stores.kv]\nids = []\n").expect("should parse"); assert!( - result.is_err(), - "mixed-case config store adapter key should fail validation" + manifest.validate().is_err(), + "empty `ids` list should fail validation" ); } #[test] - fn config_store_unknown_adapter_key_fails_validation() { - let src = r#" -[stores.config.adapters.clouflare] -name = "APP_CONFIG" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); + fn store_declaration_requires_default_with_multiple_ids() { + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"a\", \"b\"]\n").expect("should parse"); + let err = manifest + .validate() + .expect_err("missing `default` with >1 id should fail validation"); assert!( - result.is_err(), - "unknown config store adapter key should fail validation" + err.to_string().contains("default"), + "error should mention `default`, got: {err}" ); } #[test] - fn config_store_spin_adapter_key_fails_validation() { - // Spin config values come from component variables; there is no - // runtime store-name concept, so a spin adapter override would be - // silently ignored. Validation rejects it to surface the mistake early. - let src = r#" -[stores.config.adapters.spin] -name = "SPIN_CONFIG" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); + fn store_declaration_default_must_be_a_declared_id() { + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"a\", \"b\"]\ndefault = \"c\"\n") + .expect("should parse"); + let err = manifest + .validate() + .expect_err("`default` outside `ids` should fail validation"); assert!( - manifest.validate().is_err(), - "spin config store adapter key should fail validation" + err.to_string().contains("declared `ids`"), + "error should explain the `default` constraint, got: {err}" ); } #[test] - fn config_store_defaults_accessible() { - let toml = r#" -[stores.config.defaults] -"feature.checkout" = "true" -"service.timeout_ms" = "1500" -"#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - let defaults = config.config_store_defaults(); - assert_eq!( - defaults.get("feature.checkout").map(String::as_str), - Some("true") - ); - assert_eq!( - defaults.get("service.timeout_ms").map(String::as_str), - Some("1500") - ); + fn legacy_store_schema_is_a_hard_load_error() { + for legacy in [ + "[stores.kv]\nname = \"MY_KV\"\n", + "[stores.config]\nids = [\"app_config\"]\n\n[stores.config.defaults]\nkey = \"value\"\n", + "[stores.kv]\nids = [\"sessions\"]\n\n[stores.kv.adapters.spin]\nname = \"label\"\n", + "[stores.secrets]\nids = [\"default\"]\nenabled = false\n", + ] { + let err = ManifestLoader::try_load_from_str(legacy) + .err() + .unwrap_or_else(|| panic!("legacy manifest must fail to load: {legacy}")); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("docs/guide/manifest-store-migration.md"), + "legacy-schema error must reference the migration guide, got: {err}" + ); + } } #[test] @@ -1624,20 +1490,6 @@ name = "SPIN_CONFIG" assert!(mfest.manifest().stores.config.is_none()); } - #[test] - fn config_store_empty_global_name_fails_validation() { - let src = r#" -[stores.config] -name = "" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); - assert!( - result.is_err(), - "empty global config store name should fail validation" - ); - } - // Multiple triggers test #[test] fn triggers_with_all_fields() { @@ -1669,65 +1521,20 @@ body-mode = "buffered" #[test] fn kv_store_name_defaults_when_omitted() { - let toml_str = r#" -[app] -name = "test" -"#; - let loader = ManifestLoader::load_from_str(toml_str); + let loader = ManifestLoader::load_from_str("[app]\nname = \"test\"\n"); let manifest = loader.manifest(); assert_eq!(manifest.kv_store_name("fastly"), "EDGEZERO_KV"); assert_eq!(manifest.kv_store_name("cloudflare"), "EDGEZERO_KV"); } #[test] - fn kv_store_name_uses_global_name() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "MY_KV" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "MY_KV"); - assert_eq!(manifest.kv_store_name("cloudflare"), "MY_KV"); - } - - #[test] - fn kv_store_name_adapter_override() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "GLOBAL_KV" - -[stores.kv.adapters.cloudflare] -name = "CF_BINDING" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("cloudflare"), "CF_BINDING"); - assert_eq!(manifest.kv_store_name("fastly"), "GLOBAL_KV"); - } - - #[test] - fn kv_store_name_case_insensitive() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "DEFAULT" - -[stores.kv.adapters.Fastly] -name = "FASTLY_STORE" -"#; - let loader = ManifestLoader::load_from_str(toml_str); + fn kv_store_name_resolves_to_default_id() { + let loader = ManifestLoader::load_from_str( + "[stores.kv]\nids = [\"sessions\", \"cache\"]\ndefault = \"cache\"\n", + ); let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); - assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); + assert_eq!(manifest.kv_store_name("fastly"), "cache"); + assert_eq!(manifest.kv_store_name("cloudflare"), "cache"); } // -- Secret store config ----------------------------------------------- @@ -1742,8 +1549,8 @@ name = "FASTLY_STORE" } #[test] - fn secret_store_binding_uses_global_name_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + fn secret_store_binding_resolves_to_default_id() { + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nids = [\"MY_SECRETS\"]\n"); assert_eq!( manifest.manifest().secret_store_binding("fastly"), "MY_SECRETS" @@ -1754,34 +1561,6 @@ name = "FASTLY_STORE" ); } - #[test] - fn secret_store_binding_uses_per_adapter_override() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n\ - [stores.secrets.adapters.fastly]\nname = \"FASTLY_STORE\"\n", - ); - assert_eq!( - manifest.manifest().secret_store_binding("fastly"), - "FASTLY_STORE" - ); - assert_eq!( - manifest.manifest().secret_store_binding("cloudflare"), - "MY_SECRETS" - ); - } - - #[test] - fn secrets_required_is_false_when_absent() { - let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert!(manifest.manifest().stores.secrets.is_none()); - } - - #[test] - fn secrets_required_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); - assert!(manifest.manifest().stores.secrets.is_some()); - } - #[test] fn secret_store_enabled_is_false_when_absent() { let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); @@ -1791,39 +1570,12 @@ name = "FASTLY_STORE" #[test] fn secret_store_enabled_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nids = [\"default\"]\n"); + assert!(manifest.manifest().stores.secrets.is_some()); assert!(manifest.manifest().secret_store_enabled("fastly")); assert!(manifest.manifest().secret_store_enabled("cloudflare")); } - #[test] - fn secret_store_enabled_can_be_disabled_per_adapter() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n\ - [stores.secrets.adapters.cloudflare]\nenabled = false\n", - ); - assert!(manifest.manifest().secret_store_enabled("fastly")); - assert!(!manifest.manifest().secret_store_enabled("cloudflare")); - } - - #[test] - fn secret_store_enabled_can_be_enabled_only_for_specific_adapter() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nenabled = false\n\ - [stores.secrets.adapters.fastly]\nenabled = true\nname = \"FASTLY_STORE\"\n", - ); - assert!(manifest.manifest().secret_store_enabled("fastly")); - assert!(!manifest.manifest().secret_store_enabled("cloudflare")); - assert_eq!( - manifest.manifest().secret_store_binding("fastly"), - "FASTLY_STORE" - ); - assert_eq!( - manifest.manifest().secret_store_binding("cloudflare"), - DEFAULT_SECRET_STORE_NAME - ); - } - // -- Adapter host/port config ------------------------------------------ #[test] diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index ba5aea2..44344f4 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -39,29 +39,20 @@ fn build_config_store_tokens(manifest: &Manifest) -> TokenStream2 { }; }; - let fallback_name = config.name.as_deref().unwrap_or(DEFAULT_CONFIG_STORE_NAME); - let fallback_name_lit = LitStr::new(fallback_name, Span::call_site()); - let override_entries: Vec<_> = config - .adapters - .iter() - .map(|(adapter, cfg)| { - let adapter_lit = LitStr::new(adapter, Span::call_site()); - let name_lit = LitStr::new(&cfg.name, Span::call_site()); - quote! { - edgezero_core::app::ConfigStoreAdapterMetadata::new(#adapter_lit, #name_lit), - } - }) - .collect(); + // The portable manifest carries no platform name — the config store name + // resolves to the declared default logical id. + let declared_default = config.default_id(); + let default_name = if declared_default.is_empty() { + DEFAULT_CONFIG_STORE_NAME + } else { + declared_default + }; + let default_name_lit = LitStr::new(default_name, Span::call_site()); quote! { fn config_store() -> Option<&'static edgezero_core::app::ConfigStoreMetadata> { static CONFIG_STORE: edgezero_core::app::ConfigStoreMetadata = - edgezero_core::app::ConfigStoreMetadata::new( - #fallback_name_lit, - &[ - #(#override_entries)* - ], - ); + edgezero_core::app::ConfigStoreMetadata::new(#default_name_lit, &[]); Some(&CONFIG_STORE) } } diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index fd3b47c..bdf066d 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -42,10 +42,10 @@ fn main() { ## Development Server -The `edgezero dev` command uses the Axum adapter: +Run your project locally on the Axum adapter: ```bash -edgezero dev +edgezero serve --adapter axum ``` This starts a server at `http://127.0.0.1:8787` with standard logging to stdout. @@ -183,7 +183,7 @@ The runtime currently binds to `127.0.0.1:8787` regardless of the `axum.toml` po A typical development workflow: -1. **Start dev server**: `edgezero dev` +1. **Run locally**: `edgezero serve --adapter axum` 2. **Make changes** to handlers in `my-app-core` 3. **Test locally** with curl or browser 4. **Run tests**: `cargo test` diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 8096b81..919da09 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -77,9 +77,8 @@ Adapters translate between provider-specific types and the portable core model: `edgezero-cli` provides the `edgezero` binary: - **`edgezero new`** - Scaffolds a new project with templates -- **`edgezero dev`** - Runs the local Axum dev server - **`edgezero build`** - Builds for a specific adapter target -- **`edgezero serve`** - Runs provider-specific local servers (Viceroy, wrangler dev) +- **`edgezero serve`** - Runs a local server for an adapter (`--adapter axum` for the native server, Viceroy for Fastly, `wrangler dev` for Cloudflare) - **`edgezero deploy`** - Deploys to production ## Data Flow @@ -125,7 +124,7 @@ Adapter crates use feature flags to gate provider SDKs and CLI integration: | `fastly` | edgezero-adapter-fastly | Fastly SDK integration | | `cloudflare` | edgezero-adapter-cloudflare | Workers SDK integration | | `cli` | adapter crates | Register adapters and scaffolding data | -| `dev-example` | edgezero-cli | Bundled demo app for development | +| `demo-example` | edgezero-cli | Bundled demo app for development | ## Next Steps diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 2c0238f..baae9ea 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -23,7 +23,6 @@ edgezero new [options] **Options:** - `--dir ` - Directory to create the project in (default: current directory) -- `--local-core` - Use local path dependency for edgezero-core (development only) **Examples:** @@ -43,30 +42,36 @@ my-app/ ├── edgezero.toml ├── crates/ │ ├── my-app-core/ +│ ├── my-app-cli/ │ ├── my-app-adapter-fastly/ │ ├── my-app-adapter-cloudflare/ -│ └── my-app-adapter-axum/ +│ ├── my-app-adapter-axum/ +│ └── my-app-adapter-spin/ ``` -The scaffolder includes all adapters registered at CLI build time. +The scaffolder includes all adapters registered at CLI build time, plus a +`my-app-cli` crate — your project's own CLI binary built on the `edgezero-cli` +library. -### edgezero dev +### edgezero demo -Start the local development server: +Run the bundled `app-demo` example locally on the axum dev server. This is a +**contributor-only** command — it depends on the in-repo `examples/app-demo` +crate and is compiled only under the `demo-example` feature, so it is not part +of an installed `edgezero` binary: ```bash -edgezero dev -``` - -**Example:** - -```bash -edgezero dev +cargo run -p edgezero-cli --features demo-example -- demo # Server starts at http://127.0.0.1:8787 ``` -If `edgezero.toml` defines an Axum adapter command, `edgezero dev` delegates to it. Otherwise it -starts the built-in dev server (default routes). +`edgezero demo` always runs the built-in example — it does not read your +project's `edgezero.toml` or delegate to its adapters. To run **your project's** +axum adapter, use `edgezero serve --adapter axum` (which runs +`[adapters.axum.commands].serve` from `edgezero.toml`). + +> The subcommand is named `demo` — the name `dev` is reserved for a future +> dev-workflow command. ### edgezero build @@ -220,6 +225,45 @@ Install the provider CLI: - Fastly: https://developer.fastly.com/learning/compute/ - Cloudflare: `npm install -g wrangler` +## Building Your Own CLI + +`edgezero-cli` is published as a library as well as a binary. Every downstream +command is exposed as a `(*Args, run_*)` pair (`BuildArgs` / `run_build`, +`DeployArgs` / `run_deploy`, `NewArgs` / `run_new`, `ServeArgs` / `run_serve`), +so a downstream project can build its own CLI binary that reuses any subset of +the built-ins and adds its own subcommands: + +```rust +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{BuildArgs, DeployArgs}; + +#[derive(Parser)] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Build(BuildArgs), // reuse the built-in + Deploy(DeployArgs), // reuse the built-in + Migrate, // your own subcommand +} + +fn main() { + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Build(args) => edgezero_cli::run_build(&args), + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::Migrate => run_migrate(), + }; + // ... +} +``` + +`edgezero new ` scaffolds exactly this pattern into a `crates/-cli` +crate, and `examples/app-demo/crates/app-demo-cli` is the in-tree reference. + ## Next Steps - Configure your project with [edgezero.toml](/guide/configuration) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9befc2a..62b61a3 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -28,17 +28,19 @@ cd my-app This generates a workspace with: - `crates/my-app-core` - Your shared handlers and routing logic +- `crates/my-app-cli` - Your project's own CLI binary, built on the `edgezero-cli` library - `crates/my-app-adapter-fastly` - Fastly Compute entrypoint - `crates/my-app-adapter-cloudflare` - Cloudflare Workers entrypoint - `crates/my-app-adapter-axum` - Native Axum entrypoint +- `crates/my-app-adapter-spin` - Fermyon Spin entrypoint - `edgezero.toml` - Manifest describing routes, middleware, and adapter config -## Start the Dev Server +## Run Your App Locally -Run the local Axum-powered development server: +Run your generated app on the native Axum adapter: ```bash -edgezero dev +edgezero serve --adapter axum ``` Your app is now running at `http://127.0.0.1:8787`. Try the generated endpoints: @@ -70,6 +72,9 @@ my-app/ │ │ └── src/ │ │ ├── lib.rs # App definition with edgezero_core::app! │ │ └── handlers.rs # Your route handlers +│ ├── my-app-cli/ +│ │ ├── Cargo.toml +│ │ └── src/main.rs # Your project's CLI, built on edgezero-cli │ ├── my-app-adapter-fastly/ │ │ ├── Cargo.toml │ │ ├── fastly.toml @@ -78,9 +83,13 @@ my-app/ │ │ ├── Cargo.toml │ │ ├── wrangler.toml │ │ └── src/main.rs -│ └── my-app-adapter-axum/ +│ ├── my-app-adapter-axum/ +│ │ ├── Cargo.toml +│ │ ├── axum.toml +│ │ └── src/main.rs +│ └── my-app-adapter-spin/ │ ├── Cargo.toml -│ ├── axum.toml +│ ├── spin.toml │ └── src/main.rs ``` diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md new file mode 100644 index 0000000..5319376 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -0,0 +1,727 @@ +# EdgeZero CLI Extensions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn `edgezero-cli` into an extensible library, rewrite the manifest store schema and runtime to a multi-store model, add `auth` / `provision` / `config validate` / `config push` commands, and update `app-demo` to exercise it all across axum / cloudflare / fastly / spin. + +**Architecture:** One PR, eight sequential stages. Stage 1 extracts the CLI library substrate. Stage 2 is an atomic manifest + runtime rewrite (hard cutoff — no backward compatibility). Stages 3–7 add app-config and the four commands. Stage 8 makes `app-demo` the full-capability showcase and audits docs. + +**Tech Stack:** Rust 1.95 (edition 2021), `clap` (derive), `serde` / `toml` / `serde_json`, `validator`, `async-trait` (`?Send`, WASM-safe), `handlebars` (templates), proc-macros (`edgezero-macros`), VitePress docs. + +**Spec:** `docs/superpowers/specs/2026-05-19-cli-extensions-design.md` — read it first. Section references (§) below point into it. + +--- + +## Preconditions (do before stage 2) + +- [x] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** Landed via the `chore/strict-clippy` merge — `crates/edgezero-adapter-spin/src/` now has `config_store.rs` / `key_value_store.rs` / `secret_store.rs`. Stage 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime. +- [ ] Working on branch `feature/extensible-cli` (stacked on `chore/strict-clippy` / PR #257). The spec and plan live in `docs/superpowers/`, which is gitignored — keep using `git add -f` for spec/plan files only. + +## Status + +- **Stage 1 — DONE.** Landed as `1d582dd` (extensible `edgezero-cli` + library + generator + `app-demo-cli`) plus follow-up `06f4b72` + (`demo` is example-only; `serve --adapter axum` runs the axum + adapter). §7 below is kept for reference — do **not** re-do it. +- **Stages 2–8 — pending.** Stage 2 is next; its PR #253 precondition is met. + +## Codebase facts this plan relies on + +- `edgezero-cli` is a binary-only crate today; `main.rs` holds private `handle_*` fns; `cli` feature gates `clap`. +- `ConfigStore::get` is **synchronous** today (`config_store.rs`). `KvStore` is already async. `SecretStore` (`get_bytes`) is async, uses `bytes::Bytes`. +- The KV handle type is `KvHandle`; config is `ConfigStoreHandle`; secrets is `SecretHandle`. +- `RequestContext` exposes `config_store() -> Option`, `kv_handle() -> Option`, `secret_handle() -> Option` — all singular. +- Axum KV is `PersistentKvStore` (redb-backed, `.edgezero/kv.redb`). +- `examples/app-demo` is a **separate workspace**, excluded from the root workspace; CI does not currently build or test it. +- CI: `.github/workflows/test.yml` and `format.yml` plus the docs ESLint/Prettier job. The exact gate commands are the five below. + +## The full gate + +Wherever a task says **"run the full gate"**, it means these exact +commands — the project's documented CI gates (`CLAUDE.md` "CI Gates" + +`.github/workflows/`). Do not substitute `--all-features` for the +feature list, or drop `--all-targets`; match CI exactly so the plan +validates the same surface CI does. + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin +``` + +Plus, where the task touches adapter runtime or `app-demo`: the +per-adapter wasm `--test contract` runs (Task 2.6), +`cd examples/app-demo && cargo test`, and — for doc changes — the docs +ESLint/Prettier job. Each stage's final task runs the full gate before +its `git commit`. + +## File structure (created / modified across the 8 stages) + +``` +crates/edgezero-cli/ + Cargo.toml # M: lib target implicit via src/lib.rs; new deps + src/lib.rs # C (stage 1): public API + src/main.rs # M (stage 1): thin wrapper; M (4-7): dispatch arms for new commands + src/args.rs # M: standalone *Args structs; M (4-7): new *Args + Command enum variants + src/demo_server.rs # M (stage 1): renamed from dev_server.rs + src/runner.rs # C (stage 5): CommandSpec + CommandRunner + src/auth.rs # C (stage 5) + src/provision.rs # C (stage 6) + src/config.rs # C (stage 7): validate + push + src/generator.rs # M (stages 1, 3): scaffold -cli, .toml + src/templates/cli/ # C (stage 1); M (stage 8): full command set + src/templates/app/ # C (stage 3) + src/templates/root/edgezero.toml.hbs # M (stage 2): new store schema + src/templates/core/src/config.rs.hbs # C (stage 3) + tests/lib_consumer.rs # C (stage 1) +crates/edgezero-core/src/ + manifest.rs # M (stage 2): store schema rewrite + capability rules + config_store.rs # M (stage 2): async trait + key_value_store.rs # M (stage 2): KvError::Unsupported + LimitExceeded + secret_store.rs # M (stage 2): bound-handle wrapper + context.rs # M (stage 2): id-keyed Bound*Store accessors + extractor.rs # M (stage 2): Kv/Secrets/Config default()/named() + app.rs # M (stage 2): Hooks + id-keyed ConfigStoreMetadata (Hooks lives in app.rs, no separate hooks.rs) + app_config.rs # C (stage 3) +crates/edgezero-macros/src/ + lib.rs # M (stage 3): AppConfig derive export + app_config.rs # C (stage 3): derive impl + app.rs # M (stage 2): emit id-keyed metadata +crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/ + {config_store,key_value_store,secret_store}.rs # M (stage 2): multi-store registries +examples/app-demo/ + Cargo.toml # M (stage 1): add app-demo-cli member + edgezero.toml # M (stage 2): new schema + app-demo.toml # C (stage 3) + crates/app-demo-cli/ # C (stage 1, extended 4-8) + crates/app-demo-core/src/config.rs # C (stage 3) + crates/app-demo-core/src/handlers.rs # M (stages 2, 8) +docs/guide/ # M: many pages per §6.12 +docs/guide/manifest-store-migration.md # C (stage 2) +docs/guide/cli-walkthrough.md # C (stage 8) +docs/.vitepress/config.mts # M (stages 2, 8): sidebar +``` + +--- + +# Stage 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton ✅ DONE (`1d582dd`, `06f4b72`) + +Spec §7. No PR #253 dependency. Goal: `edgezero-cli` becomes lib + bin; the `demo` subcommand replaces `dev`; the generator scaffolds `-cli`; a handwritten `app-demo-cli` exists. + +### Task 1.1: Promote `Command` variant fields into standalone `*Args` structs + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` + +- [ ] **Step 1: Write failing test** in `args.rs` `#[cfg(test)] mod tests` — assert `BuildArgs`, `DeployArgs`, `ServeArgs` exist, are `Default`, and parse: + +```rust +#[test] +fn build_args_default_and_mutate() { + let mut a = BuildArgs::default(); + a.adapter = "fastly".to_string(); + assert_eq!(a.adapter, "fastly"); +} +``` + +- [ ] **Step 2: Run** `cargo test -p edgezero-cli args::tests::build_args_default_and_mutate` — expect FAIL (`BuildArgs` not found). + +- [ ] **Step 3: Implement.** Add `#[derive(clap::Args, Debug, Default)] #[non_exhaustive]` structs `BuildArgs { adapter: String, adapter_args: Vec }`, `DeployArgs { adapter: String, adapter_args: Vec }`, `ServeArgs { adapter: String }` carrying the exact `#[arg(...)]` attributes currently inline in the `Command` enum variants. Keep `NewArgs` as-is (already standalone). Rewrite `Command` to: `Build(BuildArgs)`, `Deploy(DeployArgs)`, `Demo`, `New(NewArgs)`, `Serve(ServeArgs)`. Note: `Demo` is the renamed `Dev` (see Task 1.3). + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli args::` — expect PASS. Update the existing `parses_build_command_with_passthrough_args` test to destructure `Command::Build(BuildArgs { adapter, adapter_args })`. + +- [ ] **Step 5: Commit** is deferred — stage 1 lands as one commit after Task 1.7. Stage progress only. + +### Task 1.2: Create `lib.rs`, move handlers, rewrite `main.rs` + +**Files:** + +- Create: `crates/edgezero-cli/src/lib.rs` +- Modify: `crates/edgezero-cli/src/main.rs` + +- [ ] **Step 1:** Create `lib.rs` under `#![cfg(feature = "cli")]`-style gating consistent with the crate. Declare the private modules (`mod adapter; mod args; mod generator; mod scaffold; #[cfg(feature = "edgezero-adapter-axum")] mod demo_server;`). Move `init_cli_logger`, `load_manifest_optional`, `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, and the handler bodies from `main.rs`. Rename `handle_build`→`run_build`, `handle_deploy`→`run_deploy`, `handle_serve`→`run_serve`; add `run_new` wrapping `generator::generate_new`; `run_demo` (Task 1.3). `pub use args::{Args, BuildArgs, Command, DeployArgs, NewArgs, ServeArgs};`. Public signatures: `pub fn run_build(args: &BuildArgs) -> Result<(), String>` etc. + +- [ ] **Step 2:** Move the `#[cfg(test)] mod tests` from `main.rs` into `lib.rs` unchanged (they test the moved fns). + +- [ ] **Step 3:** Rewrite `main.rs` to ~25 lines: `use edgezero_cli::{...}; fn main() { edgezero_cli::init_cli_logger(); match Args::parse().cmd { Command::Build(a) => exit_on_err(edgezero_cli::run_build(&a)), ... Command::Demo => exit_on_err(edgezero_cli::run_demo()), ... } }`. Keep the `#[cfg(not(feature = "cli"))]` fallback `main`. + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli` — expect PASS (all relocated tests green). + +- [ ] **Step 5: Run** `cargo build -p edgezero-cli` and `./target/debug/edgezero --help` — expect four subcommands (`build`, `deploy`, `new`, `serve`); `demo` is gated behind the `demo-example` feature. + +### Task 1.3: Rename `dev` → `demo` + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs`, `crates/edgezero-cli/src/main.rs`, `crates/edgezero-cli/src/lib.rs` +- Rename: `crates/edgezero-cli/src/dev_server.rs` → `crates/edgezero-cli/src/demo_server.rs` + +- [ ] **Step 1:** `git mv crates/edgezero-cli/src/dev_server.rs crates/edgezero-cli/src/demo_server.rs`. Inside it, rename `pub fn run_dev()` → `pub fn run_demo() -> Result<(), String>` — change the return type: `Ok(())` on graceful shutdown, `Err(String)` on bind failure. Update internal references. + +- [ ] **Step 2:** In `args.rs`, the `Command` enum variant is `Demo` (done in Task 1.1). In `lib.rs` declare `#[cfg(feature = "edgezero-adapter-axum")] mod demo_server;` and `pub use demo_server::run_demo;` (feature-gated). Add the non-axum fallback: `run_demo` errors "built without edgezero-adapter-axum". + +- [ ] **Step 3:** Update `CLAUDE.md`'s `cargo run -p edgezero-cli --features dev-example -- dev` reference is doc-only — leave the `dev-example` feature name as-is (out of scope) but the invocation becomes `-- demo`. (Doc fix happens in Task 1.7.) + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli && cargo build -p edgezero-cli` — expect PASS; with `--features demo-example` built in, `./target/debug/edgezero demo --help` works. + +### Task 1.4: Extend the generator to scaffold `-cli` + +**Files:** + +- Modify: `crates/edgezero-cli/src/generator.rs`, `crates/edgezero-cli/src/scaffold.rs` +- Create: `crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs`, `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/Cargo.toml.hbs` + +- [ ] **Step 1: Write failing test** in `generator.rs` tests: `generate_new` into a `tempfile::TempDir` produces `crates/-cli/Cargo.toml` and `crates/-cli/src/main.rs`, and the root `Cargo.toml` `members` list contains `crates/-cli`. + +- [ ] **Step 2: Run** the test — expect FAIL. + +- [ ] **Step 3: Implement.** Add `templates/cli/Cargo.toml.hbs` (package `{{name}}-cli`, depends on `edgezero-cli` with default features, `clap` derive, `log`). Add `templates/cli/src/main.rs.hbs` — the canonical downstream pattern: a `clap::Parser` `Args` with a `Cmd` `Subcommand` enum listing the four downstream built-ins (`Build(BuildArgs)`, `Deploy(DeployArgs)`, `New(NewArgs)`, `Serve(ServeArgs)`), `main` dispatching to `edgezero_cli::run_*`. Register the new templates in `scaffold.rs::register_templates`. In `generator.rs`, render the cli crate and append `crates/{{name}}-cli` to the root `Cargo.toml` members. + +- [ ] **Step 4: Run** the generator test — expect PASS. + +- [ ] **Step 5: Manual check:** generate into an explicit fresh temp dir and build it — do **not** assume the project lands in CWD. Example: + +```bash +TMP="$(mktemp -d)" +cargo run -p edgezero-cli -- new throwaway --dir "$TMP" +# cd into the generated project root (confirm the exact path the generator +# prints — `--dir` is "the directory to create the app in"): +cd "$TMP"/* 2>/dev/null || cd "$TMP" +cargo check --workspace +cd - && rm -rf "$TMP" +``` + +Expected: `cargo check --workspace` in the generated project succeeds. + +### Task 1.5: Add the handwritten `app-demo-cli` crate + +**Files:** + +- Create: `examples/app-demo/crates/app-demo-cli/Cargo.toml`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `examples/app-demo/crates/app-demo-cli/tests/help.rs` +- Modify: `examples/app-demo/Cargo.toml` + +- [ ] **Step 1:** Add `"crates/app-demo-cli"` to `examples/app-demo/Cargo.toml` `members`. Add `edgezero-cli = { path = "../../crates/edgezero-cli" }` to that workspace's `[workspace.dependencies]` — the path is relative to the workspace manifest (`examples/app-demo/Cargo.toml`), matching the existing `edgezero-core = { path = "../../crates/edgezero-core" }` line. + +- [ ] **Step 2:** Write `app-demo-cli/Cargo.toml` — `name = "app-demo-cli"`, `publish = false`, `[lints] workspace = true`, deps `edgezero-cli = { workspace = true }`, `clap = { version = "4", features = ["derive"] }`, `log = { workspace = true }`. + +- [ ] **Step 3:** Write `app-demo-cli/src/main.rs` mirroring the generated `templates/cli/src/main.rs.hbs` pattern — the four downstream built-ins, no custom subcommands yet. `#[command(name = "app-demo-cli", about = "app-demo edge CLI")]`. + +- [ ] **Step 4:** Write `tests/help.rs`: `Args::try_parse_from(["app-demo-cli", "--help"])` returns the clap help error (not a panic). Since `Args` is private to `main.rs`, instead spawn the built binary: `assert_cmd`-style or `std::process::Command::new(env!("CARGO_BIN_EXE_app-demo-cli")).arg("--help")` exits 0 and stdout contains `build`, `deploy`, `new`, `serve`. + +- [ ] **Step 5: Run** `cd examples/app-demo && cargo test -p app-demo-cli` — expect PASS. + +### Task 1.6: External-consumer integration test + +**Files:** + +- Create: `crates/edgezero-cli/tests/lib_consumer.rs` + +- [ ] **Step 1: Write the test:** `use edgezero_cli::{BuildArgs, run_build};` — construct `let mut a = BuildArgs::default(); a.adapter = "fastly".into();`, write a minimal `edgezero.toml` into a `tempfile::TempDir`, set `EDGEZERO_MANIFEST`, call `run_build(&a)`, assert `Ok` (mirror the existing `handle_build_executes_manifest_command` test's manifest fixture). + + **Env-mutation guard (required).** `EDGEZERO_MANIFEST` is process-global; concurrent tests mutating it flake. Two rules: (a) restore the variable with an RAII guard — copy the `EnvOverride` struct from `edgezero-cli`'s existing `main.rs`/`lib.rs` tests (it saves the prior value in `new` and restores it in `Drop`); (b) keep `tests/lib_consumer.rs` to **exactly one** `#[test]`, so there is no in-binary parallelism on the env var. If a second env-touching test is ever added to this file, gate both with a shared `std::sync::Mutex` guard (the same `manifest_guard()` pattern the crate's unit tests use) — do not rely on `--test-threads=1`. + +- [ ] **Step 2: Run** `cargo test -p edgezero-cli --test lib_consumer` — expect PASS. This proves the public API is usable from outside the crate. + +### Task 1.7: Stage-1 documentation + commit + +**Files:** + +- Modify: `docs/guide/cli-reference.md`, `docs/guide/getting-started.md`, `CLAUDE.md` + +- [ ] **Step 1:** In `cli-reference.md` rename `dev` → `demo` and add a short "Building your own CLI" section pointing at the `edgezero-cli` library + the `-cli` scaffold. In `getting-started.md` note that `edgezero new` now also scaffolds `-cli`. In `CLAUDE.md` change the `dev` invocation example to `demo`. + +- [ ] **Step 2: Run the full gate** (the five commands in "The full gate" above) plus `cd examples/app-demo && cargo test`. All green. + +- [ ] **Step 3: Commit:** + +```bash +git add crates/edgezero-cli examples/app-demo docs/guide/cli-reference.md docs/guide/getting-started.md CLAUDE.md +git commit -m "Extensible edgezero-cli library + generator + app-demo-cli; rename dev->demo" +``` + +--- + +# Stage 2 — Manifest + runtime rewrite (atomic, all four adapters) + +Spec §8, §6.6, §6.7, §6.9. This is the largest stage and the review hotspot. Hard cutoff — legacy store schema is removed outright. + +## Design inputs added post-review — resolve in the Stage 2 design pass + +Two requirements surfaced after Stage 1 review. They revise the manifest +model and **must be reconciled with the §8 multi-store design before +implementing** — do not bolt them on piecemeal: + +- **A downstream binary must build without an `edgezero.toml` present.** + Manifest/store config reaches the runtime through the `App` / `Hooks` + type — macro-baked when `app!` is used, programmatic defaults otherwise — + never a runtime `include_str!` of a manifest file. `run_app` must not + hard-require a manifest file to exist at compile time. (Today every + adapter entrypoint does `include_str!("../../../edgezero.toml")`, which + breaks any downstream project that builds its `App` without a manifest.) +- **`edgezero.toml` defines only non-adapter-specific (portable) config.** + Routes, app metadata, logical store declarations, and env-var + declarations live in `edgezero.toml`; adapter-specific config lives in + the adapter layer (per-adapter manifests / adapter crate config), not the + shared manifest. + +### Task 2.1: Portable manifest schema + +**Files:** `crates/edgezero-core/src/manifest.rs` (+ `manifest_definitions.rs`) + +Rewrite `ManifestStores` to the §6.6 portable schema: `[stores.]` +carries only `ids` (non-empty) and `default` (required when +`ids.len() > 1`, else `ids[0]`). Remove the `[adapters.*]` store and +runtime tables from the manifest model. Pre-rewrite fields +(`[stores.] name`, `[stores.config.defaults]`, +`[adapters.*.stores.*]`) → hard load error pointing at +`docs/guide/manifest-store-migration.md`. + +- [ ] Tests: round-trip; non-empty ids; default required when >1 id; + legacy manifest → hard error with migration message. +- [ ] Full gate. + +### Task 2.2: `EDGEZERO__*` environment-config layer + +**Files:** `crates/edgezero-core/src/env_config.rs` (new) + +Parse `EDGEZERO__`-prefixed env vars (`__` = key-path separator) into an +adapter runtime-config value: per-store `NAME` + free-form tuning, bind +host/port, logging level. Absent vars resolve to the §6.6 defaults (a +store's platform name defaults to its logical id). + +- [ ] Tests: nesting, defaults, store-name resolution; zero-env case. +- [ ] Full gate. + +### Task 2.3: `app!` macro bakes portable config into `Hooks` + +**Files:** `crates/edgezero-macros/src/app.rs`, `crates/edgezero-core/src/app.rs` + +The `app!` macro reads `edgezero.toml` at compile time and codegens the +logical store registry + id-keyed `ConfigStoreMetadata` into the +generated `App` / `Hooks` type, alongside routing. `Hooks` exposes the +portable store config. The macro and manifest stay optional — an `App` +built without the macro supplies empty defaults, so a downstream binary +compiles with no `edgezero.toml`. + +- [ ] Tests: `app!` macro metadata-registry test. +- [ ] Full gate. + +### Task 2.4: `run_app::()` drops `manifest_src` (all four adapters) + +**Files:** `run_app` in each adapter crate; the four entrypoint templates; `edgezero-cli/src/demo_server.rs` + +`run_app` takes no manifest string. It reads portable config from `A` +and layers `EDGEZERO__*` env config (Task 2.2) for adapter-specific +values. Remove every `include_str!("edgezero.toml")`; update the four +adapter entrypoint templates and `demo_server.rs`. + +- [ ] Tests: `run_app` builds and runs with no manifest file / zero env. +- [ ] Full gate. + +### Task 2.5: Async `ConfigStore`, `KvError` variants, bound handles, id-keyed context + +**Files:** `config_store.rs`, `key_value_store.rs`, `secret_store.rs`, `context.rs`, `error.rs` + +`ConfigStore::get` → `async` (`#[async_trait(?Send)]`). Add +`KvError::Unsupported` and `KvError::LimitExceeded` with 5xx-class +`EdgeError` mappings. Add `BoundKvStore` / `BoundConfigStore` / +`BoundSecretStore` and a `StoreRegistry`; `RequestContext` accessors +become id-keyed with `_default()` helpers. + +- [ ] Tests: async config round-trip; new `KvError` mappings; registry. +- [ ] Full gate. + +### Task 2.6: Adapter store registries — all four adapters + +**Files:** `{config_store,key_value_store,secret_store}.rs` in each adapter crate + +Each adapter builds a `StoreRegistry` keyed by logical id, platform +names from `EDGEZERO__STORES__*`. axum: local KV + local-file config + +env secrets. cloudflare: KV registry, config `[vars]`→KV async, worker +secrets. fastly: KV / config / secret registries. spin: `SpinKvStore` +(labels from env, `max_list_keys`), `SpinConfigStore` (`.`→`__`), +`SpinSecretStore`. + +- [ ] Tests: id-keyed contract factories ×4; cross-adapter named KV; + cloudflare config-from-KV; spin `.`→`__`; spin TTL → `Unsupported`; + spin listing-cap pagination. +- [ ] Full gate incl. per-adapter wasm `--test contract`. + +### Task 2.7: `Kv` / `Secrets` / `Config` extractors + +**Files:** `crates/edgezero-core/src/extractor.rs` + +Refactor `Kv` / `Secrets` to `default()` / `named()`; add the `Config` +extractor (§6.9). + +- [ ] Tests: extractor tests for all three. +- [ ] Full gate. + +### Task 2.8: Migrate `app-demo`, templates, docs + +**Files:** `examples/app-demo/edgezero.toml` + handlers + adapter run config; `templates/root/edgezero.toml.hbs`; `docs/guide/manifest-store-migration.md`; affected `docs/guide/` pages + +Rewrite `examples/app-demo/edgezero.toml` and +`templates/root/edgezero.toml.hbs` to the portable schema (≥2 KV ids, +one config id, one secrets id). Migrate app-demo handlers for the +store-accessor change only. Publish `manifest-store-migration.md`; +update affected `docs/guide/` pages. + +- [ ] Full gate + `cd examples/app-demo && cargo test` + docs CI. + +### Task 2.9: Stage-2 ship gate + commit + +- [ ] Run the full gate (all five CI gates + per-adapter wasm contract + tests + `examples/app-demo` + the `generated_project_builds` + opt-in test). +- [ ] Verify an adapter binary builds and runs with no `edgezero.toml` + and zero env vars (defaults). +- [ ] Commit. + +--- + +# Stage 3 — App-config schema, derive macro, env-overlay loader + +Spec §9, §6.7, §6.8, §6.10. + +### Task 3.1: `edgezero-core::app_config` module + +**Files:** + +- Create: `crates/edgezero-core/src/app_config.rs`; Modify: `crates/edgezero-core/src/lib.rs` + +- [ ] **Step 1: Write failing tests:** valid `.toml` loads; missing file, bad TOML, missing `[config]` table, validator failure each produce a distinct `AppConfigError`. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** per §4. Types: `AppConfigMeta` trait with `const SECRET_FIELDS: &'static [SecretField]`; `SecretField { name, kind }`; `SecretKind { KeyInDefault, StoreRef }`; `AppConfigError`; `AppConfigLoadOptions { env_overlay: bool }` with `Default` = `{ env_overlay: true }`. + + Loader API — **one consistent shape, no hidden bool param.** The simple functions apply the env overlay (the default); the `_with_options` variants take `AppConfigLoadOptions` explicitly: + - `load_app_config(path, app_name) -> Result` — overlay on. + - `load_app_config_with_options(path, app_name, opts: &AppConfigLoadOptions) -> Result`. + - `load_app_config_raw(path, app_name) -> Result` — overlay on. + - `load_app_config_raw_with_options(path, app_name, opts: &AppConfigLoadOptions) -> Result`. + + The simple functions delegate to the `_with_options` form with `AppConfigLoadOptions::default()`. `--no-env` (Tasks 4.1 / 7.1) calls the `_with_options` variant with `env_overlay: false`. `load_app_config*` parses the `[config]` table, applies the env overlay when `opts.env_overlay`, then (typed) deserializes + `validate()`. `pub mod app_config;` in `lib.rs`. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.2: `AppConfig` derive macro + +**Files:** + +- Create: `crates/edgezero-macros/src/app_config.rs`; Modify: `crates/edgezero-macros/src/lib.rs`, `crates/edgezero-core/src/lib.rs` + +**Macro availability — chosen route: re-export through `edgezero-core`.** +`edgezero-core` already re-exports the `action` and `app` proc-macros +from `edgezero-macros` (handlers do `use edgezero_core::action`). +`AppConfig` follows the _same_ route: the derive is defined in +`edgezero-macros` and **re-exported from `edgezero-core`** so consumers +write `use edgezero_core::AppConfig`. Consequence: a crate that derives +`AppConfig` needs **only `edgezero-core`** as a dependency for the +macro — no direct `edgezero-macros` dependency. (`#[derive(Validate)]` +and `#[validate(...)]` still need the `validator` crate directly — see +Task 3.4 / 3.5.) + +- [ ] **Step 1a: Add the `trybuild` dev-dependency.** Compile-fail tests need `trybuild`; `crates/edgezero-macros/Cargo.toml` currently has only `tempfile` under `[dev-dependencies]`. Add `trybuild = "1"` to `[dev-dependencies]` there (and to `[workspace.dependencies]` in the root `Cargo.toml` if the workspace pins dev-deps centrally — check first and follow the existing convention). + +- [ ] **Step 1b: Write macro tests** in `crates/edgezero-macros/tests/app_config_derive.rs`: empty `SECRET_FIELDS` with no annotation; one `KeyInDefault` from `#[secret]`; one `StoreRef` from `#[secret(store_ref)]`; both kinds. Add a `trybuild` compile-fail harness — `let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs");` — with one `tests/ui/*.rs` fixture per rejected case: `#[secret]` + `#[serde(flatten)]`, `#[secret]` + `#[serde(rename)]`, `#[secret(bogus)]`, `#[secret]` on a non-scalar field. Each fixture has a matching `.stderr` golden file (generate with `TRYBUILD=overwrite` once the `compile_error!` messages are final). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement.** `#[proc_macro_derive(AppConfig, attributes(secret))]` in `edgezero-macros/src/lib.rs` delegating to `app_config::derive`. The impl scans fields for `#[secret]` / `#[secret(store_ref)]`, enforces the §6.7 constraints with `compile_error!`, and emits `impl ::edgezero_core::app_config::AppConfigMeta` with the `SECRET_FIELDS` array (Rust field name verbatim). **Also re-export it from `edgezero-core/src/lib.rs`** — `pub use edgezero_macros::AppConfig;` — next to the existing `action` / `app` re-exports, so downstream code uses `edgezero_core::AppConfig`. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.3: Env-overlay resolution + +**Files:** + +- Modify: `crates/edgezero-core/src/app_config.rs` + +- [ ] **Step 1: Write tests:** `APP_DEMO__GREETING` overrides a top-level key; `APP_DEMO__SERVICE__TIMEOUT_MS` overrides a nested key; type coercion against the existing TOML value; a non-parseable value errors; two sibling keys mapping to the same env segment errors; `load_app_config_with_options` with `AppConfigLoadOptions { env_overlay: false }` skips the overlay entirely. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** per §6.10: walk the parsed `[config]` tree; for each existing key compute `__
__…__` (uppercase, `-`→`_`, `__` separators); look up the env var; coerce to the existing value's type; reject ambiguous sibling mappings. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.4: Generator templates for app-config + +**Files:** + +- Create: `crates/edgezero-cli/src/templates/app/.toml.hbs`, `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/core/Cargo.toml.hbs`, `crates/edgezero-cli/src/generator.rs`, `scaffold.rs` + +- [ ] **Step 1: Add a `NameUpperCamel` key to the generator Handlebars context.** The config templates name the struct `{{NameUpperCamel}}Config` (e.g. `my-app` → `MyAppConfig`), and the CLI template in stage 8 reuses the same key. The generator's Handlebars data today exposes only `name`, `proj_core`, `proj_core_mod`, `proj_mod` (`generator.rs`). + + Derivation — **must yield a valid Rust type identifier** (the result is used as `{{NameUpperCamel}}Config`, a `struct` name): + 1. Start from the **sanitized** crate name (reuse `sanitize_crate_name` from `scaffold.rs`, so it stays consistent with the crate name). + 2. Split on `-` and `_`; drop empty segments (this naturally absorbs a leading `_` that `sanitize_crate_name` may have inserted). + 3. Upper-case the first character of each segment, lower-case the rest; join. + 4. **If the result is empty, or its first character is not an ASCII letter** (e.g. the project name started with a digit, giving something like `123App`), prefix it with `App`. A Rust type name cannot begin with a digit. + + Insert the result under the context key `NameUpperCamel`. Add a unit test covering: `my-app` → `MyApp`; `foo` → `Foo`; `a_b-c` → `ABC`; `_foo` → `Foo` (empty leading segment dropped); `123-app` → `App123App` (digit-leading → `App` prefix). This key lands here in stage 3 because `config.rs.hbs` is its first consumer; stage 8's `templates/cli/` reuses it. + +- [ ] **Step 2:** `app/.toml.hbs` — a `[config]` table with `greeting` and a nested `[config.service]` section. `core/src/config.rs.hbs` — `{{NameUpperCamel}}Config` with `#[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)]` + `#[serde(deny_unknown_fields)]`, a `greeting` field, a nested `service` field, **one plain `#[secret]` field**, and a commented-out `#[secret(store_ref)]` example (§6.8 — the generated template does not include `store_ref` live). + +- [ ] **Step 3: Update `templates/core/Cargo.toml.hbs` deps + the workspace-dep seed.** The generated config struct needs `validator` (for `#[derive(Validate)]` / `#[validate(...)]`) and `serde` with the `derive` feature. The `AppConfig` derive comes via the `edgezero-core` re-export (Task 3.2) — the core template already depends on `edgezero-core`, so **no `edgezero-macros` dependency is added**. Add `validator = { workspace = true }` to `templates/core/Cargo.toml.hbs` (it currently lacks it); confirm `serde` is present with `features = ["derive"]`. Because the generated project is itself a workspace, a `workspace = true` dep only resolves if the generated **root** `Cargo.toml` lists it: add `validator` to the generator's workspace-dependency seed (the `seed_workspace_dependencies` function / data in `generator.rs` — confirm the exact name by reading the file; it seeds the generated root `[workspace.dependencies]` and does **not** include `validator` today). Match whatever version-pin the seed already uses for `serde` etc. + +- [ ] **Step 4:** Render both new templates in `generate_new`; register them in `scaffold.rs`. + +- [ ] **Step 5: Write/extend the generator test** to assert `.toml` and `-core/src/config.rs` are produced, the struct name is `{{NameUpperCamel}}Config` for the test project name, **and** that the generated `-core` builds (the seeded `validator` dep resolves and `edgezero_core::AppConfig` is in scope) — `cargo check -p -core` in the scaffolded project. + +- [ ] **Step 6: Run** the generator test — PASS. + +### Task 3.5: `app-demo` app-config + commit + +**Files:** + +- Create: `examples/app-demo/app-demo.toml`, `examples/app-demo/crates/app-demo-core/src/config.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs`, `examples/app-demo/crates/app-demo-core/Cargo.toml` (verify deps), `docs/guide/configuration.md`, `getting-started.md` + +- [ ] **Step 1:** Write `app-demo.toml` — `[config]` with `greeting`, `feature_new_checkout`, a `[config.service]` with `timeout_ms`, `api_token` (a `#[secret]` value), `vault` (a `#[secret(store_ref)]` value = the single secrets id). Write `app-demo-core/src/config.rs` — `AppDemoConfig` with the §6.8 shape (nested `ServiceConfig`, one `#[secret]`, one `#[secret(store_ref)]`), deriving `serde::{Deserialize, Serialize}`, `validator::Validate`, `edgezero_core::AppConfig`. Export it from `lib.rs`. **Verify `app-demo-core/Cargo.toml` deps:** it must have `edgezero-core` (for the `AppConfig` re-export), `validator`, and `serde` with `derive`. `app-demo-core` already depends on all three today — confirm and add any that are somehow missing. No `edgezero-macros` dependency is needed (macro comes via the `edgezero-core` re-export, Task 3.2). + +- [ ] **Step 2: Write a round-trip test** in `app-demo-core`: `load_app_config::` against `app-demo.toml` succeeds; `AppDemoConfig::SECRET_FIELDS` has the expected two entries; an env var overrides the nested value. + +- [ ] **Step 3:** Update `configuration.md` (app-config file + env overlay) and `getting-started.md` (generator now emits `.toml`). + +- [ ] **Step 4: Run** the full gate. **Commit:** `git commit -m "App-config schema, #[derive(AppConfig)] macro, env-overlay loader"` + +--- + +# Stage 4 — `config validate` command + +Spec §10. New: `ConfigValidateArgs`, `run_config_validate`, `run_config_validate_typed`. + +### Task 4.1: `config validate` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (add `ConfigValidateArgs` + a `ConfigCmd` subcommand enum), `crates/edgezero-cli/src/lib.rs` +- Create: `crates/edgezero-cli/src/config.rs` + +- [ ] **Step 1: Write failing tests** with fixtures for each failure mode (§10): valid passes; bad TOML; missing `[config]`; unknown field (struct with `deny_unknown_fields`); type mismatch; validator-rule failure; empty `#[secret]`; `#[secret(store_ref)]` value not in `[stores.secrets].ids`; missing per-adapter mapping; the three Spin checks (key syntax, collision — typed-only, component discovery). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ConfigValidateArgs { manifest, app_config, strict, no_env }` (`#[derive(clap::Args, Default, Debug)] #[non_exhaustive]`). `run_config_validate` (raw) and `run_config_validate_typed` in `config.rs`. Raw does TOML + manifest checks + Spin key-syntax + component discovery; typed adds deserialize + `validate()` + secret checks + the collision check. Both run manifest `ManifestLoader` validation; `--strict` adds capability completeness + handler-path checks. + +- [ ] **Step 4: Run** — PASS. + +### Task 4.2: Wire `config` into the default `edgezero` binary + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`Command` enum), `crates/edgezero-cli/src/main.rs` + +The spec (§1, §8) requires the new subcommands to be available on the +**default `edgezero` binary**, not only on `app-demo-cli`. The default +binary has no app-config struct, so it uses the **raw** functions. + +- [ ] **Step 1:** Add `Config(ConfigCmd)` to the default `edgezero-cli` `Command` enum in `args.rs` (the same `ConfigCmd` subcommand enum from Task 4.1; `ConfigCmd::Validate(ConfigValidateArgs)` for now, `Push` added in stage 7). + +- [ ] **Step 2:** Add the dispatch arm in `main.rs`: `Command::Config(ConfigCmd::Validate(a)) => exit_on_err(edgezero_cli::run_config_validate(&a))` — the **raw** validator (the default binary has no `C`). + +- [ ] **Step 3: Write a test** (in `args.rs` or an integration test): `Args::try_parse_from(["edgezero", "config", "validate", "--strict"])` parses to `Command::Config(ConfigCmd::Validate(_))`; and `cargo run -p edgezero-cli -- --help` lists `config`. + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli && cargo build -p edgezero-cli && ./target/debug/edgezero config validate --help` — expect PASS / the subcommand help. + +### Task 4.3: Wire `app-demo-cli config validate` + docs + commit + +**Files:** + +- Modify: `examples/app-demo/crates/app-demo-cli/Cargo.toml`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` + +- [ ] **Step 1: Add the `app-demo-core` dependency.** `app-demo-cli` is about to reference `AppDemoConfig`, which lives in `app-demo-core` (created in stage 3, Task 3.5). Its `Cargo.toml` so far has only `edgezero-cli` / `clap` / `log` (Task 1.5). Add `app-demo-core = { path = "../app-demo-core" }` to `app-demo-cli/Cargo.toml` (path dep within the `examples/app-demo` workspace). + +- [ ] **Step 2:** Add a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in stage 7). `use app_demo_core::AppDemoConfig;` and dispatch `Validate` to `edgezero_cli::run_config_validate_typed::` — the **typed** validator (`app-demo-cli` knows `AppDemoConfig`). + +- [ ] **Step 3:** Document `config validate` in `cli-reference.md` — note the default `edgezero` binary runs the raw validator, downstream CLIs the typed one. + +- [ ] **Step 4: Run** the full gate; `cd examples/app-demo && cargo run -p app-demo-cli -- config validate --strict` exits 0; `./target/debug/edgezero config validate --strict` (raw path) also exits 0 against a fixture. **Commit:** `git commit -m "config validate command (raw + typed)"` + +--- + +# Stage 5 — `auth` command (+ `CommandRunner`) + +Spec §11, §6.1. + +### Task 5.1: `CommandRunner` infrastructure + +**Files:** + +- Create: `crates/edgezero-cli/src/runner.rs`; Modify: `lib.rs` + +- [ ] **Step 1: Write a test** using `MockCommandRunner` — assert a recorded `CommandSpec` matches `{ program: "echo", args: ["hi"], cwd: None, ... }`. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** per §6.1: private `CommandSpec<'a>`, `CommandRunner` trait, `CommandOutput`, `RealCommandRunner` (`std::process::Command`), `#[cfg(test)] MockCommandRunner`. + +- [ ] **Step 4: Run** — PASS. + +### Task 5.2: `auth` command + docs + commit + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`AuthArgs`, `AuthSub`), `lib.rs` +- Create: `crates/edgezero-cli/src/auth.rs` +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` + +- [ ] **Step 1: Write tests:** for each (adapter, sub) pair a `MockCommandRunner` expectation asserting the exact `CommandSpec` (per the §11 table); tool-not-found and non-zero-exit cases. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement.** `AuthArgs { sub: AuthSub }` — `#[derive(clap::Args, Debug)] #[non_exhaustive]`, **no `Default`** (§6.11). `AuthSub { Login{adapter}, Logout{adapter}, Status{adapter} }`. `run_auth` → `run_auth_with(&RealCommandRunner, args)` dispatching per the §11 table. + +- [ ] **Step 4: Run** — PASS. Document `auth` in `cli-reference.md`. + +- [ ] **Step 5: Wire both binaries.** Add `Auth(AuthArgs)` to the **default `edgezero-cli` `Command` enum** (`args.rs`) and a dispatch arm in `main.rs`: `Command::Auth(a) => exit_on_err(edgezero_cli::run_auth(&a))`. Also add `Auth(AuthArgs)` to `app-demo-cli`'s `Cmd` enum and dispatch it to `run_auth`. Write a test that `Args::try_parse_from(["edgezero", "auth", "login", "--adapter", "cloudflare"])` parses and that `edgezero --help` lists `auth`. + +- [ ] **Step 6: Run** the full gate; `./target/debug/edgezero auth --help` shows the `login`/`logout`/`status` subcommands. **Commit:** `git commit -m "auth command + CommandRunner infrastructure"` + +--- + +# Stage 6 — `provision` command + +Spec §12, §13 (Fastly contract). + +### Task 6.1: `provision` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ProvisionArgs`), `lib.rs` +- Create: `crates/edgezero-cli/src/provision.rs` + +- [ ] **Step 1: Write tests:** per-(adapter, kind) `MockCommandRunner` expectations with scripted stdout; golden ID-extraction parsers; temp-fixture writeback verified for `wrangler.toml`, `fastly.toml`, and the Spin `key_value_stores` array in `spin.toml`; axum no-op output asserted; `--dry-run` invokes nothing. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ProvisionArgs { manifest, adapter, dry_run }`. `run_provision` per the §12 per-adapter table: axum no-op; cloudflare `wrangler kv namespace create` + `wrangler.toml` `[[kv_namespaces]]` writeback; fastly `fastly -store create` + `[setup.*]`/`[local_server.*]` `fastly.toml` writeback; spin KV-label `spin.toml` writeback only (component resolved per §6.7). + +- [ ] **Step 4: Run** — PASS. Document `provision` in `cli-reference.md`. + +- [ ] **Step 5: Wire both binaries.** Add `Provision(ProvisionArgs)` to the **default `edgezero-cli` `Command` enum** (`args.rs`) and a dispatch arm in `main.rs`: `Command::Provision(a) => exit_on_err(edgezero_cli::run_provision(&a))`. Also add `Provision(ProvisionArgs)` to `app-demo-cli`'s `Cmd` enum, dispatched to `run_provision`. Write a test that `Args::try_parse_from(["edgezero", "provision", "--adapter", "cloudflare", "--dry-run"])` parses and that `edgezero --help` lists `provision`. + +- [ ] **Step 6: Run** the full gate; `./target/debug/edgezero provision --adapter cloudflare --dry-run` runs. **Commit:** `git commit -m "provision command (cloudflare/fastly/spin writeback, axum no-op)"` + +--- + +# Stage 7 — `config push` command + +Spec §13, §6.4, §6.5. + +### Task 7.1: `config push` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ConfigPushArgs`, extend `ConfigCmd`), `lib.rs`, `crates/edgezero-cli/src/config.rs` + +- [ ] **Step 1: Write tests:** typed + raw; per-adapter mock-runner/fixture with golden payloads; secret fields absent; missing native-manifest id (cloudflare) → clear error; Spin `.`→`__` translation; Spin writes both `spin.toml` tables; Spin component-resolution failure errors; `--store` selection; `--dry-run` invokes nothing; the §13 "validate passes, push serialization fails" cases; the Spin `spin.toml` golden test (strongest-first validation ladder, §13). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ConfigPushArgs { manifest, adapter, store, app_config, no_env, dry_run }`. `run_config_push` / `run_config_push_typed`: strict pre-flight validation, load app-config, flatten + serialize per §6.4/§6.5 (skip `SECRET_FIELDS`), resolve target id, push per the §13 per-adapter table (axum local JSON file; cloudflare `wrangler kv bulk put`; fastly `config-store-entry create`; spin both `spin.toml` tables). + +- [ ] **Step 4: Run** — PASS. + +### Task 7.2: Wire `config push` into both binaries + docs + commit + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ConfigCmd`), `crates/edgezero-cli/src/main.rs`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md`, `configuration.md` + +- [ ] **Step 1: Default `edgezero` binary.** Extend the `ConfigCmd` enum (defined in Task 4.1, used by the default `Command::Config` arm from Task 4.2) with `Push(ConfigPushArgs)`. Add the dispatch arm in `main.rs`: `Command::Config(ConfigCmd::Push(a)) => exit_on_err(edgezero_cli::run_config_push(&a))` — the **raw** push. + +- [ ] **Step 2: `app-demo-cli`.** Extend `app-demo-cli`'s `ConfigCmd` with `Push(ConfigPushArgs)`; dispatch to `run_config_push_typed::` — the **typed** push. + +- [ ] **Step 3:** Write a test that `Args::try_parse_from(["edgezero", "config", "push", "--adapter", "axum"])` parses to `Command::Config(ConfigCmd::Push(_))` and that `edgezero config --help` lists both `validate` and `push`. + +- [ ] **Step 4:** Document `config push` in `cli-reference.md` (note raw vs typed per binary); cross-reference from `configuration.md`. + +- [ ] **Step 5: Run** the full gate. **Commit:** `git commit -m "config push command (per-adapter, secret-skipping, env overlay)"` + +--- + +# Stage 8 — `app-demo` integration polish + docs audit + +Spec §15, §6.12. + +### Task 8.1: Full `app-demo` capability exercise + +**Files:** + +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `examples/app-demo/crates/app-demo-core/src/handlers.rs`, `examples/app-demo/edgezero.toml`, `examples/app-demo/app-demo.toml`, `examples/app-demo/crates/app-demo-adapter-spin/spin.toml` + +- [ ] **Step 1:** Confirm `app-demo-cli`'s `Cmd` has the four downstream built-ins + `Auth` + `Provision` + `Config(Validate|Push)`. Ensure handlers exercise: two named KV ids (`sessions`, `cache`) via `Kv::named`; async `config_store_default().get("greeting")`; the nested `service.timeout_ms`; both secret forms. Add the manual Spin secret-variable declarations to `app-demo-adapter-spin/spin.toml` (`secret = true`, bound under `[component..variables]`). + +- [ ] **Step 2: Write integration tests** in `app-demo`: `config validate --strict` exits 0; `config push --adapter axum` writes `.edgezero/local-config-app_config.json` and a running demo server returns `greeting` on `/config/greeting`; `config push --adapter spin --dry-run` **prints** the would-be `__`-encoded keys and the would-be content of both `spin.toml` tables — and the test asserts the on-disk `spin.toml` is **unchanged** (dry-run never mutates); an env-override test asserts `APP_DEMO__SERVICE__TIMEOUT_MS` takes effect. + + **Demo-server lifecycle (required, to keep the e2e test non-flaky):** + - **Port:** do not hard-code `8787`. Bind an ephemeral port — either bind `127.0.0.1:0` and read back the assigned port, or pick a free port in the test and pass it to the server. Concurrent CI jobs must not collide. + - **Readiness:** after spawning the server, poll `GET /` (or a health route) with a short retry loop — e.g. up to ~50 attempts, 100ms apart (~5s budget) — and only proceed once a request succeeds. Never use a bare `sleep`. + - **Teardown:** spawn the server as a child process and kill it in an RAII guard (a struct that holds the `Child` and calls `.kill()` + `.wait()` in `Drop`), so it is reaped even when an assertion fails or panics. Also clean up the `.edgezero/local-config-*.json` files the test wrote. + +- [ ] **Step 3: Run** `cd examples/app-demo && cargo test` — PASS. + +### Task 8.2: Upgrade the generated `-cli` template to the full command set + +**Files:** + +- Modify: `crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs`, `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`, `crates/edgezero-cli/src/generator.rs` (tests) + +Stage 1 created the `-cli` template with only the four downstream +built-ins (`auth` / `provision` / `config` did not exist yet). Now that +stages 4–7 have landed them, a freshly-scaffolded project must expose +the full command surface (spec §1: downstream CLIs reuse the +post-effort built-ins). + +- [ ] **Step 1: Add the core-crate dependency to the CLI template.** The full-command template references the typed config functions with `{{NameUpperCamel}}Config`, which lives in the generated `{{name}}-core` crate. The `templates/cli/Cargo.toml.hbs` from stage 1 depends only on `edgezero-cli` / `clap` / `log` — add `{{name}}-core = { path = "../{{name}}-core" }` (path dep within the generated workspace). Without this the scaffolded CLI will not compile. + +- [ ] **Step 2:** Update `templates/cli/src/main.rs.hbs` so the generated `Cmd` enum lists **all seven** commands: `Build`, `Deploy`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/new/serve/auth/provision` to the raw `edgezero_cli::run_*`. The `use` statement must reference the core crate's **Rust module name**, not the package name — use `use {{proj_core_mod}}::{{NameUpperCamel}}Config;` (the generator already exposes `proj_core_mod`, the hyphen-to-underscore module form; `{{name}}_core` would render `my-app_core` for `my-app`, which is invalid Rust). Dispatch the `Config` arm to the **typed** `run_config_validate_typed::<{{NameUpperCamel}}Config>` / `run_config_push_typed::<{{NameUpperCamel}}Config>` — a generated project has its own core config struct (from the Task 3.4 `config.rs.hbs` template), so the scaffold wires the typed path, matching how `app-demo-cli` does it. + +- [ ] **Step 3:** Extend the generator structure test (from Task 1.4 / 3.4): the scaffolded `-cli/Cargo.toml` depends on `-core`; `-cli/src/main.rs` contains `Auth`, `Provision`, and `Config` variants and references the typed config functions with the project's config type. + +- [ ] **Step 4: Run** the generator tests, then `cargo run -p edgezero-cli -- new --dir …` and `cargo check --workspace` in the generated project — the scaffolded CLI builds with all seven commands **and** resolves `{{NameUpperCamel}}Config` from its core crate. + +### Task 8.3: CI wiring for the `app-demo` loop + +**Files:** + +- Modify: `.github/workflows/test.yml` (or `scripts/run_tests.sh`) + +- [ ] **Step 1:** CI does not currently build `app-demo`. Add a job/step that runs `cd examples/app-demo && cargo test`. Prefer expressing the end-to-end axum loop **as a Rust integration test inside `app-demo`** (the Task 8.1 `app-demo` integration test) rather than as raw shell in the workflow — the Rust test already owns ephemeral-port binding, the readiness poll, and RAII teardown (Task 8.1 step 2). The CI job then just needs `cargo test`; it does not hand-roll `start server / curl / kill` in YAML, which is where shell-based e2e steps go flaky. Keep this job off the wasm matrix — axum only, no live external calls. + +- [ ] **Step 2:** If any loop step must stay as a shell step in the workflow (e.g. invoking the built `app-demo-cli` binary), it must still: select a free port (not a hard-coded one), poll readiness before curl-ing, and `kill` the server in a `trap`/`always()` cleanup so a failed assertion never leaves an orphan process. Mirror the Task 8.1 lifecycle rules. + +- [ ] **Step 3: Run** the workflow logic locally to confirm the loop passes and leaves no orphan processes or `.edgezero/` artifacts. + +### Task 8.4: Walkthrough doc + documentation audit + commit + +**Files:** + +- Create: `docs/guide/cli-walkthrough.md`; Modify: `docs/.vitepress/config.mts`, any pages still stale + +- [ ] **Step 1:** Write `docs/guide/cli-walkthrough.md` — the full `myapp` loop (`new`, `auth`, `provision`, `config validate`, `config push`, `deploy`, `demo`), an env-override example, all four adapters, the manual Spin secret-variable `spin.toml` entries, the explicit `[adapters.spin.adapter].component` form. Add it + `manifest-store-migration.md` to the `config.mts` sidebar. + +- [ ] **Step 2: Documentation audit** (§6.12): `grep -rn` the `docs/` tree for stale references — old `[stores.*]` keys (`stores.config.defaults`, `[stores.kv] name`), the `dev` subcommand, the old singular store API (`config_store()` with no arg, `kv_handle`, `secret_handle`). Fix every hit. Confirm every page in the §6.12 table was updated and every page is in the sidebar. + +- [ ] **Step 3: Run the full gate** (the five commands in "The full gate" above), plus all three per-adapter wasm `--test contract` runs (Task 2.7 step 6), `cd examples/app-demo && cargo test`, and the docs ESLint/Prettier job. All green. + +- [ ] **Step 4: Commit:** `git commit -m "app-demo full-capability showcase + documentation audit"` + +--- + +## Self-review notes + +- **Spec coverage:** §7→C1, §8/§6.6/§6.7/§6.9→C2, §9/§6.8/§6.10→C3, §10→C4, §11/§6.1→C5, §12→C6, §13/§6.4/§6.5→C7, §15/§6.12→C8. §6.3 (feature gates) is honored throughout. §6.11 (`Default` on `*Args`) is in Tasks 1.1, 4.1, 5.2, 6.1, 7.1. §6.12 docs are in every stage's final task. +- **Precondition:** PR #253 is a hard precondition for stage 2 — called out at the top and in the stage-2 header. +- **Bisectability:** each stage ends with a green-gate step before its commit step; stage 1 needs no PR #253; stage 2's axum config tests seed the JSON fixture directly (Task 2.7 step 1 — "absent ⇒ empty"; tests write the file). +- **Known drift risk:** stages 3–8's exact code depends on the `Bound*Store` / `StoreRegistry` shapes finalized in stage 2. Re-read stage 2's actual output before executing each later stage; adjust signatures to match. +- **`app-demo` in CI:** Task 8.3 adds the missing CI wiring — the spec's §15 ship gate assumed CI exercises `app-demo`, which it does not today. diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md new file mode 100644 index 0000000..043a362 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -0,0 +1,1309 @@ +# EdgeZero CLI Extensions — Full Design + +**Date:** 2026-05-19 +**Status:** Approved design (single-spec form), pending implementation plan +**Branch:** `docs/extensible-cli-library-spec` +**Baseline assumption:** PR #253 (`feat/spin-store-support`) is merged — +the Spin adapter has `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` +and is a first-class store-capable adapter. + +This single spec covers the full effort: + +- a **hard-cutoff manifest schema rewrite** introducing a logical-store / + per-adapter-mapping model for KV / secrets / config, +- the matching runtime rewrite — `ConfigStore` becomes async, the + Cloudflare config backend moves from `[vars]` to KV, bound store + handles are introduced, `Kv` / `Secrets` / `Config` extractors gain + named-store support, and `Hooks` / `ConfigStoreMetadata` / the `app!` + macro become id-keyed, +- turning `edgezero-cli` into an extensible library, +- a per-service typed app-config file with `#[derive(AppConfig)]`, + `#[secret]` / `#[secret(store_ref)]` annotations, and environment + variable override resolution, +- four new commands (`auth`, `provision`, `config validate`, `config push`), +- generator extensions to scaffold the new pieces, +- and an `app-demo` overhaul that exercises every new capability across + all four adapters (axum, cloudflare, fastly, spin) end-to-end. + +There is **no backward compatibility** with the pre-rewrite manifest +schema or runtime store API. The legacy store fields (`name`, legacy +`adapters` overrides, `[stores.config.defaults]`) become hard +validation errors immediately. Every in-tree project is migrated as +part of the work; external projects do a one-time migration following +the published guide. No compatibility shims, no dual-schema parsing. + +The work ships as **one pull request with eight stages** — one stage +per sub-project, in the §16 order. The design decisions live here +together. + +--- + +## 1. Goal + +Let downstream projects (e.g. a future `myapp` from `edgezero new +myapp`) build their own CLI binary that: + +- Reuses any subset of edgezero's built-in commands (`build`, `deploy`, + `demo`, `new`, `serve`; after this effort also `auth`, `provision`, + `config validate`, `config push`). The subcommand that runs the + example app locally on axum is named `demo` — the name `dev` is + **reserved** for a future dev-workflow command and is intentionally + not used by this effort. +- Adds their own subcommands. +- Owns the binary name, `about` text, and top-level help. + +Alongside the extensibility substrate, ship: + +- A **multi-store manifest model**: the app declares logical stores it + uses (`[stores.kv] ids = ["foo", "bar"]`); for each store kind an + adapter is _Multi-capable_ for, it maps every logical id to a + platform-specific `name`, with room for adapter-specific tuning. + Stores are addressed in code by logical id. Per-adapter, per-kind + **capability rules** (§6.6) constrain what is valid — some adapters + support multiple named stores of a kind, others only a single flat + one, and the per-adapter mapping block is required for the former and + forbidden for the latter. +- A **typed per-service app-config file** (`myapp.toml`) with a + Rust-defined schema, validated by `config validate`, uploaded by + `config push`. `#[secret]` / `#[secret(store_ref)]` fields are + skipped during push. +- **Environment-variable override resolution** for app config (§6.10). +- **Async `ConfigStore`** and the **Cloudflare config backend on KV** + so `config push` reaches the runtime without redeploying. +- **Bound store handles** so callers don't pass store names around. +- **Refactored `Kv` / `Secrets` / `Config` extractors** resolving the + default store or a named one (§6.8). +- Platform credential and resource management (`auth`, `provision`) + shelling out to each platform's native CLI, wrapped in a mockable + `CommandRunner` so CI stays hermetic. +- A generator that scaffolds a new project complete with `-cli`, + `.toml`, `-core/src/config.rs`, and an `edgezero.toml` + using the new schema. +- An `app-demo` overhaul exercising all of the above across all four + adapters end-to-end. + +The default `edgezero` binary keeps its existing subcommands' names and +flags; new subcommands are added. + +## 2. Non-goals + +- No runtime command registry; no PATH-based external subcommand + discovery. +- No edgezero-managed credentials. `auth` delegates to `wrangler` / + `fastly` / `spin`. +- No direct REST API calls; everything goes through the platform's + native CLI. +- No environment-sectioned app-config (`[config.production]` etc.). + Single `[config]` table per file. (Env-var _override_ is in scope; + per-environment _files_ are not.) +- No live-platform CI smoke tests. Mock `CommandRunner` only. +- **No backward compatibility** with the old manifest schema or runtime + store API. A pre-rewrite `edgezero.toml` is a hard load error. +- No dynamic Spin variable provider integration (Vault, Fermyon Cloud + variable provider). `config push --adapter spin` writes static Spin + variables; live cloud variable push is a future enhancement. + +## 3. Architecture overview + +```mermaid +graph TB + Lib["edgezero-cli (lib)
pub *Args + pub run_*
internal: CommandRunner / adapter / generator"] + + Macros["edgezero-macros
#[derive(AppConfig)]
#[secret] / #[secret(store_ref)]"] + + Core["edgezero-core
app_config: load_app_config<C> (toml + env overlay)
manifest: logical-id stores + per-adapter map + capability rules
async ConfigStore + Bound*Store handles
RequestContext: id-keyed bound store accessors
Hooks / ConfigStoreMetadata: id-keyed static metadata
extractor: Kv / Secrets / Config (default or named)"] + + Lib --> EZ["edgezero (default bin)"] + Lib --> ADC["app-demo-cli (example)
all built-ins + Auth/Provision/Config"] + Lib --> MAC["myapp-cli (downstream)"] + + ADC --> ADCore["app-demo-core
#[derive(AppConfig)] AppDemoConfig
nested section + #[secret] + #[secret(store_ref)]"] + MAC --> MACore["myapp-core
#[derive(AppConfig)] MyappConfig"] + + Macros -.AppConfigMeta impl.-> ADCore + Macros -.AppConfigMeta impl.-> MACore + Core -.traits + APIs.-> ADCore + Core -.traits + APIs.-> MACore +``` + +Key contracts: + +- **Substrate**: each built-in command is a `(pub *Args, pub run_*)` + pair. Non-subcommand `*Args` derive `Default`; subcommand-wrapping + `AuthArgs` does not (§6.11). +- **Multi-store manifest model**: §6.6, rewritten outright. Per-adapter + per-kind capability rules drive validation. +- **Async `ConfigStore`**: `ConfigStore::get` is `async fn` + (`#[async_trait(?Send)]`, WASM-safe). Cascades through **all four** + adapter config-store impls. +- **Bound store handles**: only `RequestContext` yields them (binding + needs per-request adapter state). +- **Static store metadata**: `Hooks` / `ConfigStoreMetadata` are + compile-time, id-keyed store _metadata_ (emitted by `app!`). Adapters + consume them at request setup to build runtime registries. +- **Cloudflare config on KV**; **Spin config / secrets on flat Spin + variables** (§6.7). +- **Extractors**: `Kv` / `Secrets` / `Config` resolve default or named. +- **Typed app-config + secrets**: §6.8. **Env-var override**: §6.10. +- **Shell-out isolation**: private `CommandRunner` + `CommandSpec`. + +## 4. End-state public API surface + +The arg structs live in a **`pub mod args`**, not a crate-root +re-export. A crate-root `pub use args::{...}` would trip +`clippy::pub_use` (the `restriction` group is `-D`-denied +workspace-wide), so the supported API is `edgezero_cli::args::BuildArgs` +etc. The `run_*` functions stay at the crate root. Downstream code +writes `use edgezero_cli::args::BuildArgs;` and +`use edgezero_cli::run_build;`. + +```rust +// crates/edgezero-cli/src/lib.rs (feature = "cli") + +/// CLI argument structs — a `pub mod`, addressed as `edgezero_cli::args::*`. +pub mod args; +// args:: { Args, Command, AuthArgs, AuthSub, BuildArgs, ConfigPushArgs, +// ConfigValidateArgs, DeployArgs, NewArgs, ProvisionArgs, ServeArgs } + +pub fn init_cli_logger(); + +pub fn run_build(args: &args::BuildArgs) -> Result<(), String>; +pub fn run_deploy(args: &args::DeployArgs) -> Result<(), String>; +pub fn run_new(args: &args::NewArgs) -> Result<(), String>; +pub fn run_serve(args: &args::ServeArgs) -> Result<(), String>; +#[cfg(feature = "edgezero-adapter-axum")] +pub fn run_demo() -> Result<(), String>; // `demo` subcommand; Ok on graceful shutdown + +pub fn run_auth(args: &args::AuthArgs) -> Result<(), String>; +pub fn run_provision(args: &args::ProvisionArgs) -> Result<(), String>; + +pub fn run_config_validate(args: &args::ConfigValidateArgs) -> Result<(), String>; +pub fn run_config_validate_typed(args: &args::ConfigValidateArgs) -> Result<(), String> +where + C: serde::de::DeserializeOwned + validator::Validate + + ::edgezero_core::app_config::AppConfigMeta; + +pub fn run_config_push(args: &args::ConfigPushArgs) -> Result<(), String>; +pub fn run_config_push_typed(args: &args::ConfigPushArgs) -> Result<(), String> +where + C: serde::de::DeserializeOwned + validator::Validate + serde::Serialize + + ::edgezero_core::app_config::AppConfigMeta; +``` + +From `edgezero-core`: + +```rust +// app_config module +pub trait AppConfigMeta { const SECRET_FIELDS: &'static [SecretField]; } +pub struct SecretField { pub name: &'static str, pub kind: SecretKind } +pub enum SecretKind { KeyInDefault, StoreRef } + +// Loader options. Default = env overlay on. +pub struct AppConfigLoadOptions { pub env_overlay: bool } +impl Default for AppConfigLoadOptions { /* env_overlay: true */ } + +// Simple forms apply the env overlay (the default). +pub fn load_app_config(path: &std::path::Path, app_name: &str) + -> Result +where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; +pub fn load_app_config_raw(path: &std::path::Path, app_name: &str) + -> Result; + +// Explicit-options forms — `--no-env` calls these with env_overlay: false. +pub fn load_app_config_with_options( + path: &std::path::Path, app_name: &str, opts: &AppConfigLoadOptions, +) -> Result +where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; +pub fn load_app_config_raw_with_options( + path: &std::path::Path, app_name: &str, opts: &AppConfigLoadOptions, +) -> Result; +// The simple forms delegate to the *_with_options forms with +// AppConfigLoadOptions::default(). + +// async config store trait +#[async_trait(?Send)] +pub trait ConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError>; +} + +// Bound store handles — wrap provider handle + resolved platform name. +pub struct BoundKvStore { /* ... */ } +pub struct BoundConfigStore { /* ... */ } +pub struct BoundSecretStore { /* ... */ } +impl BoundConfigStore { pub async fn get(&self, key: &str) -> Result, ConfigStoreError>; } +impl BoundKvStore { /* async CRUD */ } +impl BoundSecretStore { + pub async fn get(&self, key: &str) -> Result, SecretError>; + pub async fn require_str(&self, key: &str) -> Result; +} + +// RequestContext store API — returns BOUND, per-request handles. +impl RequestContext { + pub fn kv_store(&self, id: &str) -> Option; + pub fn kv_store_default(&self) -> Option; + pub fn config_store(&self, id: &str) -> Option; + pub fn config_store_default(&self) -> Option; + pub fn secret_store(&self, id: &str) -> Option; + pub fn secret_store_default(&self) -> Option; +} + +// Hooks / ConfigStoreMetadata: static, compile-time, id-keyed store +// metadata (no bound handles). +``` + +From `edgezero-macros`: + +```rust +#[proc_macro_derive(AppConfig, attributes(secret))] +pub fn derive_app_config(input: TokenStream) -> TokenStream { /* ... */ } +``` + +## 5. End-state file layout + +``` +crates/edgezero-cli/ + Cargo.toml + src/ + lib.rs / main.rs / args.rs / adapter.rs / scaffold.rs / demo_server.rs + generator.rs # extended: scaffolds -cli + .toml + -core/src/config.rs + runner.rs # NEW: CommandSpec + CommandRunner + Real/Mock + auth.rs / provision.rs / config.rs # NEW command impls + templates/{core,root,cli,app}/ # cli/ + app/ new; root edgezero.toml.hbs rewritten + +crates/edgezero-core/src/ + manifest.rs # store schema rewritten outright; capability rules + context.rs # store accessors id-keyed, return Bound*Store + app_config.rs # NEW: AppConfigMeta + SecretField/Kind + loaders w/ env overlay + config_store.rs # ConfigStore trait becomes async + key_value_store.rs / secret_store.rs # bound-handle wrappers; secret keeps bytes::Bytes + extractor.rs # Kv / Secrets / Config refactored to default-or-named + hooks.rs / app.rs # id-keyed static store metadata + +crates/edgezero-macros/src/ + lib.rs # ADD #[proc_macro_derive(AppConfig, attributes(secret))] + app_config.rs # NEW derive impl + app.rs # app! macro emits id-keyed ConfigStoreMetadata + +# All FOUR adapters' store impls touched in sub-project #2: +crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{config_store,key_value_store,secret_store}.rs +# Cloudflare config_store: [vars] -> KV. Spin already has Spin* stores (PR #253); +# they are wired into the multi-store registry + async ConfigStore here. + +examples/app-demo/ + Cargo.toml # adds crates/app-demo-cli + app-demo.toml # NEW typed config: nested section + #[secret] + #[secret(store_ref)] + edgezero.toml # rewritten to the new schema; all four adapters declare stores + crates/ + app-demo-core/src/config.rs # NEW AppDemoConfig + app-demo-core/src/handlers.rs # handlers read config + named kv across adapters + app-demo-cli/ # NEW + app-demo-adapter-*/ # store-setup rewrites (all four) + +docs/guide/{cli-walkthrough,manifest-store-migration}.md # NEW +docs/.vitepress/config.mts # UPDATED sidebar (note: .mts, not .ts) +``` + +## 6. Cross-cutting designs + +### 6.1 `CommandSpec` + `CommandRunner` (sub-project #5) + +```rust +// crates/edgezero-cli/src/runner.rs (private) +pub(crate) struct CommandSpec<'a> { + pub program: &'a str, pub args: &'a [&'a str], + pub cwd: Option<&'a std::path::Path>, pub stdin: Option<&'a [u8]>, + pub env: &'a [(&'a str, &'a str)], +} +pub(crate) trait CommandRunner: Send + Sync { + fn run(&self, spec: &CommandSpec<'_>) -> std::io::Result; +} +pub(crate) struct CommandOutput { pub status: i32, pub stdout: String, pub stderr: String } +pub(crate) struct RealCommandRunner; +#[cfg(test)] pub(crate) struct MockCommandRunner { /* recorded expectations */ } +``` + +### 6.2 Error model + +All public `run_*` return `Result<(), String>`. Binaries log and exit. + +### 6.3 Feature gates + +- `cli` (default) gates clap + public API. +- `edgezero-adapter-{axum,fastly,cloudflare,spin}` (all default) gate + each adapter's dispatch path. + +### 6.4 Config key model and platform encoding + +App config can be nested (`service: ServiceConfig { timeout_ms }`). +`config push` **flattens nested structs into hierarchical keys** — it +does not store JSON blobs for nested structs. The canonical, +handler-facing key form is **dotted**: `service.timeout_ms`. + +Genuine compound _values_ (arrays, maps — not nested structs) are +JSON-encoded into a single string value; the key stays flat. + +Each platform's config store has different key constraints, so the key +form is translated per adapter: + +| Adapter | Stored key form for `service.timeout_ms` | +| ---------- | ---------------------------------------------------------------------------------------------------- | +| axum | `service.timeout_ms` (local JSON file; dots fine) | +| cloudflare | `service.timeout_ms` (KV key; arbitrary strings) | +| fastly | `service.timeout_ms` (config-store key; dots fine) | +| spin | `service__timeout_ms` (Spin variable; see §6.7 — dots and uppercase are invalid Spin variable names) | + +The translation is an **adapter-internal detail**. Handlers always use +the canonical dotted form: `ctx.config_store_default()?.get("service.timeout_ms")`. +The Spin config-store impl translates `.` → `__` on the way in/out; +the others pass through. `config push` writes the platform-native form +for the target adapter. + +### 6.5 Typed vs raw config serialization + +**Validate (both flavours):** TOML syntax OK; `[config]` table present; +structure parses. Typed additionally: deserialises into `C`; runs +`C::validate()`; for each `SecretField`, value is a non-empty string, +and `StoreRef` values appear in `[stores.secrets].ids`. Validate does +not require `Serialize` and performs no `to_value` check. + +**Push (both flavours):** all validate checks run first as a strict +pre-flight. Then each leaf field is serialised to a string: `String` +as-is; `bool`/numbers via `to_string()`; arrays/maps via +`serde_json::to_string`; `Option::None` / `Value::Null` skipped; +nested structs flattened into dotted keys (§6.4). `SECRET_FIELDS` +skipped (typed only). Typed additionally: asserts +`serde_json::to_value(&c)` is `Value::Object` (else error before any +runner call); honors `#[serde(rename)]`, `#[serde(skip_serializing*)]`; +supports `#[serde(flatten)]` on non-secret fields. Raw: `toml::Value` +tree from `[config]`, same rules, no `Validate`, no secret skipping. + +**Unknown fields:** serde ignores them unless `C` has +`#[serde(deny_unknown_fields)]`. The generator template emits it. + +### 6.6 Manifest schema, environment config, and capability rules + +`edgezero.toml` is **portable, non-adapter-specific, and never compiled +into the binary**. It declares what the app _is_ — not how any platform +runs it. Adapter-specific runtime config is supplied at runtime through +`EDGEZERO__*` environment variables. There is no legacy shape; a +manifest using the pre-rewrite `[stores.] name` / +`[stores.config.defaults]` / `[adapters.*.stores.*]` fields is a **hard +load error** pointing at `docs/guide/manifest-store-migration.md`. + +**`edgezero.toml` — portable schema:** + +```toml +[app] +name = "my-app" + +[[triggers.http]] +id = "root" +path = "/" +methods = ["GET"] +handler = "my_app_core::handlers::root" + +[environment] +# portable env-var / secret declarations + +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" # REQUIRED when ids.len() > 1 + +[stores.config] +ids = ["app_config"] # default optional when exactly one id + +[stores.secrets] +ids = ["default"] +``` + +`[stores.]` declares **logical store ids only** — the portable +fact that "this app uses a KV store called `sessions`". No platform +names, no per-adapter tuning, and **no `[adapters.*]` table**. + +| Field | Role | +| ------------------------- | ----------------------------------------------------------------------------- | +| `[stores.].ids` | logical ids (`Vec`, non-empty) | +| `[stores.].default` | resolved default; **required when `ids.len() > 1`**, else resolves to `ids[0]` | + +The `app!` macro consumes `edgezero.toml` at **compile time** and +codegens routing plus the logical store registry into the `App` / +`Hooks` type. The manifest text is **not** embedded — `include_str!` is +gone; only the derived code is. The manifest and the `app!` macro are +optional: a project may build `App` programmatically, so a downstream +binary compiles with no `edgezero.toml` present. + +**Adapter-specific config — `EDGEZERO__*` environment variables.** +Platform store names, store tuning, bind host/port, and logging are +resolved at **runtime** from environment variables. `__` (double +underscore) separates key-path segments: + +| Variable | Role | Default | +| --------------------------------------- | ----------------------------------------- | --------------- | +| `EDGEZERO__STORES______NAME` | platform name for logical store `` | the logical id | +| `EDGEZERO__STORES______` | free-form adapter tuning for store `` | — | +| `EDGEZERO__ADAPTER__HOST` | bind host (axum) | `127.0.0.1` | +| `EDGEZERO__ADAPTER__PORT` | bind port (axum) | `8787` | +| `EDGEZERO__LOGGING__LEVEL` | log level | adapter default | + +`` ∈ `KV` / `CONFIG` / `SECRETS`; `` is the upper-cased +logical id. Absent variables fall back to the listed defaults — an +adapter binary runs with **zero env vars set**, using each logical id +as its own platform name. + +**Adapter × kind capability matrix.** Each (adapter, kind) pair has a +capability: + +| Adapter | KV | Config | Secrets | +| ---------- | ---------------- | ----------------------- | ----------------------- | +| axum | Multi (local) | Multi (local files) | Single (env vars) | +| cloudflare | Multi (KV ns) | Multi (KV ns) | Single (worker secrets) | +| fastly | Multi (KV store) | Multi (config store) | Multi (secret store) | +| spin | Multi (KV label) | Single (flat variables) | Single (flat variables) | + +- **Multi**: the adapter supports multiple named stores of that kind; + each logical id resolves to its own platform store via + `EDGEZERO__STORES______NAME` (or the id default). +- **Single**: the adapter has exactly one flat store of that kind; + every logical id maps to that one store, and per-id `NAME` variables + are ignored. + +**Capability validation** — declaring two config ids while targeting an +adapter that is `Single` for config (Spin) — is performed by `config +validate` (§10) and `provision` (§12). It is no longer expressible as +an in-manifest error: the manifest carries no per-adapter blocks. + +**Runtime resolution:** each adapter builds a +`StoreRegistry { by_id: BTreeMap, default_id: String }` +at request setup, keyed by logical id, platform names resolved from +`EDGEZERO__STORES__*` (or the id default). For `Single` (adapter, kind) +pairs every id maps to the one flat store. + +### 6.7 Spin store semantics + +PR #253 makes Spin store-capable, but Spin's model differs from +Cloudflare/Fastly and the spec must encode that explicitly. + +**KV — label-backed, multi-store.** `SpinKvStore` is backed by +`spin_sdk::key_value`. Each logical KV id maps to a Spin KV store +**label** via `[adapters.spin.stores.kv.].name`. Multiple labels +are fine. The runtime adapter opens each configured label and +registers it by logical id. + +- **TTL is unsupported.** `spin_sdk::key_value` has no expiry. The + `BoundKvStore` surface still exposes `put_*_with_ttl` (used by other + adapters). On Spin, those operations **must return a deterministic + error**, never silently store the value without expiry. The current + `KvError` enum has **no `Unsupported` variant** — **stage 2 adds + `KvError::Unsupported`** and its `EdgeError` mapping. Because an + unsupported operation is not a client mistake, it maps to a + 5xx-class `EdgeError` (the exact constructor — `EdgeError::internal` + or a dedicated one — is pinned in stage 2). The Spin KV contract + test asserts this error. +- **Listing is capped.** `SpinKvStore` carries a `max_list_keys` cap + and must error rather than silently truncate when exceeded. A store + growing beyond a cap is a server/limit condition, not a malformed + client request, so PR #253's current `KvError::Validation` (which an + adapter may map to HTTP 400) is the wrong variant. **Resolved here, + not left open: stage 2 adds `KvError::LimitExceeded`** (5xx-class + `EdgeError` mapping, like `Unsupported`) and the Spin KV listing + path returns it when `max_list_keys` is exceeded, replacing + `Validation` for this case. Stage 2 also tests the pagination logic + directly (not only the cap error). + +**Config — flat Spin variables, single-store.** `SpinConfigStore` is +backed by `spin_sdk::variables`. Spin has **one** flat variable +namespace per component — there is no notion of multiple named config +stores. Therefore `[stores.config].ids` must have exactly one id for +any project targeting Spin (enforced by the §6.6 capability check), +and a `[adapters.spin.stores.config.*]` block is a validation error +(Single capability, §6.6). + +Spin variable names must match `^[a-z][a-z0-9_]*$` — lowercase, +starting with a letter, alphanumeric + underscore. **This is Spin's +own rule** (see the Spin manifest reference, +), not an EdgeZero-added +restriction; the EdgeZero config-store impl simply conforms to it. The +impl translates the canonical dotted key (`service.timeout_ms`) to a +Spin variable (`service__timeout_ms`); a dotted or uppercase key +reaching the real Spin backend yields `InvalidName`. + +**Secrets — flat Spin variables, single-store, manual declaration.** +`SpinSecretStore` is also backed by `spin_sdk::variables` — the **same +flat namespace** as Spin config. `store_name` passed to `get_bytes` is +ignored (the adapter logs a debug line when non-empty). +`[stores.secrets].ids` must have exactly one id for a Spin project, +and `[adapters.spin.stores.secrets.*]` is a validation error. + +Spin **secret variables are declared manually** by the developer in +`spin.toml` (as `[variables]` entries with `secret = true`, bound via +`[component..variables]`). Neither `provision` nor `config +push` writes secret variables — `config push` skips `SECRET_FIELDS`, +and the secret key names are not reliably knowable: a +`#[secret(store_ref)]` field's runtime key (e.g. +`ctx.secret_store(&cfg.vault)?.require_str("active")`) is code-local, +appearing in neither the manifest nor `.toml`. The CLI cannot +infer it, so secret-variable declaration stays with the developer. +The `cli-walkthrough.md` doc shows the required `spin.toml` entries. + +**Config/secret variable collision check (replaces an over-strong +guarantee).** Spin config and secret variables share one flat +namespace, so their _effective Spin variable names_ must not collide. +The earlier claim that distinct struct fields guarantee this is wrong: +a `#[secret]` field's **value** (not its Rust field name) is the +secret key, so a config key `api_token` and a `#[secret]` field whose +value is `"api_token"` would collide. When `spin` is in the adapter +set, `config validate` computes the effective Spin variable name set — +{flattened config keys} ∪ {`#[secret]` field values} — each after +`.`→`__` lowercase translation, and **errors on any duplicate**. +`#[secret(store_ref)]` runtime keys are code-local and outside this +check; the walkthrough doc warns the developer to keep them clear of +config keys. + +**Spin component discovery.** Writing `[component..*]` +tables (for KV labels in `provision`, for variable bindings in `config +push`) needs the **component id**, not just the `spin.toml` path. +`[adapters.spin.adapter].manifest` points at `spin.toml`, which may +declare several components. Resolution rule: + +- The CLI parses `spin.toml` and enumerates `[component.*]` ids. +- If exactly one component exists, it is used. +- If more than one exists, `[adapters.spin.adapter]` **must** carry an + explicit `component = ""` field; otherwise the command errors. +- An explicit `component` that does not match any `[component.*]` id + is an error. + +`config validate` performs this resolution as part of `--strict` +checks when `spin` is in the adapter set, so the failure surfaces +before `provision` / `config push` run. + +**Implication for app config targeting Spin.** If the adapter set +includes `spin`, `config validate` additionally checks that every +flattened config key, after `.`→`__` translation, matches +`^[a-z][a-z0-9_]*$` — i.e. config field names must be lowercase +snake_case. This is consistent with idiomatic serde field naming. + +### 6.8 Secret annotations via `#[derive(AppConfig)]` + +```rust +#[derive(Debug, Deserialize, Serialize, Validate, AppConfig)] +#[serde(deny_unknown_fields)] +pub struct AppDemoConfig { + pub greeting: String, + pub feature_new_checkout: bool, + pub service: ServiceConfig, // nested section (env-overridable, §6.10) + + #[secret] // key inside the resolved default secret store + pub api_token: String, + + #[secret(store_ref)] // logical store id in [stores.secrets].ids + pub vault: String, +} + +#[derive(Debug, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct ServiceConfig { + #[validate(range(min = 100, max = 60000))] + pub timeout_ms: u32, +} +``` + +The derive emits `impl AppConfigMeta` with a `SECRET_FIELDS` array. + +**Constraints (compile errors):** `#[secret]` / `#[secret(store_ref)]` +only on scalar string fields; error if combined with +`#[serde(flatten)]` / `#[serde(rename)]` / `#[serde(skip*)]`; +`#[secret(x)]` with `x` outside `{store_ref}` is an error; +`SECRET_FIELDS` uses the Rust field name verbatim. + +**Validate:** `KeyInDefault` — value non-empty + `[stores.secrets]` +declared. `StoreRef` — value appears in `[stores.secrets].ids`. +**Push:** both kinds skipped. + +**Interaction with the secrets capability matrix.** Axum, Cloudflare, +and Spin are all `Single` for secrets (§6.6) — only Fastly is `Multi`. +So any project whose adapter set includes axum, cloudflare, or spin +can declare exactly **one** secrets id (the capability check forces +`[stores.secrets].ids.len() == 1`). For such a project — which +includes any all-four-adapter app — every `#[secret(store_ref)]` +field's value must be that single secrets id; there is no other valid +target. `#[secret(store_ref)]` only buys multiple distinct secret +stores on a Fastly-only project. `config validate` already enforces +"value ∈ `[stores.secrets].ids`", so a wrong id fails validation; the +walkthrough doc calls this out explicitly. + +**Runtime usage:** + +```rust +// #[secret] (KeyInDefault): +let token = ctx.secret_store_default()?.require_str(&cfg.api_token).await?; +// #[secret(store_ref)] (StoreRef) — on an all-four-adapter app, +// cfg.vault is necessarily the single declared secrets id: +let token = ctx.secret_store(&cfg.vault)?.require_str("active").await?; +``` + +### 6.9 Extractor design + +`Kv` / `Secrets` / `Config` extractors yield a per-request registry +handle; the handler picks the store by id at the call site (no +const-generic `&'static str`, unsupported on stable Rust 1.95): + +```rust +pub struct Kv(KvRegistryHandle); +impl Kv { + pub fn default(&self) -> Option; + pub fn named(&self, id: &str) -> Option; +} +// Secrets / Config identical in shape. +``` + +The only in-tree consumers of the old single-store extractors are the +`app-demo` handlers, updated in sub-project #2. + +### 6.10 App-config environment-variable resolution + +`load_app_config` / `load_app_config_raw` resolve in two layers: +(1) the `[config]` table from `.toml`; (2) env-var overrides. + +**Env vars override existing keys only.** An env var overrides a value +only if that key already exists in the parsed `[config]` tree (the +loader infers the type from the existing TOML value and parses the env +string accordingly — there is no pre-deserialization reflection over +`C`). To make a key env-overridable it must appear in `.toml`. + +**Env var naming.** `__
__…__`. `` is +`[app].name` uppercased with `-`→`_`. `__` separates every nesting +level; a single `_` is literal. + +**Deterministic, ambiguity-rejecting matching.** Each config key is +transformed to its env-segment form (uppercase, `_` left as-is) and +compared exactly. Two sibling keys mapping to the same segment is an +`AppConfigError`. + +**Type coercion.** The env string is parsed against the existing TOML +value's type; parse failure → `AppConfigError`. + +**Scope.** `config validate` and `config push` both see env-resolved +values; `--no-env` disables the overlay. `--no-env` is implemented by +calling `load_app_config_with_options` (§4) with +`AppConfigLoadOptions { env_overlay: false }`; the default (no flag) +uses the simple `load_app_config` form (overlay on). The axum demo +server (the `demo` subcommand) resolves via the same path. + +Note the deliberate consistency: the env separator (`__`) is the same +as the Spin config-key separator (§6.4/§6.7). + +### 6.11 `Default` on `*Args` + +Non-subcommand `*Args` derive `Default` (external construction despite +`#[non_exhaustive]`). Subcommand-wrapping `AuthArgs` does not (a +defaulted required subcommand could leak into a real auth path); +external tests construct it via `clap::Parser::try_parse_from`. + +### 6.12 Documentation updates (definition-of-done for every stage) + +This effort changes the manifest schema, the runtime store API, the +CLI surface, and the `dev`→`demo` subcommand. The VitePress docs site +under `docs/guide/` has existing pages describing all of these, which +go stale. **Updating documentation is part of every stage's +definition-of-done** — a stage that changes user-facing behaviour +updates the affected `docs/guide/` pages _in the same stage_, so the +PR never has a docs-lag window. The docs CI (ESLint + Prettier on +`docs/`) must pass. + +Affected existing pages and the stage that owns each update: + +| Page | What changes | Stage | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `docs/guide/cli-reference.md` | `dev`→`demo` rename; `edgezero-cli` as a library; new `auth` / `provision` / `config` commands | 1, 5, 6, 7 | +| `docs/guide/configuration.md` | new `[stores]` logical-id schema + per-adapter mapping + capability rules; removal of `[stores.config.defaults]`; the `.toml` app-config file + env overlay | 2, 3 | +| `docs/guide/kv.md` | multi-store model, `ctx.kv_store(id)` / bound handles, `Kv` extractor `default()`/`named()` | 2 | +| `docs/guide/handlers.md` | extractor refactor; async `ConfigStore`; reading config/secrets by logical id | 2 | +| `docs/guide/getting-started.md` | generator now scaffolds `-cli` and `.toml` | 1, 3 | +| `docs/guide/adapters/cloudflare.md` | config store moves `[vars]` → KV | 2 | +| `docs/guide/adapters/overview.md` + Spin adapter docs | Spin store semantics (KV labels, flat-variable config/secrets) | 2 | +| `docs/guide/architecture.md` | light review — store/adapter description | 2 | + +New pages (created in their owning stage): + +- `docs/guide/manifest-store-migration.md` — stage 2 (how to migrate a + pre-rewrite `edgezero.toml`). +- `docs/guide/cli-walkthrough.md` — stage 8 (full `myapp` loop). + +Stage 8 additionally performs a **documentation audit**: grep the +`docs/` tree for stale references (old manifest store keys, the `dev` +subcommand, the old single-store runtime API) and confirm none remain; +verify every page is listed in the `docs/.vitepress/config.mts` +sidebar. The audit is a checklist item in stage 8's ship gate. + +--- + +## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton + +**Goal:** establish the substrate. + +**Source changes:** promote `Command` variant fields into +`#[derive(clap::Args)]` structs (`#[non_exhaustive]`, `Default` per +§6.11); add `lib.rs` with `run_*` handlers; shrink `main.rs`; move +existing tests to `lib.rs`; extend the generator to scaffold +`crates/-cli`; add the handwritten `examples/app-demo/crates/ +app-demo-cli` parallel. + +The `dev` subcommand is renamed to **`demo`** — it runs the example +app locally on axum, which is a demo workflow, not a dev workflow; the +name `dev` is reserved for a future dev-workflow command. Stage 1 +renames the CLI's `dev_server` module to `demo_server`, the public +function `run_dev` to `run_demo`, and the `Command::Dev` variant to +`Command::Demo`. `run_demo` returns `Result<(), String>` (consistent +with the other `run_*` functions) — `Ok(())` on graceful shutdown, +`Err(String)` on startup failure (e.g. port bind). It is **not** +`-> !` — the demo server is allowed to return. The current +`dev_server::run_dev()` returns `()`; stage 1 adjusts that boundary. +(The `edgezero-adapter-axum` crate's own internal `dev_server` module +is not user-facing and is left as-is.) + +**Tests:** existing tests pass post-relocation; `tests/lib_consumer.rs`; +`app-demo-cli/tests/help.rs`; generator structure test. + +**Ship gate:** existing `edgezero` commands keep the same flags; +`app-demo-cli --help` shows the four downstream built-ins (`build`, `deploy`, `new`, `serve`); `edgezero new +throwaway-app && cargo check --workspace` succeeds. + +## 8. Sub-project 2 — Manifest + runtime rewrite (atomic, all four adapters) + +**Goal:** the big atomic sub-project. The manifest becomes portable and +non-adapter-specific (§6.6), adapter config moves to `EDGEZERO__*` +environment variables, and the runtime store API is rewritten. With a +hard cutoff these ship together as one stage (stage 2 of the +eight-stage PR). + +**Scope:** + +- **Manifest → portable schema:** rewrite `ManifestStores` to the §6.6 + portable schema — `[stores.]` carries only logical `ids` / + `default`. The `[adapters.*]` store/runtime tables are removed. + Legacy fields are a hard load error. +- **`EDGEZERO__*` env-config layer:** a new `edgezero-core` module + parses `EDGEZERO__`-prefixed environment variables (`__` nesting) + into adapter runtime config — store platform names + tuning, bind + host/port, logging. Absent variables fall back to defaults (§6.6). +- **No compiled-in manifest:** `run_app` drops its `manifest_src` + parameter on all four adapters. The `app!` macro bakes the portable + config (routes + logical store registry) into the `App` / `Hooks` + type; `run_app::()` reads it from `A` and layers `EDGEZERO__*` env + config on top. `include_str!("edgezero.toml")` is removed everywhere. +- **`ConfigStore` async:** `get` becomes `async` + (`#[async_trait(?Send)]`). +- **New `KvError` variants:** add `KvError::Unsupported` (Spin TTL + writes, §6.7) and `KvError::LimitExceeded` (Spin listing past + `max_list_keys`, §6.7), each with a 5xx-class `EdgeError` mapping. +- **Bound handles:** `BoundKvStore` / `BoundConfigStore` / + `BoundSecretStore`; `RequestContext` accessors id-keyed, with + `_default()` helpers. +- **Static metadata:** `Hooks` / `ConfigStoreMetadata` rewritten to + id-keyed metadata; `app!` macro emits them from the portable schema. +- **Adapter store rewrites — ALL FOUR adapters:** each builds a + `StoreRegistry` keyed by logical id, platform names resolved from + `EDGEZERO__STORES__*` (or the id default): + - **axum:** local KV registry; config from + `.edgezero/local-config-.json` (§15); secrets from env vars. + - **cloudflare:** KV registry; **config rewritten `[vars]` → KV** + with async reads; secrets from worker secrets. + - **fastly:** KV / config / secret store registries. + - **spin:** wire `SpinKvStore` (label registry, `max_list_keys` + respected), `SpinConfigStore` (single flat-variable store, `.`→`__` + key translation), `SpinSecretStore` (single flat-variable store) + into the registry; KV labels come from + `EDGEZERO__STORES__KV____NAME`, not hardcoded defaults. +- **Extractors:** `Kv` / `Secrets` refactored to `default()` / + `named()`; `Config` extractor added. +- **`[stores.config.defaults]` removed** (hard error). Replaced by the + axum config-store file flow (§15). The axum dev-server config seeding + is removed. +- **Migrate in-tree:** `examples/app-demo/edgezero.toml` rewritten to + the portable schema (≥2 KV ids `sessions`+`cache`; one config id; + one secrets id). The app-demo adapter crates' `EDGEZERO__*` env + config lives in their run configuration. `app-demo` handlers are + migrated **only for the store-accessor change** — `ctx.kv_store(id)` + / `config_store` / the refactored `Kv` / `Secrets` / `Config` + extractors. Stage 2 does **not** introduce `AppDemoConfig` or any + typed-app-config handler work: that lands in stage 3 (§9). This keeps + stage 2 independently buildable. +- **`docs/guide/manifest-store-migration.md`** published. + +**Tests:** manifest round-trip + validation (non-empty ids; default +required when `ids.len() > 1`; pre-rewrite manifest → hard error with +migration message); `EDGEZERO__*` env-layer parsing (nesting, defaults, +store-name resolution); `run_app` builds and runs with no manifest file +and zero env vars; id-keyed contract-test factories across all four +adapters; cross-adapter named-KV test; Cloudflare config-from-KV async +round-trip; Spin config `.`→`__` translation test; **Spin TTL write +returns `KvError::Unsupported`** (contract test); Spin KV listing-cap +pagination test; `Kv`/`Secrets`/`Config` extractor tests; `app!` macro +metadata registry test. + +**Bisectability — config seeding before `config push` exists.** Stage +2 removes `[stores.config.defaults]` and makes the axum config store +read `.edgezero/local-config-.json`, but `config push` (which +_writes_ that file) does not land until stage 7, and `edgezero demo`'s +auto-regeneration of the file depends on the stage-3 loader and the +stage-7 resolve-and-write step. So between stage 2 and stage 7: + +- The axum config store's backing-file **contract** is what stage 2 + establishes; stage 2 does not need anything to _produce_ the file. +- Stage 2's axum config-store tests **write the JSON fixture file + directly** in test setup (a temp-dir fixture) — they exercise the + read path without depending on `config push`. +- `app-demo`'s stage-2 state: if no fixture file is present the axum + config store is empty (the documented "absent → empty" behaviour). + Any stage-2 `app-demo` test that asserts a config value seeds the + fixture file itself. The full `config push` → running-demo-server + read-back end-to-end test lands in stage 8. + +This keeps stage 2 independently buildable and testable. + +**Ship gate:** multi-store handlers work on axum, cloudflare, fastly, +and spin; async config reads work; an adapter binary builds and runs +with no `edgezero.toml` and zero env vars (falling back to defaults); +all five CI gates green (including the wasm32 spin gate). + +## 9. Sub-project 3 — App-config schema, derive macro, env-overlay loader + +**Goal:** the `.toml` format, `#[derive(AppConfig)]`, and the +generic loader with env-var overlay (§6.10). + +**Source changes:** `edgezero-core::app_config`; `edgezero-macros` +`AppConfig` derive + `#[proc_macro_derive]` export; generator +templates for `.toml` (with a nested `[config.service]` section) +and `-core/src/config.rs` (with `#[serde(deny_unknown_fields)]`); +`examples/app-demo/app-demo.toml` + `app-demo-core/src/config.rs`. + +**Generated template vs the `app-demo` example — deliberately +different.** The **generated** `-core/src/config.rs` (what +`edgezero new` scaffolds) is the _common-case_ starting point: a +`greeting` field, the nested `[config.service]` section (to exercise +env overlay), and a single plain `#[secret]` field as the common +secret pattern. It does **not** include `#[secret(store_ref)]` — +`store_ref` only buys multiple secret stores on a Fastly-only project +(§6.8), so putting it in every fresh scaffold would teach the edge +case as the default. A commented line in the template shows how to add +`#[secret(store_ref)]` if needed. The **`app-demo` example** is the +opposite: it deliberately exercises _everything_, so its +`app-demo-core/src/config.rs` includes a nested section, one +`#[secret]`, **and** one `#[secret(store_ref)]` — `app-demo` is the +full-capability showcase, not a representative new project. + +**Tests:** `load_app_config` (valid, missing file, bad TOML, validator +failure, missing `[config]`); env-overlay tests (top-level, nested +`__`, type coercion, parse failure, ambiguous key → error, `--no-env`); +round-trip for `AppDemoConfig`; macro tests for all §6.8 compile-error +constraints. + +**Ship gate:** `AppDemoConfig::SECRET_FIELDS` matches; `load_app_config` +succeeds; `APP_DEMO__SERVICE__TIMEOUT_MS` overrides the nested value +in a test. + +## 10. Sub-project 4 — `config validate` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ConfigValidateArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub app_config: Option, + #[arg(long)] pub strict: bool, + #[arg(long)] pub no_env: bool, +} +``` + +Bound: `DeserializeOwned + Validate + AppConfigMeta` (no `Serialize`). + +App-config validation: TOML syntax; `[config]` present; deserialises +into `C`; types; `validator` rules; unknown fields rejected when `C` +opts in; `#[secret]` non-empty; `#[secret(store_ref)]` in +`[stores.secrets].ids`. **When `spin` is in the adapter set**, three +additional Spin checks (all per §6.7): + +1. every flattened config key, `.`→`__` translated, matches + `^[a-z][a-z0-9_]*$` — **typed and raw** (both flavours have the + config keys); +2. the effective Spin variable name set — {flattened config keys} ∪ + {`#[secret]` field values}, after `.`→`__` translation — has no + duplicate (config/secret namespace collision check). **Typed + only** — `#[secret]` fields are identified via + `AppConfigMeta::SECRET_FIELDS`, which the raw flavour does not + have. `run_config_validate` (raw) cannot tell which keys are + secrets, so it performs check 1 and check 3 but **not** check 2; + its diagnostics say so. The collision check is therefore guaranteed + only for the typed path, which is the one downstream CLIs wire up; +3. Spin component discovery resolves (exactly one `[component.*]` in + `spin.toml`, or an explicit, matching `[adapters.spin.adapter] +.component`) — **typed and raw** (manifest-based, no struct + needed). + +Manifest: `ManifestLoader` checks; under `--strict`, capability-aware +completeness and well-formed handler paths. + +**Tests:** dedicated fixtures per failure mode incl. all three Spin +checks above (key-syntax, collision, component discovery); env-overlay +on/off. + +**Ship gate:** `app-demo-cli config validate --strict` exits 0; +corrupted fixtures fail with expected messages. + +## 11. Sub-project 5 — `auth` command (+ `CommandRunner`) + +```rust +#[derive(clap::Args, Debug)] // NO Default — §6.11 +#[non_exhaustive] +pub struct AuthArgs { #[command(subcommand)] pub sub: AuthSub } + +#[derive(clap::Subcommand, Debug)] +pub enum AuthSub { + Login { #[arg(long)] adapter: String }, + Logout { #[arg(long)] adapter: String }, + Status { #[arg(long)] adapter: String }, +} +``` + +UX: `auth login --adapter cloudflare`. Per-adapter: axum no-ops; +cloudflare `wrangler login/logout/whoami`; fastly `fastly profile +create/delete/list`; spin `spin cloud login/logout/info`. All via +`CommandRunner` (the `runner` module lands here). + +**Tests:** mock-runner matrix; ENOENT + non-zero-exit cases. + +## 12. Sub-project 6 — `provision` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ProvisionArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub adapter: String, + #[arg(long)] pub dry_run: bool, +} +``` + +Iterate every id in `[stores.].ids`. Per-adapter behaviour: + +**axum** — no remote resources. `provision --adapter axum` is an +explicit no-op: it prints, for each store, "axum store `` is local +(KV in-memory; config in `.edgezero/local-config-.json`; secrets +from env vars) — nothing to provision." Exit 0. + +**cloudflare** — for KV and config ids: `wrangler kv namespace create +`; parse the namespace id from stdout; patch `wrangler.toml` +`[[kv_namespaces]] binding = ""`, `id = ""`. Secrets: +no-op (worker secrets are runtime-managed via `wrangler secret put`). + +**fastly** — for each id: `fastly -store create --name=`; +ensure `fastly.toml` contains `[setup._stores.]` and +`[local_server._stores.]` table entries (keyed by the +resource-link name = our `name`). Store IDs are not persisted; `config +push` resolves them on demand (§13). + +**spin** — no remote `create` step (Spin KV stores and variables are +provisioned by the Spin runtime / Fermyon at deploy). `provision +--adapter spin` performs **KV-label `spin.toml` writeback only**: + +- KV: ensure each KV label (`[adapters.spin.stores.kv.].name`) + appears in the resolved component's `key_value_stores` array field + (`key_value_stores = [...]` under `[component.]`). +- **Config and secret variables are NOT handled by `provision`.** The + manifest only carries store _ids_, not app-config field keys or + secret key names — `provision` cannot know which Spin variables to + declare. Config-variable declaration is done by `config push +--adapter spin` (which loads `.toml` and therefore knows the + keys; see §13). Secret-variable declaration is **manual** — the + developer declares Spin secret variables in `spin.toml` themselves + (§6.7); the CLI never writes secret variables. + +Component resolution for the KV writeback follows §6.7's rule. No +`CommandRunner` calls for Spin — it is pure manifest editing. + +`--dry-run` prints the would-be `CommandSpec`s and would-be manifest +edits without performing them. + +**Tests:** per-(adapter, kind) mock-runner for cloudflare/fastly with +scripted stdout; golden ID-extraction parsers; temp-fixture writeback +verified for `wrangler.toml`, `fastly.toml`, and the Spin +`key_value_stores` array in `spin.toml`; axum no-op output asserted; +`--dry-run` performs nothing. + +## 13. Sub-project 7 — `config push` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ConfigPushArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub adapter: String, + #[arg(long)] pub store: Option, // logical config id; default resolved + #[arg(long)] pub app_config: Option, + #[arg(long)] pub no_env: bool, + #[arg(long)] pub dry_run: bool, +} +``` + +Bound: `DeserializeOwned + Validate + Serialize + AppConfigMeta`. + +**Behaviour:** strict pre-flight validation; load app-config (env +overlay unless `--no-env`); flatten + serialise per §6.4/§6.5 (skip +`SECRET_FIELDS`); resolve target id (`--store` or resolved default). +Push is **split by adapter** — there is no single "resource-ID" model: + +| Adapter | Push behaviour | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| axum | Write resolved values to `.edgezero/local-config-.json` (the file the axum config store reads, §15). No runner call. | +| cloudflare | Read the namespace id from `wrangler.toml` (error "did you run `provision`?" if absent); `wrangler kv bulk put --namespace-id=`. Keys in dotted form. | +| fastly | Resolve the store id on demand: `fastly config-store list --json`, match by ``; per key `fastly config-store-entry create --store-id= --key= --value=` (`--stdin` for large values). Keys in dotted form. | +| spin | Declare + set each config value as a Spin variable, writing **both** `spin.toml` tables (see below). Keys in `.`→`__` lowercase form (§6.7). No remote call — live Fermyon Cloud variable push is out of scope (§2). | + +**Spin `config push` writes two `spin.toml` tables.** A Spin variable +is not readable by a component unless it is both _declared_ and +_bound_. `config push --adapter spin` therefore writes: + +1. `[variables].` — the application-level variable declaration, + with `default = ""`. +2. `[component..variables].` — the component binding, + ` = "{{ }}"`, surfacing the application variable into the + component. Without this, the component cannot read the variable. + +If the component-bindings table is missing entries for keys this push +needs and `config push` cannot resolve the component (§6.7), it +errors rather than writing a half-configured manifest. The component +is resolved per §6.7's discovery rule. Config-variable _declaration_ +lives here (not in `provision`) because only `config push` loads +`.toml` and thus knows the keys. Secret variables remain manual +(§6.7) — `config push` skips `SECRET_FIELDS` and never writes secret +variables. + +**Tests:** typed + raw; per-adapter mock-runner / fixture with golden +payloads; `#[secret]` / `#[secret(store_ref)]` absent from payload; +missing native-manifest id (cloudflare) → clear error; Spin key +`.`→`__` translation asserted; Spin writeback updates **both** +`[variables]` and `[component..variables]`; Spin push errors +when the component cannot be resolved; `--store` selection; `--dry-run` +performs nothing; env-overlay on vs `--no-env`. **Explicit "validate +passes, push serialization fails" cases:** non-object typed config, +unsupported compound shape, `skip_serializing_if`, `Option::None`, +`#[serde(flatten)]` on a non-secret field. + +**Spin `spin.toml` golden test.** A golden-file test captures the +generated `spin.toml` after a Spin `config push` and asserts: every +written variable name matches `^[a-z][a-z0-9_]*$` (§6.7); the +generated manifest **parses** (round-trips through the same TOML / +Spin-manifest parser the runtime uses), so the `^[a-z][a-z0-9_]*$` +rule cannot silently drift from Spin's actual manifest behaviour. + +**Validation strength, strongest first:** the test uses the strongest +check available in its environment. (1) If the `spin` CLI is present +(the wasm32 spin CI job already installs it), the test runs Spin's own +manifest validation against the generated file — this is authoritative +and catches semantic errors a plain TOML parse cannot. (2) Else if +`spin_sdk` exposes a manifest-validation entry point, it calls that. +(3) Otherwise it falls back to `toml` parsing + the variable-name +regex. The regex is the **floor**, not the ceiling — the +implementation prefers real Spin validation wherever it is reachable +and treats the TOML-only fallback as the weakest acceptable check. +The golden file is regenerated only on an intentional format change. + +**Ship gate:** `app-demo-cli config push --adapter cloudflare +--dry-run` and `--adapter spin --dry-run` each show the expected +output; secret fields absent; Spin keys `__`-encoded. + +## 14. (reserved — sub-project numbering uses the `#` column in §16) + +## 15. Sub-project 8 — `app-demo` integration polish (all four adapters) + +**Goal:** `app-demo` demonstrates the **full** feature set in CI across +all four adapters. + +- **Extensible CLI:** `app-demo-cli` with the four downstream built-ins plus + `Auth`, `Provision`, `Config` (`Validate` / `Push`); the `Config` + arm wired to the **typed** functions with `AppDemoConfig`. +- **Multi-store manifest + runtime:** `edgezero.toml` declares 2 KV ids + (`sessions`, `cache`), one config id, one secrets id, with per-adapter + mappings for **all four** adapters (Spin KV labels included). The + Spin capability rule is satisfied (one config id, one secrets id). +- **Multi-store runtime:** handlers read both `sessions` and `cache` + via the `Kv` extractor's `named()`. +- **Async config:** a handler does + `ctx.config_store_default()?.get("greeting").await?`. +- **Nested config + Spin key encoding:** `AppDemoConfig.service. +timeout_ms` is read at runtime; the Spin path proves `.`→`__` + translation. +- **Env-var override:** an integration test sets + `APP_DEMO__SERVICE__TIMEOUT_MS` and asserts the override. +- **Secrets:** one `#[secret]` (`api_token`) and one + `#[secret(store_ref)]` (`vault`); a handler reads each. `app-demo` + targets all four adapters, so `[stores.secrets].ids` has exactly one + id (§6.6 capability rule) and the `vault` field's value **is** that + single secrets id — the walkthrough doc explicitly shows + `#[secret(store_ref)]` resolving to the one declared id for an + all-four-adapter app (§6.8). `app-demo`'s `spin.toml` **manually + declares** its Spin secret variables (with `secret = true`, bound + under `[component..variables]`), demonstrating the §6.7 + manual-secret rule. The `app-demo-core` handler keeps its + `#[secret(store_ref)]` runtime key clear of every config key so the + Spin flat namespace does not collide. +- **Spin component:** `app-demo`'s `spin.toml` is single-component, so + component discovery resolves implicitly; the walkthrough doc also + shows the explicit `[adapters.spin.adapter].component` form. +- **`config validate` / `config push`:** CI runs `config validate +--strict` (exit 0 — including the three Spin checks of §10) then + `config push --adapter axum` and reads the value back through a + running axum demo server on `/config/greeting`. `config push + --adapter spin --dry-run` is asserted to **print** the would-be + `__`-encoded keys and the would-be content of **both** `spin.toml` + tables — and the on-disk `spin.toml` is asserted **unchanged** + (dry-run never mutates). The non-dry-run Spin push writing both + tables is covered by stage 7's tests, not the dry-run assertion. +- **`auth` / `provision`:** exercised against `MockCommandRunner` (and, + for spin/axum provision, against temp-fixture manifests) in tests. + Spin `provision` is asserted to write only the `key_value_stores` + array, not variables. + +**Axum config store backing.** The axum config store is backed by +`.edgezero/local-config-.json` (gitignored). `config push +--adapter axum` writes it from `.toml` (env overlay applied); +the axum config store reads the same file; `edgezero demo` regenerates +it at startup. If absent, the axum config store is empty. + +**Docs:** create `docs/guide/cli-walkthrough.md` (full `myapp` loop — +`new`, `auth`, `provision`, `config validate`, `config push`, `deploy`, +the `demo` subcommand, an env-override example, all four adapters, +including the manual Spin secret-variable `spin.toml` entries and the +explicit `[adapters.spin.adapter].component` form). Update +`docs/.vitepress/config.mts` so the sidebar lists `cli-walkthrough.md` +and `manifest-store-migration.md`. + +**Documentation audit (§6.12).** Stage 8 finishes with a docs audit: +grep `docs/` for stale references — old `[stores.*]` manifest keys, +the `dev` subcommand, the pre-rewrite single-store runtime API — and +confirm none remain; confirm every page in §6.12's table was updated +by its owning stage; confirm the docs CI (ESLint + Prettier) passes. + +**Ship gate:** CI runs the full loop on axum end-to-end; manifest / +runtime behaviour for cloudflare, fastly, and spin is covered by +contract + mock tests; the documentation audit passes with zero stale +references. + +--- + +## 16. Implementation order and milestones + +The whole effort is **a single pull request containing eight stages**, +one per sub-project, applied in this order: + +| Stage | § | Title | Risk | +| ----- | --- | ------------------------------------------------------ | ---- | +| 1 | §7 | Extensible lib + scaffold | M | +| 2 | §8 | Manifest + runtime rewrite (atomic, all four adapters) | H | +| 3 | §9 | App-config schema + derive macro + env-overlay loader | M | +| 4 | §10 | `config validate` | L | +| 5 | §11 | `auth` + `CommandRunner` | M | +| 6 | §12 | `provision` | H | +| 7 | §13 | `config push` | M | +| 8 | §15 | `app-demo` polish (all four adapters) + docs audit | M | + +Every stage also updates the `docs/guide/` pages it makes stale +(§6.12) — documentation is part of each stage's definition-of-done, +not a deferred afterthought. Stage 8 closes with a documentation +audit. + +**CI and bisectability.** CI gates the PR as a whole on its head +commit; all four gates (`fmt`, `clippy -D warnings`, `cargo test`, +feature `cargo check`) plus the wasm32 spin gate must pass there. Each +of the eight stages should nonetheless compile and pass tests on its +own so the history stays bisectable — stage boundaries are chosen so +that each is a self-contained, buildable increment. Stage 2 is the one +unavoidably large stage (the atomic manifest+runtime rewrite); the +other seven are individually small. + +**Review note.** Because this is one PR, the reviewer sees all eight +stages together. The PR description should list the eight stages and +point at this spec. Reviewing stage-by-stage is recommended. +**Stage 2 is the review hotspot** — the atomic manifest+runtime +rewrite is intentionally large (the hard cutoff leaves no smaller +coherent unit), so it warrants the most reviewer attention. Its +per-adapter contract tests (§8) are the primary mitigation and should +be reviewed alongside the code. + +**Highest-risk:** stage 2 — atomic manifest+runtime rewrite touching the +schema, `ConfigStore` (async), **all four** adapters' store impls, the +Cloudflare `[vars]`→KV swap, Spin store wiring, `Hooks` / +`ConfigStoreMetadata` / `app!`, and the extractors, in one stage. +Large by necessity under the hard-cutoff decision. Mitigated by +per-adapter contract tests and `app-demo` as the in-tree canary. +Stage 6 (`provision`) — shell-out + multi-file native-manifest +writeback across four adapters (`wrangler.toml`, `fastly.toml`, +`spin.toml`). + +## 17. Risks and trade-offs + +- **Hard manifest cutoff:** a pre-rewrite `edgezero.toml` fails to + load with a migration-guide error. All in-tree projects migrated in + stage 2; external projects migrate once. +- **Large atomic stage (stage 2):** unavoidable without a + compatibility layer, which the hard-cutoff decision rejects. It is + one stage, not one PR — the PR carries all eight. +- **Async `ConfigStore` cascade:** `get` becomes async across the + trait and **all four** adapter impls, handlers, and the `Config` + extractor. `#[async_trait(?Send)]` keeps WASM compatibility. +- **Cloudflare `[vars]`→KV swap:** deployed workers migrate once. +- **Spin model asymmetry:** Spin config/secrets are a single flat + variable namespace; multi-config/multi-secret projects cannot target + Spin. The capability matrix (§6.6) enforces this at validate time + with a clear error. Spin config keys are `__`-encoded lowercase. +- **Spin config is build-time:** `config push --adapter spin` writes + static `spin.toml` variables; changing them needs a redeploy. Live + Spin variable providers are out of scope (§2). +- **Spin secret variables are manual:** the CLI never declares Spin + secret variables (their key names are not reliably knowable, §6.7). + A project targeting Spin must declare them in `spin.toml` by hand; + the walkthrough doc covers this. `#[secret(store_ref)]` is the + awkward case on Spin (single flat secret namespace, code-local + keys) — supported, but the developer owns the `spin.toml` entries. +- **Spin KV TTL / listing-cap:** stage 2 adds two new `KvError` + variants — `Unsupported` (Spin TTL writes) and `LimitExceeded` + (Spin listing past `max_list_keys`) — both 5xx-class in their + `EdgeError` mapping. Spin TTL writes return `Unsupported` + deterministically (not silent); the Spin listing path returns + `LimitExceeded`, replacing PR #253's `KvError::Validation` for that + case. Both are settled in this spec, not left open. +- **Spin component discovery:** writing `[component..*]` tables + needs the component id; single-component `spin.toml` resolves + implicitly, multi-component requires `[adapters.spin.adapter] +.component`. `config validate --strict` surfaces a failure early. +- **Env overlay surprising `config push`:** `--no-env` is the escape + hatch. +- **Shell-out + ID-writeback fragility:** current platform syntax + pinned; golden parser tests; `--dry-run` available. +- **Extractor breaking change:** `Kv(handle)` → `kv.default()`; only + in-tree consumer is `app-demo`. +- **API stability:** non-subcommand `*Args` are `#[non_exhaustive]` + + `Default`; `AuthArgs` without `Default`. + +## 18. What this spec does not cover + +- Anthropic credentials, edge DNS / TLS, observability / metrics. +- Per-environment config _files_ (env-var override is in scope). +- Restructuring `app-demo-core` handlers beyond what §15 requires. +- `edgezero-core` changes beyond `app_config`, the rewritten + `manifest` / `RequestContext` / `Hooks` / `ConfigStore` (async) / + extractor / `ConfigStoreMetadata` / `app!` surface, and the + Cloudflare adapter config backend. +- A migration _tool_; migration is manual via the published guide. +- Dynamic Spin variable providers (Fermyon Cloud variable push, Vault). + +When all eight sub-projects ship, `edgezero new myapp` produces a +workspace with `myapp-cli`, a typed `MyappConfig` +(`#[derive(AppConfig)]`, `#[serde(deny_unknown_fields)]`, optional +`#[secret]` / `#[secret(store_ref)]`), a `myapp.toml`, and an +`edgezero.toml` using the new logical-store schema with capability- +correct store declarations. The developer authenticates, provisions, +validates, pushes config (with optional env overrides), and deploys. +At runtime the service reads config (async) and secrets by logical id +across all four adapters. `app-demo` demonstrates every capability in +CI. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 1dea610..b6aa264 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -95,6 +145,15 @@ dependencies = [ "spin-sdk", ] +[[package]] +name = "app-demo-cli" +version = "0.1.0" +dependencies = [ + "clap", + "edgezero-cli", + "log", +] + [[package]] name = "app-demo-core" version = "0.1.0" @@ -266,6 +325,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "brotli" version = "8.0.2" @@ -342,6 +410,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -351,6 +459,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "2.2.0" @@ -432,6 +546,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "darling" version = "0.20.11" @@ -477,6 +611,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.9.0" @@ -486,6 +651,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -509,6 +684,13 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "edgezero-adapter" +version = "0.1.0" +dependencies = [ + "toml", +] + [[package]] name = "edgezero-adapter-axum" version = "0.1.0" @@ -517,6 +699,8 @@ dependencies = [ "async-trait", "axum", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "futures", "futures-util", @@ -527,8 +711,10 @@ dependencies = [ "simple_logger 5.1.0", "thiserror 2.0.18", "tokio", + "toml", "tower", "tracing", + "walkdir", ] [[package]] @@ -539,12 +725,15 @@ dependencies = [ "async-trait", "brotli", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "flate2", "futures", "futures-util", "log", "serde_json", + "walkdir", "worker", ] @@ -558,6 +747,8 @@ dependencies = [ "brotli", "bytes", "chrono", + "ctor", + "edgezero-adapter", "edgezero-core", "fastly", "fern", @@ -567,6 +758,7 @@ dependencies = [ "log", "log-fastly", "thiserror 2.0.18", + "walkdir", ] [[package]] @@ -577,12 +769,36 @@ dependencies = [ "async-trait", "brotli", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "flate2", "futures", "futures-util", "log", "spin-sdk", + "walkdir", +] + +[[package]] +name = "edgezero-cli" +version = "0.1.0" +dependencies = [ + "clap", + "edgezero-adapter", + "edgezero-adapter-axum", + "edgezero-adapter-cloudflare", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", + "edgezero-core", + "futures", + "handlebars", + "log", + "serde", + "serde_json", + "simple_logger 5.1.0", + "thiserror 2.0.18", + "toml", ] [[package]] @@ -678,7 +894,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "sha2", + "sha2 0.9.9", "smallvec", "thiserror 1.0.69", "time", @@ -896,6 +1112,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "handlebars" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1189,6 +1421,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1266,6 +1504,18 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "litemap" version = "0.8.1" @@ -1352,6 +1602,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1376,6 +1641,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1394,6 +1665,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -1931,13 +2245,24 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if", "cpufeatures", - "digest", + "digest 0.9.0", "opaque-debug", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2423,6 +2748,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2459,6 +2790,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "validator" version = "0.20.0" diff --git a/examples/app-demo/Cargo.toml b/examples/app-demo/Cargo.toml index ba14fbd..a40b732 100644 --- a/examples/app-demo/Cargo.toml +++ b/examples/app-demo/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/app-demo-core", + "crates/app-demo-cli", "crates/app-demo-adapter-axum", "crates/app-demo-adapter-cloudflare", "crates/app-demo-adapter-fastly", @@ -16,10 +17,12 @@ anyhow = "1" async-trait = "0.1" axum = "0.8" bytes = "1" +clap = { version = "4", features = ["derive"] } edgezero-adapter-axum = { path = "../../crates/edgezero-adapter-axum" } edgezero-adapter-cloudflare = { path = "../../crates/edgezero-adapter-cloudflare" } edgezero-adapter-fastly = { path = "../../crates/edgezero-adapter-fastly" } edgezero-adapter-spin = { path = "../../crates/edgezero-adapter-spin" } +edgezero-cli = { path = "../../crates/edgezero-cli" } edgezero-core = { path = "../../crates/edgezero-core" } spin-sdk = { version = "5.2", default-features = false } fastly = "0.12" diff --git a/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs index 0a102e1..03490c5 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs @@ -1,3 +1,11 @@ +#![cfg_attr( + target_arch = "wasm32", + allow( + unsafe_code, + reason = "spin's #[http_component] macro generates the unsafe wasm export" + ) +)] + #[cfg(target_arch = "wasm32")] use app_demo_core::App; #[cfg(target_arch = "wasm32")] diff --git a/examples/app-demo/crates/app-demo-cli/Cargo.toml b/examples/app-demo/crates/app-demo-cli/Cargo.toml new file mode 100644 index 0000000..58cb169 --- /dev/null +++ b/examples/app-demo/crates/app-demo-cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "app-demo-cli" +version = "0.1.0" +edition = "2021" +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +clap = { workspace = true } +edgezero-cli = { workspace = true } +log = { workspace = true } diff --git a/examples/app-demo/crates/app-demo-cli/src/main.rs b/examples/app-demo/crates/app-demo-cli/src/main.rs new file mode 100644 index 0000000..4429a49 --- /dev/null +++ b/examples/app-demo/crates/app-demo-cli/src/main.rs @@ -0,0 +1,43 @@ +//! `app-demo` CLI — built on the `edgezero-cli` library. +//! +//! Reuses the built-in `edgezero` commands via the `edgezero_cli` +//! library. This is the canonical example of a downstream project +//! building its own CLI binary on the `EdgeZero` substrate. + +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; + +#[derive(Parser, Debug)] +#[command(name = "app-demo-cli", about = "app-demo edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Build the project for a target edge. + Build(BuildArgs), + /// Deploy to a target edge. + Deploy(DeployArgs), + /// Create a new `EdgeZero` app skeleton. + New(NewArgs), + /// Run a local simulation (adapter-specific). + Serve(ServeArgs), +} + +fn main() { + use std::process; + + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Build(args) => edgezero_cli::run_build(&args), + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::New(args) => edgezero_cli::run_new(&args), + Cmd::Serve(args) => edgezero_cli::run_serve(&args), + }; + if let Err(err) = result { + log::error!("[app-demo] {err}"); + process::exit(1); + } +} diff --git a/examples/app-demo/crates/app-demo-cli/tests/help.rs b/examples/app-demo/crates/app-demo-cli/tests/help.rs new file mode 100644 index 0000000..8fd393a --- /dev/null +++ b/examples/app-demo/crates/app-demo-cli/tests/help.rs @@ -0,0 +1,28 @@ +//! Smoke test: the `app-demo-cli` binary parses its CLI without panicking +//! and `--help` lists the built-in commands. + +#[cfg(test)] +mod tests { + use std::process::Command; + + #[test] + fn help_lists_builtin_commands() { + let output = Command::new(env!("CARGO_BIN_EXE_app-demo-cli")) + .arg("--help") + .output() + .expect("run app-demo-cli --help"); + + assert!( + output.status.success(), + "`app-demo-cli --help` should exit 0" + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + for command in ["build", "deploy", "new", "serve"] { + assert!( + stdout.contains(command), + "`--help` output should list the `{command}` command" + ); + } + } +} diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 3685d80..10147b0 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -104,25 +104,20 @@ adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Echo an allowlisted smoke-test secret value (smoke-test only — do not use in production)" # -- Stores ---------------------------------------------------------------- +# +# `[stores.]` declares logical store ids only — the portable fact that +# this app uses a store. Platform names are resolved at runtime; a store's +# name defaults to its logical id. [stores.kv] -# Uses the default name "EDGEZERO_KV". Uncomment to customise: -# name = "MY_CUSTOM_KV" -# -# Per-adapter overrides: -# [stores.kv.adapters.cloudflare] -# name = "CF_KV_BINDING" +ids = ["sessions", "cache"] +default = "sessions" -[stores.kv.adapters.spin] -# Spin's local runtime auto-provisions the "default" label. Custom labels -# require a Spin runtime config or cloud link. -name = "default" +[stores.config] +ids = ["app_config"] [stores.secrets] -# Uses the default name "EDGEZERO_SECRETS". -# Axum reads secrets from environment variables of the same name. -# Cloudflare reads from Worker secret bindings (local: .dev.vars). -# Fastly reads from the declared secret store (local: fastly.toml [local_server.secret_stores]). +ids = ["default"] # [environment] # @@ -138,14 +133,6 @@ name = "default" # adapters = ["axum", "cloudflare", "fastly"] # env = "API_TOKEN" -[stores.config] -name = "app_config" - -[stores.config.defaults] -"feature.new_checkout" = "false" -"service.timeout_ms" = "1500" -"greeting" = "hello from config store" - [adapters.axum.adapter] crate = "crates/app-demo-adapter-axum" manifest = "crates/app-demo-adapter-axum/axum.toml"