From 67d0cdf11f6d13688812eb221452a62224090a87 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 16:03:14 -0700 Subject: [PATCH 01/38] Add design spec for extensible edgezero-cli library Sub-project #1 of 7 in the CLI extensions roadmap. Turns edgezero-cli into lib + bin, exposes per-command Args structs and run_* functions for downstream projects to compose their own CLIs via clap subcommand flattening, and adds app-demo-cli as the canonical consumer. Force-added because docs/superpowers/ is gitignored project-wide for plans; this spec is shared design intent and meant to be reviewed in the repo. --- ...026-05-19-extensible-cli-library-design.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md diff --git a/docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md b/docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md new file mode 100644 index 0000000..720aa83 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md @@ -0,0 +1,333 @@ +# Extensible `edgezero-cli` Library (sub-project #1) + +**Date:** 2026-05-19 +**Status:** Approved design, pending implementation plan +**Roadmap position:** Sub-project 1 of 7 in the CLI extensions effort +(extensible lib + `app-demo-cli` skeleton → app-config schema → `config +validate` → `auth` → `provision` → `config push` → `app-demo` integration +polish). This spec covers sub-project #1 only. `app-demo` is updated +incrementally across all seven sub-projects, not backloaded to the end. + +## Goal + +Let downstream projects build their own CLI binary that: + +- Reuses any subset of edgezero's built-in commands (today: `build`, `deploy`, + `dev`, `new`, `serve`). +- Adds their own subcommands. +- Owns the binary name, `about` text, and top-level help. + +The default `edgezero` binary keeps working unchanged for users who do not +build their own CLI. + +Ship `app-demo-cli` in the same sub-project as the canonical downstream +consumer. It uses every built-in verbatim today (no custom subcommands +yet) and becomes the staging ground each later sub-project extends. + +## Non-goals + +- No runtime command registry (`inventory` / `linkme`-style). +- No cargo-style external subcommand discovery on PATH. +- No re-exposing internal modules (`adapter`, `generator`, `scaffold`, + `dev_server`) — only high-level `run_*` entry points and per-command + `*Args` structs. +- No renaming or hiding individual built-ins via a library API — opt-out + happens by omission in the downstream `Subcommand` enum. +- No new commands (`auth`, `provision`, `config`). Those are sub-projects + 3–6 and will add their own `*Args` + `run_*` pairs once this substrate + ships. + +## Approach + +Use clap-derive composition. `edgezero-cli` becomes lib + bin in one crate: + +- New `crates/edgezero-cli/src/lib.rs` — public API surface. +- Existing `crates/edgezero-cli/src/main.rs` — rewritten as a thin wrapper + that depends only on the public API. + +The library exposes one `*Args` struct per built-in command plus one +`run_*` function per command. Downstream projects compose their own +`#[derive(Subcommand)]` enum that mixes edgezero variants with their own, +and write a small `main` that dispatches each variant. + +## Public API surface + +```rust +// crates/edgezero-cli/src/lib.rs (feature = "cli") + +pub use args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; + +pub fn init_cli_logger(); + +pub fn run_build(args: &BuildArgs) -> Result<(), String>; +pub fn run_deploy(args: &DeployArgs) -> Result<(), String>; +pub fn run_new(args: &NewArgs) -> Result<(), String>; +pub fn run_serve(args: &ServeArgs) -> Result<(), String>; + +#[cfg(feature = "edgezero-adapter-axum")] +pub fn run_dev() -> !; +``` + +Everything else (`adapter`, `generator`, `scaffold`, `dev_server` modules; +`load_manifest_optional`, `ensure_adapter_defined`, `store_bindings_message`) +stays private to the crate. + +### Pattern for adding future built-ins (informational) + +When sub-projects 3–6 add their commands, each one follows the same +two-symbol pattern: + +```rust +pub use args::AuthArgs; +pub fn run_auth(args: &AuthArgs) -> Result<(), String>; +``` + +This pattern is established here; later specs will not need to re-justify +the shape. + +## Downstream usage (canonical example) + +```rust +// myapp-cli/src/main.rs +use clap::{Parser, Subcommand}; +use edgezero_cli::{BuildArgs, DeployArgs, ServeArgs}; + +#[derive(Parser)] +#[command(name = "myapp", about = "MyApp edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Build(BuildArgs), + Deploy(DeployArgs), + Serve(ServeArgs), + // Opt out of `new` and `dev`: simply not listed. + Migrate(MigrateArgs), // downstream's own + Seed, +} + +fn main() { + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Build(a) => edgezero_cli::run_build(&a), + Cmd::Deploy(a) => edgezero_cli::run_deploy(&a), + Cmd::Serve(a) => edgezero_cli::run_serve(&a), + Cmd::Migrate(a) => run_migrate(a), + Cmd::Seed => run_seed(), + }; + if let Err(err) = result { + log::error!("[myapp] {err}"); + std::process::exit(1); + } +} +``` + +Opt-in is "add the variant"; opt-out is "don't". No machinery beyond clap. + +## Source layout changes + +1. **`crates/edgezero-cli/src/args.rs`** — promote each `Command` variant's + inline fields into a standalone `#[derive(clap::Args)]` struct. `NewArgs` + already exists. The internal `Command` enum (used only by the default + `edgezero` binary) becomes: + + ```rust + #[derive(Subcommand, Debug)] + pub enum Command { + Build(BuildArgs), + Deploy(DeployArgs), + Dev, + New(NewArgs), + Serve(ServeArgs), + } + ``` + + The four new public structs each carry exactly the fields the variant + currently inlines (`adapter`, `adapter_args`, etc.). No new fields. + +2. **`crates/edgezero-cli/src/lib.rs` (new)** — declares the private + `adapter`, `generator`, `scaffold`, and (feature-gated) `dev_server` + modules. Moves `init_cli_logger`, `load_manifest_optional`, + `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, + and the five handlers (renamed `handle_*` → `run_*`) into this file. + +3. **`crates/edgezero-cli/src/main.rs`** — shrinks to roughly: + + ```rust + use clap::Parser as _; + use edgezero_cli::{run_build, run_deploy, run_new, run_serve}; + + fn main() { + edgezero_cli::init_cli_logger(); + let args = edgezero_cli::Args::parse(); + let result = match args.cmd { + edgezero_cli::Command::Build(a) => run_build(&a), + edgezero_cli::Command::Deploy(a) => run_deploy(&a), + edgezero_cli::Command::New(a) => run_new(&a), + edgezero_cli::Command::Serve(a) => run_serve(&a), + edgezero_cli::Command::Dev => edgezero_cli::run_dev(), + }; + if let Err(err) = result { + log::error!("[edgezero] {err}"); + std::process::exit(1); + } + } + ``` + + `Args` and `Command` are re-exported from `lib.rs` only so the default + binary can build against the public API. + +4. **Existing tests** — move from `main.rs` to `lib.rs` (they test what are + now public functions). Assertions are unchanged. + +5. **`examples/app-demo/crates/app-demo-cli` (new crate)** — added as a + member of the `examples/app-demo` workspace. That workspace is + excluded from the root `Cargo.toml` workspace and stays that way. + Layout: + + ``` + examples/app-demo/crates/app-demo-cli/ + Cargo.toml + src/main.rs + ``` + + Wiring: + + - Add `"crates/app-demo-cli"` to `members` in + `examples/app-demo/Cargo.toml`. + - Add `edgezero-cli = { path = "../../../../crates/edgezero-cli" }` to + the example workspace's `[workspace.dependencies]` (mirroring the + existing pattern for `edgezero-core` at line 23 of that file). + - The new crate's `Cargo.toml` declares: + - `name = "app-demo-cli"` (package and default binary name match; + no `[[bin]]` section needed) + - `edgezero-cli = { workspace = true }` with the default feature set + - `clap = { version = "4", features = ["derive"] }` + - `log = { workspace = true }` + - `publish = false`, `[lints] workspace = true` to match siblings. + + `src/main.rs` implements the canonical downstream pattern from the + "Downstream usage" section above, with **all five built-ins included + verbatim and no custom subcommands yet**: + + ```rust + use clap::{Parser, Subcommand}; + use edgezero_cli::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; + + #[derive(Parser)] + #[command(name = "app-demo-cli", about = "app-demo edge CLI")] + struct Args { #[command(subcommand)] cmd: Cmd } + + #[derive(Subcommand)] + enum Cmd { + Build(BuildArgs), + Deploy(DeployArgs), + Dev, + New(NewArgs), + Serve(ServeArgs), + } + + fn main() { + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Build(a) => edgezero_cli::run_build(&a), + Cmd::Deploy(a) => edgezero_cli::run_deploy(&a), + Cmd::Dev => edgezero_cli::run_dev(), + Cmd::New(a) => edgezero_cli::run_new(&a), + Cmd::Serve(a) => edgezero_cli::run_serve(&a), + }; + if let Err(err) = result { + log::error!("[app-demo-cli] {err}"); + std::process::exit(1); + } + } + ``` + + No changes to existing `app-demo` crates, `edgezero.toml`, or routes. + `app-demo-cli` is purely additive: it gives the example workspace a + binary that exercises the new lib end-to-end. Later sub-projects add + custom variants (`Auth`, `Provision`, `Config`) to this same `Cmd` + enum and the matching `app-demo.toml` plumbing. + +## Cargo manifest changes + +- `crates/edgezero-cli/Cargo.toml`: the crate already builds an implicit + binary from `src/main.rs`; adding `src/lib.rs` makes it lib + bin + automatically. No explicit `[lib]` or `[[bin]]` section needed. +- The `cli` feature continues to gate clap. All public API lives under + `#[cfg(feature = "cli")]`. `cli` remains in the `default` feature set so + normal consumers are unaffected. +- Adapter feature gates carry over: `run_dev` requires + `edgezero-adapter-axum`. `run_build`, `run_deploy`, and `run_serve` + dispatch by adapter name at runtime and surface a clear error if the + named adapter's feature is disabled (current behavior preserved). + +## Tests + +- **Move existing tests:** every `#[test]` currently in `main.rs` moves to + `lib.rs`. No behavior change. +- **New integration test:** `crates/edgezero-cli/tests/lib_consumer.rs`. + Imports `edgezero_cli` as an external consumer would, constructs + `BuildArgs` programmatically, and invokes `run_build` against a temp-dir + manifest (mirroring the existing `handle_build_executes_manifest_command` + test). This proves the public API actually compiles from outside the + crate root and produces the same result. +- **`app-demo-cli` build smoke test:** the example workspace must + successfully compile the new binary. The implementation plan will + identify the existing CI step or script that validates the + `examples/app-demo` workspace and extend it to run `cargo build -p + app-demo-cli` (or `cargo build --workspace` from inside the example + workspace, if that's what's already in use). A minimal `--help` + invocation test in + `examples/app-demo/crates/app-demo-cli/tests/help.rs` confirms the + binary parses its CLI without panicking. +- All four CI gates (`fmt`, `clippy -D warnings`, `cargo test`, feature + `cargo check`) must pass. The wasm32 spin gate is unaffected by this + change (no adapter crate touched). + +## Documentation + +- New page at `docs/cli/extending.md` (linked from the docs sidebar) showing + the canonical downstream example, the list of public `*Args` / `run_*` + symbols, and which Cargo features to enable. +- `CLAUDE.md` workspace-layout section gets one sentence noting + `edgezero-cli` is lib + bin. + +## Risks and trade-offs + +- **API stability:** promoting the four arg structs to public surface means + future field additions become semver-affecting. Mitigation: every + `*Args` struct gets `#[non_exhaustive]` so we can add fields without a + breaking change. New constructors are not needed — clap derive is the + intended construction path. +- **Test relocation churn:** moving ~10 tests from `main.rs` to `lib.rs` is + mechanical but touches a familiar file. Reviewers will see a large diff + with no behavior change; PR description must call this out. +- **Adapter-feature coupling:** `run_dev` being gated on + `edgezero-adapter-axum` means a downstream that disables that feature + loses access to the symbol entirely. This is the same constraint the + current `edgezero dev` command has; we're not making it worse, just + exposing it through the type system. + +## What this spec does NOT cover + +- The new commands (`auth`, `provision`, `config validate`, `config push`) + — each gets its own spec. Those specs will add new public `*Args` / + `run_*` symbols to `edgezero-cli` and new variants to `app-demo-cli`'s + `Cmd` enum, without modifying the substrate established here. +- The new `app-demo.toml` schema and loader — separate spec. +- Any change to existing `app-demo` crates (`app-demo-core`, + `app-demo-adapter-*`), to `edgezero.toml`, or to routes. + +When sub-project #1 ships: + +- The default `edgezero` binary still works exactly as before. +- An unrelated downstream project can already build its own CLI against + `edgezero-cli` as a library. +- `app-demo-cli` exists as the canonical consumer and is wired into the + example workspace. + +Sub-projects 2–7 extend this substrate; they do not modify it. From a78284cd8d1edcf72af3db84ca50ff447bca8d30 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 16:16:08 -0700 Subject: [PATCH 02/38] Expand CLI extensions spec to cover all 7 sub-projects Replaces the sub-project-#1-only spec with a single design document that covers the full effort: extensible edgezero-cli library, generator updates for -cli and .toml scaffolding, per-service typed app-config schema with validator integration, four new commands (auth, provision, config validate, config push), shell-out mocking via a private CommandRunner trait, and the app-demo overhaul that exercises everything end-to-end. Implementation still ships in 7 incremental PRs but the design decisions live in one place so reviewers see the whole picture. Force-added because docs/superpowers/ is gitignored project-wide. --- .../specs/2026-05-19-cli-extensions-design.md | 822 ++++++++++++++++++ ...026-05-19-extensible-cli-library-design.md | 333 ------- 2 files changed, 822 insertions(+), 333 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-19-cli-extensions-design.md delete mode 100644 docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md 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..b47ba31 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -0,0 +1,822 @@ +# EdgeZero CLI Extensions — Full Design + +**Date:** 2026-05-19 +**Status:** Approved design (single-spec form), pending implementation plan +**Branch:** `docs/extensible-cli-library-spec` + +This single spec covers the full effort: turning `edgezero-cli` into an +extensible library, defining a per-service app-config file, adding four +new commands (`auth`, `provision`, `config validate`, `config push`), +extending the project generator to scaffold the new pieces, and updating +`app-demo` to exercise everything end-to-end. + +The work is organised into seven sub-projects so it can ship in seven +incremental PRs, but the design decisions live here together so reviewers +see the full picture in one place. + +--- + +## 1. Goal + +Let downstream projects (e.g. a future `myapp` created by `edgezero new +myapp`) build their own CLI binary that: + +- Reuses any subset of edgezero's built-in commands (today: `build`, + `deploy`, `dev`, `new`, `serve`; after this effort: also `auth`, + `provision`, `config validate`, `config push`). +- Adds their own subcommands. +- Owns the binary name, `about` text, and top-level help. + +Alongside the extensibility substrate, ship: + +- A typed per-service app-config file (e.g. `myapp.toml`) whose schema is + defined by the downstream app as a Rust struct, validated at lint time + by `config validate`, and uploaded to the platform config store by + `config push`. +- Platform credential and resource management (`auth`, `provision`) that + shells out to each platform's official CLI tool, with all shell-out + calls wrapped in a mockable `CommandRunner` trait so CI can stay + hermetic. +- A generator that scaffolds a new project complete with its own + `-cli` crate (using the lib substrate) and a stub `.toml` + app-config file. +- An `app-demo` overhaul that demonstrates the finished system: + `app-demo.toml` with typed `AppDemoConfig`, `app-demo-cli` exposing + every built-in plus the new commands, and one `app-demo-core` handler + that reads a config value from the config store at runtime (proving + the push-then-read flow). + +The default `edgezero` binary keeps working unchanged. + +## 2. Non-goals + +- No runtime command registry (`inventory` / `linkme`-style); no + PATH-based external subcommand discovery. +- No edgezero-managed credentials. `auth` delegates entirely to + `wrangler` / `fastly` / `spin`; we store nothing. +- No direct REST API calls to platforms. All platform interactions go + through the platform's official CLI tool. +- No environment-sectioned app-config (`[config.production]`, + `[config.staging]`). Single `[config]` table per file; multi-environment + workflows are deferred until a real need surfaces. +- No live-platform CI smoke tests. All tests run against a mock + `CommandRunner`. +- No `app-demo` overhaul beyond what is needed to demonstrate the new + features. Existing handlers, the `app!` macro, and the manifest + schema stay as they are except for the additive changes called out + below. + +## 3. Architecture overview + +``` + ┌─────────────────────────────┐ + │ edgezero-cli (lib) │ + │ ───────────────────────── │ + │ pub *Args + pub run_* │ + │ internal: CommandRunner │ + │ internal: adapter/gen/... │ + └────────────┬────────────────┘ + │ used by + ┌─────────────────────┼──────────────────────┐ + │ │ │ +┌──────┴───────┐ ┌────────┴─────────┐ ┌────────┴────────┐ +│ edgezero │ │ app-demo-cli │ │ myapp-cli │ +│ (bin) │ │ (example) │ │ (downstream) │ +│ │ │ │ │ │ +│ default │ │ all built-ins + │ │ subset of │ +│ binary; │ │ Auth/Provision/ │ │ built-ins + │ +│ all built- │ │ Config typed on │ │ custom typed │ +│ ins; no app │ │ AppDemoConfig │ │ AppConfig │ +│ struct │ │ │ │ │ +└──────────────┘ └─────────┬────────┘ └─────────────────┘ + │ + ┌───────────┴────────────┐ + │ app-demo-core │ + │ pub struct │ + │ AppDemoConfig: │ + │ Deserialize + │ + │ Validate │ + └────────────────────────┘ +``` + +Key contracts: + +- **Substrate**: each built-in command is a `(pub *Args, pub run_*)` pair + in `edgezero-cli`. Downstream `Subcommand` enums opt in by listing the + variants they want. Opt-out is omission. +- **Typed app-config**: downstream defines a `#[derive(Deserialize, + Validate)]` struct; downstream CLI passes that type as a generic + parameter to `run_config_validate_typed::` and + `run_config_push_typed::`. The non-typed `run_config_validate` / + `run_config_push` are also exposed for the default `edgezero` binary + (which validates only TOML syntax and the `edgezero.toml` schema). +- **Shell-out isolation**: every subprocess call goes through a private + `CommandRunner` trait. Tests inject a `MockCommandRunner` that records + invocations and returns scripted outputs. CI never touches a real + platform. +- **Generator**: `edgezero new ` produces a workspace with + `crates/-core`, `crates/-cli`, per-adapter crates, + `.toml` app-config stub, and `edgezero.toml`. The new + `-cli` uses the lib substrate verbatim. + +## 4. End-state public API surface + +Final shape after all seven sub-projects ship: + +```rust +// crates/edgezero-cli/src/lib.rs (feature = "cli") + +// Re-exports of arg structs (all #[non_exhaustive] for forward-compat) +pub use args::{ + AuthArgs, AuthSub, BuildArgs, ConfigPushArgs, ConfigValidateArgs, + DeployArgs, NewArgs, ProvisionArgs, ServeArgs, +}; + +pub fn init_cli_logger(); + +// Built-in commands from the original CLI +pub fn run_build(args: &BuildArgs) -> Result<(), String>; +pub fn run_deploy(args: &DeployArgs) -> Result<(), String>; +pub fn run_new(args: &NewArgs) -> Result<(), String>; +pub fn run_serve(args: &ServeArgs) -> Result<(), String>; +#[cfg(feature = "edgezero-adapter-axum")] +pub fn run_dev() -> !; + +// New commands +pub fn run_auth(args: &AuthArgs) -> Result<(), String>; +pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; + +// Config commands: untyped (default edgezero binary) and typed (downstream) +pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; +pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> +where + C: serde::de::DeserializeOwned + validator::Validate; + +pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; +pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> +where + C: serde::de::DeserializeOwned + validator::Validate + serde::Serialize; +``` + +Internal modules (`adapter`, `generator`, `scaffold`, `dev_server`, +`runner`, `provision`, `auth`, `config`) all stay private. Only the +symbols above are public. + +## 5. End-state file layout + +``` +crates/edgezero-cli/ + Cargo.toml # lib + bin + src/ + lib.rs # public API; declares private modules + main.rs # thin wrapper for the default edgezero bin + args.rs # all pub *Args structs + private Args/Command + adapter.rs # (unchanged, private) + generator.rs # extended: also scaffolds -cli + .toml + scaffold.rs # (unchanged-ish, private) + dev_server.rs # (unchanged, private; feature-gated) + runner.rs # NEW: CommandRunner trait + Real/Mock impls + auth.rs # NEW: auth subcommand impl (uses runner) + provision.rs # NEW: provision impl (uses runner) + config.rs # NEW: validate + push impl (uses runner) + templates/ + core/ # (existing) + root/ # (existing; edgezero.toml.hbs updated) + cli/ # NEW: templates for -cli + Cargo.toml.hbs + src/main.rs.hbs + app/ # NEW: .toml.hbs stub app-config + tests/ + lib_consumer.rs # NEW: external-consumer compile test + +crates/edgezero-core/src/ + app_config.rs # NEW: generic load_app_config(path) + manifest.rs # (unchanged for this effort) + +examples/app-demo/ + Cargo.toml # adds crates/app-demo-cli to members + app-demo.toml # NEW: typed app config + crates/ + app-demo-core/ + src/config.rs # NEW: pub struct AppDemoConfig + src/handlers.rs # one handler reads from config store + app-demo-cli/ # NEW + Cargo.toml + src/main.rs # full Cmd enum: all built-ins + Auth/Provision/Config + tests/help.rs # smoke test + app-demo-adapter-*/ # (unchanged) +``` + +## 6. Cross-cutting designs + +### 6.1 `CommandRunner` trait (sub-project #4 introduces; #5 and #6 reuse) + +```rust +// crates/edgezero-cli/src/runner.rs (private) +pub(crate) trait CommandRunner: Send + Sync { + fn run(&self, program: &str, args: &[&str]) -> std::io::Result; +} + +pub(crate) struct CommandOutput { + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +pub(crate) struct RealCommandRunner; +impl CommandRunner for RealCommandRunner { /* std::process::Command */ } + +#[cfg(test)] +pub(crate) struct MockCommandRunner { /* recorded expectations */ } +``` + +The trait is **private to the crate**. Public command functions +(`run_auth`, `run_provision`, `run_config_push`) use a private +`*_with` inner function: + +```rust +pub fn run_auth(args: &AuthArgs) -> Result<(), String> { + run_auth_with(&RealCommandRunner, args) +} + +fn run_auth_with(runner: &R, args: &AuthArgs) -> Result<(), String> { + // shell out via runner +} + +#[cfg(test)] +mod tests { + fn it_logs_into_cloudflare() { + let mock = MockCommandRunner::expect(&[("wrangler", &["login"])]); + run_auth_with(&mock, &AuthArgs { adapter: "cloudflare".into(), sub: AuthSub::Login }).unwrap(); + } +} +``` + +Public surface stays clean (`run_auth(&args)`); tests bypass to inject +the mock. No public trait, no semver risk on the mock. + +### 6.2 Error model + +All public `run_*` functions return `Result<(), String>`. This matches +the existing pattern in `edgezero-cli` today. Error formatting is the +function's responsibility; callers (binaries) log and exit. + +### 6.3 Feature gates + +- `cli` (default) — gates clap and the whole public API. +- `edgezero-adapter-{axum,fastly,cloudflare,spin}` (default) — gate the + matching adapter dispatch paths in build / deploy / serve / provision / + auth / config push. +- The new `auth`, `provision`, and `config-push` paths do not introduce + new feature flags. They are part of `cli`. Per-adapter logic inside + them is gated on the existing adapter features. + +### 6.4 Generic typed config — why two flavours per `config` command + +The default `edgezero` binary cannot know the user's `AppConfig` type, so +its `config validate` / `config push` operate in a non-typed mode that +only checks TOML syntax and serialises to a flat string map. Downstream +binaries that know their type call the `_typed::` variants and get +full schema validation via `validator::Validate`. + +This is one shared pair of `*Args` structs and two public functions per +command. Not a perfect surface (two names), but the alternative — +type-erasing the schema check via a trait object — costs more in +complexity than the duplication saves. + +### 6.5 Test strategy summary + +- Existing CLI tests move alongside their handlers. +- New tests are added per sub-project for that sub-project's surface. +- Every test that would touch a platform uses `MockCommandRunner`. +- One external-consumer integration test (`tests/lib_consumer.rs`) + exercises the public API as a downstream binary would. +- `examples/app-demo/crates/app-demo-cli/tests/help.rs` smoke-tests the + generated/handwritten downstream pattern. + +--- + +## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton + +**Goal:** establish the substrate. After this ships, downstream projects +can build their own CLI against the lib using only the existing five +built-ins. Default `edgezero` is unchanged for users. + +**Source changes:** + +- `crates/edgezero-cli/src/args.rs` — promote each `Command` variant's + inline fields into a standalone `#[derive(clap::Args)]` struct + (`#[non_exhaustive]`). `NewArgs` already exists. The internal + `Command` enum becomes: + + ```rust + pub enum Command { + Build(BuildArgs), + Deploy(DeployArgs), + Dev, + New(NewArgs), + Serve(ServeArgs), + } + ``` + +- `crates/edgezero-cli/src/lib.rs` (new) — declares the private modules, + moves `init_cli_logger`, `load_manifest_optional`, + `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, + and the five handlers (renamed `handle_*` → `run_*`). +- `crates/edgezero-cli/src/main.rs` — shrinks to ~20 lines, dispatches + to the public `run_*` functions. +- Existing CLI tests move from `main.rs` to `lib.rs`. No assertion + changes. +- **Generator update**: `generator.rs` and `templates/` extended so that + `edgezero new ` also produces: + - `crates/-cli/Cargo.toml` (depends on `edgezero-cli` with + default features + clap + log) + - `crates/-cli/src/main.rs` (uses all five built-ins via the lib + substrate; same shape as the canonical downstream example in §3) + - Root `Cargo.toml.hbs` updated to include `crates/-cli` in + workspace members. + - `templates/cli/` directory created to hold the new Handlebars + templates. + - **No app-config file yet** — `.toml` arrives in sub-project #2. +- `examples/app-demo/crates/app-demo-cli` (new crate, handwritten — + parallel to what the generator will produce): + - Added to `examples/app-demo/Cargo.toml` `members` list. + - `edgezero-cli = { path = "../../../../crates/edgezero-cli" }` added + to that workspace's `[workspace.dependencies]` (mirroring the + existing `edgezero-core` pattern in that file). + - `src/main.rs` mirrors the canonical downstream pattern, all five + built-ins, no custom subcommands yet. + +**Tests:** + +- All existing CLI tests pass after relocation. +- New `crates/edgezero-cli/tests/lib_consumer.rs`: external-consumer + integration test constructing `BuildArgs` and invoking `run_build` + against a temp-dir manifest. +- New `examples/app-demo/crates/app-demo-cli/tests/help.rs`: + `Args::try_parse_from(["app-demo-cli", "--help"])` exits with help + output and no panic. +- New generator test verifies `generate_new("test-app", ...)` produces + `crates/test-app-cli/Cargo.toml` and `src/main.rs` referencing the + right names. + +**CI:** all four existing gates (`fmt`, `clippy -D warnings`, +`cargo test`, feature `cargo check`). Spin wasm32 gate unaffected. + +**Ship gate:** `edgezero --help` output identical; `app-demo-cli --help` +prints the five built-ins; `edgezero new throwaway-app && cd +throwaway-app && cargo check --workspace` succeeds. + +## 8. Sub-project 2 — App-config schema and generic loader + +**Goal:** define the file format for per-service app config and the +generic loader the CLI uses. + +**Source changes:** + +- `crates/edgezero-core/src/app_config.rs` (new): + + ```rust + use serde::de::DeserializeOwned; + use validator::Validate; + + #[derive(Debug)] + pub struct AppConfigError(String); + impl std::fmt::Display for AppConfigError { /* ... */ } + impl std::error::Error for AppConfigError {} + + pub fn load_app_config(path: &std::path::Path) -> Result + where + C: DeserializeOwned + Validate, + { + // 1. Read file. + // 2. Parse TOML into a wrapper { config: C }. + // + // File shape: + // + // [config] + // key = "value" + // ... + // + // 3. Run C::validate(). + // 4. Return C. + } + + // For the non-typed (default-binary) path: + pub fn load_app_config_raw(path: &std::path::Path) + -> Result, AppConfigError>; + ``` + + `app_config` is `pub use`d from `edgezero-core`'s `lib.rs`. No new + workspace deps (serde, validator, toml are already there). + +- `crates/edgezero-cli/src/templates/app/.toml.hbs` (new): + + ```toml + # {{name}} app runtime config. + # Values are pushed to the active config store via `edgezero config push`. + # Service code reads them at runtime via the config store binding. + + [config] + greeting = "hello from {{name}}" + ``` + + Generator emits this as `.toml` at the project root. + +- `examples/app-demo/app-demo.toml` (new, handwritten parallel): + + ```toml + [config] + greeting = "hello from app-demo" + timeout_ms = 1500 + feature_new_checkout = false + ``` + +- `examples/app-demo/crates/app-demo-core/src/config.rs` (new): + + ```rust + use serde::{Deserialize, Serialize}; + use validator::Validate; + + #[derive(Debug, Deserialize, Serialize, Validate)] + pub struct AppDemoConfig { + #[validate(length(min = 1))] + pub greeting: String, + #[validate(range(min = 100, max = 60000))] + pub timeout_ms: u32, + pub feature_new_checkout: bool, + } + ``` + +- Generator emits a `-core/src/config.rs` stub mirroring the + pattern (struct named `Config`). + +**Tests:** + +- Unit tests for `load_app_config`: valid file, missing file, bad TOML, + validator failure, missing `[config]` table. +- Round-trip test in `app-demo-core` that the example `app-demo.toml` + parses into `AppDemoConfig` and passes validation. + +**Ship gate:** +`edgezero_core::app_config::load_app_config::(Path::new("examples/app-demo/app-demo.toml"))` +succeeds in a test. + +## 9. Sub-project 3 — `config validate` command + +**Goal:** lint the project's TOML files locally with zero platform calls. + +**Public API additions:** + +```rust +pub use args::ConfigValidateArgs; +pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; +pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> +where C: DeserializeOwned + Validate; +``` + +`ConfigValidateArgs`: + +```rust +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct ConfigValidateArgs { + #[arg(long, default_value = "edgezero.toml")] + pub manifest: PathBuf, + /// .toml; auto-detected from [app].name if None. + #[arg(long)] + pub app_config: Option, + /// Also check cross-references (handlers, adapter consistency). + #[arg(long)] + pub strict: bool, +} +``` + +**Validation steps (in order):** + +1. Parse `edgezero.toml` via existing `ManifestLoader`. Report TOML + syntax errors with file/line. +2. If an app-config file is provided or auto-detected, parse it: + - Non-typed path: `load_app_config_raw` — confirms structure. + - Typed path: `load_app_config::` — also runs `Validate`. +3. If `--strict`: cross-check that every adapter referenced in + `[adapters.*]` has a matching `[stores.*.adapters.*]` if it overrides + bindings, every handler path in `[[triggers.http]]` is well-formed, + etc. (Concrete checks listed in the implementation plan.) + +**Output:** human-readable diagnostics; exits 0 on success, 1 on failure. + +**Tests:** + +- Valid manifest passes. +- Each kind of failure (syntax, schema, validator failure, missing + cross-reference) produces a distinct error message. +- Typed and non-typed paths covered. +- `app-demo-cli config validate` is the canonical typed integration test. + +**Ship gate:** `app-demo-cli config validate` exits 0 against the +example workspace; deliberately corrupted fixtures fail. + +## 10. Sub-project 4 — `auth` command + +**Goal:** delegate per-adapter authentication to the native tool. No +edgezero-stored credentials. + +**Public API additions:** + +```rust +pub use args::{AuthArgs, AuthSub}; +pub fn run_auth(args: &AuthArgs) -> Result<(), String>; +``` + +```rust +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct AuthArgs { + #[arg(long)] + pub adapter: String, // axum | cloudflare | fastly | spin + #[command(subcommand)] + pub sub: AuthSub, +} + +#[derive(clap::Subcommand, Debug)] +pub enum AuthSub { + Login, + Logout, + Status, +} +``` + +**Per-adapter behaviour:** + +| Adapter | Login | Logout | Status | +|------------|-------------------------|-------------------------|-----------------------| +| axum | no-op (log message) | no-op | always "ok" | +| cloudflare | `wrangler login` | `wrangler logout` | `wrangler whoami` | +| fastly | `fastly profile create` | `fastly profile delete` | `fastly profile list` | +| spin | `spin cloud login` | `spin cloud logout` | `spin cloud info` | + +All invocations go through `CommandRunner`. This sub-project introduces +the `runner` module (`runner.rs`). + +**Tests:** + +- For each (adapter, sub) pair: `MockCommandRunner` expectation. The + mock records the exact program and args; the test asserts them. +- Error cases: tool not found (program returns ENOENT), tool returns + non-zero exit. + +**Ship gate:** with the mock runner, `run_auth` produces the exact +expected subprocess call for every (adapter, sub) pair. + +## 11. Sub-project 5 — `provision` command + +**Goal:** create the underlying platform resources (KV namespace, secret +store, config store) declared in `[stores.*]` of `edgezero.toml`. + +**Public API additions:** + +```rust +pub use args::ProvisionArgs; +pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; +``` + +```rust +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct ProvisionArgs { + #[arg(long)] + pub adapter: String, + #[arg(long)] + pub dry_run: bool, +} +``` + +**Behaviour:** + +For the named adapter, iterate over `[stores.kv]`, `[stores.secrets]`, +`[stores.config]` in the manifest. For each enabled store, shell out to +create the resource: + +| Adapter | KV | Secrets | Config | +|------------|-----------------------------------|---------------------------------------|-----------------------------------| +| axum | no-op (local; env-backed) | no-op | no-op | +| cloudflare | `wrangler kv:namespace create N` | (no-op; wrangler-managed at runtime) | `wrangler kv:namespace create N` | +| fastly | `fastly kv-store create --name N` | `fastly secret-store create --name N` | `fastly config-store create --name N` | +| spin | (Spin auto-creates KV at deploy) | (Spin variables file) | (Spin variables file) | + +`--dry-run` prints the would-be commands without running them. + +**Write-back to per-adapter manifests:** when Cloudflare creates a KV +namespace, the resulting ID must land in `wrangler.toml` so deploys can +bind it. The implementation parses the tool's stdout, extracts the ID, +and patches the per-adapter manifest declared in +`[adapters..adapter] manifest = "..."`. This is a documented +side-effect of `provision`. + +**Tests:** + +- For each (adapter, store-kind) tuple, `MockCommandRunner` expectation. +- Manifest write-back tested with a temp-dir fixture: provision runs, + then the per-adapter manifest is re-read and contains the new ID. +- `--dry-run` produces output but does not invoke the runner. + +**Ship gate:** `app-demo-cli provision --adapter cloudflare --dry-run` +prints the expected three `wrangler` invocations. + +## 12. Sub-project 6 — `config push` command + +**Goal:** upload `.toml`'s `[config]` values to the live config +store on a given adapter. + +**Public API additions:** + +```rust +pub use args::ConfigPushArgs; +pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; +pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> +where C: DeserializeOwned + Validate + Serialize; +``` + +```rust +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct ConfigPushArgs { + #[arg(long)] + pub adapter: String, + /// Auto-detect .toml from [app].name if None. + #[arg(long)] + pub app_config: Option, + #[arg(long)] + pub dry_run: bool, +} +``` + +**Behaviour:** + +1. Load app-config (raw map or typed struct). +2. Serialise each top-level field to a string: + - `String` → as-is. + - `bool` / numbers → `to_string()`. + - Compound types (only via the typed path) → JSON-encoded. +3. Shell out to the platform tool for bulk upload: + +| Adapter | Push | +|------------|----------------------------------------------------------------------------| +| axum | Write to `.edgezero/local-config.env` (gitignored). | +| cloudflare | `wrangler kv:bulk put ` | +| fastly | Iterate: `fastly config-store-entry create --store-id … --key … --value …` | +| spin | Write to the Spin variables file referenced in the spin manifest. | + +Typed variant also runs `Validate` before pushing (refuses to upload +invalid config). + +**Tests:** + +- Typed and non-typed paths. +- For each adapter, `MockCommandRunner` expectations including the + exact serialised payload. +- `--dry-run` prints the serialised payload and would-be commands; does + not invoke the runner. + +**Ship gate:** `app-demo-cli config push --adapter cloudflare --dry-run` +shows the expected `wrangler kv:bulk put` invocation with the JSON +payload derived from `app-demo.toml`. + +## 13. Sub-project 7 — `app-demo` integration polish + +**Goal:** prove the full system works end-to-end via the example. + +**Source changes (all in `examples/app-demo/`):** + +- `crates/app-demo-cli/src/main.rs`: extend `Cmd` enum to include the + new variants: + + ```rust + #[derive(Subcommand)] + enum Cmd { + // Built-ins (same as sub-project #1): + Build(BuildArgs), Deploy(DeployArgs), Dev, New(NewArgs), Serve(ServeArgs), + // New commands: + Auth(AuthArgs), + Provision(ProvisionArgs), + #[command(subcommand)] + Config(ConfigCmd), + } + + #[derive(Subcommand)] + enum ConfigCmd { + Validate(ConfigValidateArgs), + Push(ConfigPushArgs), + } + ``` + + Dispatch for `Config::Validate` and `Config::Push` calls the **typed** + variants with `AppDemoConfig` as the type parameter. + +- `crates/app-demo-core/src/handlers.rs`: extend one existing handler + (e.g. `config_get`) so it reads a key via the config store binding. + Already partly there — confirm the integration after `config push` + pushes real data to a local axum store. + +- Documentation: a new `docs/cli/walkthrough.md` page showing the full + loop: + + 1. `edgezero new myapp` + 2. `cd myapp && cargo build` + 3. `myapp-cli auth login --adapter cloudflare` + 4. `myapp-cli provision --adapter cloudflare` + 5. `myapp-cli config validate` + 6. `myapp-cli config push --adapter cloudflare` + 7. `myapp-cli deploy --adapter cloudflare` + 8. `curl https://myapp.example/config/greeting` + +**Tests:** + +- `app-demo-cli config validate` exits 0 against `app-demo.toml`. +- `app-demo-cli config push --adapter axum` writes a local-config file; + the running axum dev server reads `greeting` from the config store + and returns it on `/config/greeting`. +- The `--help` smoke test from sub-project #1 is extended to assert all + subcommands are listed. + +**Ship gate:** end-to-end demo of the full loop in CI, using +`--adapter axum` and the local file-backed config store. No live +external calls; the `axum` adapter is the substrate for verifying real +push-then-read behaviour. + +--- + +## 14. Implementation order and milestones + +Each sub-project ships as one PR. Order is the §7–§13 order. Each PR +must keep all four CI gates green; no skipping (`-D warnings` stays). + +| # | Title | Net new public symbols | Risk | +|---|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|------| +| 1 | Extensible lib + scaffold | `BuildArgs`, `DeployArgs`, `NewArgs`, `ServeArgs`, `run_build`, `run_deploy`, `run_new`, `run_serve`, `run_dev`, `init_cli_logger` | M | +| 2 | App-config schema | `edgezero_core::app_config::{load_app_config, load_app_config_raw, AppConfigError}` | L | +| 3 | `config validate` | `ConfigValidateArgs`, `run_config_validate`, `run_config_validate_typed` | L | +| 4 | `auth` | `AuthArgs`, `AuthSub`, `run_auth` | M | +| 5 | `provision` | `ProvisionArgs`, `run_provision` | H | +| 6 | `config push` | `ConfigPushArgs`, `run_config_push`, `run_config_push_typed` | M | +| 7 | `app-demo` polish + walk-through | (none) — uses everything above | L | + +**Risk notes:** + +- Sub-project #1 is the substrate; getting the `*Args` shape wrong here + forces churn later. Mitigated by `#[non_exhaustive]` on every Args + struct and the external-consumer integration test. +- Sub-project #5 (`provision`) is the highest risk: it both shells out + and writes back to per-adapter manifest files. We constrain blast + radius by treating manifest write-back as a separate step with its + own tests and by supporting `--dry-run`. + +## 15. Risks and trade-offs + +- **API stability:** every public `*Args` struct is `#[non_exhaustive]` + so adding fields is non-breaking. New `run_*` functions are additive. + The `_typed::` / non-typed split adds two names per `config` + command, which is the deliberate trade — see §6.4. +- **Shell-out fragility:** the platform tools' CLI surface can change + between versions. We pin no specific tool version; we just report a + clear error when the tool is missing or fails. Tool versions are + already pinned via the project's `.tool-versions` for the supported + combinations. +- **Generator drift:** the generator produces a `-cli` whose + shape must stay in sync with the canonical pattern used by + `app-demo-cli`. Sub-project #1 introduces a generator test that + compares structural expectations (file existence + key tokens). +- **`provision` manifest write-back:** parsing tool stdout to extract + resource IDs is brittle. Mitigation: each tool's parser is its own + isolated function with golden-file tests over recorded sample + outputs. +- **Multi-environment app-config:** explicitly out of scope (§2). When + needed, a follow-up spec will add `[config.]` support and a + `--env` flag on `config push`/`validate`. +- **Test relocation in sub-project #1:** ~10 tests move from `main.rs` + to `lib.rs`. Diff looks large but is mechanical; reviewers will be + warned in the PR description. + +## 16. What this spec does not cover + +- Anthropic credentials, edge-network DNS / TLS, observability / + metrics: separate concerns. +- Per-environment config (`production` vs. `staging`): explicit follow-up. +- Replacing or restructuring existing handlers in `app-demo-core` beyond + the single one that demonstrates push-then-read. +- Any change to `edgezero-core` beyond adding the `app_config` module. + +When all seven sub-projects ship, the system supports: + +- `edgezero new myapp` produces a workspace ready to build with + `myapp-cli`, a typed `MyappConfig`, and a `myapp.toml`. +- The developer logs into their platforms (`myapp-cli auth login + --adapter X`), provisions stores (`myapp-cli provision --adapter X`), + validates and pushes their app config (`myapp-cli config validate && + myapp-cli config push --adapter X`), and deploys (`myapp-cli deploy + --adapter X`). +- At runtime, the deployed service reads its config from the platform + config store via the existing edgezero store binding. +- The default `edgezero` binary keeps working unchanged for everyone + who is not building their own CLI. diff --git a/docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md b/docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md deleted file mode 100644 index 720aa83..0000000 --- a/docs/superpowers/specs/2026-05-19-extensible-cli-library-design.md +++ /dev/null @@ -1,333 +0,0 @@ -# Extensible `edgezero-cli` Library (sub-project #1) - -**Date:** 2026-05-19 -**Status:** Approved design, pending implementation plan -**Roadmap position:** Sub-project 1 of 7 in the CLI extensions effort -(extensible lib + `app-demo-cli` skeleton → app-config schema → `config -validate` → `auth` → `provision` → `config push` → `app-demo` integration -polish). This spec covers sub-project #1 only. `app-demo` is updated -incrementally across all seven sub-projects, not backloaded to the end. - -## Goal - -Let downstream projects build their own CLI binary that: - -- Reuses any subset of edgezero's built-in commands (today: `build`, `deploy`, - `dev`, `new`, `serve`). -- Adds their own subcommands. -- Owns the binary name, `about` text, and top-level help. - -The default `edgezero` binary keeps working unchanged for users who do not -build their own CLI. - -Ship `app-demo-cli` in the same sub-project as the canonical downstream -consumer. It uses every built-in verbatim today (no custom subcommands -yet) and becomes the staging ground each later sub-project extends. - -## Non-goals - -- No runtime command registry (`inventory` / `linkme`-style). -- No cargo-style external subcommand discovery on PATH. -- No re-exposing internal modules (`adapter`, `generator`, `scaffold`, - `dev_server`) — only high-level `run_*` entry points and per-command - `*Args` structs. -- No renaming or hiding individual built-ins via a library API — opt-out - happens by omission in the downstream `Subcommand` enum. -- No new commands (`auth`, `provision`, `config`). Those are sub-projects - 3–6 and will add their own `*Args` + `run_*` pairs once this substrate - ships. - -## Approach - -Use clap-derive composition. `edgezero-cli` becomes lib + bin in one crate: - -- New `crates/edgezero-cli/src/lib.rs` — public API surface. -- Existing `crates/edgezero-cli/src/main.rs` — rewritten as a thin wrapper - that depends only on the public API. - -The library exposes one `*Args` struct per built-in command plus one -`run_*` function per command. Downstream projects compose their own -`#[derive(Subcommand)]` enum that mixes edgezero variants with their own, -and write a small `main` that dispatches each variant. - -## Public API surface - -```rust -// crates/edgezero-cli/src/lib.rs (feature = "cli") - -pub use args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; - -pub fn init_cli_logger(); - -pub fn run_build(args: &BuildArgs) -> Result<(), String>; -pub fn run_deploy(args: &DeployArgs) -> Result<(), String>; -pub fn run_new(args: &NewArgs) -> Result<(), String>; -pub fn run_serve(args: &ServeArgs) -> Result<(), String>; - -#[cfg(feature = "edgezero-adapter-axum")] -pub fn run_dev() -> !; -``` - -Everything else (`adapter`, `generator`, `scaffold`, `dev_server` modules; -`load_manifest_optional`, `ensure_adapter_defined`, `store_bindings_message`) -stays private to the crate. - -### Pattern for adding future built-ins (informational) - -When sub-projects 3–6 add their commands, each one follows the same -two-symbol pattern: - -```rust -pub use args::AuthArgs; -pub fn run_auth(args: &AuthArgs) -> Result<(), String>; -``` - -This pattern is established here; later specs will not need to re-justify -the shape. - -## Downstream usage (canonical example) - -```rust -// myapp-cli/src/main.rs -use clap::{Parser, Subcommand}; -use edgezero_cli::{BuildArgs, DeployArgs, ServeArgs}; - -#[derive(Parser)] -#[command(name = "myapp", about = "MyApp edge CLI")] -struct Args { - #[command(subcommand)] - cmd: Cmd, -} - -#[derive(Subcommand)] -enum Cmd { - Build(BuildArgs), - Deploy(DeployArgs), - Serve(ServeArgs), - // Opt out of `new` and `dev`: simply not listed. - Migrate(MigrateArgs), // downstream's own - Seed, -} - -fn main() { - edgezero_cli::init_cli_logger(); - let result = match Args::parse().cmd { - Cmd::Build(a) => edgezero_cli::run_build(&a), - Cmd::Deploy(a) => edgezero_cli::run_deploy(&a), - Cmd::Serve(a) => edgezero_cli::run_serve(&a), - Cmd::Migrate(a) => run_migrate(a), - Cmd::Seed => run_seed(), - }; - if let Err(err) = result { - log::error!("[myapp] {err}"); - std::process::exit(1); - } -} -``` - -Opt-in is "add the variant"; opt-out is "don't". No machinery beyond clap. - -## Source layout changes - -1. **`crates/edgezero-cli/src/args.rs`** — promote each `Command` variant's - inline fields into a standalone `#[derive(clap::Args)]` struct. `NewArgs` - already exists. The internal `Command` enum (used only by the default - `edgezero` binary) becomes: - - ```rust - #[derive(Subcommand, Debug)] - pub enum Command { - Build(BuildArgs), - Deploy(DeployArgs), - Dev, - New(NewArgs), - Serve(ServeArgs), - } - ``` - - The four new public structs each carry exactly the fields the variant - currently inlines (`adapter`, `adapter_args`, etc.). No new fields. - -2. **`crates/edgezero-cli/src/lib.rs` (new)** — declares the private - `adapter`, `generator`, `scaffold`, and (feature-gated) `dev_server` - modules. Moves `init_cli_logger`, `load_manifest_optional`, - `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, - and the five handlers (renamed `handle_*` → `run_*`) into this file. - -3. **`crates/edgezero-cli/src/main.rs`** — shrinks to roughly: - - ```rust - use clap::Parser as _; - use edgezero_cli::{run_build, run_deploy, run_new, run_serve}; - - fn main() { - edgezero_cli::init_cli_logger(); - let args = edgezero_cli::Args::parse(); - let result = match args.cmd { - edgezero_cli::Command::Build(a) => run_build(&a), - edgezero_cli::Command::Deploy(a) => run_deploy(&a), - edgezero_cli::Command::New(a) => run_new(&a), - edgezero_cli::Command::Serve(a) => run_serve(&a), - edgezero_cli::Command::Dev => edgezero_cli::run_dev(), - }; - if let Err(err) = result { - log::error!("[edgezero] {err}"); - std::process::exit(1); - } - } - ``` - - `Args` and `Command` are re-exported from `lib.rs` only so the default - binary can build against the public API. - -4. **Existing tests** — move from `main.rs` to `lib.rs` (they test what are - now public functions). Assertions are unchanged. - -5. **`examples/app-demo/crates/app-demo-cli` (new crate)** — added as a - member of the `examples/app-demo` workspace. That workspace is - excluded from the root `Cargo.toml` workspace and stays that way. - Layout: - - ``` - examples/app-demo/crates/app-demo-cli/ - Cargo.toml - src/main.rs - ``` - - Wiring: - - - Add `"crates/app-demo-cli"` to `members` in - `examples/app-demo/Cargo.toml`. - - Add `edgezero-cli = { path = "../../../../crates/edgezero-cli" }` to - the example workspace's `[workspace.dependencies]` (mirroring the - existing pattern for `edgezero-core` at line 23 of that file). - - The new crate's `Cargo.toml` declares: - - `name = "app-demo-cli"` (package and default binary name match; - no `[[bin]]` section needed) - - `edgezero-cli = { workspace = true }` with the default feature set - - `clap = { version = "4", features = ["derive"] }` - - `log = { workspace = true }` - - `publish = false`, `[lints] workspace = true` to match siblings. - - `src/main.rs` implements the canonical downstream pattern from the - "Downstream usage" section above, with **all five built-ins included - verbatim and no custom subcommands yet**: - - ```rust - use clap::{Parser, Subcommand}; - use edgezero_cli::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; - - #[derive(Parser)] - #[command(name = "app-demo-cli", about = "app-demo edge CLI")] - struct Args { #[command(subcommand)] cmd: Cmd } - - #[derive(Subcommand)] - enum Cmd { - Build(BuildArgs), - Deploy(DeployArgs), - Dev, - New(NewArgs), - Serve(ServeArgs), - } - - fn main() { - edgezero_cli::init_cli_logger(); - let result = match Args::parse().cmd { - Cmd::Build(a) => edgezero_cli::run_build(&a), - Cmd::Deploy(a) => edgezero_cli::run_deploy(&a), - Cmd::Dev => edgezero_cli::run_dev(), - Cmd::New(a) => edgezero_cli::run_new(&a), - Cmd::Serve(a) => edgezero_cli::run_serve(&a), - }; - if let Err(err) = result { - log::error!("[app-demo-cli] {err}"); - std::process::exit(1); - } - } - ``` - - No changes to existing `app-demo` crates, `edgezero.toml`, or routes. - `app-demo-cli` is purely additive: it gives the example workspace a - binary that exercises the new lib end-to-end. Later sub-projects add - custom variants (`Auth`, `Provision`, `Config`) to this same `Cmd` - enum and the matching `app-demo.toml` plumbing. - -## Cargo manifest changes - -- `crates/edgezero-cli/Cargo.toml`: the crate already builds an implicit - binary from `src/main.rs`; adding `src/lib.rs` makes it lib + bin - automatically. No explicit `[lib]` or `[[bin]]` section needed. -- The `cli` feature continues to gate clap. All public API lives under - `#[cfg(feature = "cli")]`. `cli` remains in the `default` feature set so - normal consumers are unaffected. -- Adapter feature gates carry over: `run_dev` requires - `edgezero-adapter-axum`. `run_build`, `run_deploy`, and `run_serve` - dispatch by adapter name at runtime and surface a clear error if the - named adapter's feature is disabled (current behavior preserved). - -## Tests - -- **Move existing tests:** every `#[test]` currently in `main.rs` moves to - `lib.rs`. No behavior change. -- **New integration test:** `crates/edgezero-cli/tests/lib_consumer.rs`. - Imports `edgezero_cli` as an external consumer would, constructs - `BuildArgs` programmatically, and invokes `run_build` against a temp-dir - manifest (mirroring the existing `handle_build_executes_manifest_command` - test). This proves the public API actually compiles from outside the - crate root and produces the same result. -- **`app-demo-cli` build smoke test:** the example workspace must - successfully compile the new binary. The implementation plan will - identify the existing CI step or script that validates the - `examples/app-demo` workspace and extend it to run `cargo build -p - app-demo-cli` (or `cargo build --workspace` from inside the example - workspace, if that's what's already in use). A minimal `--help` - invocation test in - `examples/app-demo/crates/app-demo-cli/tests/help.rs` confirms the - binary parses its CLI without panicking. -- All four CI gates (`fmt`, `clippy -D warnings`, `cargo test`, feature - `cargo check`) must pass. The wasm32 spin gate is unaffected by this - change (no adapter crate touched). - -## Documentation - -- New page at `docs/cli/extending.md` (linked from the docs sidebar) showing - the canonical downstream example, the list of public `*Args` / `run_*` - symbols, and which Cargo features to enable. -- `CLAUDE.md` workspace-layout section gets one sentence noting - `edgezero-cli` is lib + bin. - -## Risks and trade-offs - -- **API stability:** promoting the four arg structs to public surface means - future field additions become semver-affecting. Mitigation: every - `*Args` struct gets `#[non_exhaustive]` so we can add fields without a - breaking change. New constructors are not needed — clap derive is the - intended construction path. -- **Test relocation churn:** moving ~10 tests from `main.rs` to `lib.rs` is - mechanical but touches a familiar file. Reviewers will see a large diff - with no behavior change; PR description must call this out. -- **Adapter-feature coupling:** `run_dev` being gated on - `edgezero-adapter-axum` means a downstream that disables that feature - loses access to the symbol entirely. This is the same constraint the - current `edgezero dev` command has; we're not making it worse, just - exposing it through the type system. - -## What this spec does NOT cover - -- The new commands (`auth`, `provision`, `config validate`, `config push`) - — each gets its own spec. Those specs will add new public `*Args` / - `run_*` symbols to `edgezero-cli` and new variants to `app-demo-cli`'s - `Cmd` enum, without modifying the substrate established here. -- The new `app-demo.toml` schema and loader — separate spec. -- Any change to existing `app-demo` crates (`app-demo-core`, - `app-demo-adapter-*`), to `edgezero.toml`, or to routes. - -When sub-project #1 ships: - -- The default `edgezero` binary still works exactly as before. -- An unrelated downstream project can already build its own CLI against - `edgezero-cli` as a library. -- `app-demo-cli` exists as the canonical consumer and is wired into the - example workspace. - -Sub-projects 2–7 extend this substrate; they do not modify it. From f1fdbfe42ce43ee896d1a05c9fa2675297293639 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 16:41:00 -0700 Subject: [PATCH 03/38] Apply review feedback and add secret annotation to CLI extensions spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-severity fixes: - Add --manifest to ProvisionArgs and ConfigPushArgs (matches validate) - Update Wrangler invocations to 3.60+ syntax (space-form, --namespace-id) - Persist provisioned IDs in edgezero.toml [stores.*.adapters.].id; cross-write to per-adapter manifests where deploys need them - Mermaid diagram in §3 replacing ASCII art Medium-severity fixes: - config push runs strict validation as pre-flight (no separate flag) - Move --adapter to each AuthSub variant so UX is `auth login --adapter X` - Constrain typed config push to serde_json::to_value(C) -> Object; document flatten / rename / skip / Option::None handling - Unify raw + typed serialization rules; raw drops Validate + secret skip - Replace CommandRunner positional args with CommandSpec struct (program, args, cwd, stdin, env) - "Backwards-compatible" language replacing "unchanged" for default bin - Move walkthrough doc to docs/guide/ with explicit sidebar update Low + open questions: - Document consumer-facing Cargo feature names and adapter opt-outs - Generator migration note: sub-project 1 outputs don't auto-migrate - Deprecate [stores.config.defaults] in favor of .toml [config] - Mark Spin provision / config push as "not yet supported" with pointer to the in-flight Spin stores PR; clear error message until then Secret annotation: - New §6.6 documenting #[derive(AppConfig)] from edgezero-macros - #[secret] field attribute marks runtime-secret-store-backed fields - Toml value for those fields is the secret-store binding name - config validate (typed) cross-checks the binding appears in [stores.secrets] - config push (typed) skips SECRET_FIELDS entirely The implementation still ships in 7 incremental PRs. --- .../specs/2026-05-19-cli-extensions-design.md | 769 +++++++++++++----- 1 file changed, 553 insertions(+), 216 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index b47ba31..c0466c2 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -5,10 +5,11 @@ **Branch:** `docs/extensible-cli-library-spec` This single spec covers the full effort: turning `edgezero-cli` into an -extensible library, defining a per-service app-config file, adding four -new commands (`auth`, `provision`, `config validate`, `config push`), -extending the project generator to scaffold the new pieces, and updating -`app-demo` to exercise everything end-to-end. +extensible library, defining a per-service app-config file with a typed +Rust schema and `#[secret]` field annotations, adding four new commands +(`auth`, `provision`, `config validate`, `config push`), extending the +project generator to scaffold the new pieces, and updating `app-demo` to +exercise everything end-to-end. The work is organised into seven sub-projects so it can ship in seven incremental PRs, but the design decisions live here together so reviewers @@ -32,21 +33,24 @@ Alongside the extensibility substrate, ship: - A typed per-service app-config file (e.g. `myapp.toml`) whose schema is defined by the downstream app as a Rust struct, validated at lint time by `config validate`, and uploaded to the platform config store by - `config push`. + `config push`. Fields annotated `#[secret]` in the struct are recognised + by the CLI: they are skipped during push (their values live in the + secret store) and their bindings are cross-checked during validate. - Platform credential and resource management (`auth`, `provision`) that shells out to each platform's official CLI tool, with all shell-out - calls wrapped in a mockable `CommandRunner` trait so CI can stay - hermetic. + calls wrapped in a mockable `CommandRunner` trait so CI stays hermetic. - A generator that scaffolds a new project complete with its own `-cli` crate (using the lib substrate) and a stub `.toml` app-config file. - An `app-demo` overhaul that demonstrates the finished system: - `app-demo.toml` with typed `AppDemoConfig`, `app-demo-cli` exposing - every built-in plus the new commands, and one `app-demo-core` handler - that reads a config value from the config store at runtime (proving - the push-then-read flow). + `app-demo.toml` with typed `AppDemoConfig` (including a `#[secret]` + field), `app-demo-cli` exposing every built-in plus the new commands, + and one `app-demo-core` handler that reads a config value from the + config store at runtime (proving the push-then-read flow). -The default `edgezero` binary keeps working unchanged. +The default `edgezero` binary remains backwards-compatible: every existing +subcommand keeps the same name, flags, and behaviour. New subcommands +(`auth`, `provision`, `config`) become additionally available. ## 2. Non-goals @@ -64,39 +68,36 @@ The default `edgezero` binary keeps working unchanged. - No `app-demo` overhaul beyond what is needed to demonstrate the new features. Existing handlers, the `app!` macro, and the manifest schema stay as they are except for the additive changes called out - below. + below (notably extending `[stores.*.adapters.]` to carry + provisioned IDs, and removing the deprecated `[stores.config.defaults]`). +- No Spin-side implementation of `provision` or `config push` in this + effort. A separate in-flight PR adds Spin support for the + `[stores.*]` schema; once that lands, the CLI's Spin path will be a + small follow-up because it uses the same manifest schema. Until then, + `--adapter spin` for these two commands logs a clear "not yet + supported" message and exits non-zero. ## 3. Architecture overview -``` - ┌─────────────────────────────┐ - │ edgezero-cli (lib) │ - │ ───────────────────────── │ - │ pub *Args + pub run_* │ - │ internal: CommandRunner │ - │ internal: adapter/gen/... │ - └────────────┬────────────────┘ - │ used by - ┌─────────────────────┼──────────────────────┐ - │ │ │ -┌──────┴───────┐ ┌────────┴─────────┐ ┌────────┴────────┐ -│ edgezero │ │ app-demo-cli │ │ myapp-cli │ -│ (bin) │ │ (example) │ │ (downstream) │ -│ │ │ │ │ │ -│ default │ │ all built-ins + │ │ subset of │ -│ binary; │ │ Auth/Provision/ │ │ built-ins + │ -│ all built- │ │ Config typed on │ │ custom typed │ -│ ins; no app │ │ AppDemoConfig │ │ AppConfig │ -│ struct │ │ │ │ │ -└──────────────┘ └─────────┬────────┘ └─────────────────┘ - │ - ┌───────────┴────────────┐ - │ app-demo-core │ - │ pub struct │ - │ AppDemoConfig: │ - │ Deserialize + │ - │ Validate │ - └────────────────────────┘ +```mermaid +graph TB + Lib["edgezero-cli (lib)
pub *Args + pub run_*
internal: CommandRunner
internal: adapter / generator / ..."] + + Macros["edgezero-macros
#[derive(AppConfig)]
#[secret] field attr"] + + Core["edgezero-core
app_config::AppConfigMeta
app_config::load_app_config<C>"] + + Lib --> EZ["edgezero (default bin)
all built-ins
no app struct"] + Lib --> ADC["app-demo-cli (example)
all built-ins +
Auth/Provision/Config
typed on AppDemoConfig"] + Lib --> MAC["myapp-cli (downstream)
subset of built-ins +
custom typed AppConfig"] + + ADC --> ADCore["app-demo-core
#[derive(AppConfig)]
pub struct AppDemoConfig
fields can be #[secret]"] + MAC --> MACore["myapp-core
#[derive(AppConfig)]
pub struct MyappConfig"] + + Macros -.emits AppConfigMeta impl.-> ADCore + Macros -.emits AppConfigMeta impl.-> MACore + Core -.AppConfigMeta trait.-> ADCore + Core -.AppConfigMeta trait.-> MACore ``` Key contracts: @@ -104,20 +105,33 @@ Key contracts: - **Substrate**: each built-in command is a `(pub *Args, pub run_*)` pair in `edgezero-cli`. Downstream `Subcommand` enums opt in by listing the variants they want. Opt-out is omission. -- **Typed app-config**: downstream defines a `#[derive(Deserialize, - Validate)]` struct; downstream CLI passes that type as a generic - parameter to `run_config_validate_typed::` and - `run_config_push_typed::`. The non-typed `run_config_validate` / - `run_config_push` are also exposed for the default `edgezero` binary - (which validates only TOML syntax and the `edgezero.toml` schema). +- **Typed app-config + secrets**: downstream defines a struct with + `#[derive(Deserialize, Validate, AppConfig)]`. Fields the runtime + should read from the secret store are annotated `#[secret]`; their + value in the toml file is the **secret binding name** (a string). + The `AppConfig` derive (from `edgezero-macros`) emits an + `impl AppConfigMeta for MyConfig` that exposes + `SECRET_FIELDS: &'static [&'static str]`. Downstream CLIs call the + generic `run_config_validate_typed::` and `run_config_push_typed::` + bound on `C: DeserializeOwned + Validate + Serialize + AppConfigMeta`. - **Shell-out isolation**: every subprocess call goes through a private - `CommandRunner` trait. Tests inject a `MockCommandRunner` that records + `CommandRunner` trait that takes a `CommandSpec` (program, args, cwd, + stdin, env). Tests inject a `MockCommandRunner` that records invocations and returns scripted outputs. CI never touches a real platform. +- **Provisioned IDs**: when `provision` creates a platform resource, the + resulting ID is written back to + `[stores..adapters.] id = "..."` in `edgezero.toml`. + This is the canonical source for `config push` and other commands. + Where the platform's own manifest also needs the ID (e.g. + `wrangler.toml [[kv_namespaces]] id = "..."`), `provision` writes + that too so deploys work, but `edgezero.toml` is the single source + the CLI reads from. - **Generator**: `edgezero new ` produces a workspace with - `crates/-core`, `crates/-cli`, per-adapter crates, - `.toml` app-config stub, and `edgezero.toml`. The new - `-cli` uses the lib substrate verbatim. + `crates/-core` (using `#[derive(AppConfig)]`), + `crates/-cli`, per-adapter crates, `.toml` app-config + stub, and `edgezero.toml`. The new `-cli` uses the lib + substrate verbatim. ## 4. End-state public API surface @@ -146,21 +160,49 @@ pub fn run_dev() -> !; pub fn run_auth(args: &AuthArgs) -> Result<(), String>; pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; -// Config commands: untyped (default edgezero binary) and typed (downstream) +// Config commands: untyped (default edgezero binary) and typed (downstream). +// Both bounds include AppConfigMeta so secret-field handling is uniform. pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> where - C: serde::de::DeserializeOwned + validator::Validate; + C: serde::de::DeserializeOwned + validator::Validate + + ::edgezero_core::app_config::AppConfigMeta; pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> where - C: serde::de::DeserializeOwned + validator::Validate + serde::Serialize; + C: serde::de::DeserializeOwned + validator::Validate + serde::Serialize + + ::edgezero_core::app_config::AppConfigMeta; +``` + +Public API from `edgezero-core` (additive): + +```rust +// crates/edgezero-core/src/app_config.rs + +pub trait AppConfigMeta { + /// Field names whose runtime value comes from the secret store, not + /// the config store. Emitted by `#[derive(AppConfig)]`. + const SECRET_FIELDS: &'static [&'static str]; +} + +pub fn load_app_config(path: &std::path::Path) -> Result +where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; + +pub fn load_app_config_raw(path: &std::path::Path) + -> Result, AppConfigError>; +``` + +Public derive from `edgezero-macros`: + +```rust +// crates/edgezero-macros/src/lib.rs (re-export) +pub use edgezero_macros_impl::AppConfig; // procedural derive ``` Internal modules (`adapter`, `generator`, `scaffold`, `dev_server`, -`runner`, `provision`, `auth`, `config`) all stay private. Only the -symbols above are public. +`runner`, `provision`, `auth`, `config`) all stay private to +`edgezero-cli`. Only the symbols above are public. ## 5. End-state file layout @@ -172,15 +214,15 @@ crates/edgezero-cli/ main.rs # thin wrapper for the default edgezero bin args.rs # all pub *Args structs + private Args/Command adapter.rs # (unchanged, private) - generator.rs # extended: also scaffolds -cli + .toml + generator.rs # extended: also scaffolds -cli + .toml + -core/src/config.rs scaffold.rs # (unchanged-ish, private) dev_server.rs # (unchanged, private; feature-gated) - runner.rs # NEW: CommandRunner trait + Real/Mock impls + runner.rs # NEW: CommandSpec + CommandRunner trait + Real/Mock impls auth.rs # NEW: auth subcommand impl (uses runner) - provision.rs # NEW: provision impl (uses runner) - config.rs # NEW: validate + push impl (uses runner) + provision.rs # NEW: provision impl (uses runner + manifest writeback) + config.rs # NEW: validate + push impl (uses runner + secret handling) templates/ - core/ # (existing) + core/ # (existing; src/config.rs.hbs added in sub-project 2) root/ # (existing; edgezero.toml.hbs updated) cli/ # NEW: templates for -cli Cargo.toml.hbs @@ -190,31 +232,51 @@ crates/edgezero-cli/ lib_consumer.rs # NEW: external-consumer compile test crates/edgezero-core/src/ - app_config.rs # NEW: generic load_app_config(path) - manifest.rs # (unchanged for this effort) + app_config.rs # NEW: AppConfigMeta trait + load_app_config + raw loader + manifest.rs # UPDATED: [stores.*.adapters.].id field, drop [stores.config.defaults] + +crates/edgezero-macros/ + Cargo.toml # adds the new proc-macro symbol + src/ + lib.rs # NEW exports: AppConfig derive + app_config.rs # NEW: AppConfig derive impl examples/app-demo/ Cargo.toml # adds crates/app-demo-cli to members - app-demo.toml # NEW: typed app config + app-demo.toml # NEW: typed app config with one #[secret] field + edgezero.toml # UPDATED: remove [stores.config.defaults]; add [stores.config.adapters.] id slots crates/ app-demo-core/ - src/config.rs # NEW: pub struct AppDemoConfig + src/config.rs # NEW: pub struct AppDemoConfig with #[derive(AppConfig)] and #[secret] src/handlers.rs # one handler reads from config store app-demo-cli/ # NEW Cargo.toml src/main.rs # full Cmd enum: all built-ins + Auth/Provision/Config tests/help.rs # smoke test app-demo-adapter-*/ # (unchanged) + +docs/guide/ + cli-walkthrough.md # NEW: full myapp loop (linked from .vitepress/config.ts sidebar) +.vitepress/config.ts # UPDATED: sidebar entry for cli-walkthrough ``` ## 6. Cross-cutting designs -### 6.1 `CommandRunner` trait (sub-project #4 introduces; #5 and #6 reuse) +### 6.1 `CommandSpec` + `CommandRunner` (sub-project #4 introduces; #5 and #6 reuse) ```rust -// crates/edgezero-cli/src/runner.rs (private) +// crates/edgezero-cli/src/runner.rs (private to the crate) + +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, program: &str, args: &[&str]) -> std::io::Result; + fn run(&self, spec: &CommandSpec<'_>) -> std::io::Result; } pub(crate) struct CommandOutput { @@ -230,9 +292,13 @@ impl CommandRunner for RealCommandRunner { /* std::process::Command */ } pub(crate) struct MockCommandRunner { /* recorded expectations */ } ``` -The trait is **private to the crate**. Public command functions -(`run_auth`, `run_provision`, `run_config_push`) use a private -`*_with` inner function: +Why a struct (not a positional-args method): provisioned commands need +`cwd` (per-adapter manifest directories), `stdin` (Fastly `--stdin` for +large payloads), and `env` overrides (token isolation in tests). +Defining `CommandSpec` up front avoids churning every command-site when +those needs surface. + +Public command functions use a private `*_with` inner function: ```rust pub fn run_auth(args: &AuthArgs) -> Result<(), String> { @@ -240,20 +306,20 @@ pub fn run_auth(args: &AuthArgs) -> Result<(), String> { } fn run_auth_with(runner: &R, args: &AuthArgs) -> Result<(), String> { - // shell out via runner + // construct CommandSpec, invoke runner } #[cfg(test)] mod tests { fn it_logs_into_cloudflare() { - let mock = MockCommandRunner::expect(&[("wrangler", &["login"])]); - run_auth_with(&mock, &AuthArgs { adapter: "cloudflare".into(), sub: AuthSub::Login }).unwrap(); + let mock = MockCommandRunner::expect("wrangler", &["login"]); + run_auth_with(&mock, &AuthArgs { sub: AuthSub::Login { adapter: "cloudflare".into() } }).unwrap(); } } ``` Public surface stays clean (`run_auth(&args)`); tests bypass to inject -the mock. No public trait, no semver risk on the mock. +the mock. No public trait, no semver risk. ### 6.2 Error model @@ -261,28 +327,76 @@ All public `run_*` functions return `Result<(), String>`. This matches the existing pattern in `edgezero-cli` today. Error formatting is the function's responsibility; callers (binaries) log and exit. -### 6.3 Feature gates +### 6.3 Feature gates (consumer-facing) + +For downstream `edgezero-cli` consumers: + +```toml +[dependencies] +edgezero-cli = { version = "...", default-features = false, features = ["cli"] } +# Plus the adapters the downstream wants: +# - edgezero-adapter-axum (only this for non-WASM, native, dev use) +# - edgezero-adapter-cloudflare +# - edgezero-adapter-fastly +# - edgezero-adapter-spin +``` -- `cli` (default) — gates clap and the whole public API. -- `edgezero-adapter-{axum,fastly,cloudflare,spin}` (default) — gate the - matching adapter dispatch paths in build / deploy / serve / provision / - auth / config push. +- `cli` (default) — gates clap and the whole public API. Required. +- `edgezero-adapter-{axum,fastly,cloudflare,spin}` (all four default) — + each gates that adapter's dispatch path in build / deploy / serve / + provision / auth / config push. Disabling an adapter feature removes + that adapter from the `--adapter` matrix and causes the CLI to surface + a clear "adapter not compiled in" error if invoked. - The new `auth`, `provision`, and `config-push` paths do not introduce new feature flags. They are part of `cli`. Per-adapter logic inside them is gated on the existing adapter features. -### 6.4 Generic typed config — why two flavours per `config` command - -The default `edgezero` binary cannot know the user's `AppConfig` type, so -its `config validate` / `config push` operate in a non-typed mode that -only checks TOML syntax and serialises to a flat string map. Downstream -binaries that know their type call the `_typed::` variants and get -full schema validation via `validator::Validate`. - -This is one shared pair of `*Args` structs and two public functions per -command. Not a perfect surface (two names), but the alternative — -type-erasing the schema check via a trait object — costs more in -complexity than the duplication saves. +Default-features-on remains the easiest mode for downstream — opting +out of adapters is for size-sensitive builds. + +### 6.4 Typed vs raw config serialization + +The two `config validate` / `config push` flavours share the same +serialization rules but differ in schema awareness: + +**Both flavours:** + +- Top-level value of the toml file must be a `[config]` table. +- Each field is serialized to a string for storage in the config store: + - `String` → as-is. + - `bool`, integer, float → `to_string()`. + - Compound types (arrays, maps, nested structs) → `serde_json::to_string`. + - `Option::None` / `Value::Null` → field skipped entirely. +- Fields whose name is in `AppConfigMeta::SECRET_FIELDS` are excluded + from push (their value is the secret-store binding name; the actual + secret material lives in the secret store). + +**Typed flavour (`run_config_*_typed::`):** + +- Requires `C: DeserializeOwned + Validate + Serialize + AppConfigMeta`. +- Validates: `serde_json::to_value(&c)` must produce `Value::Object`; + any other shape errors out before the runner is touched. +- Honors serde attributes on `C`: + - `#[serde(rename = "k")]` — the renamed name is the storage key. + - `#[serde(flatten)]` — nested fields are merged into the top-level + map after the typed serialize step. + - `#[serde(skip_serializing, skip_serializing_if = ...)]` — honored; + such fields never reach the runner. +- Runs `C::validate()` before serialization. + +**Raw flavour (`run_config_*`):** + +- Loads `BTreeMap` from the `[config]` table. +- Same scalar/compound serialization rules. +- No `Validate` (the default `edgezero` binary doesn't know the schema). +- Secret-field exclusion is skipped (no `AppConfigMeta` available) — + the raw flavour pushes every field present in the toml. Operators + using the raw flavour must put secret references in a separate part + of their workflow or use the typed flavour instead. + +`config validate` and `config push` apply the same rules; push is just +validate + upload, with `push` running validate's strict checks as a +pre-flight before invoking any runner. ### 6.5 Test strategy summary @@ -294,13 +408,94 @@ complexity than the duplication saves. - `examples/app-demo/crates/app-demo-cli/tests/help.rs` smoke-tests the generated/handwritten downstream pattern. +### 6.6 Secret annotation via `#[derive(AppConfig)]` + +**Goal:** let app-config structs declare which fields are secret-backed +without inventing a new toml grammar. The Rust struct is the source of +truth; the toml just contains the secret-store binding names. + +**Syntax:** + +```rust +use serde::{Deserialize, Serialize}; +use validator::Validate; +use edgezero_macros::AppConfig; + +#[derive(Debug, Deserialize, Serialize, Validate, AppConfig)] +pub struct AppDemoConfig { + #[validate(length(min = 1))] + pub greeting: String, + + pub timeout_ms: u32, + + pub feature_new_checkout: bool, + + /// Runtime value comes from the secret store. The `String` here is the + /// secret-store binding name written in app-demo.toml. + #[secret] + pub api_token: String, +} +``` + +**Toml shape (no new syntax):** + +```toml +[config] +greeting = "hello from app-demo" +timeout_ms = 1500 +feature_new_checkout = false +api_token = "APP_DEMO_API_TOKEN" # secret-store binding name +``` + +**What the derive emits:** + +```rust +impl ::edgezero_core::app_config::AppConfigMeta for AppDemoConfig { + const SECRET_FIELDS: &'static [&'static str] = &["api_token"]; +} +``` + +Field names match the on-the-wire key (so `#[serde(rename = "...")]` is +honored — the derive reads the serde rename and uses the renamed name in +`SECRET_FIELDS`). + +**CLI behaviour:** + +- `config validate --typed`: for each name in `SECRET_FIELDS`, looks up + the corresponding toml value (must be a non-empty string) and asserts + it appears in `[stores.secrets]` (either directly as the store name + or as a per-adapter override). Failure: "field `api_token` is marked + `#[secret]` but its binding `APP_DEMO_API_TOKEN` is not declared in + `[stores.secrets]`". +- `config push --typed`: skips every `SECRET_FIELDS` entry. The secret + material is never written to the config store. (Seeding the secret + store itself is out of scope; users do that via `wrangler secret put`, + `fastly secret-store-entry create`, or env vars for axum.) + +**Runtime usage in service code:** + +```rust +// Inside a handler +let binding = &config.api_token; // "APP_DEMO_API_TOKEN" +let token = ctx.secrets().get(binding).await?; // actual secret value +``` + +The service code is explicit about reading from the secret store; the +struct's String field just carries the binding name. + +**`Validate` interaction:** `#[secret]` and `#[validate(...)]` compose +freely. `Validate` runs against the binding name (the string in the +struct), so e.g. `#[validate(length(min = 1))]` on a `#[secret]` field +enforces the binding-name is non-empty. + --- ## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton **Goal:** establish the substrate. After this ships, downstream projects can build their own CLI against the lib using only the existing five -built-ins. Default `edgezero` is unchanged for users. +built-ins. Default `edgezero` is backwards-compatible (no new commands, +no flag changes). **Source changes:** @@ -337,7 +532,8 @@ built-ins. Default `edgezero` is unchanged for users. workspace members. - `templates/cli/` directory created to hold the new Handlebars templates. - - **No app-config file yet** — `.toml` arrives in sub-project #2. + - **No app-config file yet, no derive yet** — `.toml` and the + `#[derive(AppConfig)]` plumbing arrive in sub-project #2. - `examples/app-demo/crates/app-demo-cli` (new crate, handwritten — parallel to what the generator will produce): - Added to `examples/app-demo/Cargo.toml` `members` list. @@ -347,6 +543,11 @@ built-ins. Default `edgezero` is unchanged for users. - `src/main.rs` mirrors the canonical downstream pattern, all five built-ins, no custom subcommands yet. +**Migration note:** projects created by sub-project #1's generator do +not auto-update when sub-project #2 lands. The generator is the source +of truth for new scaffolds; existing projects follow the documented +manual migration (add `app-config.rs`, add `.toml`). + **Tests:** - All existing CLI tests pass after relocation. @@ -363,14 +564,16 @@ built-ins. Default `edgezero` is unchanged for users. **CI:** all four existing gates (`fmt`, `clippy -D warnings`, `cargo test`, feature `cargo check`). Spin wasm32 gate unaffected. -**Ship gate:** `edgezero --help` output identical; `app-demo-cli --help` -prints the five built-ins; `edgezero new throwaway-app && cd -throwaway-app && cargo check --workspace` succeeds. +**Ship gate:** `edgezero --help` lists the same five subcommands as +before with identical flags; `app-demo-cli --help` prints the same five +built-ins; `edgezero new throwaway-app && cd throwaway-app && cargo +check --workspace` succeeds. -## 8. Sub-project 2 — App-config schema and generic loader +## 8. Sub-project 2 — App-config schema, derive macro, generic loader -**Goal:** define the file format for per-service app config and the -generic loader the CLI uses. +**Goal:** define the file format for per-service app config, the +`#[derive(AppConfig)]` macro that produces secret-field metadata, and +the generic loader the CLI uses. **Source changes:** @@ -380,29 +583,24 @@ generic loader the CLI uses. use serde::de::DeserializeOwned; use validator::Validate; + pub trait AppConfigMeta { + const SECRET_FIELDS: &'static [&'static str]; + } + #[derive(Debug)] pub struct AppConfigError(String); - impl std::fmt::Display for AppConfigError { /* ... */ } - impl std::error::Error for AppConfigError {} + // Display + Error impls pub fn load_app_config(path: &std::path::Path) -> Result where - C: DeserializeOwned + Validate, + C: DeserializeOwned + Validate + AppConfigMeta, { // 1. Read file. // 2. Parse TOML into a wrapper { config: C }. - // - // File shape: - // - // [config] - // key = "value" - // ... - // // 3. Run C::validate(). // 4. Return C. } - // For the non-typed (default-binary) path: pub fn load_app_config_raw(path: &std::path::Path) -> Result, AppConfigError>; ``` @@ -410,18 +608,40 @@ generic loader the CLI uses. `app_config` is `pub use`d from `edgezero-core`'s `lib.rs`. No new workspace deps (serde, validator, toml are already there). +- `crates/edgezero-macros/src/app_config.rs` (new): the `AppConfig` + derive. Parses the input struct, scans each field for the `#[secret]` + attribute, honors `#[serde(rename = "...")]`, and emits a single + `impl ::edgezero_core::app_config::AppConfigMeta` block with + `SECRET_FIELDS`. No other code is generated; the user's `Deserialize`, + `Validate`, etc., come from their own derives. + + Errors at compile time on: + - Non-struct inputs. + - Tuple structs. + - Unknown attributes nested inside `#[secret(...)]` (the attribute is + a marker; `#[secret]` is accepted, `#[secret(name = "x")]` is not in + this version). + +- `crates/edgezero-macros/src/lib.rs`: re-export the new derive + alongside the existing `action` / `app` proc macros. + - `crates/edgezero-cli/src/templates/app/.toml.hbs` (new): ```toml # {{name}} app runtime config. # Values are pushed to the active config store via `edgezero config push`. # Service code reads them at runtime via the config store binding. + # Secret-annotated fields are skipped by push; their values are the + # secret-store binding names and the actual secrets live in the secret store. [config] greeting = "hello from {{name}}" ``` - Generator emits this as `.toml` at the project root. +- `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` (new): + a `Config` struct with `#[derive(Deserialize, Serialize, + Validate, AppConfig)]` and a `greeting: String` field as the default + template. - `examples/app-demo/app-demo.toml` (new, handwritten parallel): @@ -430,6 +650,7 @@ generic loader the CLI uses. greeting = "hello from app-demo" timeout_ms = 1500 feature_new_checkout = false + api_token = "APP_DEMO_API_TOKEN" ``` - `examples/app-demo/crates/app-demo-core/src/config.rs` (new): @@ -437,30 +658,42 @@ generic loader the CLI uses. ```rust use serde::{Deserialize, Serialize}; use validator::Validate; + use edgezero_macros::AppConfig; - #[derive(Debug, Deserialize, Serialize, Validate)] + #[derive(Debug, Deserialize, Serialize, Validate, AppConfig)] pub struct AppDemoConfig { #[validate(length(min = 1))] pub greeting: String, #[validate(range(min = 100, max = 60000))] pub timeout_ms: u32, pub feature_new_checkout: bool, + #[secret] + #[validate(length(min = 1))] + pub api_token: String, } ``` -- Generator emits a `-core/src/config.rs` stub mirroring the - pattern (struct named `Config`). +- Generator extension (continuation from sub-project #1's generator + work): also emit `-core/src/config.rs` from the new template, + and emit `.toml` at the project root. **Tests:** - Unit tests for `load_app_config`: valid file, missing file, bad TOML, - validator failure, missing `[config]` table. + validator failure, missing `[config]` table, missing-required-field. - Round-trip test in `app-demo-core` that the example `app-demo.toml` parses into `AppDemoConfig` and passes validation. +- Macro tests (`crates/edgezero-macros/tests/app_config_derive.rs`): + - Struct with no `#[secret]` fields emits empty `SECRET_FIELDS`. + - Struct with one `#[secret]` field emits `&["that_field"]`. + - `#[serde(rename = "k")]` is honored; the renamed key appears in + `SECRET_FIELDS`. + - Non-struct input fails with a clear `compile_error!`. **Ship gate:** -`edgezero_core::app_config::load_app_config::(Path::new("examples/app-demo/app-demo.toml"))` -succeeds in a test. +`AppDemoConfig::SECRET_FIELDS == ["api_token"]` is asserted in a unit +test; `edgezero_core::app_config::load_app_config::(Path::new("examples/app-demo/app-demo.toml"))` +succeeds. ## 9. Sub-project 3 — `config validate` command @@ -472,7 +705,8 @@ succeeds in a test. pub use args::ConfigValidateArgs; pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> -where C: DeserializeOwned + Validate; +where + C: DeserializeOwned + Validate + AppConfigMeta; ``` `ConfigValidateArgs`: @@ -486,7 +720,7 @@ pub struct ConfigValidateArgs { /// .toml; auto-detected from [app].name if None. #[arg(long)] pub app_config: Option, - /// Also check cross-references (handlers, adapter consistency). + /// Also check cross-references (handlers, adapter consistency, secret bindings). #[arg(long)] pub strict: bool, } @@ -494,15 +728,20 @@ pub struct ConfigValidateArgs { **Validation steps (in order):** -1. Parse `edgezero.toml` via existing `ManifestLoader`. Report TOML - syntax errors with file/line. +1. Parse `edgezero.toml` at `args.manifest` via the existing + `ManifestLoader`. Report TOML syntax errors with file/line. 2. If an app-config file is provided or auto-detected, parse it: - Non-typed path: `load_app_config_raw` — confirms structure. - Typed path: `load_app_config::` — also runs `Validate`. -3. If `--strict`: cross-check that every adapter referenced in - `[adapters.*]` has a matching `[stores.*.adapters.*]` if it overrides - bindings, every handler path in `[[triggers.http]]` is well-formed, - etc. (Concrete checks listed in the implementation plan.) +3. If `--strict`: + - Every adapter referenced in `[adapters.*]` has a matching set of + `[stores.*.adapters.*]` entries when bindings are overridden. + - Every handler path in `[[triggers.http]]` is well-formed. + - **Typed path only:** for each field in `C::SECRET_FIELDS`, look up + its value in the parsed toml (must be a non-empty string) and + assert that string appears as a `[stores.secrets]` binding (either + `stores.secrets.name` or a per-adapter override). + - (Full check list pinned in the implementation plan.) **Output:** human-readable diagnostics; exits 0 on success, 1 on failure. @@ -510,17 +749,21 @@ pub struct ConfigValidateArgs { - Valid manifest passes. - Each kind of failure (syntax, schema, validator failure, missing - cross-reference) produces a distinct error message. + cross-reference, missing secret binding) produces a distinct error + message. - Typed and non-typed paths covered. -- `app-demo-cli config validate` is the canonical typed integration test. +- `app-demo-cli config validate --strict` is the canonical typed + integration test. -**Ship gate:** `app-demo-cli config validate` exits 0 against the -example workspace; deliberately corrupted fixtures fail. +**Ship gate:** `app-demo-cli config validate --strict` exits 0 against +the example workspace; deliberately corrupted fixtures (bad syntax, +unbound secret) each fail with the expected error. ## 10. Sub-project 4 — `auth` command **Goal:** delegate per-adapter authentication to the native tool. No -edgezero-stored credentials. +edgezero-stored credentials. Introduces the `runner` module that +sub-projects 5 and 6 reuse. **Public API additions:** @@ -529,21 +772,23 @@ pub use args::{AuthArgs, AuthSub}; pub fn run_auth(args: &AuthArgs) -> Result<(), String>; ``` +**Clap shape:** `--adapter` lives on each subcommand, not the parent, +so the UX is `auth login --adapter cloudflare` (not `auth --adapter +cloudflare login`): + ```rust #[derive(clap::Args, Debug)] #[non_exhaustive] pub struct AuthArgs { - #[arg(long)] - pub adapter: String, // axum | cloudflare | fastly | spin #[command(subcommand)] pub sub: AuthSub, } #[derive(clap::Subcommand, Debug)] pub enum AuthSub { - Login, - Logout, - Status, + Login { #[arg(long)] adapter: String }, + Logout { #[arg(long)] adapter: String }, + Status { #[arg(long)] adapter: String }, } ``` @@ -556,23 +801,26 @@ pub enum AuthSub { | fastly | `fastly profile create` | `fastly profile delete` | `fastly profile list` | | spin | `spin cloud login` | `spin cloud logout` | `spin cloud info` | -All invocations go through `CommandRunner`. This sub-project introduces -the `runner` module (`runner.rs`). +All invocations go through `CommandRunner` using `CommandSpec` with +`cwd: None` and inherited env. The `runner` module (§6.1) lands here. **Tests:** - For each (adapter, sub) pair: `MockCommandRunner` expectation. The - mock records the exact program and args; the test asserts them. + mock records the exact `CommandSpec` (program, args, cwd, env); + the test asserts them. - Error cases: tool not found (program returns ENOENT), tool returns non-zero exit. **Ship gate:** with the mock runner, `run_auth` produces the exact -expected subprocess call for every (adapter, sub) pair. +expected subprocess invocation for every (adapter, sub) pair. ## 11. Sub-project 5 — `provision` command **Goal:** create the underlying platform resources (KV namespace, secret -store, config store) declared in `[stores.*]` of `edgezero.toml`. +store, config store) declared in `[stores.*]` of `edgezero.toml`, and +write the resulting IDs back to `edgezero.toml` and (where the platform +requires) to the per-adapter manifest. **Public API additions:** @@ -585,6 +833,8 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; #[derive(clap::Args, Debug)] #[non_exhaustive] pub struct ProvisionArgs { + #[arg(long, default_value = "edgezero.toml")] + pub manifest: PathBuf, #[arg(long)] pub adapter: String, #[arg(long)] @@ -595,39 +845,67 @@ pub struct ProvisionArgs { **Behaviour:** For the named adapter, iterate over `[stores.kv]`, `[stores.secrets]`, -`[stores.config]` in the manifest. For each enabled store, shell out to -create the resource: +`[stores.config]` in `args.manifest`. For each enabled store, shell out +to create the resource (using the current platform-CLI syntax): + +| Adapter | KV | Secrets | Config | +|------------|---------------------------------------------|-----------------------------------------------|----------------------------------------------| +| axum | no-op (local; env-backed) | no-op | no-op | +| cloudflare | `wrangler kv namespace create ` | (no-op; wrangler-managed at runtime via `wrangler secret put`) | `wrangler kv namespace create ` (config store is a separate KV namespace) | +| fastly | `fastly kv-store create --name ` | `fastly secret-store create --name ` | `fastly config-store create --name ` | +| spin | **not yet supported** — error out with a clear message pointing at the in-flight stores PR | same | same | + +(Spin behaviour: log "spin provision is not yet supported; a separate +PR is in flight to add `[stores.*]` support for the Spin adapter. Until +that lands, configure Spin variables manually." Exit non-zero.) + +`--dry-run` prints the would-be `CommandSpec`s without running them. -| Adapter | KV | Secrets | Config | -|------------|-----------------------------------|---------------------------------------|-----------------------------------| -| axum | no-op (local; env-backed) | no-op | no-op | -| cloudflare | `wrangler kv:namespace create N` | (no-op; wrangler-managed at runtime) | `wrangler kv:namespace create N` | -| fastly | `fastly kv-store create --name N` | `fastly secret-store create --name N` | `fastly config-store create --name N` | -| spin | (Spin auto-creates KV at deploy) | (Spin variables file) | (Spin variables file) | +**Writeback to `edgezero.toml`:** after each successful create, parse +the tool's stdout to extract the resource ID, then update the manifest +in place by writing: -`--dry-run` prints the would-be commands without running them. +```toml +[stores.kv.adapters.cloudflare] +id = "" +``` + +The ID lives in `[stores..adapters.] id`. This is the +single source of truth for `config push` and other ID-consuming +commands. The `id` field is added to `ManifestLoader`'s schema as an +optional string. + +**Writeback to per-adapter manifest:** for adapters whose own tooling +also needs the ID at deploy time: -**Write-back to per-adapter manifests:** when Cloudflare creates a KV -namespace, the resulting ID must land in `wrangler.toml` so deploys can -bind it. The implementation parses the tool's stdout, extracts the ID, -and patches the per-adapter manifest declared in -`[adapters..adapter] manifest = "..."`. This is a documented -side-effect of `provision`. +- **Cloudflare:** also patch `wrangler.toml` to add the namespace ID to + the matching `[[kv_namespaces]]` block. (Standard wrangler binding + pattern; needed for `wrangler deploy` to bind the namespace.) +- **Fastly:** no per-adapter manifest writeback needed; the Fastly + service references the store by ID at API call time, not at deploy + time. **Tests:** -- For each (adapter, store-kind) tuple, `MockCommandRunner` expectation. -- Manifest write-back tested with a temp-dir fixture: provision runs, - then the per-adapter manifest is re-read and contains the new ID. -- `--dry-run` produces output but does not invoke the runner. +- For each (adapter, store-kind) tuple, `MockCommandRunner` + expectations including the exact `CommandSpec` and a scripted stdout + that includes a sample ID. +- ID extraction: golden-file tests over recorded sample outputs from + `wrangler kv namespace create` and Fastly's create commands. +- Manifest writeback: temp-dir fixture provisions, then `edgezero.toml` + is re-read and contains the expected `[stores..adapters.] id`. +- `--dry-run` produces a list of would-be `CommandSpec`s without + invoking the runner; no manifest writeback either. **Ship gate:** `app-demo-cli provision --adapter cloudflare --dry-run` -prints the expected three `wrangler` invocations. +prints the expected `wrangler kv namespace create` invocations (one per +store kind that applies); a non-dry-run against the mock writes the IDs +back into the temp-fixture manifest. ## 12. Sub-project 6 — `config push` command **Goal:** upload `.toml`'s `[config]` values to the live config -store on a given adapter. +store on a given adapter, skipping `#[secret]` fields. **Public API additions:** @@ -635,13 +913,16 @@ store on a given adapter. pub use args::ConfigPushArgs; pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> -where C: DeserializeOwned + Validate + Serialize; +where + C: DeserializeOwned + Validate + Serialize + AppConfigMeta; ``` ```rust #[derive(clap::Args, Debug)] #[non_exhaustive] pub struct ConfigPushArgs { + #[arg(long, default_value = "edgezero.toml")] + pub manifest: PathBuf, #[arg(long)] pub adapter: String, /// Auto-detect .toml from [app].name if None. @@ -654,34 +935,46 @@ pub struct ConfigPushArgs { **Behaviour:** -1. Load app-config (raw map or typed struct). -2. Serialise each top-level field to a string: - - `String` → as-is. - - `bool` / numbers → `to_string()`. - - Compound types (only via the typed path) → JSON-encoded. -3. Shell out to the platform tool for bulk upload: - -| Adapter | Push | -|------------|----------------------------------------------------------------------------| -| axum | Write to `.edgezero/local-config.env` (gitignored). | -| cloudflare | `wrangler kv:bulk put ` | -| fastly | Iterate: `fastly config-store-entry create --store-id … --key … --value …` | -| spin | Write to the Spin variables file referenced in the spin manifest. | - -Typed variant also runs `Validate` before pushing (refuses to upload -invalid config). +1. **Pre-flight validation** — internally run the same checks as + `run_config_validate_typed` (typed path) or `run_config_validate` + (raw path) with `--strict` semantics. Abort before any runner call + if validation fails. No separate `--strict` flag on push; it is + always strict. +2. Load app-config (raw map or typed struct). +3. Apply §6.4 serialization rules: + - Skip fields in `AppConfigMeta::SECRET_FIELDS` (typed path only). + - `Option::None` / `Value::Null` skipped. + - Scalars `to_string`, compounds `serde_json::to_string`. + - Typed path: assert `serde_json::to_value(&c)` is `Value::Object`; + error otherwise. +4. Read the resource ID from `[stores.config.adapters.].id` + in `args.manifest`. Error with "did you run `provision` first?" if + missing for a platform that needs it. +5. Shell out to the platform tool for bulk upload: + +| Adapter | Push | +|------------|-----------------------------------------------------------------------------------------------| +| axum | Write to `.edgezero/local-config.env` (gitignored). No runner call. | +| cloudflare | `wrangler kv bulk put --namespace-id=` (Wrangler 3.60+ syntax, space-form)| +| fastly | Per key: `fastly config-store-entry create --store-id= --key= --value=` via stdin where the value is large | +| spin | **not yet supported** — error message pointing at the in-flight stores PR; exit non-zero | **Tests:** - Typed and non-typed paths. -- For each adapter, `MockCommandRunner` expectations including the - exact serialised payload. -- `--dry-run` prints the serialised payload and would-be commands; does - not invoke the runner. +- For each supported adapter, `MockCommandRunner` expectations + including the exact serialised payload (golden-file the JSON tempfile + contents for Cloudflare, golden-file each Fastly per-key spec). +- `#[secret]` field in `AppDemoConfig` confirmed to be **absent** from + the pushed payload. +- Missing `[stores.config.adapters.].id` → clear error. +- `--dry-run` prints the serialised payload and would-be `CommandSpec`s; + does not invoke the runner. **Ship gate:** `app-demo-cli config push --adapter cloudflare --dry-run` -shows the expected `wrangler kv:bulk put` invocation with the JSON -payload derived from `app-demo.toml`. +shows the expected `wrangler kv bulk put` invocation, the JSON payload +omits `api_token`, and the namespace ID matches the fixture manifest's +`[stores.config.adapters.cloudflare] id`. ## 13. Sub-project 7 — `app-demo` integration polish @@ -689,6 +982,12 @@ payload derived from `app-demo.toml`. **Source changes (all in `examples/app-demo/`):** +- `edgezero.toml`: + - Remove `[stores.config.defaults]` entirely. Add a comment explaining + that `app-demo.toml` is now the source of truth. + - Leave `[stores.config]`, `[stores.kv]`, `[stores.secrets]` blocks; + `[stores..adapters.].id` slots will populate when + `provision` runs. - `crates/app-demo-cli/src/main.rs`: extend `Cmd` enum to include the new variants: @@ -716,24 +1015,31 @@ payload derived from `app-demo.toml`. - `crates/app-demo-core/src/handlers.rs`: extend one existing handler (e.g. `config_get`) so it reads a key via the config store binding. - Already partly there — confirm the integration after `config push` - pushes real data to a local axum store. + Verify the integration after `config push` pushes real data to the + local axum config store. -- Documentation: a new `docs/cli/walkthrough.md` page showing the full - loop: +**Documentation:** + +- New `docs/guide/cli-walkthrough.md` page (not `docs/cli/...` — the + VitePress sidebar groups everything under `docs/guide/`) showing the + full loop: 1. `edgezero new myapp` 2. `cd myapp && cargo build` 3. `myapp-cli auth login --adapter cloudflare` 4. `myapp-cli provision --adapter cloudflare` - 5. `myapp-cli config validate` + 5. `myapp-cli config validate --strict` 6. `myapp-cli config push --adapter cloudflare` 7. `myapp-cli deploy --adapter cloudflare` 8. `curl https://myapp.example/config/greeting` +- `.vitepress/config.ts` sidebar updated to include the new page under + the existing guide group. Without this, the page exists but is not + navigable. + **Tests:** -- `app-demo-cli config validate` exits 0 against `app-demo.toml`. +- `app-demo-cli config validate --strict` exits 0 against `app-demo.toml`. - `app-demo-cli config push --adapter axum` writes a local-config file; the running axum dev server reads `greeting` from the config store and returns it on `/config/greeting`. @@ -743,7 +1049,8 @@ payload derived from `app-demo.toml`. **Ship gate:** end-to-end demo of the full loop in CI, using `--adapter axum` and the local file-backed config store. No live external calls; the `axum` adapter is the substrate for verifying real -push-then-read behaviour. +push-then-read behaviour. The Cloudflare/Fastly paths are exercised in +mock-runner tests but not against real platforms in CI. --- @@ -755,9 +1062,9 @@ must keep all four CI gates green; no skipping (`-D warnings` stays). | # | Title | Net new public symbols | Risk | |---|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|------| | 1 | Extensible lib + scaffold | `BuildArgs`, `DeployArgs`, `NewArgs`, `ServeArgs`, `run_build`, `run_deploy`, `run_new`, `run_serve`, `run_dev`, `init_cli_logger` | M | -| 2 | App-config schema | `edgezero_core::app_config::{load_app_config, load_app_config_raw, AppConfigError}` | L | +| 2 | App-config schema + derive | `edgezero_core::app_config::*`, `edgezero_macros::AppConfig` | M | | 3 | `config validate` | `ConfigValidateArgs`, `run_config_validate`, `run_config_validate_typed` | L | -| 4 | `auth` | `AuthArgs`, `AuthSub`, `run_auth` | M | +| 4 | `auth` (+ `CommandRunner`) | `AuthArgs`, `AuthSub`, `run_auth` | M | | 5 | `provision` | `ProvisionArgs`, `run_provision` | H | | 6 | `config push` | `ConfigPushArgs`, `run_config_push`, `run_config_push_typed` | M | | 7 | `app-demo` polish + walk-through | (none) — uses everything above | L | @@ -767,10 +1074,14 @@ must keep all four CI gates green; no skipping (`-D warnings` stays). - Sub-project #1 is the substrate; getting the `*Args` shape wrong here forces churn later. Mitigated by `#[non_exhaustive]` on every Args struct and the external-consumer integration test. -- Sub-project #5 (`provision`) is the highest risk: it both shells out - and writes back to per-adapter manifest files. We constrain blast - radius by treating manifest write-back as a separate step with its - own tests and by supporting `--dry-run`. +- Sub-project #2 now includes the `AppConfig` proc macro; macro testing + uses `trybuild`-style fixtures (or the project's existing macro test + pattern in `edgezero-macros`). +- Sub-project #5 (`provision`) is the highest risk: it shells out, + parses stdout to extract IDs, and writes back to both `edgezero.toml` + and per-adapter manifest files. We constrain blast radius by treating + manifest writeback as a separate step with golden-file tests on + recorded stdout samples and by supporting `--dry-run`. ## 15. Risks and trade-offs @@ -778,22 +1089,33 @@ must keep all four CI gates green; no skipping (`-D warnings` stays). so adding fields is non-breaking. New `run_*` functions are additive. The `_typed::` / non-typed split adds two names per `config` command, which is the deliberate trade — see §6.4. -- **Shell-out fragility:** the platform tools' CLI surface can change - between versions. We pin no specific tool version; we just report a - clear error when the tool is missing or fails. Tool versions are - already pinned via the project's `.tool-versions` for the supported - combinations. +- **Shell-out fragility:** platform CLI surfaces change over time + (Wrangler 3.60+ moved from `kv:bulk` to `kv bulk`, etc.). We pin to + the current syntax at spec time, surface clear errors when tools are + missing or fail, and rely on tool versions already pinned via the + project's `.tool-versions`. Adapting to future syntax changes is one + edit per command in the relevant private module. +- **ID writeback brittleness:** parsing tool stdout to extract IDs is + inherently version-sensitive. Mitigation: per-tool parser functions + with golden-file tests over recorded sample outputs; `--dry-run` + available for safe inspection. - **Generator drift:** the generator produces a `-cli` whose shape must stay in sync with the canonical pattern used by `app-demo-cli`. Sub-project #1 introduces a generator test that compares structural expectations (file existence + key tokens). -- **`provision` manifest write-back:** parsing tool stdout to extract - resource IDs is brittle. Mitigation: each tool's parser is its own - isolated function with golden-file tests over recorded sample - outputs. + Sub-project #2 extends the test to cover `-core/src/config.rs` + and `.toml`. +- **Proc macro coupling:** the `AppConfig` derive lives in + `edgezero-macros` but emits a path referencing `edgezero_core`. This + is the same pattern the existing `#[action]` macro uses; downstream + consumers must depend on both crates (already the workspace norm). - **Multi-environment app-config:** explicitly out of scope (§2). When needed, a follow-up spec will add `[config.]` support and a `--env` flag on `config push`/`validate`. +- **Spin support gap:** `provision` and `config push` do not work for + `--adapter spin` in this effort; both error out with a pointer to + the in-flight stores PR. Sub-project ship gates work around this by + only smoke-testing the axum / mock paths. - **Test relocation in sub-project #1:** ~10 tests move from `main.rs` to `lib.rs`. Diff looks large but is mechanical; reviewers will be warned in the PR description. @@ -805,18 +1127,33 @@ must keep all four CI gates green; no skipping (`-D warnings` stays). - Per-environment config (`production` vs. `staging`): explicit follow-up. - Replacing or restructuring existing handlers in `app-demo-core` beyond the single one that demonstrates push-then-read. -- Any change to `edgezero-core` beyond adding the `app_config` module. +- Any change to `edgezero-core` beyond adding the `app_config` module + and the `[stores.*.adapters.].id` field on `ManifestLoader`. +- Removal of `[stores.config.defaults]` from anywhere except + `examples/app-demo/edgezero.toml`. Other consumers (if any in this + repo) that rely on `defaults` are unaffected for now; full deprecation + is a follow-up. +- Spin-side store provisioning and config push: deferred until the + separate in-flight Spin stores PR lands. The CLI's Spin code paths + return a clear "not yet supported" error in the meantime. When all seven sub-projects ship, the system supports: - `edgezero new myapp` produces a workspace ready to build with - `myapp-cli`, a typed `MyappConfig`, and a `myapp.toml`. + `myapp-cli`, a typed `MyappConfig` (using `#[derive(AppConfig)]` and + optional `#[secret]` fields), and a `myapp.toml`. - The developer logs into their platforms (`myapp-cli auth login --adapter X`), provisions stores (`myapp-cli provision --adapter X`), - validates and pushes their app config (`myapp-cli config validate && - myapp-cli config push --adapter X`), and deploys (`myapp-cli deploy - --adapter X`). + validates and pushes their app config (`myapp-cli config validate + --strict && myapp-cli config push --adapter X`), and deploys + (`myapp-cli deploy --adapter X`). +- Resource IDs flow `provision` → `edgezero.toml [stores.*.adapters.*] + .id` → `config push`. Per-adapter manifests (e.g. `wrangler.toml`) + also get the IDs they need for deploy-time binding. - At runtime, the deployed service reads its config from the platform - config store via the existing edgezero store binding. -- The default `edgezero` binary keeps working unchanged for everyone - who is not building their own CLI. + config store via the existing edgezero store binding, and reads + secret-annotated fields from the secret store using the binding name + the struct carries. +- The default `edgezero` binary remains backwards-compatible for + everyone not building their own CLI, with the new commands additionally + available. From 2e6904be7167796b595d7286b158dd142f3b8588 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 17:21:54 -0700 Subject: [PATCH 04/38] Expand spec for multi-store manifest + finalize naming and validate scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manifest schema rewrite (new sub-projects #2 and #3): - [stores.].ids = [...] + default declare the logical stores the app uses (kv, secrets, config all multi-store) - [adapters..stores..].name = "..." maps each logical id to the platform-specific name on adapter X, with optional adapter-specific tuning fields stored as free-form extras - Provisioned platform resource IDs (Cloudflare namespace ID, Fastly store ID) live in each platform's native manifest (wrangler.toml, fastly.toml), not in edgezero.toml. provision writes them there; config push reads them back. - RequestContext store accessors become id-keyed: ctx.kv_store("id") / ctx.kv_store_default() (and similarly for config_store / secret_store). Each adapter builds a StoreRegistry at request setup from [adapters..stores.*]. - Manifest validator enforces: ids non-empty; default in ids; every adapter has a name mapping for every id. Naming: - Field on the per-adapter block is `name` (matches the user's example), not `binding`. The Cloudflare wrangler.toml term `binding` is now called out as wrangler's terminology, not ours. Secret references (§6.7): - The string a #[secret] field holds is an app-defined reference; the spec documents both valid runtime patterns (logical store id or key within the default secret store). Validate just confirms the string is non-empty and that the app has a secret store available. config validate (§11) explicitly covers app-config validation: - TOML syntax, [config] table presence, type matching against C, serde-rejected unknown fields, validator business rules, non-empty secret references, and the manifest-side cross-checks. Sub-project count: 7 → 9 (added schema rewrite + RequestContext API rewrite as #2 and #3; existing app-config/validate/auth/provision/push/ polish become #4-#9). This is a breaking change to the on-disk manifest schema; the in-tree example/app-demo is migrated as part of the work, and a migration guide ships with sub-project #2. --- .../specs/2026-05-19-cli-extensions-design.md | 1238 +++++++++-------- 1 file changed, 636 insertions(+), 602 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index c0466c2..1734b16 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -4,14 +4,17 @@ **Status:** Approved design (single-spec form), pending implementation plan **Branch:** `docs/extensible-cli-library-spec` -This single spec covers the full effort: turning `edgezero-cli` into an -extensible library, defining a per-service app-config file with a typed -Rust schema and `#[secret]` field annotations, adding four new commands -(`auth`, `provision`, `config validate`, `config push`), extending the -project generator to scaffold the new pieces, and updating `app-demo` to -exercise everything end-to-end. - -The work is organised into seven sub-projects so it can ship in seven +This single spec covers the full effort: a manifest schema rewrite that +introduces a logical-store / per-adapter-mapping model for KV / secrets / +config, a runtime API rewrite that supports multiple stores per kind, +turning `edgezero-cli` into an extensible library, defining a per-service +app-config file with a typed Rust schema and `#[secret]` field +annotations, adding four new commands (`auth`, `provision`, `config +validate`, `config push`), extending the project generator to scaffold +the new pieces, and updating `app-demo` to exercise everything +end-to-end. + +The work is organised into nine sub-projects so it can ship in nine incremental PRs, but the design decisions live here together so reviewers see the full picture in one place. @@ -30,12 +33,18 @@ myapp`) build their own CLI binary that: Alongside the extensibility substrate, ship: -- A typed per-service app-config file (e.g. `myapp.toml`) whose schema is - defined by the downstream app as a Rust struct, validated at lint time - by `config validate`, and uploaded to the platform config store by - `config push`. Fields annotated `#[secret]` in the struct are recognised - by the CLI: they are skipped during push (their values live in the - secret store) and their bindings are cross-checked during validate. +- A **multi-store manifest model**. The app declares logical stores it + uses (`[stores.kv] ids = ["foo", "bar"]`) and each adapter declares the + platform-specific name for each logical id, with room for + adapter-specific tuning. Stores are addressed in code by their logical + id (`ctx.kv_store("foo")`). +- A **typed per-service app-config file** (e.g. `myapp.toml`) whose + schema is defined by the downstream app as a Rust struct, validated at + lint time by `config validate`, and uploaded to the platform config + store by `config push`. Fields annotated `#[secret]` in the struct are + recognised by the CLI: they are skipped during push (their values live + in the secret store) and their references are sanity-checked during + validate. - Platform credential and resource management (`auth`, `provision`) that shells out to each platform's official CLI tool, with all shell-out calls wrapped in a mockable `CommandRunner` trait so CI stays hermetic. @@ -43,14 +52,17 @@ Alongside the extensibility substrate, ship: `-cli` crate (using the lib substrate) and a stub `.toml` app-config file. - An `app-demo` overhaul that demonstrates the finished system: - `app-demo.toml` with typed `AppDemoConfig` (including a `#[secret]` + multiple KV stores, typed `AppDemoConfig` (including a `#[secret]` field), `app-demo-cli` exposing every built-in plus the new commands, and one `app-demo-core` handler that reads a config value from the config store at runtime (proving the push-then-read flow). -The default `edgezero` binary remains backwards-compatible: every existing -subcommand keeps the same name, flags, and behaviour. New subcommands -(`auth`, `provision`, `config`) become additionally available. +The default `edgezero` binary remains backwards-compatible in spirit: +every existing subcommand keeps the same name and flag shape. The +manifest schema rewrite is a **breaking change** to the on-disk format — +the in-tree `examples/app-demo/edgezero.toml` is migrated as part of the +work. New subcommands (`auth`, `provision`, `config`) become additionally +available. ## 2. Non-goals @@ -65,15 +77,15 @@ subcommand keeps the same name, flags, and behaviour. New subcommands workflows are deferred until a real need surfaces. - No live-platform CI smoke tests. All tests run against a mock `CommandRunner`. -- No `app-demo` overhaul beyond what is needed to demonstrate the new - features. Existing handlers, the `app!` macro, and the manifest - schema stay as they are except for the additive changes called out - below (notably extending `[stores.*.adapters.]` to carry - provisioned IDs, and removing the deprecated `[stores.config.defaults]`). +- No on-disk migration helper for older `edgezero.toml` files using the + pre-rewrite store schema. The in-tree `examples/app-demo/edgezero.toml` + is the only file we migrate; external users follow the migration + guide in the new docs page. - No Spin-side implementation of `provision` or `config push` in this effort. A separate in-flight PR adds Spin support for the - `[stores.*]` schema; once that lands, the CLI's Spin path will be a - small follow-up because it uses the same manifest schema. Until then, + `[stores.*]` schema (which will adopt the new logical-id model); + once that lands, the CLI's Spin path will be a small follow-up + because it uses the same manifest schema. Until then, `--adapter spin` for these two commands logs a clear "not yet supported" message and exits non-zero. @@ -85,7 +97,7 @@ graph TB Macros["edgezero-macros
#[derive(AppConfig)]
#[secret] field attr"] - Core["edgezero-core
app_config::AppConfigMeta
app_config::load_app_config<C>"] + Core["edgezero-core
app_config::AppConfigMeta
app_config::load_app_config<C>
manifest::ManifestStores (logical ids)
manifest::AdapterStoresConfig (per-adapter mapping)
RequestContext::kv_store(id) / config_store(id) / secret_store(id)"] Lib --> EZ["edgezero (default bin)
all built-ins
no app struct"] Lib --> ADC["app-demo-cli (example)
all built-ins +
Auth/Provision/Config
typed on AppDemoConfig"] @@ -98,6 +110,8 @@ graph TB Macros -.emits AppConfigMeta impl.-> MACore Core -.AppConfigMeta trait.-> ADCore Core -.AppConfigMeta trait.-> MACore + Core -.RequestContext store API.-> ADCore + Core -.RequestContext store API.-> MACore ``` Key contracts: @@ -105,10 +119,22 @@ Key contracts: - **Substrate**: each built-in command is a `(pub *Args, pub run_*)` pair in `edgezero-cli`. Downstream `Subcommand` enums opt in by listing the variants they want. Opt-out is omission. +- **Multi-store manifest model**: the app declares logical store ids in + `[stores.]`; each adapter maps every logical id to a + platform-specific `name` in `[adapters..stores..]`, + optionally with adapter-specific tuning fields. Provisioned platform + resource IDs (Cloudflare namespace IDs, Fastly store IDs) live in the + adapter's native manifest (`wrangler.toml`, `fastly.toml`), not in + `edgezero.toml`. See §6.6 for the full schema. +- **Multi-store runtime API**: `ctx._store(logical_id) -> + Option` and `ctx._store_default() -> Option`. + Each adapter's setup builds a `BTreeMap` keyed by + the ids the manifest declares. - **Typed app-config + secrets**: downstream defines a struct with `#[derive(Deserialize, Validate, AppConfig)]`. Fields the runtime should read from the secret store are annotated `#[secret]`; their - value in the toml file is the **secret binding name** (a string). + value in the toml file is the **secret reference** (an app-defined + string — see §6.7 for the two valid runtime patterns). The `AppConfig` derive (from `edgezero-macros`) emits an `impl AppConfigMeta for MyConfig` that exposes `SECRET_FIELDS: &'static [&'static str]`. Downstream CLIs call the @@ -119,28 +145,18 @@ Key contracts: stdin, env). Tests inject a `MockCommandRunner` that records invocations and returns scripted outputs. CI never touches a real platform. -- **Provisioned IDs**: when `provision` creates a platform resource, the - resulting ID is written back to - `[stores..adapters.] id = "..."` in `edgezero.toml`. - This is the canonical source for `config push` and other commands. - Where the platform's own manifest also needs the ID (e.g. - `wrangler.toml [[kv_namespaces]] id = "..."`), `provision` writes - that too so deploys work, but `edgezero.toml` is the single source - the CLI reads from. - **Generator**: `edgezero new ` produces a workspace with `crates/-core` (using `#[derive(AppConfig)]`), `crates/-cli`, per-adapter crates, `.toml` app-config - stub, and `edgezero.toml`. The new `-cli` uses the lib - substrate verbatim. + stub, and `edgezero.toml` using the new logical-id store model. ## 4. End-state public API surface -Final shape after all seven sub-projects ship: +After all nine sub-projects ship: ```rust // crates/edgezero-cli/src/lib.rs (feature = "cli") -// Re-exports of arg structs (all #[non_exhaustive] for forward-compat) pub use args::{ AuthArgs, AuthSub, BuildArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, NewArgs, ProvisionArgs, ServeArgs, @@ -148,7 +164,6 @@ pub use args::{ pub fn init_cli_logger(); -// Built-in commands from the original CLI pub fn run_build(args: &BuildArgs) -> Result<(), String>; pub fn run_deploy(args: &DeployArgs) -> Result<(), String>; pub fn run_new(args: &NewArgs) -> Result<(), String>; @@ -156,12 +171,9 @@ pub fn run_serve(args: &ServeArgs) -> Result<(), String>; #[cfg(feature = "edgezero-adapter-axum")] pub fn run_dev() -> !; -// New commands pub fn run_auth(args: &AuthArgs) -> Result<(), String>; pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; -// Config commands: untyped (default edgezero binary) and typed (downstream). -// Both bounds include AppConfigMeta so secret-field handling is uniform. pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> where @@ -175,34 +187,37 @@ where + ::edgezero_core::app_config::AppConfigMeta; ``` -Public API from `edgezero-core` (additive): +From `edgezero-core`: ```rust -// crates/edgezero-core/src/app_config.rs - +// app_config module (new in sub-project #4) pub trait AppConfigMeta { - /// Field names whose runtime value comes from the secret store, not - /// the config store. Emitted by `#[derive(AppConfig)]`. const SECRET_FIELDS: &'static [&'static str]; } - pub fn load_app_config(path: &std::path::Path) -> Result where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; - pub fn load_app_config_raw(path: &std::path::Path) -> Result, AppConfigError>; + +// RequestContext store API (rewritten in sub-project #3) +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; +} ``` -Public derive from `edgezero-macros`: +From `edgezero-macros`: ```rust -// crates/edgezero-macros/src/lib.rs (re-export) pub use edgezero_macros_impl::AppConfig; // procedural derive ``` -Internal modules (`adapter`, `generator`, `scaffold`, `dev_server`, -`runner`, `provision`, `auth`, `config`) all stay private to -`edgezero-cli`. Only the symbols above are public. +Internal modules in `edgezero-cli` (`adapter`, `generator`, `scaffold`, +`dev_server`, `runner`, `provision`, `auth`, `config`) stay private. ## 5. End-state file layout @@ -218,12 +233,12 @@ crates/edgezero-cli/ scaffold.rs # (unchanged-ish, private) dev_server.rs # (unchanged, private; feature-gated) runner.rs # NEW: CommandSpec + CommandRunner trait + Real/Mock impls - auth.rs # NEW: auth subcommand impl (uses runner) - provision.rs # NEW: provision impl (uses runner + manifest writeback) - config.rs # NEW: validate + push impl (uses runner + secret handling) + auth.rs # NEW: auth subcommand impl + provision.rs # NEW: provision impl (writes IDs to native manifests) + config.rs # NEW: validate + push impl (secret handling, store targeting) templates/ - core/ # (existing; src/config.rs.hbs added in sub-project 2) - root/ # (existing; edgezero.toml.hbs updated) + core/ # (existing; src/config.rs.hbs added in sub-project #4) + root/ # (existing; edgezero.toml.hbs rewritten for new schema) cli/ # NEW: templates for -cli Cargo.toml.hbs src/main.rs.hbs @@ -232,37 +247,47 @@ crates/edgezero-cli/ lib_consumer.rs # NEW: external-consumer compile test crates/edgezero-core/src/ + manifest.rs # REWRITTEN store schema (logical ids + per-adapter name map) + context.rs # REWRITTEN store accessors (id-keyed; *_default helpers) app_config.rs # NEW: AppConfigMeta trait + load_app_config + raw loader - manifest.rs # UPDATED: [stores.*.adapters.].id field, drop [stores.config.defaults] + config_store.rs # (unchanged trait; contract macro takes id-keyed factory) + key_value_store.rs # (unchanged trait) + secret_store.rs # (unchanged trait) + +crates/edgezero-core/ # adapter store impls rewritten: +crates/edgezero-adapter-axum/src/{config_store,key_value_store,secret_store}.rs +crates/edgezero-adapter-cloudflare/src/{config_store,key_value_store,secret_store}.rs +crates/edgezero-adapter-fastly/src/{config_store,key_value_store,secret_store}.rs crates/edgezero-macros/ - Cargo.toml # adds the new proc-macro symbol + Cargo.toml src/ - lib.rs # NEW exports: AppConfig derive + lib.rs # NEW export: AppConfig derive app_config.rs # NEW: AppConfig derive impl examples/app-demo/ Cargo.toml # adds crates/app-demo-cli to members app-demo.toml # NEW: typed app config with one #[secret] field - edgezero.toml # UPDATED: remove [stores.config.defaults]; add [stores.config.adapters.] id slots + edgezero.toml # REWRITTEN to new logical-id store schema crates/ app-demo-core/ - src/config.rs # NEW: pub struct AppDemoConfig with #[derive(AppConfig)] and #[secret] - src/handlers.rs # one handler reads from config store + src/config.rs # NEW: pub struct AppDemoConfig with #[derive(AppConfig)] + src/handlers.rs # one handler reads from config store via id app-demo-cli/ # NEW Cargo.toml src/main.rs # full Cmd enum: all built-ins + Auth/Provision/Config tests/help.rs # smoke test - app-demo-adapter-*/ # (unchanged) + app-demo-adapter-*/ # store setup updates only (read manifest, build registry) docs/guide/ - cli-walkthrough.md # NEW: full myapp loop (linked from .vitepress/config.ts sidebar) -.vitepress/config.ts # UPDATED: sidebar entry for cli-walkthrough + cli-walkthrough.md # NEW + manifest-store-migration.md # NEW: migrate pre-rewrite stores schemas +.vitepress/config.ts # UPDATED: sidebar entries for the new pages ``` ## 6. Cross-cutting designs -### 6.1 `CommandSpec` + `CommandRunner` (sub-project #4 introduces; #5 and #6 reuse) +### 6.1 `CommandSpec` + `CommandRunner` (sub-project #6 introduces; #7 and #8 reuse) ```rust // crates/edgezero-cli/src/runner.rs (private to the crate) @@ -292,39 +317,24 @@ impl CommandRunner for RealCommandRunner { /* std::process::Command */ } pub(crate) struct MockCommandRunner { /* recorded expectations */ } ``` -Why a struct (not a positional-args method): provisioned commands need -`cwd` (per-adapter manifest directories), `stdin` (Fastly `--stdin` for -large payloads), and `env` overrides (token isolation in tests). -Defining `CommandSpec` up front avoids churning every command-site when -those needs surface. +Defining the spec up front avoids churning every command-site when +`cwd` (per-adapter manifest directories), `stdin` (Fastly `--stdin`), +or `env` overrides (token isolation in tests) become necessary. -Public command functions use a private `*_with` inner function: +Public command functions use a private `*_with` inner function so tests +inject the mock: ```rust pub fn run_auth(args: &AuthArgs) -> Result<(), String> { run_auth_with(&RealCommandRunner, args) } - -fn run_auth_with(runner: &R, args: &AuthArgs) -> Result<(), String> { - // construct CommandSpec, invoke runner -} - -#[cfg(test)] -mod tests { - fn it_logs_into_cloudflare() { - let mock = MockCommandRunner::expect("wrangler", &["login"]); - run_auth_with(&mock, &AuthArgs { sub: AuthSub::Login { adapter: "cloudflare".into() } }).unwrap(); - } -} +fn run_auth_with(runner: &R, args: &AuthArgs) -> Result<(), String> { ... } ``` -Public surface stays clean (`run_auth(&args)`); tests bypass to inject -the mock. No public trait, no semver risk. - ### 6.2 Error model -All public `run_*` functions return `Result<(), String>`. This matches -the existing pattern in `edgezero-cli` today. Error formatting is the +All public `run_*` functions return `Result<(), String>`. Matches the +existing pattern in `edgezero-cli` today. Error formatting is the function's responsibility; callers (binaries) log and exit. ### 6.3 Feature gates (consumer-facing) @@ -335,7 +345,7 @@ For downstream `edgezero-cli` consumers: [dependencies] edgezero-cli = { version = "...", default-features = false, features = ["cli"] } # Plus the adapters the downstream wants: -# - edgezero-adapter-axum (only this for non-WASM, native, dev use) +# - edgezero-adapter-axum # - edgezero-adapter-cloudflare # - edgezero-adapter-fastly # - edgezero-adapter-spin @@ -343,33 +353,29 @@ edgezero-cli = { version = "...", default-features = false, features = ["cli"] } - `cli` (default) — gates clap and the whole public API. Required. - `edgezero-adapter-{axum,fastly,cloudflare,spin}` (all four default) — - each gates that adapter's dispatch path in build / deploy / serve / - provision / auth / config push. Disabling an adapter feature removes - that adapter from the `--adapter` matrix and causes the CLI to surface - a clear "adapter not compiled in" error if invoked. -- The new `auth`, `provision`, and `config-push` paths do not introduce - new feature flags. They are part of `cli`. Per-adapter logic inside - them is gated on the existing adapter features. - -Default-features-on remains the easiest mode for downstream — opting -out of adapters is for size-sensitive builds. + each gates that adapter's dispatch path. Disabling one removes the + adapter from the `--adapter` matrix and produces a clear + "adapter not compiled in" error. +- The new commands (`auth`, `provision`, `config-*`) don't introduce + new feature flags. Per-adapter logic inside them is gated on the + existing adapter features. ### 6.4 Typed vs raw config serialization The two `config validate` / `config push` flavours share the same -serialization rules but differ in schema awareness: +serialization rules but differ in schema awareness. **Both flavours:** - Top-level value of the toml file must be a `[config]` table. -- Each field is serialized to a string for storage in the config store: +- Each field is serialised to a string for storage in the config store: - `String` → as-is. - `bool`, integer, float → `to_string()`. - Compound types (arrays, maps, nested structs) → `serde_json::to_string`. - `Option::None` / `Value::Null` → field skipped entirely. - Fields whose name is in `AppConfigMeta::SECRET_FIELDS` are excluded - from push (their value is the secret-store binding name; the actual - secret material lives in the secret store). + from push (their value is the secret reference; the actual secret + material lives in the secret store). **Typed flavour (`run_config_*_typed::`):** @@ -377,9 +383,9 @@ serialization rules but differ in schema awareness: - Validates: `serde_json::to_value(&c)` must produce `Value::Object`; any other shape errors out before the runner is touched. - Honors serde attributes on `C`: - - `#[serde(rename = "k")]` — the renamed name is the storage key. - - `#[serde(flatten)]` — nested fields are merged into the top-level - map after the typed serialize step. + - `#[serde(rename = "k")]` — renamed name is the storage key. + - `#[serde(flatten)]` — nested fields merge into the top-level map + after the typed serialize step. - `#[serde(skip_serializing, skip_serializing_if = ...)]` — honored; such fields never reach the runner. - Runs `C::validate()` before serialization. @@ -394,7 +400,7 @@ serialization rules but differ in schema awareness: using the raw flavour must put secret references in a separate part of their workflow or use the typed flavour instead. -`config validate` and `config push` apply the same rules; push is just +`config validate` and `config push` apply the same rules; push is validate + upload, with `push` running validate's strict checks as a pre-flight before invoking any runner. @@ -407,12 +413,108 @@ pre-flight before invoking any runner. exercises the public API as a downstream binary would. - `examples/app-demo/crates/app-demo-cli/tests/help.rs` smoke-tests the generated/handwritten downstream pattern. +- Manifest contract tests grow to cover multi-store schemas, default + resolution, and unknown-id rejection. + +### 6.6 Multi-store manifest schema + +This is the cornerstone of sub-projects #2 and #3. + +**App-level (logical) declaration in `edgezero.toml`:** + +```toml +[stores.kv] +ids = ["foo", "bar"] +default = "foo" # optional when ids has exactly one entry + +[stores.config] +ids = ["app_config"] +default = "app_config" + +[stores.secrets] +ids = ["default"] +default = "default" +``` + +**Per-adapter mapping + optional tuning in `edgezero.toml`:** + +```toml +[adapters.cloudflare.stores.kv.foo] +name = "FOO_CLOUDFLARE" # the platform-specific name + +[adapters.cloudflare.stores.kv.bar] +name = "BAR_CLOUDFLARE" -### 6.6 Secret annotation via `#[derive(AppConfig)]` +[adapters.fastly.stores.kv.foo] +name = "FOO_FASTLY" +max_value = "1MB" # adapter-specific tuning, free-form + +[adapters.cloudflare.stores.config.app_config] +name = "APP_CONFIG_JSON" + +[adapters.cloudflare.stores.secrets.default] +name = "EDGEZERO_SECRETS" +``` + +**Field reference:** + +| Field | Where | Role | +|---|---|---| +| `[stores.].ids` | top level | logical ids the app's code uses (`Vec`). Must be non-empty. | +| `[stores.].default` | top level | which id is used when none is specified. Optional if `ids.len() == 1` (defaults to that one); required otherwise. Must appear in `ids`. | +| `[adapters..stores..].name` | per-adapter | the platform-specific name for that logical store on adapter X. Required. | +| any other field in that block | per-adapter | adapter-specific tuning. Stored as a `BTreeMap`; opaque to core; each adapter parses its own slice. | + +**Provisioned platform resource IDs (Cloudflare namespace IDs, Fastly +store IDs) do NOT live in `edgezero.toml`.** They live in each +platform's native manifest: + +- `wrangler.toml` for Cloudflare: + ```toml + [[kv_namespaces]] + binding = "FOO_CLOUDFLARE" # wrangler's term for what we call `name` in edgezero.toml + id = "abc123def456" + ``` +- `fastly.toml` for Fastly (each store kind has its own section). + +`provision` writes IDs into the native manifest. `config push` parses +the native manifest to find the ID it needs (e.g. `wrangler kv bulk +put --namespace-id=…`). + +**Validation rules (enforced by `ManifestLoader` and by `config validate`):** + +- `[stores.].ids` is non-empty. +- `[stores.].default` is in `ids`, or absent (then defaults to + `ids[0]`). +- For every adapter declared in `[adapters.*]` and every id in + `[stores.].ids`, there must be a corresponding + `[adapters..stores..]` block with a `name` field. + Missing mappings are errors. +- `name` strings are platform-syntax-validated where possible + (Cloudflare wrangler bindings must match JavaScript identifier + syntax — at least a warning if they don't). + +**Runtime resolution at adapter init:** + +The adapter walks `[adapters..stores..*]` and builds: + +```rust +struct StoreRegistry { + by_id: BTreeMap, + default_id: String, +} +``` + +`ctx.kv_store("foo")` returns `Some(registry.by_id["foo"])` or `None` if +unknown. `ctx.kv_store_default()` returns +`Some(registry.by_id[®istry.default_id])`. + +### 6.7 Secret annotation via `#[derive(AppConfig)]` **Goal:** let app-config structs declare which fields are secret-backed without inventing a new toml grammar. The Rust struct is the source of -truth; the toml just contains the secret-store binding names. +truth; the toml field carries a string the app uses to look up the +actual secret value at runtime. **Syntax:** @@ -430,8 +532,12 @@ pub struct AppDemoConfig { pub feature_new_checkout: bool, - /// Runtime value comes from the secret store. The `String` here is the - /// secret-store binding name written in app-demo.toml. + /// Runtime value comes from the secret store. The string in + /// app-demo.toml is the lookup key the app passes to its secret + /// store at runtime (either as a logical store id when calling + /// `ctx.secret_store(...)`, or as a key inside the default store + /// when calling `ctx.secret_store_default()?.get(...)` — the app + /// chooses). #[secret] pub api_token: String, } @@ -444,7 +550,7 @@ pub struct AppDemoConfig { greeting = "hello from app-demo" timeout_ms = 1500 feature_new_checkout = false -api_token = "APP_DEMO_API_TOKEN" # secret-store binding name +api_token = "APP_DEMO_API_TOKEN" # secret reference (app-defined semantics) ``` **What the derive emits:** @@ -461,32 +567,26 @@ honored — the derive reads the serde rename and uses the renamed name in **CLI behaviour:** -- `config validate --typed`: for each name in `SECRET_FIELDS`, looks up - the corresponding toml value (must be a non-empty string) and asserts - it appears in `[stores.secrets]` (either directly as the store name - or as a per-adapter override). Failure: "field `api_token` is marked - `#[secret]` but its binding `APP_DEMO_API_TOKEN` is not declared in - `[stores.secrets]`". +- `config validate --typed`: for each name in `SECRET_FIELDS`, asserts + the corresponding toml value is a non-empty string and that + `[stores.secrets]` is declared in the manifest (i.e. the app has *a* + secret store available at runtime). We do not cross-check the value + against `[stores.secrets].ids` because the semantics of the string + (store id vs. key within the default store) are app-defined. - `config push --typed`: skips every `SECRET_FIELDS` entry. The secret - material is never written to the config store. (Seeding the secret - store itself is out of scope; users do that via `wrangler secret put`, - `fastly secret-store-entry create`, or env vars for axum.) + material is never written to the config store. -**Runtime usage in service code:** +**Runtime usage in service code (two valid patterns):** ```rust -// Inside a handler -let binding = &config.api_token; // "APP_DEMO_API_TOKEN" -let token = ctx.secrets().get(binding).await?; // actual secret value -``` - -The service code is explicit about reading from the secret store; the -struct's String field just carries the binding name. +// Pattern A: treat the value as a logical store id (multi-store secrets). +let store_id = &config.api_token; // "APP_DEMO_API_TOKEN" +let token = ctx.secret_store(store_id)?.get("value").await?; -**`Validate` interaction:** `#[secret]` and `#[validate(...)]` compose -freely. `Validate` runs against the binding name (the string in the -struct), so e.g. `#[validate(length(min = 1))]` on a `#[secret]` field -enforces the binding-name is non-empty. +// Pattern B: treat the value as a key within the default secret store. +let key = &config.api_token; // "APP_DEMO_API_TOKEN" +let token = ctx.secret_store_default()?.get(key).await?; +``` --- @@ -494,208 +594,199 @@ enforces the binding-name is non-empty. **Goal:** establish the substrate. After this ships, downstream projects can build their own CLI against the lib using only the existing five -built-ins. Default `edgezero` is backwards-compatible (no new commands, -no flag changes). +built-ins. Default `edgezero` is backwards-compatible. **Source changes:** - `crates/edgezero-cli/src/args.rs` — promote each `Command` variant's inline fields into a standalone `#[derive(clap::Args)]` struct - (`#[non_exhaustive]`). `NewArgs` already exists. The internal - `Command` enum becomes: - - ```rust - pub enum Command { - Build(BuildArgs), - Deploy(DeployArgs), - Dev, - New(NewArgs), - Serve(ServeArgs), - } - ``` - + (`#[non_exhaustive]`). `NewArgs` already exists. - `crates/edgezero-cli/src/lib.rs` (new) — declares the private modules, moves `init_cli_logger`, `load_manifest_optional`, `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, and the five handlers (renamed `handle_*` → `run_*`). -- `crates/edgezero-cli/src/main.rs` — shrinks to ~20 lines, dispatches - to the public `run_*` functions. -- Existing CLI tests move from `main.rs` to `lib.rs`. No assertion - changes. -- **Generator update**: `generator.rs` and `templates/` extended so that - `edgezero new ` also produces: - - `crates/-cli/Cargo.toml` (depends on `edgezero-cli` with - default features + clap + log) - - `crates/-cli/src/main.rs` (uses all five built-ins via the lib - substrate; same shape as the canonical downstream example in §3) - - Root `Cargo.toml.hbs` updated to include `crates/-cli` in - workspace members. - - `templates/cli/` directory created to hold the new Handlebars - templates. - - **No app-config file yet, no derive yet** — `.toml` and the - `#[derive(AppConfig)]` plumbing arrive in sub-project #2. +- `crates/edgezero-cli/src/main.rs` — shrinks to ~20 lines. +- Existing CLI tests move from `main.rs` to `lib.rs`. +- **Generator update**: `edgezero new ` produces a + `crates/-cli` crate that uses all five built-ins via the lib + substrate. Root `Cargo.toml.hbs` updated to include the new crate. + **No app-config file yet, no derive yet, no new manifest schema yet** + — those arrive in sub-projects #2 and #4. - `examples/app-demo/crates/app-demo-cli` (new crate, handwritten — - parallel to what the generator will produce): - - Added to `examples/app-demo/Cargo.toml` `members` list. - - `edgezero-cli = { path = "../../../../crates/edgezero-cli" }` added - to that workspace's `[workspace.dependencies]` (mirroring the - existing `edgezero-core` pattern in that file). - - `src/main.rs` mirrors the canonical downstream pattern, all five - built-ins, no custom subcommands yet. + parallel to what the generator produces). **Migration note:** projects created by sub-project #1's generator do -not auto-update when sub-project #2 lands. The generator is the source -of truth for new scaffolds; existing projects follow the documented -manual migration (add `app-config.rs`, add `.toml`). +not auto-update when later sub-projects land. The generator is the +source of truth for new scaffolds; existing projects follow the +documented manual migration. **Tests:** - All existing CLI tests pass after relocation. -- New `crates/edgezero-cli/tests/lib_consumer.rs`: external-consumer - integration test constructing `BuildArgs` and invoking `run_build` - against a temp-dir manifest. -- New `examples/app-demo/crates/app-demo-cli/tests/help.rs`: - `Args::try_parse_from(["app-demo-cli", "--help"])` exits with help - output and no panic. -- New generator test verifies `generate_new("test-app", ...)` produces - `crates/test-app-cli/Cargo.toml` and `src/main.rs` referencing the - right names. - -**CI:** all four existing gates (`fmt`, `clippy -D warnings`, -`cargo test`, feature `cargo check`). Spin wasm32 gate unaffected. - -**Ship gate:** `edgezero --help` lists the same five subcommands as -before with identical flags; `app-demo-cli --help` prints the same five -built-ins; `edgezero new throwaway-app && cd throwaway-app && cargo -check --workspace` succeeds. - -## 8. Sub-project 2 — App-config schema, derive macro, generic loader +- New `crates/edgezero-cli/tests/lib_consumer.rs`. +- New `examples/app-demo/crates/app-demo-cli/tests/help.rs`. +- Generator test verifies `generate_new("test-app", ...)` produces the + right crate and main file. -**Goal:** define the file format for per-service app config, the -`#[derive(AppConfig)]` macro that produces secret-field metadata, and -the generic loader the CLI uses. +**Ship gate:** `edgezero --help` lists the same five subcommands with +identical flags; `app-demo-cli --help` prints the same five built-ins; +`edgezero new throwaway-app && cd throwaway-app && cargo check +--workspace` succeeds. -**Source changes:** +## 8. Sub-project 2 — Manifest schema rewrite (logical stores + per-adapter mapping) -- `crates/edgezero-core/src/app_config.rs` (new): +**Goal:** replace the single-store-per-kind manifest schema with the +logical-id + per-adapter-mapping model described in §6.6. - ```rust - use serde::de::DeserializeOwned; - use validator::Validate; +**Source changes:** - pub trait AppConfigMeta { - const SECRET_FIELDS: &'static [&'static str]; - } +- `crates/edgezero-core/src/manifest.rs`: + - Replace `ManifestStores`, `ManifestKvConfig`, + `ManifestSecretsConfig`, `ManifestConfigStoreConfig` with new types + matching §6.6. Each `ManifestStoresKind` carries `ids: Vec` + and `default: Option` (resolves to `ids[0]` when absent). + - Add `ManifestAdapter.stores: AdapterStoresConfig` — a nested map of + kind → id → `AdapterStoreMapping { name: String, extras: + BTreeMap }`. + - Drop the old per-adapter override types (`ManifestKvAdapterConfig`, + `ManifestConfigAdapterConfig`, etc.) — superseded. + - Drop `[stores.config.defaults]` (was a fallback table; replaced by + `.toml` `[config]` once sub-project #9 lands; see §15 + note on the temporary axum-allowlist gap). + - Validation: enforce that `default` is in `ids`; enforce that every + adapter listed in `[adapters.*]` has a mapping block for every id + in every store kind; warn on platform-syntax-invalid `name` values. +- `crates/edgezero-core/src/manifest.rs` tests: + - Replace existing single-store contract tests with multi-store + versions. + - Add tests for default resolution, missing per-adapter mapping + errors, `extras` round-trip. + +- `examples/app-demo/edgezero.toml` migrated to the new schema. The + example introduces **two** KV ids (`session`, `cache`) and one each + for `config` and `secrets`, so the multi-store behaviour is + exercised end-to-end (downstream sub-projects #5, #7, #8 lean on + this). + +- New `docs/guide/manifest-store-migration.md` page documenting how to + migrate from the old single-store schema (referenced by `.vitepress` + sidebar). + +**No CLI or runtime changes in this sub-project** — only the manifest +schema and its validation. The runtime adapter code keeps compiling +because we update `examples/app-demo`'s manifest in lock-step, but the +runtime is still single-store-by-accident until sub-project #3 +rewrites the context API. + +To bridge: in this sub-project, the adapter store setup reads the new +schema and constructs only the `default` id's store (single-store +behaviour at runtime). Sub-project #3 replaces that placeholder with +true multi-store registries. - #[derive(Debug)] - pub struct AppConfigError(String); - // Display + Error impls +**Tests:** - pub fn load_app_config(path: &std::path::Path) -> Result - where - C: DeserializeOwned + Validate + AppConfigMeta, - { - // 1. Read file. - // 2. Parse TOML into a wrapper { config: C }. - // 3. Run C::validate(). - // 4. Return C. - } +- Manifest deserialization round-trips for the new schema. +- Default-resolution tests: omitted default with single id; omitted + default with multiple ids (error); explicit default not in ids + (error). +- Per-adapter mapping completeness test: missing `name` for a declared + id on a declared adapter → error. +- `extras` map captures unknown fields. - pub fn load_app_config_raw(path: &std::path::Path) - -> Result, AppConfigError>; - ``` +**Ship gate:** the example workspace builds and all existing handlers +keep working against the rewritten manifest, with the temporary +"single-default-id" runtime behaviour. - `app_config` is `pub use`d from `edgezero-core`'s `lib.rs`. No new - workspace deps (serde, validator, toml are already there). +## 9. Sub-project 3 — `RequestContext` store API rewrite + adapter store registries -- `crates/edgezero-macros/src/app_config.rs` (new): the `AppConfig` - derive. Parses the input struct, scans each field for the `#[secret]` - attribute, honors `#[serde(rename = "...")]`, and emits a single - `impl ::edgezero_core::app_config::AppConfigMeta` block with - `SECRET_FIELDS`. No other code is generated; the user's `Deserialize`, - `Validate`, etc., come from their own derives. +**Goal:** rewrite `RequestContext`'s store accessors to be +id-keyed, and update every adapter's store setup to build a registry +of stores keyed by logical id. - Errors at compile time on: - - Non-struct inputs. - - Tuple structs. - - Unknown attributes nested inside `#[secret(...)]` (the attribute is - a marker; `#[secret]` is accepted, `#[secret(name = "x")]` is not in - this version). +**Source changes:** -- `crates/edgezero-macros/src/lib.rs`: re-export the new derive - alongside the existing `action` / `app` proc macros. +- `crates/edgezero-core/src/context.rs`: + - Replace single-instance store accessors with id-keyed ones (§4 + excerpt). Existing handles inserted via `Extensions` are replaced + by a `StoreRegistry` type that holds the `BTreeMap` plus + the resolved `default_id`. + - Add `_default()` helpers that look up `default_id`. + - Existing tests for store accessors are rewritten for the new shape. + +- `crates/edgezero-adapter-axum/src/{config,key_value,secret}_store.rs`, + `crates/edgezero-adapter-cloudflare/src/{...}_store.rs`, + `crates/edgezero-adapter-fastly/src/{...}_store.rs`: + - Each `*Setup` (the code that builds the store handles during + request setup) walks `[adapters..stores..*]`, instantiates + one store per id using the per-adapter `name`, and inserts the + resulting `StoreRegistry` into the context's `Extensions`. + - Each individual `*Store` impl stays the same shape (`AxumConfigStore`, + `CloudflareConfigStore`, etc.) — they're still single-store types. + Only the *number of them per request* changes. + - For Cloudflare config: the platform model is one JSON binding per + store, so multi-config means multiple JSON bindings. + - Adapter-specific extras (the `extras` map on each mapping) are + parsed by the adapter when building the registry; current + adapters use none, but the extension point is in place. + +- `examples/app-demo` handlers: any handler reaching for `kv_store()`, + `config_store()`, or `secret_store()` is updated to pass an explicit + id (or call `_default()`). For app-demo's two KV ids, the demo + handlers use both to prove the registry works. -- `crates/edgezero-cli/src/templates/app/.toml.hbs` (new): +**Tests:** - ```toml - # {{name}} app runtime config. - # Values are pushed to the active config store via `edgezero config push`. - # Service code reads them at runtime via the config store binding. - # Secret-annotated fields are skipped by push; their values are the - # secret-store binding names and the actual secrets live in the secret store. - - [config] - greeting = "hello from {{name}}" - ``` +- Contract test macros gain an id-keyed factory variant. The old + factory shape (returns a single store) is reused for single-id + scenarios via `*_default()`. +- New cross-adapter test in `examples/app-demo`: a handler that reads + from a specific KV id works on every adapter that has a mapping + declared. -- `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` (new): - a `Config` struct with `#[derive(Deserialize, Serialize, - Validate, AppConfig)]` and a `greeting: String` field as the default - template. +**Ship gate:** multi-store handlers in `app-demo` work on at least the +axum adapter (the fully wired adapter in CI); contract tests pass on +all adapters. -- `examples/app-demo/app-demo.toml` (new, handwritten parallel): +## 10. Sub-project 4 — App-config schema, derive macro, generic loader - ```toml - [config] - greeting = "hello from app-demo" - timeout_ms = 1500 - feature_new_checkout = false - api_token = "APP_DEMO_API_TOKEN" - ``` +**Goal:** define the file format for per-service app config, the +`#[derive(AppConfig)]` macro that produces secret-field metadata, and +the generic loader the CLI uses. -- `examples/app-demo/crates/app-demo-core/src/config.rs` (new): - - ```rust - use serde::{Deserialize, Serialize}; - use validator::Validate; - use edgezero_macros::AppConfig; - - #[derive(Debug, Deserialize, Serialize, Validate, AppConfig)] - pub struct AppDemoConfig { - #[validate(length(min = 1))] - pub greeting: String, - #[validate(range(min = 100, max = 60000))] - pub timeout_ms: u32, - pub feature_new_checkout: bool, - #[secret] - #[validate(length(min = 1))] - pub api_token: String, - } - ``` +**Source changes:** -- Generator extension (continuation from sub-project #1's generator - work): also emit `-core/src/config.rs` from the new template, - and emit `.toml` at the project root. +- `crates/edgezero-core/src/app_config.rs` (new): `AppConfigMeta` trait, + `load_app_config(path)`, + `load_app_config_raw(path) -> BTreeMap`. +- `crates/edgezero-macros/src/app_config.rs` (new): the `AppConfig` + derive. Parses the input struct, scans for `#[secret]`, honors + `#[serde(rename = "...")]`, emits `AppConfigMeta` impl with + `SECRET_FIELDS`. Compile errors on non-struct / tuple-struct input + and on unknown nested attributes inside `#[secret(...)]`. +- `crates/edgezero-macros/src/lib.rs`: re-export `AppConfig` alongside + existing `action` / `app`. +- `crates/edgezero-cli/src/templates/app/.toml.hbs` (new): stub + app-config; greeting only. +- `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` (new): + `Config` with the derives. +- `examples/app-demo/app-demo.toml` (new) — typed values including the + `#[secret]` example. +- `examples/app-demo/crates/app-demo-core/src/config.rs` (new) — + `AppDemoConfig` struct. +- Generator extension: emit `.toml` and `-core/src/config.rs`. **Tests:** -- Unit tests for `load_app_config`: valid file, missing file, bad TOML, - validator failure, missing `[config]` table, missing-required-field. -- Round-trip test in `app-demo-core` that the example `app-demo.toml` - parses into `AppDemoConfig` and passes validation. -- Macro tests (`crates/edgezero-macros/tests/app_config_derive.rs`): - - Struct with no `#[secret]` fields emits empty `SECRET_FIELDS`. - - Struct with one `#[secret]` field emits `&["that_field"]`. - - `#[serde(rename = "k")]` is honored; the renamed key appears in - `SECRET_FIELDS`. - - Non-struct input fails with a clear `compile_error!`. +- `load_app_config` unit tests (valid, missing file, bad TOML, validator + failure, missing `[config]` table). +- Round-trip test for `AppDemoConfig` against `app-demo.toml`. +- Macro tests (`crates/edgezero-macros/tests/app_config_derive.rs`). -**Ship gate:** -`AppDemoConfig::SECRET_FIELDS == ["api_token"]` is asserted in a unit -test; `edgezero_core::app_config::load_app_config::(Path::new("examples/app-demo/app-demo.toml"))` -succeeds. +**Ship gate:** `AppDemoConfig::SECRET_FIELDS == ["api_token"]` asserted +in a unit test; `load_app_config::` succeeds against +the example. -## 9. Sub-project 3 — `config validate` command +## 11. Sub-project 5 — `config validate` command **Goal:** lint the project's TOML files locally with zero platform calls. @@ -705,65 +796,74 @@ succeeds. pub use args::ConfigValidateArgs; pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> -where - C: DeserializeOwned + Validate + AppConfigMeta; +where C: DeserializeOwned + Validate + AppConfigMeta; ``` -`ConfigValidateArgs`: - ```rust #[derive(clap::Args, Debug)] #[non_exhaustive] pub struct ConfigValidateArgs { #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, - /// .toml; auto-detected from [app].name if None. #[arg(long)] pub app_config: Option, - /// Also check cross-references (handlers, adapter consistency, secret bindings). #[arg(long)] pub strict: bool, } ``` -**Validation steps (in order):** +**Validation steps:** -1. Parse `edgezero.toml` at `args.manifest` via the existing - `ManifestLoader`. Report TOML syntax errors with file/line. -2. If an app-config file is provided or auto-detected, parse it: - - Non-typed path: `load_app_config_raw` — confirms structure. - - Typed path: `load_app_config::` — also runs `Validate`. +1. Parse `edgezero.toml`. Report syntax errors with file/line. +2. Parse `.toml` (raw or typed). 3. If `--strict`: - - Every adapter referenced in `[adapters.*]` has a matching set of - `[stores.*.adapters.*]` entries when bindings are overridden. + - Every adapter in `[adapters.*]` has a `name` mapping block for + every id in every `[stores.].ids`. - Every handler path in `[[triggers.http]]` is well-formed. - - **Typed path only:** for each field in `C::SECRET_FIELDS`, look up - its value in the parsed toml (must be a non-empty string) and - assert that string appears as a `[stores.secrets]` binding (either - `stores.secrets.name` or a per-adapter override). - - (Full check list pinned in the implementation plan.) - -**Output:** human-readable diagnostics; exits 0 on success, 1 on failure. - -**Tests:** - -- Valid manifest passes. -- Each kind of failure (syntax, schema, validator failure, missing - cross-reference, missing secret binding) produces a distinct error - message. -- Typed and non-typed paths covered. -- `app-demo-cli config validate --strict` is the canonical typed - integration test. + - **Typed path only:** for each name in `C::SECRET_FIELDS`, the + corresponding toml value is a non-empty string and + `[stores.secrets]` is declared (the app has a secret store + available at runtime). + +### What "validate the app config" means concretely + +The app-config file (`.toml`) is **validated in its own right**, +not just as a source of cross-references for the manifest. Concretely: + +| Check | Raw flavour | Typed flavour | +|------------------------------------|-------------|----------------| +| TOML syntax | yes | yes | +| Top-level `[config]` table exists | yes | yes | +| All entries are scalar/array/table | yes | yes | +| Deserialises into `C` | n/a | yes | +| Required fields present, types match `C` | n/a | yes (via serde) | +| Unknown fields rejected | n/a | yes (`#[serde(deny_unknown_fields)]` on `C` is the recommended pattern) | +| `C::validate()` business rules | n/a | yes (via `validator`) | +| `#[secret]` field values non-empty | n/a | yes (via `--strict`) | + +The typed flavour is the canonical one; downstream CLIs always wire it +up because they own the struct. The raw flavour exists for the default +`edgezero` binary, which doesn't know the struct. + +**Output:** human-readable diagnostics; exit 0 on success, 1 on failure. +Errors point at the file path and line where possible (`toml::de` carries +spans for most cases). + +**Tests:** valid manifest + valid app-config passes; each failure mode +above (TOML syntax, missing `[config]`, unknown field, type mismatch, +validator rule failure, missing required field, empty secret reference, +missing per-adapter store mapping, default-id not in ids) has a +dedicated fixture and produces a distinct error. `app-demo-cli config +validate --strict` is the canonical typed integration test. **Ship gate:** `app-demo-cli config validate --strict` exits 0 against -the example workspace; deliberately corrupted fixtures (bad syntax, -unbound secret) each fail with the expected error. +the example workspace; corrupted fixtures fail with expected messages. -## 10. Sub-project 4 — `auth` command +## 12. Sub-project 6 — `auth` command (+ `CommandRunner` infrastructure) -**Goal:** delegate per-adapter authentication to the native tool. No -edgezero-stored credentials. Introduces the `runner` module that -sub-projects 5 and 6 reuse. +**Goal:** delegate per-adapter authentication to the native tool; no +edgezero-stored credentials. Introduces the `runner` module reused by +later sub-projects. **Public API additions:** @@ -772,19 +872,10 @@ pub use args::{AuthArgs, AuthSub}; pub fn run_auth(args: &AuthArgs) -> Result<(), String>; ``` -**Clap shape:** `--adapter` lives on each subcommand, not the parent, -so the UX is `auth login --adapter cloudflare` (not `auth --adapter -cloudflare login`): +**Clap shape:** `--adapter` lives on each subcommand, not the parent: ```rust -#[derive(clap::Args, Debug)] -#[non_exhaustive] -pub struct AuthArgs { - #[command(subcommand)] - pub sub: AuthSub, -} - -#[derive(clap::Subcommand, Debug)] +pub struct AuthArgs { #[command(subcommand)] pub sub: AuthSub } pub enum AuthSub { Login { #[arg(long)] adapter: String }, Logout { #[arg(long)] adapter: String }, @@ -792,35 +883,29 @@ pub enum AuthSub { } ``` -**Per-adapter behaviour:** +UX: `auth login --adapter cloudflare`. + +**Per-adapter behaviour:** unchanged from the previous spec. | Adapter | Login | Logout | Status | |------------|-------------------------|-------------------------|-----------------------| -| axum | no-op (log message) | no-op | always "ok" | +| axum | no-op | no-op | always "ok" | | cloudflare | `wrangler login` | `wrangler logout` | `wrangler whoami` | | fastly | `fastly profile create` | `fastly profile delete` | `fastly profile list` | | spin | `spin cloud login` | `spin cloud logout` | `spin cloud info` | -All invocations go through `CommandRunner` using `CommandSpec` with -`cwd: None` and inherited env. The `runner` module (§6.1) lands here. - -**Tests:** +All invocations through `CommandRunner` using `CommandSpec`. -- For each (adapter, sub) pair: `MockCommandRunner` expectation. The - mock records the exact `CommandSpec` (program, args, cwd, env); - the test asserts them. -- Error cases: tool not found (program returns ENOENT), tool returns - non-zero exit. +**Tests:** for each (adapter, sub) pair, `MockCommandRunner` expectation +asserting exact `CommandSpec`; error cases (ENOENT, non-zero exit). -**Ship gate:** with the mock runner, `run_auth` produces the exact -expected subprocess invocation for every (adapter, sub) pair. +**Ship gate:** mock-runner verification across the full matrix. -## 11. Sub-project 5 — `provision` command +## 13. Sub-project 7 — `provision` command -**Goal:** create the underlying platform resources (KV namespace, secret -store, config store) declared in `[stores.*]` of `edgezero.toml`, and -write the resulting IDs back to `edgezero.toml` and (where the platform -requires) to the per-adapter manifest. +**Goal:** create the underlying platform resources for every logical +id in `[stores.].ids` on the named adapter, writing resulting +platform resource IDs to the **per-adapter native manifest**. **Public API additions:** @@ -844,68 +929,53 @@ pub struct ProvisionArgs { **Behaviour:** -For the named adapter, iterate over `[stores.kv]`, `[stores.secrets]`, -`[stores.config]` in `args.manifest`. For each enabled store, shell out -to create the resource (using the current platform-CLI syntax): +For the named adapter, iterate over every id in +`[stores.].ids` for kind ∈ {kv, secrets, config}. For each, look +up `[adapters..stores..].name` and shell out: -| Adapter | KV | Secrets | Config | -|------------|---------------------------------------------|-----------------------------------------------|----------------------------------------------| -| axum | no-op (local; env-backed) | no-op | no-op | -| cloudflare | `wrangler kv namespace create ` | (no-op; wrangler-managed at runtime via `wrangler secret put`) | `wrangler kv namespace create ` (config store is a separate KV namespace) | -| fastly | `fastly kv-store create --name ` | `fastly secret-store create --name ` | `fastly config-store create --name ` | -| spin | **not yet supported** — error out with a clear message pointing at the in-flight stores PR | same | same | - -(Spin behaviour: log "spin provision is not yet supported; a separate -PR is in flight to add `[stores.*]` support for the Spin adapter. Until -that lands, configure Spin variables manually." Exit non-zero.) +| Adapter | KV per id | Secrets per id | Config per id | +|------------|----------------------------------------------|---------------------------------------------|---------------------------------------------| +| axum | no-op (local; env-backed) | no-op | no-op | +| cloudflare | `wrangler kv namespace create ` | (no-op; secrets are runtime-managed) | `wrangler kv namespace create ` | +| fastly | `fastly kv-store create --name ` | `fastly secret-store create --name ` | `fastly config-store create --name ` | +| spin | **not yet supported** — error with pointer to the in-flight stores PR | same | same | `--dry-run` prints the would-be `CommandSpec`s without running them. -**Writeback to `edgezero.toml`:** after each successful create, parse -the tool's stdout to extract the resource ID, then update the manifest -in place by writing: +**Writeback to per-adapter native manifest:** -```toml -[stores.kv.adapters.cloudflare] -id = "" -``` +- **Cloudflare:** after each create, extract the namespace ID from the + tool's stdout and patch `wrangler.toml`: -The ID lives in `[stores..adapters.] id`. This is the -single source of truth for `config push` and other ID-consuming -commands. The `id` field is added to `ManifestLoader`'s schema as an -optional string. + ```toml + [[kv_namespaces]] + binding = "" + id = "" + ``` -**Writeback to per-adapter manifest:** for adapters whose own tooling -also needs the ID at deploy time: + (Wrangler's `binding` field is the same string as our + `[adapters.cloudflare.stores.kv.].name`.) -- **Cloudflare:** also patch `wrangler.toml` to add the namespace ID to - the matching `[[kv_namespaces]]` block. (Standard wrangler binding - pattern; needed for `wrangler deploy` to bind the namespace.) -- **Fastly:** no per-adapter manifest writeback needed; the Fastly - service references the store by ID at API call time, not at deploy - time. +- **Fastly:** patch `fastly.toml` with the resulting store ID under the + appropriate section. -**Tests:** +`edgezero.toml` is not modified by `provision`. The CLI parses +`wrangler.toml` / `fastly.toml` at `config push` time to find IDs. -- For each (adapter, store-kind) tuple, `MockCommandRunner` - expectations including the exact `CommandSpec` and a scripted stdout - that includes a sample ID. -- ID extraction: golden-file tests over recorded sample outputs from - `wrangler kv namespace create` and Fastly's create commands. -- Manifest writeback: temp-dir fixture provisions, then `edgezero.toml` - is re-read and contains the expected `[stores..adapters.] id`. -- `--dry-run` produces a list of would-be `CommandSpec`s without - invoking the runner; no manifest writeback either. +**Tests:** per-(adapter, store-kind) `MockCommandRunner` with scripted +stdout; ID-extraction parsers tested with golden recordings; +temp-fixture writeback verified; `--dry-run` produces commands without +invoking the runner or writing files. **Ship gate:** `app-demo-cli provision --adapter cloudflare --dry-run` -prints the expected `wrangler kv namespace create` invocations (one per -store kind that applies); a non-dry-run against the mock writes the IDs -back into the temp-fixture manifest. +prints the expected create invocations for every id; non-dry-run +against the mock writes IDs to the fixture `wrangler.toml`. -## 12. Sub-project 6 — `config push` command +## 14. Sub-project 8 — `config push` command **Goal:** upload `.toml`'s `[config]` values to the live config -store on a given adapter, skipping `#[secret]` fields. +store on a given adapter, skipping `#[secret]` fields. Targets the +default config store unless `--store` selects another. **Public API additions:** @@ -913,8 +983,7 @@ store on a given adapter, skipping `#[secret]` fields. pub use args::ConfigPushArgs; pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> -where - C: DeserializeOwned + Validate + Serialize + AppConfigMeta; +where C: DeserializeOwned + Validate + Serialize + AppConfigMeta; ``` ```rust @@ -925,7 +994,10 @@ pub struct ConfigPushArgs { pub manifest: PathBuf, #[arg(long)] pub adapter: String, - /// Auto-detect .toml from [app].name if None. + /// Logical id of the config store to push to. + /// Defaults to `[stores.config].default`. + #[arg(long)] + pub store: Option, #[arg(long)] pub app_config: Option, #[arg(long)] @@ -935,225 +1007,187 @@ pub struct ConfigPushArgs { **Behaviour:** -1. **Pre-flight validation** — internally run the same checks as - `run_config_validate_typed` (typed path) or `run_config_validate` - (raw path) with `--strict` semantics. Abort before any runner call - if validation fails. No separate `--strict` flag on push; it is - always strict. -2. Load app-config (raw map or typed struct). -3. Apply §6.4 serialization rules: - - Skip fields in `AppConfigMeta::SECRET_FIELDS` (typed path only). - - `Option::None` / `Value::Null` skipped. - - Scalars `to_string`, compounds `serde_json::to_string`. - - Typed path: assert `serde_json::to_value(&c)` is `Value::Object`; - error otherwise. -4. Read the resource ID from `[stores.config.adapters.].id` - in `args.manifest`. Error with "did you run `provision` first?" if - missing for a platform that needs it. -5. Shell out to the platform tool for bulk upload: - -| Adapter | Push | -|------------|-----------------------------------------------------------------------------------------------| -| axum | Write to `.edgezero/local-config.env` (gitignored). No runner call. | -| cloudflare | `wrangler kv bulk put --namespace-id=` (Wrangler 3.60+ syntax, space-form)| -| fastly | Per key: `fastly config-store-entry create --store-id= --key= --value=` via stdin where the value is large | -| spin | **not yet supported** — error message pointing at the in-flight stores PR; exit non-zero | +1. **Pre-flight strict validation.** Internally run the same checks as + `config validate --strict`. Abort before any runner call if it + fails. No separate `--strict` flag on push; it's always strict. +2. Load app-config (raw or typed) per §6.4. +3. Serialise per §6.4 (skipping `SECRET_FIELDS` in typed mode). +4. Resolve the target config id: `args.store.unwrap_or_else(|| + stores.config.default_id)`. Error if not in `[stores.config].ids`. +5. Look up `[adapters..stores.config.].name`. +6. For platforms that need a resource ID for the push command, parse + the adapter's native manifest (`wrangler.toml`, `fastly.toml`) to + find the ID matching that name. Error with "did you run `provision` + first?" if missing. +7. Shell out: + +| Adapter | Push | +|------------|---------------------------------------------------------------------------------------------------| +| axum | Write to `.edgezero/local-config-.env` (gitignored). No runner call. | +| cloudflare | `wrangler kv bulk put --namespace-id=` (Wrangler 3.60+ syntax, space-form) | +| fastly | Per key: `fastly config-store-entry create --store-id= --key= --value=` (large values via stdin) | +| spin | **not yet supported** — error with pointer to the in-flight stores PR | **Tests:** - Typed and non-typed paths. -- For each supported adapter, `MockCommandRunner` expectations - including the exact serialised payload (golden-file the JSON tempfile - contents for Cloudflare, golden-file each Fastly per-key spec). -- `#[secret]` field in `AppDemoConfig` confirmed to be **absent** from - the pushed payload. -- Missing `[stores.config.adapters.].id` → clear error. -- `--dry-run` prints the serialised payload and would-be `CommandSpec`s; - does not invoke the runner. - -**Ship gate:** `app-demo-cli config push --adapter cloudflare --dry-run` -shows the expected `wrangler kv bulk put` invocation, the JSON payload -omits `api_token`, and the namespace ID matches the fixture manifest's -`[stores.config.adapters.cloudflare] id`. - -## 13. Sub-project 7 — `app-demo` integration polish +- Per-adapter `MockCommandRunner` with golden JSON payloads. +- `#[secret]` field absent from pushed payload. +- Missing native-manifest ID → clear error. +- `--store` selects the named config store; default used when omitted. +- `--dry-run` prints payload + commands; no runner invocation. + +**Ship gate:** `app-demo-cli config push --adapter cloudflare +--dry-run` shows the expected invocation; `api_token` is omitted; +namespace ID comes from the fixture `wrangler.toml`. + +## 15. Sub-project 9 — `app-demo` integration polish **Goal:** prove the full system works end-to-end via the example. **Source changes (all in `examples/app-demo/`):** -- `edgezero.toml`: - - Remove `[stores.config.defaults]` entirely. Add a comment explaining - that `app-demo.toml` is now the source of truth. - - Leave `[stores.config]`, `[stores.kv]`, `[stores.secrets]` blocks; - `[stores..adapters.].id` slots will populate when - `provision` runs. +- `edgezero.toml` already migrated in sub-project #2. Sub-project #9 + adds the realistic multi-store demo data and removes the temporary + workarounds from sub-project #2 (none expected). - `crates/app-demo-cli/src/main.rs`: extend `Cmd` enum to include the - new variants: - - ```rust - #[derive(Subcommand)] - enum Cmd { - // Built-ins (same as sub-project #1): - Build(BuildArgs), Deploy(DeployArgs), Dev, New(NewArgs), Serve(ServeArgs), - // New commands: - Auth(AuthArgs), - Provision(ProvisionArgs), - #[command(subcommand)] - Config(ConfigCmd), - } - - #[derive(Subcommand)] - enum ConfigCmd { - Validate(ConfigValidateArgs), - Push(ConfigPushArgs), - } - ``` - - Dispatch for `Config::Validate` and `Config::Push` calls the **typed** - variants with `AppDemoConfig` as the type parameter. - -- `crates/app-demo-core/src/handlers.rs`: extend one existing handler - (e.g. `config_get`) so it reads a key via the config store binding. - Verify the integration after `config push` pushes real data to the - local axum config store. + new variants (`Auth`, `Provision`, `Config(ConfigCmd)`); dispatch + the `Config` arm to the **typed** variants with `AppDemoConfig`. +- `crates/app-demo-core/src/handlers.rs`: extend at least one handler + to read a key via `ctx.config_store_default()` so the + push-then-read flow is exercised end-to-end against the axum + adapter's file-backed store. +- **Axum allowlist gap from §6.6 / sub-project #2:** the old + `AxumConfigStore::from_env` used `[stores.config.defaults]` keys as + the env-var allowlist; that's now gone. Sub-project #9 wires the + axum config store init to read **app-config keys** (the loaded + `.toml` `[config]` table) as the allowlist instead. Same + ergonomic behaviour, one source. **Documentation:** -- New `docs/guide/cli-walkthrough.md` page (not `docs/cli/...` — the - VitePress sidebar groups everything under `docs/guide/`) showing the - full loop: - - 1. `edgezero new myapp` - 2. `cd myapp && cargo build` - 3. `myapp-cli auth login --adapter cloudflare` - 4. `myapp-cli provision --adapter cloudflare` - 5. `myapp-cli config validate --strict` - 6. `myapp-cli config push --adapter cloudflare` - 7. `myapp-cli deploy --adapter cloudflare` - 8. `curl https://myapp.example/config/greeting` - -- `.vitepress/config.ts` sidebar updated to include the new page under - the existing guide group. Without this, the page exists but is not - navigable. +- New `docs/guide/cli-walkthrough.md` showing the full myapp loop + (`new`, `auth`, `provision`, `validate`, `push`, `deploy`, + curl-verify). +- New `docs/guide/manifest-store-migration.md` (introduced in + sub-project #2 but finalised here once the full feature set is + reachable from docs). +- `.vitepress/config.ts` sidebar updated for both pages. **Tests:** -- `app-demo-cli config validate --strict` exits 0 against `app-demo.toml`. -- `app-demo-cli config push --adapter axum` writes a local-config file; - the running axum dev server reads `greeting` from the config store +- `app-demo-cli config validate --strict` exits 0. +- `app-demo-cli config push --adapter axum` writes the local file; a + running axum dev server reads `greeting` via `config_store_default()` and returns it on `/config/greeting`. -- The `--help` smoke test from sub-project #1 is extended to assert all - subcommands are listed. +- `--help` smoke test asserts all top-level subcommands. -**Ship gate:** end-to-end demo of the full loop in CI, using -`--adapter axum` and the local file-backed config store. No live -external calls; the `axum` adapter is the substrate for verifying real -push-then-read behaviour. The Cloudflare/Fastly paths are exercised in -mock-runner tests but not against real platforms in CI. +**Ship gate:** end-to-end demo of the full loop in CI using the axum +adapter. Cloudflare / Fastly paths exercised via mock-runner tests; no +real platform calls in CI. --- -## 14. Implementation order and milestones - -Each sub-project ships as one PR. Order is the §7–§13 order. Each PR -must keep all four CI gates green; no skipping (`-D warnings` stays). - -| # | Title | Net new public symbols | Risk | -|---|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|------| -| 1 | Extensible lib + scaffold | `BuildArgs`, `DeployArgs`, `NewArgs`, `ServeArgs`, `run_build`, `run_deploy`, `run_new`, `run_serve`, `run_dev`, `init_cli_logger` | M | -| 2 | App-config schema + derive | `edgezero_core::app_config::*`, `edgezero_macros::AppConfig` | M | -| 3 | `config validate` | `ConfigValidateArgs`, `run_config_validate`, `run_config_validate_typed` | L | -| 4 | `auth` (+ `CommandRunner`) | `AuthArgs`, `AuthSub`, `run_auth` | M | -| 5 | `provision` | `ProvisionArgs`, `run_provision` | H | -| 6 | `config push` | `ConfigPushArgs`, `run_config_push`, `run_config_push_typed` | M | -| 7 | `app-demo` polish + walk-through | (none) — uses everything above | L | - -**Risk notes:** - -- Sub-project #1 is the substrate; getting the `*Args` shape wrong here - forces churn later. Mitigated by `#[non_exhaustive]` on every Args - struct and the external-consumer integration test. -- Sub-project #2 now includes the `AppConfig` proc macro; macro testing - uses `trybuild`-style fixtures (or the project's existing macro test - pattern in `edgezero-macros`). -- Sub-project #5 (`provision`) is the highest risk: it shells out, - parses stdout to extract IDs, and writes back to both `edgezero.toml` - and per-adapter manifest files. We constrain blast radius by treating - manifest writeback as a separate step with golden-file tests on - recorded stdout samples and by supporting `--dry-run`. - -## 15. Risks and trade-offs - -- **API stability:** every public `*Args` struct is `#[non_exhaustive]` - so adding fields is non-breaking. New `run_*` functions are additive. - The `_typed::` / non-typed split adds two names per `config` - command, which is the deliberate trade — see §6.4. -- **Shell-out fragility:** platform CLI surfaces change over time - (Wrangler 3.60+ moved from `kv:bulk` to `kv bulk`, etc.). We pin to - the current syntax at spec time, surface clear errors when tools are - missing or fail, and rely on tool versions already pinned via the - project's `.tool-versions`. Adapting to future syntax changes is one - edit per command in the relevant private module. +## 16. Implementation order and milestones + +Each sub-project ships as one PR. Order is §7–§15. Each PR must keep +all four CI gates green; no skipping (`-D warnings` stays). + +| # | Title | Risk | +|---|------------------------------------------------|------| +| 1 | Extensible lib + scaffold | M | +| 2 | Manifest schema rewrite | H | +| 3 | RequestContext store API + adapter registries | H | +| 4 | App-config schema + derive macro | M | +| 5 | `config validate` | L | +| 6 | `auth` + `CommandRunner` | M | +| 7 | `provision` | H | +| 8 | `config push` | M | +| 9 | `app-demo` integration polish | L | + +**Highest-risk sub-projects:** + +- **#2 (manifest schema rewrite):** breaking change to on-disk format; + ripples to every test that constructs a `ManifestStores`. Mitigated + by migrating in-tree only and shipping the migration guide. +- **#3 (RequestContext API):** every existing handler reading a store + needs an explicit id or `_default()` call. The `app-demo` handlers + are the only in-tree consumers; they get updated alongside the API. +- **#7 (`provision`):** shells out and writes to multiple native + manifest files. Manifest write-back is a separate step with golden + parser tests and `--dry-run` available. + +## 17. Risks and trade-offs + +- **Manifest breaking change:** every external user editing + `edgezero.toml` will need to update their store sections. Mitigation: + the `manifest-store-migration.md` guide is published with sub-project + #2; the validator emits a useful error pointing at the guide if it + sees the old shape. +- **API stability of new types:** every public `*Args` struct is + `#[non_exhaustive]`. New `run_*` functions and `RequestContext` + methods are additive within this effort. +- **Shell-out fragility:** platform CLI surfaces change over time. We + pin to current syntax (Wrangler 3.60+ space-form), surface clear + errors when tools are missing or fail, and rely on `.tool-versions`. + Adapting to future syntax changes is one edit per command in the + relevant private module. - **ID writeback brittleness:** parsing tool stdout to extract IDs is inherently version-sensitive. Mitigation: per-tool parser functions - with golden-file tests over recorded sample outputs; `--dry-run` - available for safe inspection. + with golden-file tests; `--dry-run` available for safe inspection. - **Generator drift:** the generator produces a `-cli` whose shape must stay in sync with the canonical pattern used by - `app-demo-cli`. Sub-project #1 introduces a generator test that - compares structural expectations (file existence + key tokens). - Sub-project #2 extends the test to cover `-core/src/config.rs` - and `.toml`. -- **Proc macro coupling:** the `AppConfig` derive lives in - `edgezero-macros` but emits a path referencing `edgezero_core`. This - is the same pattern the existing `#[action]` macro uses; downstream - consumers must depend on both crates (already the workspace norm). -- **Multi-environment app-config:** explicitly out of scope (§2). When - needed, a follow-up spec will add `[config.]` support and a - `--env` flag on `config push`/`validate`. -- **Spin support gap:** `provision` and `config push` do not work for - `--adapter spin` in this effort; both error out with a pointer to - the in-flight stores PR. Sub-project ship gates work around this by - only smoke-testing the axum / mock paths. -- **Test relocation in sub-project #1:** ~10 tests move from `main.rs` - to `lib.rs`. Diff looks large but is mechanical; reviewers will be - warned in the PR description. - -## 16. What this spec does not cover + `app-demo-cli`. Sub-projects #1 and #4 introduce generator tests + comparing structural expectations. +- **Proc macro coupling:** `AppConfig` derive emits a path referencing + `edgezero_core`. Same pattern as `#[action]`; downstream depends on + both crates already. +- **Cross-adapter name-syntax validity:** `[adapters.cloudflare. + stores..].name` must match JS identifier syntax (Cloudflare + worker binding constraint); `[adapters.fastly.stores..].name` + is freer. The validator warns on Cloudflare names that wouldn't work, + but does not block. +- **Multi-environment app-config:** explicitly out of scope. Follow-up + spec will add `[config.]` and `--env`. +- **Spin support gap:** `provision` and `config push` error out + for Spin until the separate stores PR lands and the CLI's small + follow-up is shipped. +- **Test relocation in sub-project #1:** ~10 tests move; mechanical diff. + +## 18. What this spec does not cover - Anthropic credentials, edge-network DNS / TLS, observability / metrics: separate concerns. -- Per-environment config (`production` vs. `staging`): explicit follow-up. -- Replacing or restructuring existing handlers in `app-demo-core` beyond - the single one that demonstrates push-then-read. -- Any change to `edgezero-core` beyond adding the `app_config` module - and the `[stores.*.adapters.].id` field on `ManifestLoader`. -- Removal of `[stores.config.defaults]` from anywhere except - `examples/app-demo/edgezero.toml`. Other consumers (if any in this - repo) that rely on `defaults` are unaffected for now; full deprecation - is a follow-up. +- Per-environment config: explicit follow-up. +- Replacing or restructuring existing handlers in `app-demo-core` + beyond the one demonstrating push-then-read and the multi-store KV + demo handler in sub-project #3. +- Any change to `edgezero-core` beyond `app_config`, the rewritten + `manifest` store schema, and the rewritten `RequestContext` store + API. +- An on-disk migration tool for the old manifest schema. Manual + migration via the published guide. - Spin-side store provisioning and config push: deferred until the - separate in-flight Spin stores PR lands. The CLI's Spin code paths - return a clear "not yet supported" error in the meantime. + separate Spin stores PR lands. -When all seven sub-projects ship, the system supports: +When all nine sub-projects ship: -- `edgezero new myapp` produces a workspace ready to build with - `myapp-cli`, a typed `MyappConfig` (using `#[derive(AppConfig)]` and - optional `#[secret]` fields), and a `myapp.toml`. +- `edgezero new myapp` produces a workspace with `myapp-cli`, a typed + `MyappConfig` (using `#[derive(AppConfig)]` and optional `#[secret]` + fields), a `myapp.toml`, and an `edgezero.toml` using the new + logical-store schema. +- App code addresses stores by logical id: + `ctx.kv_store("sessions")`, `ctx.config_store_default()`, + `ctx.secret_store("default")`. - The developer logs into their platforms (`myapp-cli auth login - --adapter X`), provisions stores (`myapp-cli provision --adapter X`), + --adapter X`), provisions stores (`myapp-cli provision --adapter X` + — creates every id declared, writes IDs to native manifests), validates and pushes their app config (`myapp-cli config validate --strict && myapp-cli config push --adapter X`), and deploys (`myapp-cli deploy --adapter X`). -- Resource IDs flow `provision` → `edgezero.toml [stores.*.adapters.*] - .id` → `config push`. Per-adapter manifests (e.g. `wrangler.toml`) - also get the IDs they need for deploy-time binding. - At runtime, the deployed service reads its config from the platform - config store via the existing edgezero store binding, and reads - secret-annotated fields from the secret store using the binding name - the struct carries. -- The default `edgezero` binary remains backwards-compatible for - everyone not building their own CLI, with the new commands additionally - available. + config store via `ctx.config_store_default()` / `ctx.config_store(id)`, + and reads secret-annotated fields from the secret store using the + reference string the struct carries. +- The default `edgezero` binary remains backwards-compatible (existing + commands stay; new subcommands are additionally available). From 0c1e118232d0ccdc0631ed6b0a7a9767bd0c94c4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 18:02:01 -0700 Subject: [PATCH 05/38] Apply second-pass review: runtime API completeness, Cloudflare KV, secret forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH severity fixes: - Cloudflare config store rewritten from [vars] to KV (§6.9) so `config push` actually reaches the runtime without redeploying. Lands in sub-project #3 alongside the rest of the runtime work. - Sub-project #2 is now purely additive on the schema: no runtime changes, no removal of [stores.config.defaults]. The runtime bridge and the defaults removal move out of #2 (into #3 and #9 respectively). - Spin completeness: validator skips adapters without an [adapters..stores] section. App-demo's Spin adapter omits stores until the in-flight Spin stores PR lands. - Extractor design (§6.8): existing Kv / Secrets extractors keep working as default-store accessors; new KvNamed / SecretsNamed extractors give type-safe named access. No handler-facing break. - Hooks, ConfigStoreMetadata, and app! macro added to sub-project #3 scope; they all become id-keyed. Multi-store rewrite is now complete. MEDIUM severity fixes: - Validate bound is DeserializeOwned + Validate + AppConfigMeta (no Serialize). The serde_json::to_value object check is push-only; push adds Serialize. - Secret semantics: two explicit forms via attribute. #[secret] = key inside the default secret store. #[secret(store_ref)] = logical store id in [stores.secrets].ids. Validate cross-checks the latter. - AppConfigMeta::SECRET_FIELDS is now &'static [SecretField] carrying SecretKind so the CLI can apply the right validation per field. - #[secret] constrained to non-flattened, non-renamed scalar fields; combinations with #[serde(flatten)] / rename / skip produce compile errors. Macro tests cover the constraints. - Unknown-field rejection is no longer a validate guarantee; the generator template emits #[serde(deny_unknown_fields)] on the generated config struct so new projects opt in by default. - Every public *Args derives Default + #[non_exhaustive]; external construction documented as Default + field mutation. LOW severity fixes: - Macro example fixed: #[proc_macro_derive(AppConfig, attributes( secret))] in edgezero-macros/src/lib.rs directly. No bogus _impl re-export. - Cloudflare-invalid JS-identifier `name` values are errors (would break worker deploy), not warnings. Sub-project ordering and risk: - #2 risk dropped to L (purely additive). - #3 grows to absorb Cloudflare KV swap + Hooks/macro/extractor. - #9 now also drops [stores.config.defaults] and wires axum dev-server to seed from .toml. --- .../specs/2026-05-19-cli-extensions-design.md | 1280 +++++++++-------- 1 file changed, 674 insertions(+), 606 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 1734b16..7ee154c 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -4,15 +4,21 @@ **Status:** Approved design (single-spec form), pending implementation plan **Branch:** `docs/extensible-cli-library-spec` -This single spec covers the full effort: a manifest schema rewrite that -introduces a logical-store / per-adapter-mapping model for KV / secrets / -config, a runtime API rewrite that supports multiple stores per kind, -turning `edgezero-cli` into an extensible library, defining a per-service -app-config file with a typed Rust schema and `#[secret]` field -annotations, adding four new commands (`auth`, `provision`, `config -validate`, `config push`), extending the project generator to scaffold -the new pieces, and updating `app-demo` to exercise everything -end-to-end. +This single spec covers the full effort: + +- a manifest schema rewrite that introduces a logical-store / + per-adapter-mapping model for KV / secrets / config, +- a runtime API rewrite that supports multiple stores per kind (including + rewriting the Cloudflare config store backend from `[vars]` to KV so + `config push` actually reaches the runtime, and updating `Hooks`, + `ConfigStoreMetadata`, the `app!` macro, and the `Kv` / `Secrets` + extractors), +- turning `edgezero-cli` into an extensible library, +- a per-service typed app-config file with `#[derive(AppConfig)]` and + `#[secret]` / `#[secret(store_ref)]` annotations, +- four new commands (`auth`, `provision`, `config validate`, `config push`), +- generator extensions to scaffold the new pieces, +- and an `app-demo` overhaul that exercises everything end-to-end. The work is organised into nine sub-projects so it can ship in nine incremental PRs, but the design decisions live here together so reviewers @@ -33,36 +39,35 @@ myapp`) build their own CLI binary that: Alongside the extensibility substrate, ship: -- A **multi-store manifest model**. The app declares logical stores it +- A **multi-store manifest model**: the app declares logical stores it uses (`[stores.kv] ids = ["foo", "bar"]`) and each adapter declares the - platform-specific name for each logical id, with room for - adapter-specific tuning. Stores are addressed in code by their logical - id (`ctx.kv_store("foo")`). + platform-specific `name` for each logical id, with room for + adapter-specific tuning. Stores are addressed in code by logical id + (`ctx.kv_store("foo")`). - A **typed per-service app-config file** (e.g. `myapp.toml`) whose schema is defined by the downstream app as a Rust struct, validated at lint time by `config validate`, and uploaded to the platform config - store by `config push`. Fields annotated `#[secret]` in the struct are - recognised by the CLI: they are skipped during push (their values live - in the secret store) and their references are sanity-checked during - validate. + store by `config push`. Fields annotated `#[secret]` are skipped during + push (the value is a key in the default secret store). Fields annotated + `#[secret(store_ref)]` are skipped during push **and** cross-checked + against `[stores.secrets].ids` (the value is a logical store id). +- **Cloudflare config-store rewrite** to read from a KV namespace + instead of a `[vars]` JSON blob. Required so `config push` reaches the + runtime without redeploying the worker. - Platform credential and resource management (`auth`, `provision`) that shells out to each platform's official CLI tool, with all shell-out calls wrapped in a mockable `CommandRunner` trait so CI stays hermetic. - A generator that scaffolds a new project complete with its own - `-cli` crate (using the lib substrate) and a stub `.toml` - app-config file. -- An `app-demo` overhaul that demonstrates the finished system: - multiple KV stores, typed `AppDemoConfig` (including a `#[secret]` - field), `app-demo-cli` exposing every built-in plus the new commands, - and one `app-demo-core` handler that reads a config value from the - config store at runtime (proving the push-then-read flow). + `-cli` crate, a stub `.toml` app-config file (with + `#[serde(deny_unknown_fields)]` on the generated config struct), and + an `edgezero.toml` using the new logical-id store model. +- An `app-demo` overhaul demonstrating the finished system end-to-end. The default `edgezero` binary remains backwards-compatible in spirit: -every existing subcommand keeps the same name and flag shape. The -manifest schema rewrite is a **breaking change** to the on-disk format — -the in-tree `examples/app-demo/edgezero.toml` is migrated as part of the -work. New subcommands (`auth`, `provision`, `config`) become additionally -available. +existing subcommands keep the same name and flag shape. The manifest +schema rewrite is a **breaking change** to the on-disk format. The +in-tree `examples/app-demo/edgezero.toml` is migrated as part of the +work; a published migration guide covers external users. ## 2. Non-goals @@ -80,14 +85,12 @@ available. - No on-disk migration helper for older `edgezero.toml` files using the pre-rewrite store schema. The in-tree `examples/app-demo/edgezero.toml` is the only file we migrate; external users follow the migration - guide in the new docs page. + guide. - No Spin-side implementation of `provision` or `config push` in this - effort. A separate in-flight PR adds Spin support for the - `[stores.*]` schema (which will adopt the new logical-id model); - once that lands, the CLI's Spin path will be a small follow-up - because it uses the same manifest schema. Until then, - `--adapter spin` for these two commands logs a clear "not yet - supported" message and exits non-zero. + effort. Spin's stores schema lands via a separate in-flight PR; + `[adapters.spin]` in `edgezero.toml` simply omits the `stores` + section until then. The CLI's Spin path is added as a small follow-up + once that PR ships. ## 3. Architecture overview @@ -95,60 +98,64 @@ available. graph TB Lib["edgezero-cli (lib)
pub *Args + pub run_*
internal: CommandRunner
internal: adapter / generator / ..."] - Macros["edgezero-macros
#[derive(AppConfig)]
#[secret] field attr"] + Macros["edgezero-macros
#[derive(AppConfig)]
#[secret] / #[secret(store_ref)] field attrs"] - Core["edgezero-core
app_config::AppConfigMeta
app_config::load_app_config<C>
manifest::ManifestStores (logical ids)
manifest::AdapterStoresConfig (per-adapter mapping)
RequestContext::kv_store(id) / config_store(id) / secret_store(id)"] + Core["edgezero-core
app_config::AppConfigMeta + load_app_config<C>
manifest::ManifestStores (logical ids)
manifest::AdapterStoresConfig (per-adapter mapping)
RequestContext::kv_store(id) / config_store(id) / secret_store(id)
Hooks::config_store(id) (id-keyed)
extractor::Kv / Secrets (default) + KvNamed / SecretsNamed"] Lib --> EZ["edgezero (default bin)
all built-ins
no app struct"] Lib --> ADC["app-demo-cli (example)
all built-ins +
Auth/Provision/Config
typed on AppDemoConfig"] Lib --> MAC["myapp-cli (downstream)
subset of built-ins +
custom typed AppConfig"] - ADC --> ADCore["app-demo-core
#[derive(AppConfig)]
pub struct AppDemoConfig
fields can be #[secret]"] + ADC --> ADCore["app-demo-core
#[derive(AppConfig)]
pub struct AppDemoConfig
fields can be #[secret] or #[secret(store_ref)]"] MAC --> MACore["myapp-core
#[derive(AppConfig)]
pub struct MyappConfig"] Macros -.emits AppConfigMeta impl.-> ADCore Macros -.emits AppConfigMeta impl.-> MACore Core -.AppConfigMeta trait.-> ADCore Core -.AppConfigMeta trait.-> MACore - Core -.RequestContext store API.-> ADCore - Core -.RequestContext store API.-> MACore + Core -.RequestContext + Hooks + extractor API.-> ADCore + Core -.RequestContext + Hooks + extractor API.-> MACore ``` Key contracts: - **Substrate**: each built-in command is a `(pub *Args, pub run_*)` pair in `edgezero-cli`. Downstream `Subcommand` enums opt in by listing the - variants they want. Opt-out is omission. -- **Multi-store manifest model**: the app declares logical store ids in + variants they want. Opt-out is omission. Every `*Args` derives `Default` + so external tests and wrappers can construct via `Default + field + mutation` despite `#[non_exhaustive]`. +- **Multi-store manifest model**: app declares logical store ids in `[stores.]`; each adapter maps every logical id to a platform-specific `name` in `[adapters..stores..]`, optionally with adapter-specific tuning fields. Provisioned platform - resource IDs (Cloudflare namespace IDs, Fastly store IDs) live in the - adapter's native manifest (`wrangler.toml`, `fastly.toml`), not in - `edgezero.toml`. See §6.6 for the full schema. + resource IDs live in each platform's native manifest (`wrangler.toml`, + `fastly.toml`). See §6.6. - **Multi-store runtime API**: `ctx._store(logical_id) -> - Option` and `ctx._store_default() -> Option`. - Each adapter's setup builds a `BTreeMap` keyed by - the ids the manifest declares. + Option` and `ctx._store_default()`. `Hooks` gains the + same id-keyed shape. The `Kv` / `Secrets` extractors continue to work + for default-store access; new `KvNamed` / + `SecretsNamed` extractors give type-safe named access. + See §6.8. +- **Cloudflare config runtime moves to KV**: `CloudflareConfigStore` + reads from a KV namespace (one namespace per logical config id), + matching the rest of the multi-store model and allowing `config push` + to update config without redeploying the worker. - **Typed app-config + secrets**: downstream defines a struct with - `#[derive(Deserialize, Validate, AppConfig)]`. Fields the runtime - should read from the secret store are annotated `#[secret]`; their - value in the toml file is the **secret reference** (an app-defined - string — see §6.7 for the two valid runtime patterns). - The `AppConfig` derive (from `edgezero-macros`) emits an - `impl AppConfigMeta for MyConfig` that exposes - `SECRET_FIELDS: &'static [&'static str]`. Downstream CLIs call the - generic `run_config_validate_typed::` and `run_config_push_typed::` - bound on `C: DeserializeOwned + Validate + Serialize + AppConfigMeta`. + `#[derive(Deserialize, Validate, AppConfig)]`. Two annotations + declare secret-backed fields: + - `#[secret]` — value is a **key inside the default secret store**. + Validate checks: non-empty, `[stores.secrets]` exists. + - `#[secret(store_ref)]` — value is a **logical store id** in + `[stores.secrets].ids`. Validate cross-checks the id exists. + Push skips both. See §6.7. - **Shell-out isolation**: every subprocess call goes through a private - `CommandRunner` trait that takes a `CommandSpec` (program, args, cwd, - stdin, env). Tests inject a `MockCommandRunner` that records - invocations and returns scripted outputs. CI never touches a real + `CommandRunner` trait taking a `CommandSpec` (program, args, cwd, + stdin, env). Tests use `MockCommandRunner`; CI never touches a real platform. - **Generator**: `edgezero new ` produces a workspace with - `crates/-core` (using `#[derive(AppConfig)]`), - `crates/-cli`, per-adapter crates, `.toml` app-config - stub, and `edgezero.toml` using the new logical-id store model. + `crates/-core` (using `#[derive(AppConfig)]` + `#[serde( + deny_unknown_fields)]`), `crates/-cli`, per-adapter crates, + `.toml`, and `edgezero.toml` using the new schema. ## 4. End-state public API surface @@ -174,12 +181,15 @@ pub fn run_dev() -> !; pub fn run_auth(args: &AuthArgs) -> Result<(), String>; pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; +// Validate bound: DeserializeOwned + Validate + AppConfigMeta (no Serialize). pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> where C: serde::de::DeserializeOwned + validator::Validate + ::edgezero_core::app_config::AppConfigMeta; +// Push bound: add Serialize (needed for the serde_json::to_value object check +// and for the actual serialization). pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> where @@ -192,8 +202,22 @@ From `edgezero-core`: ```rust // app_config module (new in sub-project #4) pub trait AppConfigMeta { - const SECRET_FIELDS: &'static [&'static str]; + /// Per-field secret metadata. Empty array when no fields are #[secret]. + const SECRET_FIELDS: &'static [SecretField]; } + +pub struct SecretField { + pub name: &'static str, // Rust field name; also the toml key + pub kind: SecretKind, +} + +pub enum SecretKind { + /// Value is a key inside the default secret store. + KeyInDefault, + /// Value is a logical store id in [stores.secrets].ids. + StoreRef, +} + pub fn load_app_config(path: &std::path::Path) -> Result where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; pub fn load_app_config_raw(path: &std::path::Path) @@ -208,12 +232,24 @@ impl RequestContext { pub fn secret_store(&self, id: &str) -> Option; pub fn secret_store_default(&self) -> Option; } + +// Hooks trait (rewritten in sub-project #3): id-keyed accessors mirroring +// RequestContext. Existing default-only call sites stay backwards-compatible +// via the `_default()` helpers. + +// Extractors (extended in sub-project #3): +pub struct Kv(/* default kv store handle */); +pub struct Secrets(/* default secret store handle */); +pub struct KvNamed(/* named kv store handle */); +pub struct SecretsNamed(/* named secret store handle */); ``` -From `edgezero-macros`: +From `edgezero-macros` (it IS the proc-macro crate; no `_impl` split): ```rust -pub use edgezero_macros_impl::AppConfig; // procedural derive +// crates/edgezero-macros/src/lib.rs +#[proc_macro_derive(AppConfig, attributes(secret))] +pub fn derive_app_config(input: TokenStream) -> TokenStream { /* ... */ } ``` Internal modules in `edgezero-cli` (`adapter`, `generator`, `scaffold`, @@ -227,71 +263,77 @@ crates/edgezero-cli/ src/ lib.rs # public API; declares private modules main.rs # thin wrapper for the default edgezero bin - args.rs # all pub *Args structs + private Args/Command + args.rs # all pub *Args structs (#[non_exhaustive] + #[derive(Default)]) adapter.rs # (unchanged, private) generator.rs # extended: also scaffolds -cli + .toml + -core/src/config.rs scaffold.rs # (unchanged-ish, private) dev_server.rs # (unchanged, private; feature-gated) runner.rs # NEW: CommandSpec + CommandRunner trait + Real/Mock impls - auth.rs # NEW: auth subcommand impl - provision.rs # NEW: provision impl (writes IDs to native manifests) - config.rs # NEW: validate + push impl (secret handling, store targeting) + auth.rs # NEW + provision.rs # NEW + config.rs # NEW templates/ - core/ # (existing; src/config.rs.hbs added in sub-project #4) - root/ # (existing; edgezero.toml.hbs rewritten for new schema) - cli/ # NEW: templates for -cli + core/ # src/config.rs.hbs added in #4 with deny_unknown_fields + root/ # edgezero.toml.hbs rewritten for new schema + cli/ # NEW Cargo.toml.hbs src/main.rs.hbs app/ # NEW: .toml.hbs stub app-config tests/ - lib_consumer.rs # NEW: external-consumer compile test + lib_consumer.rs # NEW crates/edgezero-core/src/ manifest.rs # REWRITTEN store schema (logical ids + per-adapter name map) context.rs # REWRITTEN store accessors (id-keyed; *_default helpers) - app_config.rs # NEW: AppConfigMeta trait + load_app_config + raw loader + app_config.rs # NEW: AppConfigMeta trait + SecretField + SecretKind + loaders + extractor.rs # EXTENDED: KvNamed / SecretsNamed; existing Kv / Secrets keep working as default-store + hooks.rs # REWRITTEN: id-keyed Hooks accessors + app.rs # REWRITTEN ConfigStoreMetadata to a registry shape config_store.rs # (unchanged trait; contract macro takes id-keyed factory) key_value_store.rs # (unchanged trait) secret_store.rs # (unchanged trait) -crates/edgezero-core/ # adapter store impls rewritten: +crates/edgezero-macros/ + Cargo.toml + src/ + lib.rs # ADD: #[proc_macro_derive(AppConfig, attributes(secret))] + app_config.rs # NEW: derive impl (only public via lib.rs re-export of proc_macro) + app.rs # UPDATED: app! macro emits id-keyed ConfigStoreMetadata from new manifest schema + +# Adapter store impls rewritten for the multi-store model (sub-project #3): crates/edgezero-adapter-axum/src/{config_store,key_value_store,secret_store}.rs crates/edgezero-adapter-cloudflare/src/{config_store,key_value_store,secret_store}.rs crates/edgezero-adapter-fastly/src/{config_store,key_value_store,secret_store}.rs -crates/edgezero-macros/ - Cargo.toml - src/ - lib.rs # NEW export: AppConfig derive - app_config.rs # NEW: AppConfig derive impl +# Cloudflare config store specifically: rewritten to read from a KV namespace +# (one namespace per logical config id), not from a [vars] JSON binding. examples/app-demo/ Cargo.toml # adds crates/app-demo-cli to members - app-demo.toml # NEW: typed app config with one #[secret] field - edgezero.toml # REWRITTEN to new logical-id store schema + app-demo.toml # NEW: typed app config with #[secret] and #[secret(store_ref)] examples + edgezero.toml # REWRITTEN to new logical-id store schema; spin adapter omits stores section crates/ app-demo-core/ - src/config.rs # NEW: pub struct AppDemoConfig with #[derive(AppConfig)] - src/handlers.rs # one handler reads from config store via id + src/config.rs # NEW: AppDemoConfig with #[derive(AppConfig)] + src/handlers.rs # one handler reads from config store via _default(); another reads named kv app-demo-cli/ # NEW Cargo.toml - src/main.rs # full Cmd enum: all built-ins + Auth/Provision/Config - tests/help.rs # smoke test - app-demo-adapter-*/ # store setup updates only (read manifest, build registry) + src/main.rs + tests/help.rs + app-demo-adapter-*/ # store setup rewrites for multi-store docs/guide/ cli-walkthrough.md # NEW - manifest-store-migration.md # NEW: migrate pre-rewrite stores schemas -.vitepress/config.ts # UPDATED: sidebar entries for the new pages + manifest-store-migration.md # NEW +.vitepress/config.ts # UPDATED sidebar ``` ## 6. Cross-cutting designs -### 6.1 `CommandSpec` + `CommandRunner` (sub-project #6 introduces; #7 and #8 reuse) +### 6.1 `CommandSpec` + `CommandRunner` (introduced in sub-project #6) ```rust -// crates/edgezero-cli/src/runner.rs (private to the crate) - +// crates/edgezero-cli/src/runner.rs (private) pub(crate) struct CommandSpec<'a> { pub program: &'a str, pub args: &'a [&'a str], @@ -304,122 +346,99 @@ 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; -impl CommandRunner for RealCommandRunner { /* std::process::Command */ } +pub(crate) struct CommandOutput { pub status: i32, pub stdout: String, pub stderr: String } +pub(crate) struct RealCommandRunner; // std::process::Command #[cfg(test)] pub(crate) struct MockCommandRunner { /* recorded expectations */ } ``` -Defining the spec up front avoids churning every command-site when -`cwd` (per-adapter manifest directories), `stdin` (Fastly `--stdin`), -or `env` overrides (token isolation in tests) become necessary. - -Public command functions use a private `*_with` inner function so tests -inject the mock: - -```rust -pub fn run_auth(args: &AuthArgs) -> Result<(), String> { - run_auth_with(&RealCommandRunner, args) -} -fn run_auth_with(runner: &R, args: &AuthArgs) -> Result<(), String> { ... } -``` +Public command functions use a private `*_with` inner so tests inject +the mock without exposing the trait. ### 6.2 Error model -All public `run_*` functions return `Result<(), String>`. Matches the -existing pattern in `edgezero-cli` today. Error formatting is the -function's responsibility; callers (binaries) log and exit. +All public `run_*` return `Result<(), String>`. Matches the existing +pattern. Error formatting is the function's responsibility; binaries +log and exit. ### 6.3 Feature gates (consumer-facing) -For downstream `edgezero-cli` consumers: - ```toml [dependencies] edgezero-cli = { version = "...", default-features = false, features = ["cli"] } -# Plus the adapters the downstream wants: +# Plus the adapters wanted: # - edgezero-adapter-axum # - edgezero-adapter-cloudflare # - edgezero-adapter-fastly # - edgezero-adapter-spin ``` -- `cli` (default) — gates clap and the whole public API. Required. +- `cli` (default) — gates clap + public API. Required. - `edgezero-adapter-{axum,fastly,cloudflare,spin}` (all four default) — - each gates that adapter's dispatch path. Disabling one removes the - adapter from the `--adapter` matrix and produces a clear - "adapter not compiled in" error. -- The new commands (`auth`, `provision`, `config-*`) don't introduce - new feature flags. Per-adapter logic inside them is gated on the - existing adapter features. + each gates that adapter's dispatch path. Disabling removes the adapter + from the `--adapter` matrix and produces "adapter not compiled in". ### 6.4 Typed vs raw config serialization -The two `config validate` / `config push` flavours share the same -serialization rules but differ in schema awareness. +The two `config validate` / `config push` flavours share serialization +rules but differ in schema awareness. + +**Validate (both flavours):** -**Both flavours:** +- TOML syntax OK; top-level `[config]` table present; structure parses. +- Typed flavour additionally: + - Deserialises into `C`. + - Runs `C::validate()`. + - For each `SecretField` in `C::SECRET_FIELDS`: value is a non-empty + string. If `SecretKind::StoreRef`, the value must appear in + `[stores.secrets].ids`. +- Validate does **not** require `Serialize`. It performs no + `serde_json::to_value` check — that's push's responsibility. -- Top-level value of the toml file must be a `[config]` table. -- Each field is serialised to a string for storage in the config store: +**Push (both flavours):** + +- All validate checks run first as pre-flight (always strict). If + validate fails, push aborts before any runner call. +- Each field is serialised to a string for storage: - `String` → as-is. - `bool`, integer, float → `to_string()`. - - Compound types (arrays, maps, nested structs) → `serde_json::to_string`. + - Compound types → `serde_json::to_string`. - `Option::None` / `Value::Null` → field skipped entirely. -- Fields whose name is in `AppConfigMeta::SECRET_FIELDS` are excluded - from push (their value is the secret reference; the actual secret - material lives in the secret store). - -**Typed flavour (`run_config_*_typed::`):** - -- Requires `C: DeserializeOwned + Validate + Serialize + AppConfigMeta`. -- Validates: `serde_json::to_value(&c)` must produce `Value::Object`; - any other shape errors out before the runner is touched. -- Honors serde attributes on `C`: - - `#[serde(rename = "k")]` — renamed name is the storage key. - - `#[serde(flatten)]` — nested fields merge into the top-level map - after the typed serialize step. - - `#[serde(skip_serializing, skip_serializing_if = ...)]` — honored; - such fields never reach the runner. -- Runs `C::validate()` before serialization. - -**Raw flavour (`run_config_*`):** - -- Loads `BTreeMap` from the `[config]` table. -- Same scalar/compound serialization rules. -- No `Validate` (the default `edgezero` binary doesn't know the schema). -- Secret-field exclusion is skipped (no `AppConfigMeta` available) — - the raw flavour pushes every field present in the toml. Operators - using the raw flavour must put secret references in a separate part - of their workflow or use the typed flavour instead. - -`config validate` and `config push` apply the same rules; push is -validate + upload, with `push` running validate's strict checks as a -pre-flight before invoking any runner. +- Fields in `C::SECRET_FIELDS` are skipped (typed flavour only). +- Typed flavour additionally: + - Asserts `serde_json::to_value(&c)` is `Value::Object`. Otherwise + errors out before the runner is touched. + - Honors `#[serde(rename = "k")]` (renamed name is the storage key) + and `#[serde(skip_serializing, skip_serializing_if = ...)]`. + - `#[serde(flatten)]` on **non-secret** fields is supported (flattened + keys land at the top level after the serialize step). `#[secret]` / + `#[secret(store_ref)]` on flattened fields is a compile error + (see §6.7). +- Raw flavour: + - `BTreeMap` from `[config]`. + - Same scalar/compound rules. + - No `Validate`, no secret-field skipping (no `AppConfigMeta`). + +**Unknown field handling:** serde's default is to silently ignore +unknown fields. The generator template emits `#[serde( +deny_unknown_fields)]` on the generated config struct so new projects +reject unknown fields by default. Existing structs without the +attribute follow serde's default behaviour; `config validate` therefore +makes no general guarantee about unknown-field rejection. ### 6.5 Test strategy summary - Existing CLI tests move alongside their handlers. -- New tests are added per sub-project for that sub-project's surface. -- Every test that would touch a platform uses `MockCommandRunner`. -- One external-consumer integration test (`tests/lib_consumer.rs`) - exercises the public API as a downstream binary would. -- `examples/app-demo/crates/app-demo-cli/tests/help.rs` smoke-tests the - generated/handwritten downstream pattern. -- Manifest contract tests grow to cover multi-store schemas, default - resolution, and unknown-id rejection. +- Per-sub-project tests for each new surface. +- Every platform-touching test uses `MockCommandRunner`. +- External-consumer integration test `tests/lib_consumer.rs`. +- `examples/app-demo/crates/app-demo-cli/tests/help.rs`. +- Manifest contract tests cover multi-store schemas, default + resolution, unknown-id rejection, Spin-skip behaviour for stores. ### 6.6 Multi-store manifest schema -This is the cornerstone of sub-projects #2 and #3. - **App-level (logical) declaration in `edgezero.toml`:** ```toml @@ -440,7 +459,7 @@ default = "default" ```toml [adapters.cloudflare.stores.kv.foo] -name = "FOO_CLOUDFLARE" # the platform-specific name +name = "FOO_CLOUDFLARE" # platform-specific name [adapters.cloudflare.stores.kv.bar] name = "BAR_CLOUDFLARE" @@ -450,24 +469,29 @@ name = "FOO_FASTLY" max_value = "1MB" # adapter-specific tuning, free-form [adapters.cloudflare.stores.config.app_config] -name = "APP_CONFIG_JSON" +name = "APP_CONFIG_KV" # KV namespace name (Cloudflare config = KV; see §6.9) [adapters.cloudflare.stores.secrets.default] name = "EDGEZERO_SECRETS" + +# spin omits the stores section entirely (until its in-flight stores PR lands): +[adapters.spin.adapter] +crate = "crates/app-demo-adapter-spin" +manifest = "crates/app-demo-adapter-spin/spin.toml" +# no [adapters.spin.stores.*] blocks; validator skips completeness for spin. ``` **Field reference:** | Field | Where | Role | |---|---|---| -| `[stores.].ids` | top level | logical ids the app's code uses (`Vec`). Must be non-empty. | -| `[stores.].default` | top level | which id is used when none is specified. Optional if `ids.len() == 1` (defaults to that one); required otherwise. Must appear in `ids`. | -| `[adapters..stores..].name` | per-adapter | the platform-specific name for that logical store on adapter X. Required. | -| any other field in that block | per-adapter | adapter-specific tuning. Stored as a `BTreeMap`; opaque to core; each adapter parses its own slice. | +| `[stores.].ids` | top level | logical ids (`Vec`). Non-empty. | +| `[stores.].default` | top level | the id used when none specified. Optional if `ids.len() == 1`. Must be in `ids`. | +| `[adapters..stores..].name` | per-adapter | platform-specific name. Required when adapter has a stores section. | +| any other field in that block | per-adapter | adapter-specific tuning. `BTreeMap` extras; opaque to core. | -**Provisioned platform resource IDs (Cloudflare namespace IDs, Fastly -store IDs) do NOT live in `edgezero.toml`.** They live in each -platform's native manifest: +**Provisioned platform resource IDs do not live in `edgezero.toml`.** +They go into each platform's native manifest: - `wrangler.toml` for Cloudflare: ```toml @@ -475,29 +499,30 @@ platform's native manifest: binding = "FOO_CLOUDFLARE" # wrangler's term for what we call `name` in edgezero.toml id = "abc123def456" ``` -- `fastly.toml` for Fastly (each store kind has its own section). +- `fastly.toml` for Fastly. `provision` writes IDs into the native manifest. `config push` parses -the native manifest to find the ID it needs (e.g. `wrangler kv bulk -put --namespace-id=…`). +the native manifest to find the ID it needs (e.g. `wrangler kv bulk put +--namespace-id=...`). -**Validation rules (enforced by `ManifestLoader` and by `config validate`):** +**Validation rules (enforced by `ManifestLoader`):** - `[stores.].ids` is non-empty. - `[stores.].default` is in `ids`, or absent (then defaults to `ids[0]`). -- For every adapter declared in `[adapters.*]` and every id in - `[stores.].ids`, there must be a corresponding +- **Adapter store completeness:** for every adapter declared in + `[adapters.*]` **that has an `[adapters..stores]` section**, every + id in every `[stores.].ids` must have a corresponding `[adapters..stores..]` block with a `name` field. - Missing mappings are errors. -- `name` strings are platform-syntax-validated where possible - (Cloudflare wrangler bindings must match JavaScript identifier - syntax — at least a warning if they don't). + Adapters without a `stores` section are skipped (this is how Spin + participates in the manifest before its stores PR lands). +- `name` strings used under `[adapters.cloudflare.stores.*]` must be + JavaScript identifier syntax (Wrangler binding constraint). Invalid + names are **errors**, not warnings — the platform would otherwise + fail to deploy. **Runtime resolution at adapter init:** -The adapter walks `[adapters..stores..*]` and builds: - ```rust struct StoreRegistry { by_id: BTreeMap, @@ -505,18 +530,12 @@ struct StoreRegistry { } ``` -`ctx.kv_store("foo")` returns `Some(registry.by_id["foo"])` or `None` if -unknown. `ctx.kv_store_default()` returns -`Some(registry.by_id[®istry.default_id])`. - -### 6.7 Secret annotation via `#[derive(AppConfig)]` +`ctx.kv_store("foo")` returns `Some(registry.by_id["foo"])` or `None` +if unknown. `ctx.kv_store_default()` returns the default-id handle. -**Goal:** let app-config structs declare which fields are secret-backed -without inventing a new toml grammar. The Rust struct is the source of -truth; the toml field carries a string the app uses to look up the -actual secret value at runtime. +### 6.7 Secret annotations via `#[derive(AppConfig)]` -**Syntax:** +**Two forms:** ```rust use serde::{Deserialize, Serialize}; @@ -524,22 +543,21 @@ use validator::Validate; use edgezero_macros::AppConfig; #[derive(Debug, Deserialize, Serialize, Validate, AppConfig)] +#[serde(deny_unknown_fields)] pub struct AppDemoConfig { - #[validate(length(min = 1))] pub greeting: String, - pub timeout_ms: u32, - pub feature_new_checkout: bool, - /// Runtime value comes from the secret store. The string in - /// app-demo.toml is the lookup key the app passes to its secret - /// store at runtime (either as a logical store id when calling - /// `ctx.secret_store(...)`, or as a key inside the default store - /// when calling `ctx.secret_store_default()?.get(...)` — the app - /// chooses). + /// Key inside the default secret store. Read via + /// `ctx.secret_store_default()?.get(&config.api_token).await`. #[secret] pub api_token: String, + + /// Logical secret-store id in [stores.secrets].ids. Read via + /// `ctx.secret_store(&config.vault).await`. + #[secret(store_ref)] + pub vault: String, } ``` @@ -550,202 +568,273 @@ pub struct AppDemoConfig { greeting = "hello from app-demo" timeout_ms = 1500 feature_new_checkout = false -api_token = "APP_DEMO_API_TOKEN" # secret reference (app-defined semantics) +api_token = "MY_API_TOKEN" # a key in the default secret store +vault = "credentials" # a logical id in [stores.secrets].ids ``` **What the derive emits:** ```rust impl ::edgezero_core::app_config::AppConfigMeta for AppDemoConfig { - const SECRET_FIELDS: &'static [&'static str] = &["api_token"]; + const SECRET_FIELDS: &'static [::edgezero_core::app_config::SecretField] = &[ + ::edgezero_core::app_config::SecretField { + name: "api_token", + kind: ::edgezero_core::app_config::SecretKind::KeyInDefault, + }, + ::edgezero_core::app_config::SecretField { + name: "vault", + kind: ::edgezero_core::app_config::SecretKind::StoreRef, + }, + ]; } ``` -Field names match the on-the-wire key (so `#[serde(rename = "...")]` is -honored — the derive reads the serde rename and uses the renamed name in -`SECRET_FIELDS`). +**Constraints (compile errors from the derive):** + +- `#[secret]` / `#[secret(store_ref)]` only on **scalar** fields + (must deserialize from a TOML string). +- Compile error if combined with `#[serde(flatten)]`, + `#[serde(rename = ...)]`, `#[serde(rename_all = ...)]` on the + containing struct in a way that changes the field's serialized + name, or `#[serde(skip_serializing)]` / `#[serde(skip)]`. +- No other `#[secret(...)]` variants. `#[secret(foo)]` with `foo` + outside `{store_ref}` is a compile error. +- `SECRET_FIELDS` uses the Rust field name verbatim. Renamed serde + keys are not supported; if you need to rename, don't make the field + secret (use a non-secret field that holds the lookup key). + +This explicit list keeps the macro implementation small and avoids the +"partial serde parser drift" risk. **CLI behaviour:** -- `config validate --typed`: for each name in `SECRET_FIELDS`, asserts - the corresponding toml value is a non-empty string and that - `[stores.secrets]` is declared in the manifest (i.e. the app has *a* - secret store available at runtime). We do not cross-check the value - against `[stores.secrets].ids` because the semantics of the string - (store id vs. key within the default store) are app-defined. -- `config push --typed`: skips every `SECRET_FIELDS` entry. The secret - material is never written to the config store. +- `config validate --typed`: for each `SecretField`: + - Both kinds: value is a non-empty string. + - `KeyInDefault`: assert `[stores.secrets]` is declared (the app has + *a* default secret store available). + - `StoreRef`: assert the value appears in `[stores.secrets].ids`. +- `config push --typed`: skips both kinds. Secret material is never + written to the config store. + +**Runtime usage in service code:** + +```rust +// #[secret] (KeyInDefault): +let token = ctx.secret_store_default()?.get(&config.api_token).await?; + +// #[secret(store_ref)] (StoreRef): +let vault = ctx.secret_store(&config.vault)?; +let token = vault.get("active").await?; +``` + +### 6.8 Extractor design + +Existing handler-facing extractors (`Kv`, `Secrets` from +[crates/edgezero-core/src/extractor.rs](crates/edgezero-core/src/extractor.rs)) +stay backwards-compatible after the runtime API rewrite: -**Runtime usage in service code (two valid patterns):** +- `Kv` resolves via `ctx.kv_store_default()` (was `kv_handle`). +- `Secrets` resolves via `ctx.secret_store_default()`. + +For named (non-default) stores, two new extractors with const-generic +ids: ```rust -// Pattern A: treat the value as a logical store id (multi-store secrets). -let store_id = &config.api_token; // "APP_DEMO_API_TOKEN" -let token = ctx.secret_store(store_id)?.get("value").await?; +pub struct KvNamed(KeyValueStoreHandle); +pub struct SecretsNamed(SecretHandle); -// Pattern B: treat the value as a key within the default secret store. -let key = &config.api_token; // "APP_DEMO_API_TOKEN" -let token = ctx.secret_store_default()?.get(key).await?; +// Usage: +#[action] +async fn handler(KvNamed(sessions): KvNamed<"sessions">) -> ... { ... } ``` +`FromRequest` impl looks up the id in the registry and fails the +extraction if missing. + +This preserves all existing handler signatures (they all use the +default store today) while adding type-safe named access. No +deprecation path needed for the default-store extractors. + +### 6.9 Cloudflare config store rewrite (`[vars]` → KV) + +Currently `CloudflareConfigStore` +([config_store.rs:1-12](crates/edgezero-adapter-cloudflare/src/config_store.rs#L1-L12)) +reads a single `[vars]` JSON-string binding. Changing config values +requires editing `wrangler.toml` and redeploying the worker. + +That's incompatible with the `config push` flow this spec describes, +which is designed to update config values without rebuild/redeploy. + +**Rewrite in sub-project #3:** `CloudflareConfigStore` reads from a KV +namespace, one per logical config id. The on-disk shape after this +ships: + +- `edgezero.toml`: + ```toml + [stores.config] + ids = ["app_config"] + default = "app_config" + + [adapters.cloudflare.stores.config.app_config] + name = "APP_CONFIG_KV" + ``` +- `wrangler.toml` (written by `provision`): + ```toml + [[kv_namespaces]] + binding = "APP_CONFIG_KV" + id = "abc123def456" + ``` +- Runtime: `await env.APP_CONFIG_KV.get("greeting")` (translated by the + adapter from the user-facing `ctx.config_store_default()?.get(...)`). + +`config push --adapter cloudflare` writes via +`wrangler kv bulk put --namespace-id=`. +No redeploy needed; values are live on the next request after KV +propagation. + +The `[vars]` model is removed entirely. Any existing +`[vars]` JSON-blob config in deployed workers gets migrated as a +one-time operation per workspace (documented in the migration guide). + +This means **the multi-store rewrite is incomplete without this Cloudflare +adapter rewrite** — they ship together in sub-project #3. + --- ## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton -**Goal:** establish the substrate. After this ships, downstream projects -can build their own CLI against the lib using only the existing five -built-ins. Default `edgezero` is backwards-compatible. +**Goal:** establish the substrate. After this ships, downstream +projects can build their own CLI against the lib using only the +existing five built-ins. Default `edgezero` is backwards-compatible. **Source changes:** - `crates/edgezero-cli/src/args.rs` — promote each `Command` variant's - inline fields into a standalone `#[derive(clap::Args)]` struct - (`#[non_exhaustive]`). `NewArgs` already exists. -- `crates/edgezero-cli/src/lib.rs` (new) — declares the private modules, - moves `init_cli_logger`, `load_manifest_optional`, - `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, - and the five handlers (renamed `handle_*` → `run_*`). + inline fields into a `#[derive(clap::Args, Default)]` struct, also + marked `#[non_exhaustive]`. `NewArgs` already exists. +- `crates/edgezero-cli/src/lib.rs` (new) — declares the private + modules, moves handlers (renamed `handle_*` → `run_*`). - `crates/edgezero-cli/src/main.rs` — shrinks to ~20 lines. -- Existing CLI tests move from `main.rs` to `lib.rs`. -- **Generator update**: `edgezero new ` produces a - `crates/-cli` crate that uses all five built-ins via the lib - substrate. Root `Cargo.toml.hbs` updated to include the new crate. - **No app-config file yet, no derive yet, no new manifest schema yet** - — those arrive in sub-projects #2 and #4. -- `examples/app-demo/crates/app-demo-cli` (new crate, handwritten — - parallel to what the generator produces). - -**Migration note:** projects created by sub-project #1's generator do -not auto-update when later sub-projects land. The generator is the -source of truth for new scaffolds; existing projects follow the -documented manual migration. +- Existing CLI tests move to `lib.rs`. +- **Generator update**: `edgezero new ` produces + `crates/-cli/{Cargo.toml, src/main.rs}` using all five + built-ins via the lib substrate. Root `Cargo.toml.hbs` updated. + **No app-config file yet, no derive yet, no new manifest schema yet.** +- `examples/app-demo/crates/app-demo-cli` (new crate, handwritten + parallel to the generator output). + +**External-construction note:** every public `*Args` derives `Default` +so external tests (including `tests/lib_consumer.rs`) construct via +`Default + field mutation` despite `#[non_exhaustive]`. **Tests:** - All existing CLI tests pass after relocation. -- New `crates/edgezero-cli/tests/lib_consumer.rs`. +- New `crates/edgezero-cli/tests/lib_consumer.rs`: constructs + `BuildArgs::default(); args.adapter = "fastly".into(); ...` and calls + `run_build(&args)`. - New `examples/app-demo/crates/app-demo-cli/tests/help.rs`. -- Generator test verifies `generate_new("test-app", ...)` produces the - right crate and main file. +- Generator test: `generate_new("test-app", ...)` produces correct + files. + +**Ship gate:** `edgezero --help` unchanged; `app-demo-cli --help` shows +the five built-ins; `edgezero new throwaway-app && cd throwaway-app && +cargo check --workspace` succeeds. -**Ship gate:** `edgezero --help` lists the same five subcommands with -identical flags; `app-demo-cli --help` prints the same five built-ins; -`edgezero new throwaway-app && cd throwaway-app && cargo check ---workspace` succeeds. +## 8. Sub-project 2 — Manifest schema additions (purely additive) -## 8. Sub-project 2 — Manifest schema rewrite (logical stores + per-adapter mapping) +**Goal:** add the new logical-store + per-adapter-mapping schema to +`ManifestStores` and `ManifestAdapter` **alongside** the existing +single-store fields. Nothing is removed yet; no runtime code changes. -**Goal:** replace the single-store-per-kind manifest schema with the -logical-id + per-adapter-mapping model described in §6.6. +This sub-project intentionally avoids any runtime adapter changes — +those land in sub-project #3 — and it does **not** drop +`[stores.config.defaults]` (still wired into axum's local-dev config +seeding via [dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349)). +Removing `defaults` happens in sub-project #9 when `.toml` +arrives as a runtime-accessible replacement. **Source changes:** - `crates/edgezero-core/src/manifest.rs`: - - Replace `ManifestStores`, `ManifestKvConfig`, - `ManifestSecretsConfig`, `ManifestConfigStoreConfig` with new types - matching §6.6. Each `ManifestStoresKind` carries `ids: Vec` - and `default: Option` (resolves to `ids[0]` when absent). - - Add `ManifestAdapter.stores: AdapterStoresConfig` — a nested map of - kind → id → `AdapterStoreMapping { name: String, extras: - BTreeMap }`. - - Drop the old per-adapter override types (`ManifestKvAdapterConfig`, - `ManifestConfigAdapterConfig`, etc.) — superseded. - - Drop `[stores.config.defaults]` (was a fallback table; replaced by - `.toml` `[config]` once sub-project #9 lands; see §15 - note on the temporary axum-allowlist gap). - - Validation: enforce that `default` is in `ids`; enforce that every - adapter listed in `[adapters.*]` has a mapping block for every id - in every store kind; warn on platform-syntax-invalid `name` values. -- `crates/edgezero-core/src/manifest.rs` tests: - - Replace existing single-store contract tests with multi-store - versions. - - Add tests for default resolution, missing per-adapter mapping - errors, `extras` round-trip. - -- `examples/app-demo/edgezero.toml` migrated to the new schema. The - example introduces **two** KV ids (`session`, `cache`) and one each - for `config` and `secrets`, so the multi-store behaviour is - exercised end-to-end (downstream sub-projects #5, #7, #8 lean on - this). - -- New `docs/guide/manifest-store-migration.md` page documenting how to - migrate from the old single-store schema (referenced by `.vitepress` - sidebar). - -**No CLI or runtime changes in this sub-project** — only the manifest -schema and its validation. The runtime adapter code keeps compiling -because we update `examples/app-demo`'s manifest in lock-step, but the -runtime is still single-store-by-accident until sub-project #3 -rewrites the context API. - -To bridge: in this sub-project, the adapter store setup reads the new -schema and constructs only the `default` id's store (single-store -behaviour at runtime). Sub-project #3 replaces that placeholder with -true multi-store registries. + - Add new `ManifestStoresKind { ids: Vec, default: Option }` + fields under `[stores.kv]`, `[stores.secrets]`, `[stores.config]`. + Old single-store fields (`name`, `enabled`, etc.) remain present + and continue to deserialise. + - Add `ManifestAdapter.stores: Option` — kind → + id → `AdapterStoreMapping { name: String, extras: BTreeMap }`. + - Validator rules from §6.6 (enforced when the new fields are + present; old-shape manifests pass unchanged). + - **Adapter store completeness skips adapters without + `[adapters..stores]`** — this is how Spin participates without + a stores impl. +- `crates/edgezero-core/src/manifest.rs` tests: cover the new schema, + default resolution, missing-mapping errors, Spin-skip behaviour, + Cloudflare JS-identifier validation as **errors**. +- `examples/app-demo/edgezero.toml` keeps its current shape; no + migration yet. (The migration happens in sub-project #3 alongside + the runtime API rewrite.) + +**No runtime, CLI, macro, or adapter changes in this sub-project.** It +only adds parseable schema and validation. **Tests:** -- Manifest deserialization round-trips for the new schema. -- Default-resolution tests: omitted default with single id; omitted - default with multiple ids (error); explicit default not in ids - (error). -- Per-adapter mapping completeness test: missing `name` for a declared - id on a declared adapter → error. -- `extras` map captures unknown fields. - -**Ship gate:** the example workspace builds and all existing handlers -keep working against the rewritten manifest, with the temporary -"single-default-id" runtime behaviour. - -## 9. Sub-project 3 — `RequestContext` store API rewrite + adapter store registries - -**Goal:** rewrite `RequestContext`'s store accessors to be -id-keyed, and update every adapter's store setup to build a registry -of stores keyed by logical id. - -**Source changes:** - -- `crates/edgezero-core/src/context.rs`: - - Replace single-instance store accessors with id-keyed ones (§4 - excerpt). Existing handles inserted via `Extensions` are replaced - by a `StoreRegistry` type that holds the `BTreeMap` plus - the resolved `default_id`. - - Add `_default()` helpers that look up `default_id`. - - Existing tests for store accessors are rewritten for the new shape. - -- `crates/edgezero-adapter-axum/src/{config,key_value,secret}_store.rs`, - `crates/edgezero-adapter-cloudflare/src/{...}_store.rs`, - `crates/edgezero-adapter-fastly/src/{...}_store.rs`: - - Each `*Setup` (the code that builds the store handles during - request setup) walks `[adapters..stores..*]`, instantiates - one store per id using the per-adapter `name`, and inserts the - resulting `StoreRegistry` into the context's `Extensions`. - - Each individual `*Store` impl stays the same shape (`AxumConfigStore`, - `CloudflareConfigStore`, etc.) — they're still single-store types. - Only the *number of them per request* changes. - - For Cloudflare config: the platform model is one JSON binding per - store, so multi-config means multiple JSON bindings. - - Adapter-specific extras (the `extras` map on each mapping) are - parsed by the adapter when building the registry; current - adapters use none, but the extension point is in place. - -- `examples/app-demo` handlers: any handler reaching for `kv_store()`, - `config_store()`, or `secret_store()` is updated to pass an explicit - id (or call `_default()`). For app-demo's two KV ids, the demo - handlers use both to prove the registry works. +- Round-trip deserialization for the new schema. +- Default-resolution: omitted with one id; omitted with multiple ids → + error; explicit not-in-ids → error. +- Per-adapter completeness: missing mapping for declared id on + adapter-with-stores → error; adapter without stores section → ok. +- Cloudflare `name` JS-syntax validation → error on invalid. +- Old-shape manifests parse unchanged. + +**Ship gate:** existing app-demo runtime keeps working unchanged +(verified by the existing test suite); manifest tests prove the new +schema is parseable and validated. + +## 9. Sub-project 3 — Runtime API + adapter store registry + macro/Hooks/extractor + Cloudflare KV rewrite + +**Goal:** the big runtime sub-project. After this, multi-store works +end-to-end at runtime on axum and Cloudflare. Includes: + +- `RequestContext` store accessors rewritten id-keyed (§4). +- `Hooks` trait gains id-keyed accessors. +- `ConfigStoreMetadata` becomes a registry shape (one entry per id). +- `app!` macro emits id-keyed metadata from the new manifest schema. +- `Kv` / `Secrets` extractors become default-store accessors; new + `KvNamed` / `SecretsNamed` const-generic extractors added (§6.8). +- Every adapter's store setup walks `[adapters..stores.*]` and + builds a `StoreRegistry`. +- **Cloudflare config store rewritten from `[vars]` to KV** (§6.9). + This is the cornerstone of `config push` working end-to-end. +- `examples/app-demo/edgezero.toml` migrated to the new schema. Spin + adapter omits the `stores` section. +- `examples/app-demo` handlers updated to call id-keyed accessors + (`config_store_default()`, `kv_store("sessions")`, etc.). + +**Compatibility:** the old single-store manifest fields removed from +`ManifestStores` and `ManifestAdapter`; in-tree consumers updated in +lockstep. External users follow the migration guide +(`docs/guide/manifest-store-migration.md`) shipped in this PR. **Tests:** -- Contract test macros gain an id-keyed factory variant. The old - factory shape (returns a single store) is reused for single-id - scenarios via `*_default()`. -- New cross-adapter test in `examples/app-demo`: a handler that reads - from a specific KV id works on every adapter that has a mapping - declared. - -**Ship gate:** multi-store handlers in `app-demo` work on at least the -axum adapter (the fully wired adapter in CI); contract tests pass on -all adapters. +- Contract-test macros gain id-keyed factory variants. +- Cross-adapter test in `examples/app-demo`: a handler reading from a + named KV id works on every adapter with the mapping declared. +- Cloudflare config-from-KV round-trip test using the existing + wasm-bindgen-test harness. +- `Kv` / `Secrets` extractors still work in default-store handler + signatures. +- `KvNamed<"sessions">` extractor compiles and works in a handler. +- `app!` macro test: generated `ConfigStoreMetadata` registry matches + the manifest's `[stores.config].ids`. + +**Ship gate:** multi-store handlers in `app-demo` work on axum and +Cloudflare (the latter via mock or wasm-bindgen-test); existing +handlers' default-store reads keep working; `config push` flow is +runtime-ready (push command itself lands in sub-project #8). ## 10. Sub-project 4 — App-config schema, derive macro, generic loader @@ -755,40 +844,54 @@ the generic loader the CLI uses. **Source changes:** -- `crates/edgezero-core/src/app_config.rs` (new): `AppConfigMeta` trait, - `load_app_config(path)`, +- `crates/edgezero-core/src/app_config.rs` (new): `AppConfigMeta` trait + with `SECRET_FIELDS: &[SecretField]`, `SecretField` + `SecretKind` + enum, `load_app_config(path)`, `load_app_config_raw(path) -> BTreeMap`. - `crates/edgezero-macros/src/app_config.rs` (new): the `AppConfig` - derive. Parses the input struct, scans for `#[secret]`, honors - `#[serde(rename = "...")]`, emits `AppConfigMeta` impl with - `SECRET_FIELDS`. Compile errors on non-struct / tuple-struct input - and on unknown nested attributes inside `#[secret(...)]`. -- `crates/edgezero-macros/src/lib.rs`: re-export `AppConfig` alongside - existing `action` / `app`. + derive. Implementation lives in the existing `edgezero-macros` + proc-macro crate (no new crate split). Parses input, scans for + `#[secret]` (KeyInDefault) and `#[secret(store_ref)]` (StoreRef), + enforces §6.7 constraints (compile errors on unsupported + combinations), emits `AppConfigMeta` impl with `SECRET_FIELDS`. +- `crates/edgezero-macros/src/lib.rs`: add the + `#[proc_macro_derive(AppConfig, attributes(secret))]` export. - `crates/edgezero-cli/src/templates/app/.toml.hbs` (new): stub - app-config; greeting only. + app-config (greeting only). - `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` (new): - `Config` with the derives. -- `examples/app-demo/app-demo.toml` (new) — typed values including the - `#[secret]` example. -- `examples/app-demo/crates/app-demo-core/src/config.rs` (new) — - `AppDemoConfig` struct. -- Generator extension: emit `.toml` and `-core/src/config.rs`. + `Config` with `#[derive(Deserialize, Serialize, + Validate, AppConfig)]` **and** `#[serde(deny_unknown_fields)]`. +- `examples/app-demo/app-demo.toml` (new) — typed values including one + `#[secret]` (`api_token`) and one `#[secret(store_ref)]` example + (`vault`). +- `examples/app-demo/crates/app-demo-core/src/config.rs` (new). +- Generator extension: emit `.toml` and + `-core/src/config.rs`. **Tests:** -- `load_app_config` unit tests (valid, missing file, bad TOML, validator - failure, missing `[config]` table). -- Round-trip test for `AppDemoConfig` against `app-demo.toml`. -- Macro tests (`crates/edgezero-macros/tests/app_config_derive.rs`). - -**Ship gate:** `AppDemoConfig::SECRET_FIELDS == ["api_token"]` asserted -in a unit test; `load_app_config::` succeeds against -the example. +- `load_app_config` unit tests. +- Round-trip for `AppDemoConfig` against `app-demo.toml`. +- Macro tests in `crates/edgezero-macros/tests/app_config_derive.rs`: + - Empty `SECRET_FIELDS` when no annotation. + - Single `KeyInDefault` entry from `#[secret]`. + - Single `StoreRef` entry from `#[secret(store_ref)]`. + - Both kinds in one struct. + - Compile error on `#[secret]` + `#[serde(flatten)]`. + - Compile error on `#[secret]` + `#[serde(rename = ...)]`. + - Compile error on `#[secret(unknown)]`. + - Compile error on `#[secret]` on a non-scalar field + (e.g. `#[secret] pub api: Vec`). + +**Ship gate:** `AppDemoConfig::SECRET_FIELDS` matches the expected two +entries; `load_app_config::` succeeds against the +example. ## 11. Sub-project 5 — `config validate` command -**Goal:** lint the project's TOML files locally with zero platform calls. +**Goal:** lint the project's TOML files locally with zero platform +calls. Validate the app config in its own right, not just as a source +of cross-references for the manifest. **Public API additions:** @@ -796,11 +899,11 @@ the example. pub use args::ConfigValidateArgs; pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> -where C: DeserializeOwned + Validate + AppConfigMeta; +where C: DeserializeOwned + Validate + AppConfigMeta; // no Serialize ``` ```rust -#[derive(clap::Args, Debug)] +#[derive(clap::Args, Default, Debug)] #[non_exhaustive] pub struct ConfigValidateArgs { #[arg(long, default_value = "edgezero.toml")] @@ -812,58 +915,38 @@ pub struct ConfigValidateArgs { } ``` -**Validation steps:** - -1. Parse `edgezero.toml`. Report syntax errors with file/line. -2. Parse `.toml` (raw or typed). -3. If `--strict`: - - Every adapter in `[adapters.*]` has a `name` mapping block for - every id in every `[stores.].ids`. - - Every handler path in `[[triggers.http]]` is well-formed. - - **Typed path only:** for each name in `C::SECRET_FIELDS`, the - corresponding toml value is a non-empty string and - `[stores.secrets]` is declared (the app has a secret store - available at runtime). - -### What "validate the app config" means concretely - -The app-config file (`.toml`) is **validated in its own right**, -not just as a source of cross-references for the manifest. Concretely: +**App-config validation (concrete checks):** | Check | Raw flavour | Typed flavour | -|------------------------------------|-------------|----------------| -| TOML syntax | yes | yes | -| Top-level `[config]` table exists | yes | yes | -| All entries are scalar/array/table | yes | yes | -| Deserialises into `C` | n/a | yes | +|------------------------------------|-------------|---------------| +| TOML syntax | yes | yes | +| `[config]` table exists | yes | yes | +| Deserialises into `C` | n/a | yes | | Required fields present, types match `C` | n/a | yes (via serde) | -| Unknown fields rejected | n/a | yes (`#[serde(deny_unknown_fields)]` on `C` is the recommended pattern) | -| `C::validate()` business rules | n/a | yes (via `validator`) | +| Unknown fields rejected | n/a | only if `C` is `#[serde(deny_unknown_fields)]` (generator template sets this) | +| `C::validate()` business rules | n/a | yes | | `#[secret]` field values non-empty | n/a | yes (via `--strict`) | +| `#[secret(store_ref)]` value in `[stores.secrets].ids` | n/a | yes (via `--strict`) | + +**Manifest validation (both flavours):** -The typed flavour is the canonical one; downstream CLIs always wire it -up because they own the struct. The raw flavour exists for the default -`edgezero` binary, which doesn't know the struct. +- TOML syntax + `ManifestLoader` schema checks. +- If `--strict`: + - Adapter-store completeness per §6.6 (Spin-skip honored). + - Handler paths in `[[triggers.http]]` well-formed. -**Output:** human-readable diagnostics; exit 0 on success, 1 on failure. -Errors point at the file path and line where possible (`toml::de` carries -spans for most cases). +**Output:** human-readable diagnostics with file/line where possible; +exit 0 on success, 1 on failure. -**Tests:** valid manifest + valid app-config passes; each failure mode -above (TOML syntax, missing `[config]`, unknown field, type mismatch, -validator rule failure, missing required field, empty secret reference, -missing per-adapter store mapping, default-id not in ids) has a -dedicated fixture and produces a distinct error. `app-demo-cli config -validate --strict` is the canonical typed integration test. +**Tests:** dedicated fixtures for every distinct failure mode. **Ship gate:** `app-demo-cli config validate --strict` exits 0 against -the example workspace; corrupted fixtures fail with expected messages. +the example; corrupted fixtures fail with expected messages. ## 12. Sub-project 6 — `auth` command (+ `CommandRunner` infrastructure) -**Goal:** delegate per-adapter authentication to the native tool; no -edgezero-stored credentials. Introduces the `runner` module reused by -later sub-projects. +**Goal:** delegate per-adapter authentication to the native tool. +Introduces the `runner` module reused by later sub-projects. **Public API additions:** @@ -872,10 +955,12 @@ pub use args::{AuthArgs, AuthSub}; pub fn run_auth(args: &AuthArgs) -> Result<(), String>; ``` -**Clap shape:** `--adapter` lives on each subcommand, not the parent: - ```rust +#[derive(clap::Args, Default, Debug)] +#[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 }, @@ -883,9 +968,10 @@ pub enum AuthSub { } ``` -UX: `auth login --adapter cloudflare`. +UX: `auth login --adapter cloudflare`. `Default` impl on `AuthArgs` +constructs a placeholder sub for trait completeness. -**Per-adapter behaviour:** unchanged from the previous spec. +**Per-adapter behaviour:** | Adapter | Login | Logout | Status | |------------|-------------------------|-------------------------|-----------------------| @@ -894,18 +980,17 @@ UX: `auth login --adapter cloudflare`. | fastly | `fastly profile create` | `fastly profile delete` | `fastly profile list` | | spin | `spin cloud login` | `spin cloud logout` | `spin cloud info` | -All invocations through `CommandRunner` using `CommandSpec`. +All via `CommandRunner`. -**Tests:** for each (adapter, sub) pair, `MockCommandRunner` expectation -asserting exact `CommandSpec`; error cases (ENOENT, non-zero exit). +**Tests:** mock-runner expectations across the full matrix; error +cases (ENOENT, non-zero exit). **Ship gate:** mock-runner verification across the full matrix. ## 13. Sub-project 7 — `provision` command -**Goal:** create the underlying platform resources for every logical -id in `[stores.].ids` on the named adapter, writing resulting -platform resource IDs to the **per-adapter native manifest**. +**Goal:** create platform resources for every logical id, writing +resulting IDs to the per-adapter native manifest. **Public API additions:** @@ -915,7 +1000,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; ``` ```rust -#[derive(clap::Args, Debug)] +#[derive(clap::Args, Default, Debug)] #[non_exhaustive] pub struct ProvisionArgs { #[arg(long, default_value = "edgezero.toml")] @@ -927,55 +1012,46 @@ pub struct ProvisionArgs { } ``` -**Behaviour:** - -For the named adapter, iterate over every id in -`[stores.].ids` for kind ∈ {kv, secrets, config}. For each, look -up `[adapters..stores..].name` and shell out: +**Behaviour:** iterate every id in `[stores.].ids` for kind ∈ +{kv, secrets, config}. For each, look up +`[adapters..stores..].name` and shell out: -| Adapter | KV per id | Secrets per id | Config per id | -|------------|----------------------------------------------|---------------------------------------------|---------------------------------------------| -| axum | no-op (local; env-backed) | no-op | no-op | -| cloudflare | `wrangler kv namespace create ` | (no-op; secrets are runtime-managed) | `wrangler kv namespace create ` | -| fastly | `fastly kv-store create --name ` | `fastly secret-store create --name ` | `fastly config-store create --name ` | -| spin | **not yet supported** — error with pointer to the in-flight stores PR | same | same | +| Adapter | KV per id | Secrets per id | Config per id | +|------------|--------------------------------------------|---------------------------------------------|-----------------------------------------------------------------| +| axum | no-op (local; env-backed) | no-op | no-op | +| cloudflare | `wrangler kv namespace create ` | (no-op; secrets are runtime-managed) | `wrangler kv namespace create ` (config is a KV namespace) | +| fastly | `fastly kv-store create --name=` | `fastly secret-store create --name=` | `fastly config-store create --name=` | +| spin | error: "not yet supported" (no stores section in manifest, so this id wouldn't appear) | same | same | -`--dry-run` prints the would-be `CommandSpec`s without running them. +`--dry-run` prints would-be `CommandSpec`s without invocation. **Writeback to per-adapter native manifest:** -- **Cloudflare:** after each create, extract the namespace ID from the - tool's stdout and patch `wrangler.toml`: - +- **Cloudflare:** patch `wrangler.toml`: ```toml [[kv_namespaces]] binding = "" id = "" ``` + (Wrangler's `binding` is the same string as our + `[adapters.cloudflare.stores..].name`.) +- **Fastly:** patch `fastly.toml` with store IDs. - (Wrangler's `binding` field is the same string as our - `[adapters.cloudflare.stores.kv.].name`.) - -- **Fastly:** patch `fastly.toml` with the resulting store ID under the - appropriate section. - -`edgezero.toml` is not modified by `provision`. The CLI parses -`wrangler.toml` / `fastly.toml` at `config push` time to find IDs. +`edgezero.toml` is not modified. -**Tests:** per-(adapter, store-kind) `MockCommandRunner` with scripted -stdout; ID-extraction parsers tested with golden recordings; -temp-fixture writeback verified; `--dry-run` produces commands without -invoking the runner or writing files. +**Tests:** per-(adapter, kind) `MockCommandRunner` with scripted +stdout; golden parser tests for ID extraction; temp-fixture writeback +verified; `--dry-run` invokes nothing. **Ship gate:** `app-demo-cli provision --adapter cloudflare --dry-run` prints the expected create invocations for every id; non-dry-run -against the mock writes IDs to the fixture `wrangler.toml`. +against the mock writes IDs to fixture `wrangler.toml`. ## 14. Sub-project 8 — `config push` command **Goal:** upload `.toml`'s `[config]` values to the live config -store on a given adapter, skipping `#[secret]` fields. Targets the -default config store unless `--store` selects another. +store, skipping `#[secret]` / `#[secret(store_ref)]` fields. Targets +the default config store unless `--store` selects another. **Public API additions:** @@ -983,11 +1059,11 @@ default config store unless `--store` selects another. pub use args::ConfigPushArgs; pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> -where C: DeserializeOwned + Validate + Serialize + AppConfigMeta; +where C: DeserializeOwned + Validate + Serialize + AppConfigMeta; // adds Serialize ``` ```rust -#[derive(clap::Args, Debug)] +#[derive(clap::Args, Default, Debug)] #[non_exhaustive] pub struct ConfigPushArgs { #[arg(long, default_value = "edgezero.toml")] @@ -1007,187 +1083,179 @@ pub struct ConfigPushArgs { **Behaviour:** -1. **Pre-flight strict validation.** Internally run the same checks as - `config validate --strict`. Abort before any runner call if it - fails. No separate `--strict` flag on push; it's always strict. +1. **Strict pre-flight validation.** Run the same checks as `config + validate --strict`. Abort before any runner call if it fails. 2. Load app-config (raw or typed) per §6.4. 3. Serialise per §6.4 (skipping `SECRET_FIELDS` in typed mode). -4. Resolve the target config id: `args.store.unwrap_or_else(|| - stores.config.default_id)`. Error if not in `[stores.config].ids`. +4. Resolve target id: `args.store.unwrap_or(stores.config.default_id)`. 5. Look up `[adapters..stores.config.].name`. -6. For platforms that need a resource ID for the push command, parse - the adapter's native manifest (`wrangler.toml`, `fastly.toml`) to - find the ID matching that name. Error with "did you run `provision` - first?" if missing. +6. For platforms needing a resource ID, parse the adapter's native + manifest. Error with "did you run `provision` first?" if absent. 7. Shell out: | Adapter | Push | |------------|---------------------------------------------------------------------------------------------------| | axum | Write to `.edgezero/local-config-.env` (gitignored). No runner call. | -| cloudflare | `wrangler kv bulk put --namespace-id=` (Wrangler 3.60+ syntax, space-form) | -| fastly | Per key: `fastly config-store-entry create --store-id= --key= --value=` (large values via stdin) | -| spin | **not yet supported** — error with pointer to the in-flight stores PR | - -**Tests:** +| cloudflare | `wrangler kv bulk put --namespace-id=` (Wrangler 3.60+ syntax) | +| fastly | Per key: `fastly config-store-entry create --store-id= --key= --value=` (`--stdin` for large values) | +| spin | error: "not yet supported" | -- Typed and non-typed paths. -- Per-adapter `MockCommandRunner` with golden JSON payloads. -- `#[secret]` field absent from pushed payload. -- Missing native-manifest ID → clear error. -- `--store` selects the named config store; default used when omitted. -- `--dry-run` prints payload + commands; no runner invocation. +**Tests:** typed + raw paths; per-adapter `MockCommandRunner` with +golden payloads; `#[secret]` and `#[secret(store_ref)]` fields absent +from pushed payload; missing native-manifest ID → clear error; +`--store` works; `--dry-run` invokes nothing. **Ship gate:** `app-demo-cli config push --adapter cloudflare ---dry-run` shows the expected invocation; `api_token` is omitted; -namespace ID comes from the fixture `wrangler.toml`. +--dry-run` shows expected invocation; secret fields absent; namespace +ID from fixture `wrangler.toml`. -## 15. Sub-project 9 — `app-demo` integration polish +## 15. Sub-project 9 — `app-demo` integration polish + drop `[stores.config.defaults]` -**Goal:** prove the full system works end-to-end via the example. +**Goal:** prove the full system works end-to-end and remove the +deprecated `[stores.config.defaults]` schema. -**Source changes (all in `examples/app-demo/`):** +**Source changes (all in `examples/app-demo/` plus the deprecation):** -- `edgezero.toml` already migrated in sub-project #2. Sub-project #9 - adds the realistic multi-store demo data and removes the temporary - workarounds from sub-project #2 (none expected). -- `crates/app-demo-cli/src/main.rs`: extend `Cmd` enum to include the - new variants (`Auth`, `Provision`, `Config(ConfigCmd)`); dispatch - the `Config` arm to the **typed** variants with `AppDemoConfig`. +- `crates/app-demo-cli/src/main.rs`: extend `Cmd` enum with + `Auth(AuthArgs)`, `Provision(ProvisionArgs)`, + `Config(ConfigCmd)`. Dispatch `Config::Validate` / + `Config::Push` to the **typed** variants with `AppDemoConfig`. - `crates/app-demo-core/src/handlers.rs`: extend at least one handler - to read a key via `ctx.config_store_default()` so the - push-then-read flow is exercised end-to-end against the axum - adapter's file-backed store. -- **Axum allowlist gap from §6.6 / sub-project #2:** the old - `AxumConfigStore::from_env` used `[stores.config.defaults]` keys as - the env-var allowlist; that's now gone. Sub-project #9 wires the - axum config store init to read **app-config keys** (the loaded - `.toml` `[config]` table) as the allowlist instead. Same - ergonomic behaviour, one source. + to read via `ctx.config_store_default()?.get("greeting")?` so the + push-then-read flow is exercised end-to-end against axum. + +**`[stores.config.defaults]` removal:** + +- Drop the `defaults` field from `ManifestConfigStoreConfig` in + `edgezero-core::manifest`. +- Drop the corresponding axum dev-server seeding code in + `dev_server.rs` (around line 349). +- Replace its behaviour: the **axum dev server seeds the local config + store from `.toml`**. The same file `config push` reads from + is now also the local-dev seed source. The allowlist behaviour + (only env-overridable keys) becomes "every key declared in + `.toml [config]`" — the typed struct's field names form the + allowlist. +- Update `examples/app-demo/edgezero.toml` to remove `[stores.config. + defaults]`. Values move to `app-demo.toml [config]`. **Documentation:** -- New `docs/guide/cli-walkthrough.md` showing the full myapp loop - (`new`, `auth`, `provision`, `validate`, `push`, `deploy`, - curl-verify). -- New `docs/guide/manifest-store-migration.md` (introduced in - sub-project #2 but finalised here once the full feature set is - reachable from docs). -- `.vitepress/config.ts` sidebar updated for both pages. +- `docs/guide/cli-walkthrough.md` finalised: full `myapp` loop. +- `docs/guide/manifest-store-migration.md` was introduced in #3; now + the navigation links resolve to a complete document. +- `.vitepress/config.ts` sidebar updated. **Tests:** - `app-demo-cli config validate --strict` exits 0. - `app-demo-cli config push --adapter axum` writes the local file; a - running axum dev server reads `greeting` via `config_store_default()` - and returns it on `/config/greeting`. -- `--help` smoke test asserts all top-level subcommands. + running axum dev server reads `greeting` via + `config_store_default()` and returns it on `/config/greeting`. +- `--help` smoke test asserts all subcommands. -**Ship gate:** end-to-end demo of the full loop in CI using the axum -adapter. Cloudflare / Fastly paths exercised via mock-runner tests; no -real platform calls in CI. +**Ship gate:** end-to-end demo of the full loop in CI on axum. --- ## 16. Implementation order and milestones -Each sub-project ships as one PR. Order is §7–§15. Each PR must keep -all four CI gates green; no skipping (`-D warnings` stays). - -| # | Title | Risk | -|---|------------------------------------------------|------| -| 1 | Extensible lib + scaffold | M | -| 2 | Manifest schema rewrite | H | -| 3 | RequestContext store API + adapter registries | H | -| 4 | App-config schema + derive macro | M | -| 5 | `config validate` | L | -| 6 | `auth` + `CommandRunner` | M | -| 7 | `provision` | H | -| 8 | `config push` | M | -| 9 | `app-demo` integration polish | L | +Each sub-project ships as one PR. Order is §7–§15. All four CI gates +green; no skipping (`-D warnings` stays). + +| # | Title | Risk | +|---|--------------------------------------------------------------------------------------------------------|------| +| 1 | Extensible lib + scaffold | M | +| 2 | Manifest schema additions (purely additive) | L | +| 3 | RequestContext + Hooks + extractor + Cloudflare KV rewrite + app! macro + adapter store registries | H | +| 4 | App-config schema + derive macro | M | +| 5 | `config validate` | L | +| 6 | `auth` + `CommandRunner` | M | +| 7 | `provision` | H | +| 8 | `config push` | M | +| 9 | `app-demo` polish + drop `[stores.config.defaults]` | M | **Highest-risk sub-projects:** -- **#2 (manifest schema rewrite):** breaking change to on-disk format; - ripples to every test that constructs a `ManifestStores`. Mitigated - by migrating in-tree only and shipping the migration guide. -- **#3 (RequestContext API):** every existing handler reading a store - needs an explicit id or `_default()` call. The `app-demo` handlers - are the only in-tree consumers; they get updated alongside the API. -- **#7 (`provision`):** shells out and writes to multiple native - manifest files. Manifest write-back is a separate step with golden - parser tests and `--dry-run` available. +- **#3 (runtime rewrite):** every store-touching path in core, + adapters, the macro, and the extractor system changes. Cloudflare + config-store backend swap is the biggest single change. Mitigations: + every adapter has contract tests; the existing default-store + handler signatures keep working; in-tree app-demo is the canary. +- **#7 (provision):** shell-out + multi-file manifest writeback. + Golden parser tests + `--dry-run` available. ## 17. Risks and trade-offs -- **Manifest breaking change:** every external user editing - `edgezero.toml` will need to update their store sections. Mitigation: - the `manifest-store-migration.md` guide is published with sub-project - #2; the validator emits a useful error pointing at the guide if it - sees the old shape. -- **API stability of new types:** every public `*Args` struct is - `#[non_exhaustive]`. New `run_*` functions and `RequestContext` - methods are additive within this effort. -- **Shell-out fragility:** platform CLI surfaces change over time. We - pin to current syntax (Wrangler 3.60+ space-form), surface clear - errors when tools are missing or fail, and rely on `.tool-versions`. - Adapting to future syntax changes is one edit per command in the - relevant private module. -- **ID writeback brittleness:** parsing tool stdout to extract IDs is - inherently version-sensitive. Mitigation: per-tool parser functions - with golden-file tests; `--dry-run` available for safe inspection. -- **Generator drift:** the generator produces a `-cli` whose - shape must stay in sync with the canonical pattern used by - `app-demo-cli`. Sub-projects #1 and #4 introduce generator tests - comparing structural expectations. -- **Proc macro coupling:** `AppConfig` derive emits a path referencing - `edgezero_core`. Same pattern as `#[action]`; downstream depends on - both crates already. -- **Cross-adapter name-syntax validity:** `[adapters.cloudflare. - stores..].name` must match JS identifier syntax (Cloudflare - worker binding constraint); `[adapters.fastly.stores..].name` - is freer. The validator warns on Cloudflare names that wouldn't work, - but does not block. -- **Multi-environment app-config:** explicitly out of scope. Follow-up - spec will add `[config.]` and `--env`. -- **Spin support gap:** `provision` and `config push` error out - for Spin until the separate stores PR lands and the CLI's small - follow-up is shipped. -- **Test relocation in sub-project #1:** ~10 tests move; mechanical diff. +- **Manifest breaking change (#3):** every external user editing + `edgezero.toml` needs to update store sections when sub-project #3 + ships. The `manifest-store-migration.md` guide ships in that PR; + the validator emits a clear error pointing at the guide on the old + shape. +- **Cloudflare runtime config swap (#3):** workers deployed against + the old `[vars]` JSON-blob config need a one-time migration to the + new KV-backed config. Documented in the migration guide. +- **`[stores.config.defaults]` removal (#9):** in-tree app-demo seeded + local-dev values from this field. #9 replaces it with reading from + `.toml`; external projects relying on `defaults` follow the + same migration. +- **API stability:** every public `*Args` is `#[non_exhaustive]` + + `Default` so adding fields stays non-breaking and external + construction works via `Default + field mutation`. +- **Shell-out fragility:** platform CLI surfaces change. We pin + current syntax, surface clear errors on missing/failing tools, and + rely on `.tool-versions`. +- **ID writeback brittleness:** stdout parsing is version-sensitive. + Per-tool golden tests; `--dry-run` available. +- **Generator drift:** generator output structure tested for shape; + sub-projects #1 and #4 add tests. +- **Macro / serde-attribute scope (#4):** `#[secret]` constrained to + non-flattened, non-renamed scalar fields with compile-error + enforcement. Avoids drift from partial serde-attribute parsing. +- **Multi-environment app-config:** out of scope. Follow-up spec. +- **Spin support gap:** until the in-flight Spin stores PR lands, + Spin omits `[adapters.spin.stores]` and is skipped by the + completeness validator. `provision` / `config push` error for + `--adapter spin`. +- **Test relocation in #1:** ~10 tests move; mechanical diff. ## 18. What this spec does not cover - Anthropic credentials, edge-network DNS / TLS, observability / - metrics: separate concerns. -- Per-environment config: explicit follow-up. -- Replacing or restructuring existing handlers in `app-demo-core` - beyond the one demonstrating push-then-read and the multi-store KV - demo handler in sub-project #3. -- Any change to `edgezero-core` beyond `app_config`, the rewritten - `manifest` store schema, and the rewritten `RequestContext` store - API. -- An on-disk migration tool for the old manifest schema. Manual - migration via the published guide. + metrics. +- Per-environment config. +- Restructuring `app-demo-core` handlers beyond the one demonstrating + push-then-read and the multi-store KV demo handler in #3. +- Changes to `edgezero-core` beyond `app_config`, the rewritten + `manifest` store schema, the rewritten `RequestContext` / + `Hooks` / `app!` macro / `ConfigStoreMetadata` / extractor surface, + and the Cloudflare adapter config-store backend. +- Migration tool for the old manifest schema. Manual via the + published guide. - Spin-side store provisioning and config push: deferred until the - separate Spin stores PR lands. + Spin stores PR lands. When all nine sub-projects ship: -- `edgezero new myapp` produces a workspace with `myapp-cli`, a typed - `MyappConfig` (using `#[derive(AppConfig)]` and optional `#[secret]` - fields), a `myapp.toml`, and an `edgezero.toml` using the new - logical-store schema. +- `edgezero new myapp` produces a workspace with `myapp-cli`, a + typed `MyappConfig` (using `#[derive(AppConfig)]` + optional + `#[secret]` / `#[secret(store_ref)]` fields, and + `#[serde(deny_unknown_fields)]`), a `myapp.toml`, and an + `edgezero.toml` using the new logical-store schema. - App code addresses stores by logical id: `ctx.kv_store("sessions")`, `ctx.config_store_default()`, - `ctx.secret_store("default")`. + `ctx.secret_store("default")`, plus handler-level `Kv` / `Secrets` / + `KvNamed<"sessions">` extractors. +- The Cloudflare config store reads from a KV namespace, so + `config push` updates values without a redeploy. - The developer logs into their platforms (`myapp-cli auth login - --adapter X`), provisions stores (`myapp-cli provision --adapter X` - — creates every id declared, writes IDs to native manifests), + --adapter X`), provisions stores (`myapp-cli provision --adapter X`), validates and pushes their app config (`myapp-cli config validate --strict && myapp-cli config push --adapter X`), and deploys (`myapp-cli deploy --adapter X`). - At runtime, the deployed service reads its config from the platform config store via `ctx.config_store_default()` / `ctx.config_store(id)`, - and reads secret-annotated fields from the secret store using the - reference string the struct carries. -- The default `edgezero` binary remains backwards-compatible (existing - commands stay; new subcommands are additionally available). + and reads secret-annotated fields from the secret store (key in + default store for `#[secret]`, logical store id for + `#[secret(store_ref)]`). +- The default `edgezero` binary remains backwards-compatible. From 197b4f9edbe0af596eb5edbab50194724f913672 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 18:51:46 -0700 Subject: [PATCH 06/38] Third-pass review: async ConfigStore, env overlay, extractor refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH severity fixes: - ConfigStore::get becomes async (#[async_trait(?Send)]). Cloudflare config moves [vars] -> KV with real async reads. Cascade (trait, 3 adapter impls, Hooks, handlers, extractors) contained to #3. - Drop const-generic &'static str extractors (don't compile on stable 1.95). Kv / Secrets extractors refactored to yield a registry handle with default() / named(id) accessors. - Introduce BoundKvStore / BoundConfigStore / BoundSecretStore so runtime accessors return a handle bound to the resolved platform name; callers just .get(key).await. - Sub-project #2 models logical store declarations as Option so old-shape manifests (None) are distinguishable from new-but-incomplete ones (Some with empty ids). Keeps #2 genuinely additive. MEDIUM severity fixes: - Fastly native-manifest writeback: spec commits to a read/write-path- agreement contract; exact fastly.toml sections pinned in #7's plan. - Adapter store completeness uses an explicit STORES_SUPPORTED_ADAPTERS allowlist (axum, cloudflare, fastly). A supported adapter omitting [adapters..stores] is an error; only non-allowlisted adapters (spin) skip. - All "default store" prose uses the resolved default id (explicit default, else single ids[0]). - AuthArgs no longer derives Default (avoids a placeholder subcommand leaking into a real auth path). §6.11 documents which *Args get Default. - config push gains explicit "validate passes, push serialization fails" test scenarios (non-object typed config, compound shapes, skip_serializing_if, Option::None, flatten). LOW severity: - Ship-gate wording: existing commands stay backwards-compatible rather than "edgezero --help unchanged" (false once auth/provision/ config land). New requirement - environment-variable override resolution (§6.10): - load_app_config overlays env vars on the toml [config] table. - Env var format: __
__..__; __ separates every nesting level; APP_NAME is [app].name uppercased, hyphens to underscores. - Type coercion against the target TOML type; --no-env escape hatch on validate and push. app-demo (§15) now explicitly exercises every new capability: multi- store, async config, named-kv extractor, nested config section, env override, both secret forms, validate/push, auth/provision via mock. --- .../specs/2026-05-19-cli-extensions-design.md | 1618 +++++++---------- 1 file changed, 665 insertions(+), 953 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 7ee154c..bb44314 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -6,23 +6,24 @@ This single spec covers the full effort: -- a manifest schema rewrite that introduces a logical-store / +- a manifest schema rewrite introducing a logical-store / per-adapter-mapping model for KV / secrets / config, -- a runtime API rewrite that supports multiple stores per kind (including - rewriting the Cloudflare config store backend from `[vars]` to KV so - `config push` actually reaches the runtime, and updating `Hooks`, - `ConfigStoreMetadata`, the `app!` macro, and the `Kv` / `Secrets` - extractors), +- a runtime API rewrite supporting multiple stores per kind — including + making `ConfigStore` async, rewriting the Cloudflare config backend + from `[vars]` to KV, introducing bound store handles, refactoring the + `Kv` / `Secrets` extractors to support named stores, and updating + `Hooks`, `ConfigStoreMetadata`, and the `app!` macro, - turning `edgezero-cli` into an extensible library, -- a per-service typed app-config file with `#[derive(AppConfig)]` and - `#[secret]` / `#[secret(store_ref)]` annotations, +- 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 everything end-to-end. +- and an `app-demo` overhaul that exercises **every** new capability + end-to-end. The work is organised into nine sub-projects so it can ship in nine -incremental PRs, but the design decisions live here together so reviewers -see the full picture in one place. +incremental PRs, but the design decisions live here together. --- @@ -31,9 +32,9 @@ see the full picture in one place. Let downstream projects (e.g. a future `myapp` created by `edgezero new myapp`) build their own CLI binary that: -- Reuses any subset of edgezero's built-in commands (today: `build`, - `deploy`, `dev`, `new`, `serve`; after this effort: also `auth`, - `provision`, `config validate`, `config push`). +- Reuses any subset of edgezero's built-in commands (`build`, `deploy`, + `dev`, `new`, `serve`; after this effort also `auth`, `provision`, + `config validate`, `config push`). - Adds their own subcommands. - Owns the binary name, `about` text, and top-level help. @@ -42,125 +43,103 @@ Alongside the extensibility substrate, ship: - A **multi-store manifest model**: the app declares logical stores it uses (`[stores.kv] ids = ["foo", "bar"]`) and each adapter declares the platform-specific `name` for each logical id, with room for - adapter-specific tuning. Stores are addressed in code by logical id - (`ctx.kv_store("foo")`). -- A **typed per-service app-config file** (e.g. `myapp.toml`) whose - schema is defined by the downstream app as a Rust struct, validated at - lint time by `config validate`, and uploaded to the platform config - store by `config push`. Fields annotated `#[secret]` are skipped during - push (the value is a key in the default secret store). Fields annotated - `#[secret(store_ref)]` are skipped during push **and** cross-checked - against `[stores.secrets].ids` (the value is a logical store id). -- **Cloudflare config-store rewrite** to read from a KV namespace - instead of a `[vars]` JSON blob. Required so `config push` reaches the - runtime without redeploying the worker. -- Platform credential and resource management (`auth`, `provision`) that - shells out to each platform's official CLI tool, with all shell-out - calls wrapped in a mockable `CommandRunner` trait so CI stays hermetic. -- A generator that scaffolds a new project complete with its own - `-cli` crate, a stub `.toml` app-config file (with - `#[serde(deny_unknown_fields)]` on the generated config struct), and - an `edgezero.toml` using the new logical-id store model. -- An `app-demo` overhaul demonstrating the finished system end-to-end. - -The default `edgezero` binary remains backwards-compatible in spirit: -existing subcommands keep the same name and flag shape. The manifest -schema rewrite is a **breaking change** to the on-disk format. The -in-tree `examples/app-demo/edgezero.toml` is migrated as part of the -work; a published migration guide covers external users. + adapter-specific tuning. Stores are addressed in code by logical id. +- A **typed per-service app-config file** (e.g. `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: values + in `.toml` can be overridden by env vars, with `__` separating + nesting levels (§6.10). +- **`ConfigStore` becomes async**, and the **Cloudflare config backend + moves from `[vars]` to KV** so `config push` reaches the runtime + without redeploying. +- **Bound store handles** (`BoundKvStore` / `BoundConfigStore` / + `BoundSecretStore`) so callers don't pass store names around. +- **Refactored `Kv` / `Secrets` extractors** that resolve either the + default store or a named store (§6.8). +- Platform credential and resource management (`auth`, `provision`) + that shells 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 that exercises all of the above end-to-end. + +The default `edgezero` binary keeps existing subcommands +backwards-compatible. The manifest schema rewrite is a **breaking +change** to the on-disk format; in-tree `examples/app-demo` is migrated, +and a published guide covers external users. ## 2. Non-goals -- No runtime command registry (`inventory` / `linkme`-style); no - PATH-based external subcommand discovery. -- No edgezero-managed credentials. `auth` delegates entirely to - `wrangler` / `fastly` / `spin`; we store nothing. -- No direct REST API calls to platforms. All platform interactions go - through the platform's official CLI tool. -- No environment-sectioned app-config (`[config.production]`, - `[config.staging]`). Single `[config]` table per file; multi-environment - workflows are deferred until a real need surfaces. -- No live-platform CI smoke tests. All tests run against a mock - `CommandRunner`. -- No on-disk migration helper for older `edgezero.toml` files using the - pre-rewrite store schema. The in-tree `examples/app-demo/edgezero.toml` - is the only file we migrate; external users follow the migration - guide. -- No Spin-side implementation of `provision` or `config push` in this - effort. Spin's stores schema lands via a separate in-flight PR; - `[adapters.spin]` in `edgezero.toml` simply omits the `stores` - section until then. The CLI's Spin path is added as a small follow-up - once that PR ships. +- 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 on-disk migration helper for old manifests. The migration guide + covers external users. +- No Spin-side `provision` / `config push`. Spin's stores schema lands + via a separate in-flight PR; `[adapters.spin]` omits the `stores` + section until then. ## 3. Architecture overview ```mermaid graph TB - Lib["edgezero-cli (lib)
pub *Args + pub run_*
internal: CommandRunner
internal: adapter / generator / ..."] + Lib["edgezero-cli (lib)
pub *Args + pub run_*
internal: CommandRunner / adapter / generator"] - Macros["edgezero-macros
#[derive(AppConfig)]
#[secret] / #[secret(store_ref)] field attrs"] + Macros["edgezero-macros
#[derive(AppConfig)]
#[secret] / #[secret(store_ref)]"] - Core["edgezero-core
app_config::AppConfigMeta + load_app_config<C>
manifest::ManifestStores (logical ids)
manifest::AdapterStoresConfig (per-adapter mapping)
RequestContext::kv_store(id) / config_store(id) / secret_store(id)
Hooks::config_store(id) (id-keyed)
extractor::Kv / Secrets (default) + KvNamed / SecretsNamed"] + Core["edgezero-core
app_config: load_app_config<C> (toml + env overlay)
manifest: logical-id stores + per-adapter name map
async ConfigStore + Bound*Store handles
RequestContext / Hooks: id-keyed store accessors
extractor: Kv / Secrets (default or named)"] - Lib --> EZ["edgezero (default bin)
all built-ins
no app struct"] - Lib --> ADC["app-demo-cli (example)
all built-ins +
Auth/Provision/Config
typed on AppDemoConfig"] - Lib --> MAC["myapp-cli (downstream)
subset of built-ins +
custom typed AppConfig"] + 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)]
pub struct AppDemoConfig
fields can be #[secret] or #[secret(store_ref)]"] - MAC --> MACore["myapp-core
#[derive(AppConfig)]
pub struct MyappConfig"] + ADC --> ADCore["app-demo-core
#[derive(AppConfig)] AppDemoConfig
nested section + #[secret] + #[secret(store_ref)]"] + MAC --> MACore["myapp-core
#[derive(AppConfig)] MyappConfig"] - Macros -.emits AppConfigMeta impl.-> ADCore - Macros -.emits AppConfigMeta impl.-> MACore - Core -.AppConfigMeta trait.-> ADCore - Core -.AppConfigMeta trait.-> MACore - Core -.RequestContext + Hooks + extractor API.-> ADCore - Core -.RequestContext + Hooks + extractor API.-> MACore + 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 - in `edgezero-cli`. Downstream `Subcommand` enums opt in by listing the - variants they want. Opt-out is omission. Every `*Args` derives `Default` - so external tests and wrappers can construct via `Default + field - mutation` despite `#[non_exhaustive]`. -- **Multi-store manifest model**: app declares logical store ids in - `[stores.]`; each adapter maps every logical id to a - platform-specific `name` in `[adapters..stores..]`, - optionally with adapter-specific tuning fields. Provisioned platform - resource IDs live in each platform's native manifest (`wrangler.toml`, - `fastly.toml`). See §6.6. -- **Multi-store runtime API**: `ctx._store(logical_id) -> - Option` and `ctx._store_default()`. `Hooks` gains the - same id-keyed shape. The `Kv` / `Secrets` extractors continue to work - for default-store access; new `KvNamed` / - `SecretsNamed` extractors give type-safe named access. - See §6.8. -- **Cloudflare config runtime moves to KV**: `CloudflareConfigStore` - reads from a KV namespace (one namespace per logical config id), - matching the rest of the multi-store model and allowing `config push` - to update config without redeploying the worker. -- **Typed app-config + secrets**: downstream defines a struct with - `#[derive(Deserialize, Validate, AppConfig)]`. Two annotations - declare secret-backed fields: - - `#[secret]` — value is a **key inside the default secret store**. - Validate checks: non-empty, `[stores.secrets]` exists. - - `#[secret(store_ref)]` — value is a **logical store id** in - `[stores.secrets].ids`. Validate cross-checks the id exists. - Push skips both. See §6.7. -- **Shell-out isolation**: every subprocess call goes through a private - `CommandRunner` trait taking a `CommandSpec` (program, args, cwd, - stdin, env). Tests use `MockCommandRunner`; CI never touches a real - platform. -- **Generator**: `edgezero new ` produces a workspace with - `crates/-core` (using `#[derive(AppConfig)]` + `#[serde( - deny_unknown_fields)]`), `crates/-cli`, per-adapter crates, - `.toml`, and `edgezero.toml` using the new schema. +- **Substrate**: each built-in command is a `(pub *Args, pub run_*)` + pair. Downstream `Subcommand` enums opt in by listing variants. + Non-subcommand `*Args` derive `Default` (for external construction + despite `#[non_exhaustive]`); subcommand-wrapping `*Args` (e.g. + `AuthArgs`) do **not** derive `Default` (§6.11). +- **Multi-store manifest model**: §6.6. +- **Async `ConfigStore`**: `ConfigStore::get` becomes + `async fn get(...)` (via `#[async_trait(?Send)]`, matching the + project's WASM-compat rule). KV and secret stores are already async. +- **Bound store handles**: `RequestContext` / `Hooks` accessors return + `BoundKvStore` / `BoundConfigStore` / `BoundSecretStore` — each wraps + the provider handle plus the resolved platform name, so callers just + do `.get(key).await`. +- **Cloudflare config moves to KV**: `CloudflareConfigStore` reads from + a KV namespace (one per logical config id). With the now-async + trait, reads are real async KV gets; `config push` updates KV + without a redeploy. +- **Extractors**: `Kv` / `Secrets` are refactored to resolve the + default store or a named one (§6.8). +- **Typed app-config + secrets**: §6.7. +- **Env-var override**: §6.10. +- **Shell-out isolation**: private `CommandRunner` + `CommandSpec`; + `MockCommandRunner` in tests. ## 4. End-state public API surface -After all nine sub-projects ship: - ```rust // crates/edgezero-cli/src/lib.rs (feature = "cli") @@ -181,15 +160,14 @@ pub fn run_dev() -> !; pub fn run_auth(args: &AuthArgs) -> Result<(), String>; pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; -// Validate bound: DeserializeOwned + Validate + AppConfigMeta (no Serialize). +// validate bound: no Serialize. pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> where C: serde::de::DeserializeOwned + validator::Validate + ::edgezero_core::app_config::AppConfigMeta; -// Push bound: add Serialize (needed for the serde_json::to_value object check -// and for the actual serialization). +// push bound: adds Serialize. pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> where @@ -202,49 +180,61 @@ From `edgezero-core`: ```rust // app_config module (new in sub-project #4) pub trait AppConfigMeta { - /// Per-field secret metadata. Empty array when no fields are #[secret]. const SECRET_FIELDS: &'static [SecretField]; } +pub struct SecretField { pub name: &'static str, pub kind: SecretKind } +pub enum SecretKind { KeyInDefault, StoreRef } -pub struct SecretField { - pub name: &'static str, // Rust field name; also the toml key - pub kind: SecretKind, -} +/// Loads .toml, overlays environment variables (§6.10), then +/// deserializes + validates into C. +pub fn load_app_config(path: &std::path::Path, app_name: &str) + -> Result +where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; + +/// Same env overlay, untyped — returns the merged tree. +pub fn load_app_config_raw(path: &std::path::Path, app_name: &str) + -> Result; -pub enum SecretKind { - /// Value is a key inside the default secret store. - KeyInDefault, - /// Value is a logical store id in [stores.secrets].ids. - StoreRef, +// async config store trait (sub-project #3) +#[async_trait(?Send)] +pub trait ConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError>; } -pub fn load_app_config(path: &std::path::Path) -> Result -where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; -pub fn load_app_config_raw(path: &std::path::Path) - -> Result, AppConfigError>; +// 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>, SecretStoreError>; } // RequestContext store API (rewritten in sub-project #3) 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; + 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 trait (rewritten in sub-project #3): id-keyed accessors mirroring -// RequestContext. Existing default-only call sites stay backwards-compatible -// via the `_default()` helpers. +// Hooks gains the same id-keyed accessors returning Bound*Store. -// Extractors (extended in sub-project #3): -pub struct Kv(/* default kv store handle */); -pub struct Secrets(/* default secret store handle */); -pub struct KvNamed(/* named kv store handle */); -pub struct SecretsNamed(/* named secret store handle */); +// Extractors (refactored in sub-project #3): see §6.8. +pub struct Kv(/* per-request KV registry */); +pub struct Secrets(/* per-request secret registry */); +impl Kv { + pub fn default(&self) -> Option; + pub fn named(&self, id: &str) -> Option; +} +impl Secrets { + pub fn default(&self) -> Option; + pub fn named(&self, id: &str) -> Option; +} ``` -From `edgezero-macros` (it IS the proc-macro crate; no `_impl` split): +From `edgezero-macros` (it IS the proc-macro crate): ```rust // crates/edgezero-macros/src/lib.rs @@ -252,194 +242,132 @@ From `edgezero-macros` (it IS the proc-macro crate; no `_impl` split): pub fn derive_app_config(input: TokenStream) -> TokenStream { /* ... */ } ``` -Internal modules in `edgezero-cli` (`adapter`, `generator`, `scaffold`, -`dev_server`, `runner`, `provision`, `auth`, `config`) stay private. - ## 5. End-state file layout ``` crates/edgezero-cli/ - Cargo.toml # lib + bin + Cargo.toml src/ lib.rs # public API; declares private modules main.rs # thin wrapper for the default edgezero bin - args.rs # all pub *Args structs (#[non_exhaustive] + #[derive(Default)]) + args.rs # *Args structs (#[non_exhaustive]; Default only where meaningful) adapter.rs # (unchanged, private) - generator.rs # extended: also scaffolds -cli + .toml + -core/src/config.rs + generator.rs # extended: scaffolds -cli + .toml + -core/src/config.rs scaffold.rs # (unchanged-ish, private) dev_server.rs # (unchanged, private; feature-gated) - runner.rs # NEW: CommandSpec + CommandRunner trait + Real/Mock impls - auth.rs # NEW - provision.rs # NEW - config.rs # NEW - templates/ - core/ # src/config.rs.hbs added in #4 with deny_unknown_fields - root/ # edgezero.toml.hbs rewritten for new schema - cli/ # NEW - Cargo.toml.hbs - src/main.rs.hbs - app/ # NEW: .toml.hbs stub app-config - tests/ - lib_consumer.rs # NEW + 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 + tests/lib_consumer.rs # NEW crates/edgezero-core/src/ - manifest.rs # REWRITTEN store schema (logical ids + per-adapter name map) - context.rs # REWRITTEN store accessors (id-keyed; *_default helpers) - app_config.rs # NEW: AppConfigMeta trait + SecretField + SecretKind + loaders - extractor.rs # EXTENDED: KvNamed / SecretsNamed; existing Kv / Secrets keep working as default-store + manifest.rs # REWRITTEN store schema (Option + per-adapter map) + context.rs # REWRITTEN 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 # (already async) + secret_store.rs # bound-handle wrapper added + extractor.rs # Kv / Secrets refactored to default-or-named hooks.rs # REWRITTEN: id-keyed Hooks accessors - app.rs # REWRITTEN ConfigStoreMetadata to a registry shape - config_store.rs # (unchanged trait; contract macro takes id-keyed factory) - key_value_store.rs # (unchanged trait) - secret_store.rs # (unchanged trait) - -crates/edgezero-macros/ - Cargo.toml - src/ - lib.rs # ADD: #[proc_macro_derive(AppConfig, attributes(secret))] - app_config.rs # NEW: derive impl (only public via lib.rs re-export of proc_macro) - app.rs # UPDATED: app! macro emits id-keyed ConfigStoreMetadata from new manifest schema + app.rs # ConfigStoreMetadata -> registry shape -# Adapter store impls rewritten for the multi-store model (sub-project #3): -crates/edgezero-adapter-axum/src/{config_store,key_value_store,secret_store}.rs -crates/edgezero-adapter-cloudflare/src/{config_store,key_value_store,secret_store}.rs -crates/edgezero-adapter-fastly/src/{config_store,key_value_store,secret_store}.rs +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 -# Cloudflare config store specifically: rewritten to read from a KV namespace -# (one namespace per logical config id), not from a [vars] JSON binding. +# Adapter store impls rewritten for multi-store (sub-project #3): +crates/edgezero-adapter-{axum,cloudflare,fastly}/src/{config_store,key_value_store,secret_store}.rs +# Cloudflare config_store specifically: [vars] -> KV namespace, async reads. examples/app-demo/ - Cargo.toml # adds crates/app-demo-cli to members - app-demo.toml # NEW: typed app config with #[secret] and #[secret(store_ref)] examples - edgezero.toml # REWRITTEN to new logical-id store schema; spin adapter omits stores section + Cargo.toml # adds crates/app-demo-cli + app-demo.toml # NEW typed config: nested section + #[secret] + #[secret(store_ref)] + edgezero.toml # REWRITTEN to new schema; spin omits stores section crates/ - app-demo-core/ - src/config.rs # NEW: AppDemoConfig with #[derive(AppConfig)] - src/handlers.rs # one handler reads from config store via _default(); another reads named kv + app-demo-core/src/config.rs # NEW AppDemoConfig + app-demo-core/src/handlers.rs # handlers read config (default + env-overridden) and named kv app-demo-cli/ # NEW - Cargo.toml - src/main.rs - tests/help.rs - app-demo-adapter-*/ # store setup rewrites for multi-store - -docs/guide/ - cli-walkthrough.md # NEW - manifest-store-migration.md # NEW + app-demo-adapter-*/ # store-setup rewrites + +docs/guide/{cli-walkthrough,manifest-store-migration}.md # NEW .vitepress/config.ts # UPDATED sidebar ``` ## 6. Cross-cutting designs -### 6.1 `CommandSpec` + `CommandRunner` (introduced in sub-project #6) +### 6.1 `CommandSpec` + `CommandRunner` (sub-project #6) ```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 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; // std::process::Command -#[cfg(test)] -pub(crate) struct MockCommandRunner { /* recorded expectations */ } +pub(crate) struct RealCommandRunner; +#[cfg(test)] pub(crate) struct MockCommandRunner { /* recorded expectations */ } ``` Public command functions use a private `*_with` inner so tests inject -the mock without exposing the trait. +the mock. ### 6.2 Error model -All public `run_*` return `Result<(), String>`. Matches the existing -pattern. Error formatting is the function's responsibility; binaries -log and exit. - -### 6.3 Feature gates (consumer-facing) +All public `run_*` return `Result<(), String>`. Binaries log and exit. -```toml -[dependencies] -edgezero-cli = { version = "...", default-features = false, features = ["cli"] } -# Plus the adapters wanted: -# - edgezero-adapter-axum -# - edgezero-adapter-cloudflare -# - edgezero-adapter-fastly -# - edgezero-adapter-spin -``` +### 6.3 Feature gates -- `cli` (default) — gates clap + public API. Required. -- `edgezero-adapter-{axum,fastly,cloudflare,spin}` (all four default) — - each gates that adapter's dispatch path. Disabling removes the adapter - from the `--adapter` matrix and produces "adapter not compiled in". +- `cli` (default) gates clap + public API. +- `edgezero-adapter-{axum,fastly,cloudflare,spin}` (all default) gate + each adapter's dispatch path. ### 6.4 Typed vs raw config serialization -The two `config validate` / `config push` flavours share serialization -rules but differ in schema awareness. - -**Validate (both flavours):** - -- TOML syntax OK; top-level `[config]` table present; structure parses. -- Typed flavour additionally: - - Deserialises into `C`. - - Runs `C::validate()`. - - For each `SecretField` in `C::SECRET_FIELDS`: value is a non-empty - string. If `SecretKind::StoreRef`, the value must appear in - `[stores.secrets].ids`. -- Validate does **not** require `Serialize`. It performs no - `serde_json::to_value` check — that's push's responsibility. - -**Push (both flavours):** - -- All validate checks run first as pre-flight (always strict). If - validate fails, push aborts before any runner call. -- Each field is serialised to a string for storage: - - `String` → as-is. - - `bool`, integer, float → `to_string()`. - - Compound types → `serde_json::to_string`. - - `Option::None` / `Value::Null` → field skipped entirely. -- Fields in `C::SECRET_FIELDS` are skipped (typed flavour only). -- Typed flavour additionally: - - Asserts `serde_json::to_value(&c)` is `Value::Object`. Otherwise - errors out before the runner is touched. - - Honors `#[serde(rename = "k")]` (renamed name is the storage key) - and `#[serde(skip_serializing, skip_serializing_if = ...)]`. - - `#[serde(flatten)]` on **non-secret** fields is supported (flattened - keys land at the top level after the serialize step). `#[secret]` / - `#[secret(store_ref)]` on flattened fields is a compile error - (see §6.7). -- Raw flavour: - - `BTreeMap` from `[config]`. - - Same scalar/compound rules. - - No `Validate`, no secret-field skipping (no `AppConfigMeta`). - -**Unknown field handling:** serde's default is to silently ignore -unknown fields. The generator template emits `#[serde( -deny_unknown_fields)]` on the generated config struct so new projects -reject unknown fields by default. Existing structs without the -attribute follow serde's default behaviour; `config validate` therefore -makes no general guarantee about unknown-field rejection. +**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 field is serialised to a string: +- `String` as-is; `bool`/numbers via `to_string()`; compound types via + `serde_json::to_string`; `Option::None` / `Value::Null` skipped. +- `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 scalar/compound rules, + no `Validate`, no secret skipping. + +**Unknown fields:** serde ignores them unless the struct has +`#[serde(deny_unknown_fields)]`. The generator template emits that +attribute; `config validate` therefore guarantees unknown-field +rejection only for structs that opt in. + +**Default-id resolution:** every reference to "the default config / +secret store" means the **resolved** default id — the explicit +`[stores.].default` if set, else the single `ids[0]` when +`ids.len() == 1`. Validation and `config push` resolve the default the +same way `ManifestLoader` does. ### 6.5 Test strategy summary -- Existing CLI tests move alongside their handlers. -- Per-sub-project tests for each new surface. -- Every platform-touching test uses `MockCommandRunner`. -- External-consumer integration test `tests/lib_consumer.rs`. -- `examples/app-demo/crates/app-demo-cli/tests/help.rs`. -- Manifest contract tests cover multi-store schemas, default - resolution, unknown-id rejection, Spin-skip behaviour for stores. +Existing tests move with their handlers; per-sub-project tests for each +new surface; every platform-touching test uses `MockCommandRunner`; +`tests/lib_consumer.rs` exercises the public API externally; manifest +contract tests cover multi-store, default resolution, Spin-skip, and +old-vs-new manifest discrimination. ### 6.6 Multi-store manifest schema -**App-level (logical) declaration in `edgezero.toml`:** +**App-level declaration (`edgezero.toml`):** ```toml [stores.kv] @@ -448,515 +376,384 @@ default = "foo" # optional when ids has exactly one entry [stores.config] ids = ["app_config"] -default = "app_config" [stores.secrets] ids = ["default"] -default = "default" ``` -**Per-adapter mapping + optional tuning in `edgezero.toml`:** +**Per-adapter mapping + tuning:** ```toml [adapters.cloudflare.stores.kv.foo] -name = "FOO_CLOUDFLARE" # platform-specific name - -[adapters.cloudflare.stores.kv.bar] -name = "BAR_CLOUDFLARE" +name = "FOO_CLOUDFLARE" [adapters.fastly.stores.kv.foo] name = "FOO_FASTLY" -max_value = "1MB" # adapter-specific tuning, free-form +max_value = "1MB" # adapter-specific tuning, free-form [adapters.cloudflare.stores.config.app_config] -name = "APP_CONFIG_KV" # KV namespace name (Cloudflare config = KV; see §6.9) +name = "APP_CONFIG_KV" # Cloudflare config is a KV namespace (§6.9) [adapters.cloudflare.stores.secrets.default] name = "EDGEZERO_SECRETS" -# spin omits the stores section entirely (until its in-flight stores PR lands): +# spin omits the stores section until its in-flight PR lands: [adapters.spin.adapter] crate = "crates/app-demo-adapter-spin" manifest = "crates/app-demo-adapter-spin/spin.toml" -# no [adapters.spin.stores.*] blocks; validator skips completeness for spin. ``` +**Old-vs-new discrimination (HIGH #4 fix):** each `[stores.]` +deserialises into `Option`. An `edgezero.toml` +written before this effort has no `[stores.]` in the new shape → +`None` → no new-schema validation. A new manifest declaring +`[stores.] ids = [...]` → `Some(LogicalStoreConfig)` → fully +validated. This keeps sub-project #2 genuinely additive: old manifests +are distinguishable from new-but-incomplete ones, so empty `ids` is a +real error rather than an accidental old-manifest match. + **Field reference:** | Field | Where | Role | |---|---|---| -| `[stores.].ids` | top level | logical ids (`Vec`). Non-empty. | -| `[stores.].default` | top level | the id used when none specified. Optional if `ids.len() == 1`. Must be in `ids`. | -| `[adapters..stores..].name` | per-adapter | platform-specific name. Required when adapter has a stores section. | -| any other field in that block | per-adapter | adapter-specific tuning. `BTreeMap` extras; opaque to core. | - -**Provisioned platform resource IDs do not live in `edgezero.toml`.** -They go into each platform's native manifest: - -- `wrangler.toml` for Cloudflare: - ```toml - [[kv_namespaces]] - binding = "FOO_CLOUDFLARE" # wrangler's term for what we call `name` in edgezero.toml - id = "abc123def456" - ``` -- `fastly.toml` for Fastly. - -`provision` writes IDs into the native manifest. `config push` parses -the native manifest to find the ID it needs (e.g. `wrangler kv bulk put ---namespace-id=...`). - -**Validation rules (enforced by `ManifestLoader`):** - -- `[stores.].ids` is non-empty. -- `[stores.].default` is in `ids`, or absent (then defaults to - `ids[0]`). -- **Adapter store completeness:** for every adapter declared in - `[adapters.*]` **that has an `[adapters..stores]` section**, every - id in every `[stores.].ids` must have a corresponding - `[adapters..stores..]` block with a `name` field. - Adapters without a `stores` section are skipped (this is how Spin - participates in the manifest before its stores PR lands). -- `name` strings used under `[adapters.cloudflare.stores.*]` must be - JavaScript identifier syntax (Wrangler binding constraint). Invalid - names are **errors**, not warnings — the platform would otherwise - fail to deploy. - -**Runtime resolution at adapter init:** - -```rust -struct StoreRegistry { - by_id: BTreeMap, - default_id: String, -} -``` +| `[stores.].ids` | top level | logical ids (`Vec`, non-empty when the table is present) | +| `[stores.].default` | top level | resolved default; optional if `ids.len() == 1`; must be in `ids` | +| `[adapters..stores..].name` | per-adapter | platform name; required | +| other fields in that block | per-adapter | free-form `BTreeMap` tuning; opaque to core | + +**Provisioned platform resource IDs** live in each platform's native +manifest (`wrangler.toml`, `fastly.toml`), not `edgezero.toml`. +`provision` writes them; `config push` reads them. + +**Validation rules:** + +- `ids` non-empty when `[stores.]` is present. +- `default` in `ids`, or absent (then resolved to `ids[0]`). +- **Adapter store completeness with an explicit allowlist (MEDIUM #6 + fix):** `STORES_SUPPORTED_ADAPTERS = ["axum", "cloudflare", "fastly"]`. + Every adapter in `[adapters.*]` **that is in this allowlist** must + declare an `[adapters..stores]` section mapping every id of every + declared store kind. A supported adapter omitting `stores` is an + **error** (it cannot silently opt out). Adapters not in the allowlist + (currently only `spin`) are skipped — this is how Spin participates + before its stores PR lands. When the Spin PR ships, `spin` joins the + allowlist. +- `name` under `[adapters.cloudflare.stores.*]` must be a JavaScript + identifier (Wrangler binding constraint); invalid names are + **errors**. -`ctx.kv_store("foo")` returns `Some(registry.by_id["foo"])` or `None` -if unknown. `ctx.kv_store_default()` returns the default-id handle. +**Runtime resolution:** each adapter builds a +`StoreRegistry { by_id: BTreeMap, default_id: String }` +at request setup. `ctx.kv_store("foo")` → `Some` / `None`; +`ctx.kv_store_default()` → the `default_id` handle. ### 6.7 Secret annotations via `#[derive(AppConfig)]` -**Two forms:** - ```rust -use serde::{Deserialize, Serialize}; -use validator::Validate; -use edgezero_macros::AppConfig; - #[derive(Debug, Deserialize, Serialize, Validate, AppConfig)] #[serde(deny_unknown_fields)] pub struct AppDemoConfig { pub greeting: String, - pub timeout_ms: u32, pub feature_new_checkout: bool, + pub service: ServiceConfig, // nested section (env-overridable, §6.10) - /// Key inside the default secret store. Read via - /// `ctx.secret_store_default()?.get(&config.api_token).await`. - #[secret] + #[secret] // key inside the resolved default secret store pub api_token: String, - /// Logical secret-store id in [stores.secrets].ids. Read via - /// `ctx.secret_store(&config.vault).await`. - #[secret(store_ref)] + #[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, +} ``` -**Toml shape (no new syntax):** +The derive emits `impl AppConfigMeta` with a `SECRET_FIELDS` array of +`SecretField { name, kind }`. -```toml -[config] -greeting = "hello from app-demo" -timeout_ms = 1500 -feature_new_checkout = false -api_token = "MY_API_TOKEN" # a key in the default secret store -vault = "credentials" # a logical id in [stores.secrets].ids +**Constraints (compile errors from the derive):** `#[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 (resolved default exists). `StoreRef` — value appears in +`[stores.secrets].ids`. **Push:** both kinds skipped. + +**Runtime usage:** + +```rust +// #[secret] (KeyInDefault): +let token = ctx.secret_store_default()?.get(&cfg.api_token).await?; +// #[secret(store_ref)] (StoreRef): +let token = ctx.secret_store(&cfg.vault)?.get("active").await?; ``` -**What the derive emits:** +### 6.8 Extractor design + +The existing `Kv` / `Secrets` extractors are **refactored to resolve +either the default store or a named one** (the user-chosen approach — +no const-generic `&'static str`, which doesn't compile on stable +Rust 1.95). + +The extractor yields a small per-request registry handle; the handler +picks the store by id at the call site: ```rust -impl ::edgezero_core::app_config::AppConfigMeta for AppDemoConfig { - const SECRET_FIELDS: &'static [::edgezero_core::app_config::SecretField] = &[ - ::edgezero_core::app_config::SecretField { - name: "api_token", - kind: ::edgezero_core::app_config::SecretKind::KeyInDefault, - }, - ::edgezero_core::app_config::SecretField { - name: "vault", - kind: ::edgezero_core::app_config::SecretKind::StoreRef, - }, - ]; +pub struct Kv(KvRegistryHandle); +impl Kv { + pub fn default(&self) -> Option; + pub fn named(&self, id: &str) -> Option; +} +// Secrets is identical in shape. + +#[action] +async fn handler(kv: Kv) -> Result { + let sessions = kv.named("sessions").ok_or_else(|| EdgeError::internal("no sessions kv"))?; + let cache = kv.default().ok_or_else(|| EdgeError::internal("no default kv"))?; + let v = sessions.get("k").await?; + // ... } ``` -**Constraints (compile errors from the derive):** +This is a **breaking change** to handlers that currently destructure +`Kv(handle)` for a single store. The only in-tree consumers are the +`app-demo` handlers, updated in sub-project #3. External handlers +migrate from `Kv(handle)` to `kv.default()`. -- `#[secret]` / `#[secret(store_ref)]` only on **scalar** fields - (must deserialize from a TOML string). -- Compile error if combined with `#[serde(flatten)]`, - `#[serde(rename = ...)]`, `#[serde(rename_all = ...)]` on the - containing struct in a way that changes the field's serialized - name, or `#[serde(skip_serializing)]` / `#[serde(skip)]`. -- No other `#[secret(...)]` variants. `#[secret(foo)]` with `foo` - outside `{store_ref}` is a compile error. -- `SECRET_FIELDS` uses the Rust field name verbatim. Renamed serde - keys are not supported; if you need to rename, don't make the field - secret (use a non-secret field that holds the lookup key). +A `Config` extractor with the same shape (`default()` / `named()`, +returning `BoundConfigStore`) is added for symmetry. -This explicit list keeps the macro implementation small and avoids the -"partial serde parser drift" risk. +### 6.9 Cloudflare config store rewrite (`[vars]` → KV, async) -**CLI behaviour:** +Current `CloudflareConfigStore` +([config_store.rs:1-12](crates/edgezero-adapter-cloudflare/src/config_store.rs#L1-L12)) +reads one `[vars]` JSON blob, parsed once at construction — which is +why the trait could be synchronous. Updating config required a worker +redeploy. -- `config validate --typed`: for each `SecretField`: - - Both kinds: value is a non-empty string. - - `KeyInDefault`: assert `[stores.secrets]` is declared (the app has - *a* default secret store available). - - `StoreRef`: assert the value appears in `[stores.secrets].ids`. -- `config push --typed`: skips both kinds. Secret material is never - written to the config store. +**Rewrite (sub-project #3):** `CloudflareConfigStore` reads from a KV +namespace, one per logical config id. Because KV reads are async, the +`ConfigStore` trait becomes async (`#[async_trait(?Send)]`). The +adapter's `get` performs a real `env..get(key)` await. -**Runtime usage in service code:** +On-disk shape after this ships: -```rust -// #[secret] (KeyInDefault): -let token = ctx.secret_store_default()?.get(&config.api_token).await?; +```toml +# edgezero.toml +[stores.config] +ids = ["app_config"] +[adapters.cloudflare.stores.config.app_config] +name = "APP_CONFIG_KV" -// #[secret(store_ref)] (StoreRef): -let vault = ctx.secret_store(&config.vault)?; -let token = vault.get("active").await?; +# wrangler.toml (written by provision) +[[kv_namespaces]] +binding = "APP_CONFIG_KV" +id = "abc123def456" ``` -### 6.8 Extractor design +`config push --adapter cloudflare` writes via `wrangler kv bulk put + --namespace-id=`. No redeploy; values live on the +next request after KV propagation. The `[vars]` model is removed; +existing deployed workers migrate once (documented in the guide). -Existing handler-facing extractors (`Kv`, `Secrets` from -[crates/edgezero-core/src/extractor.rs](crates/edgezero-core/src/extractor.rs)) -stay backwards-compatible after the runtime API rewrite: +### 6.10 App-config environment-variable resolution -- `Kv` resolves via `ctx.kv_store_default()` (was `kv_handle`). -- `Secrets` resolves via `ctx.secret_store_default()`. +`load_app_config` / `load_app_config_raw` resolve values in two +layers, lowest priority first: -For named (non-default) stores, two new extractors with const-generic -ids: +1. The `[config]` table parsed from `.toml`. +2. Environment-variable overrides. -```rust -pub struct KvNamed(KeyValueStoreHandle); -pub struct SecretsNamed(SecretHandle); +**Env var naming.** `__
__…__`: -// Usage: -#[action] -async fn handler(KvNamed(sessions): KvNamed<"sessions">) -> ... { ... } +- `` is `[app].name` from `edgezero.toml`, uppercased, with + `-` replaced by `_` (so `app-demo` → `APP_DEMO`). Passed to + `load_app_config` as the `app_name` argument. +- `__` (double underscore) separates **every** nesting level, + including app-name → first key. A single `_` is a literal character + within a name; only `__` is a separator. +- Each segment after the prefix is matched case-insensitively against + the config key at that level. + +Examples for `app-demo.toml`: + +```toml +[config] +greeting = "hello" +[config.service] +timeout_ms = 1500 ``` -`FromRequest` impl looks up the id in the registry and fails the -extraction if missing. +| Env var | Overrides | +|---|---| +| `APP_DEMO__GREETING` | `config.greeting` | +| `APP_DEMO__SERVICE__TIMEOUT_MS` | `config.service.timeout_ms` | -This preserves all existing handler signatures (they all use the -default store today) while adding type-safe named access. No -deprecation path needed for the default-store extractors. +**Type coercion.** Env var values are strings. During overlay they are +parsed against the target field's TOML type (the overlay produces a +`toml::Value` tree; integers/bools are parsed from the string, parse +failure is an `AppConfigError`). For the typed loader this happens +before `serde` deserialization. -### 6.9 Cloudflare config store rewrite (`[vars]` → KV) +**Scope.** Resolution happens inside `load_app_config*`. Therefore +`config validate` and `config push` both see env-resolved values — +useful for injecting per-environment values from a deploy pipeline. A +`--no-env` flag on `validate` and `push` disables the overlay when the +raw file contents are wanted. The axum dev server also resolves via +this path, so `APP_DEMO__GREETING=hi cargo run …` overrides locally. -Currently `CloudflareConfigStore` -([config_store.rs:1-12](crates/edgezero-adapter-cloudflare/src/config_store.rs#L1-L12)) -reads a single `[vars]` JSON-string binding. Changing config values -requires editing `wrangler.toml` and redeploying the worker. - -That's incompatible with the `config push` flow this spec describes, -which is designed to update config values without rebuild/redeploy. - -**Rewrite in sub-project #3:** `CloudflareConfigStore` reads from a KV -namespace, one per logical config id. The on-disk shape after this -ships: - -- `edgezero.toml`: - ```toml - [stores.config] - ids = ["app_config"] - default = "app_config" - - [adapters.cloudflare.stores.config.app_config] - name = "APP_CONFIG_KV" - ``` -- `wrangler.toml` (written by `provision`): - ```toml - [[kv_namespaces]] - binding = "APP_CONFIG_KV" - id = "abc123def456" - ``` -- Runtime: `await env.APP_CONFIG_KV.get("greeting")` (translated by the - adapter from the user-facing `ctx.config_store_default()?.get(...)`). - -`config push --adapter cloudflare` writes via -`wrangler kv bulk put --namespace-id=`. -No redeploy needed; values are live on the next request after KV -propagation. - -The `[vars]` model is removed entirely. Any existing -`[vars]` JSON-blob config in deployed workers gets migrated as a -one-time operation per workspace (documented in the migration guide). - -This means **the multi-store rewrite is incomplete without this Cloudflare -adapter rewrite** — they ship together in sub-project #3. +### 6.11 `Default` on `*Args` + +Non-subcommand `*Args` (`BuildArgs`, `DeployArgs`, `NewArgs`, +`ServeArgs`, `ProvisionArgs`, `ConfigValidateArgs`, `ConfigPushArgs`) +derive `Default` so external tests/wrappers construct them via +`Default::default()` + field mutation despite `#[non_exhaustive]`. + +Subcommand-wrapping `*Args` (`AuthArgs`) do **not** derive `Default` — +a defaulted required subcommand could leak into a test and run a real +auth path. External tests construct `AuthArgs` via +`clap::Parser::try_parse_from`. --- ## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton -**Goal:** establish the substrate. After this ships, downstream -projects can build their own CLI against the lib using only the -existing five built-ins. Default `edgezero` is backwards-compatible. - -**Source changes:** - -- `crates/edgezero-cli/src/args.rs` — promote each `Command` variant's - inline fields into a `#[derive(clap::Args, Default)]` struct, also - marked `#[non_exhaustive]`. `NewArgs` already exists. -- `crates/edgezero-cli/src/lib.rs` (new) — declares the private - modules, moves handlers (renamed `handle_*` → `run_*`). -- `crates/edgezero-cli/src/main.rs` — shrinks to ~20 lines. -- Existing CLI tests move to `lib.rs`. -- **Generator update**: `edgezero new ` produces - `crates/-cli/{Cargo.toml, src/main.rs}` using all five - built-ins via the lib substrate. Root `Cargo.toml.hbs` updated. - **No app-config file yet, no derive yet, no new manifest schema yet.** -- `examples/app-demo/crates/app-demo-cli` (new crate, handwritten - parallel to the generator output). - -**External-construction note:** every public `*Args` derives `Default` -so external tests (including `tests/lib_consumer.rs`) construct via -`Default + field mutation` despite `#[non_exhaustive]`. - -**Tests:** - -- All existing CLI tests pass after relocation. -- New `crates/edgezero-cli/tests/lib_consumer.rs`: constructs - `BuildArgs::default(); args.adapter = "fastly".into(); ...` and calls - `run_build(&args)`. -- New `examples/app-demo/crates/app-demo-cli/tests/help.rs`. -- Generator test: `generate_new("test-app", ...)` produces correct - files. - -**Ship gate:** `edgezero --help` unchanged; `app-demo-cli --help` shows -the five built-ins; `edgezero new throwaway-app && cd throwaway-app && -cargo check --workspace` succeeds. +**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. + +**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 +(backwards-compatible — new subcommands are added by later +sub-projects, so help output is *not* frozen forever, only the +existing commands' shape); `app-demo-cli --help` shows the five +built-ins; `edgezero new throwaway-app && cargo check --workspace` +succeeds. ## 8. Sub-project 2 — Manifest schema additions (purely additive) -**Goal:** add the new logical-store + per-adapter-mapping schema to -`ManifestStores` and `ManifestAdapter` **alongside** the existing -single-store fields. Nothing is removed yet; no runtime code changes. - -This sub-project intentionally avoids any runtime adapter changes — -those land in sub-project #3 — and it does **not** drop -`[stores.config.defaults]` (still wired into axum's local-dev config -seeding via [dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349)). -Removing `defaults` happens in sub-project #9 when `.toml` -arrives as a runtime-accessible replacement. - -**Source changes:** - -- `crates/edgezero-core/src/manifest.rs`: - - Add new `ManifestStoresKind { ids: Vec, default: Option }` - fields under `[stores.kv]`, `[stores.secrets]`, `[stores.config]`. - Old single-store fields (`name`, `enabled`, etc.) remain present - and continue to deserialise. - - Add `ManifestAdapter.stores: Option` — kind → - id → `AdapterStoreMapping { name: String, extras: BTreeMap }`. - - Validator rules from §6.6 (enforced when the new fields are - present; old-shape manifests pass unchanged). - - **Adapter store completeness skips adapters without - `[adapters..stores]`** — this is how Spin participates without - a stores impl. -- `crates/edgezero-core/src/manifest.rs` tests: cover the new schema, - default resolution, missing-mapping errors, Spin-skip behaviour, - Cloudflare JS-identifier validation as **errors**. -- `examples/app-demo/edgezero.toml` keeps its current shape; no - migration yet. (The migration happens in sub-project #3 alongside - the runtime API rewrite.) - -**No runtime, CLI, macro, or adapter changes in this sub-project.** It -only adds parseable schema and validation. - -**Tests:** - -- Round-trip deserialization for the new schema. -- Default-resolution: omitted with one id; omitted with multiple ids → - error; explicit not-in-ids → error. -- Per-adapter completeness: missing mapping for declared id on - adapter-with-stores → error; adapter without stores section → ok. -- Cloudflare `name` JS-syntax validation → error on invalid. -- Old-shape manifests parse unchanged. - -**Ship gate:** existing app-demo runtime keeps working unchanged -(verified by the existing test suite); manifest tests prove the new -schema is parseable and validated. - -## 9. Sub-project 3 — Runtime API + adapter store registry + macro/Hooks/extractor + Cloudflare KV rewrite +**Goal:** add the new schema as `Option` + +`Option` so old-shape manifests are +distinguishable and validation only runs on new-shape declarations. +No runtime changes; nothing removed; `[stores.config.defaults]` +stays. -**Goal:** the big runtime sub-project. After this, multi-store works -end-to-end at runtime on axum and Cloudflare. Includes: - -- `RequestContext` store accessors rewritten id-keyed (§4). -- `Hooks` trait gains id-keyed accessors. -- `ConfigStoreMetadata` becomes a registry shape (one entry per id). -- `app!` macro emits id-keyed metadata from the new manifest schema. -- `Kv` / `Secrets` extractors become default-store accessors; new - `KvNamed` / `SecretsNamed` const-generic extractors added (§6.8). -- Every adapter's store setup walks `[adapters..stores.*]` and - builds a `StoreRegistry`. -- **Cloudflare config store rewritten from `[vars]` to KV** (§6.9). - This is the cornerstone of `config push` working end-to-end. -- `examples/app-demo/edgezero.toml` migrated to the new schema. Spin - adapter omits the `stores` section. -- `examples/app-demo` handlers updated to call id-keyed accessors - (`config_store_default()`, `kv_store("sessions")`, etc.). - -**Compatibility:** the old single-store manifest fields removed from -`ManifestStores` and `ManifestAdapter`; in-tree consumers updated in -lockstep. External users follow the migration guide -(`docs/guide/manifest-store-migration.md`) shipped in this PR. - -**Tests:** - -- Contract-test macros gain id-keyed factory variants. -- Cross-adapter test in `examples/app-demo`: a handler reading from a - named KV id works on every adapter with the mapping declared. -- Cloudflare config-from-KV round-trip test using the existing - wasm-bindgen-test harness. -- `Kv` / `Secrets` extractors still work in default-store handler - signatures. -- `KvNamed<"sessions">` extractor compiles and works in a handler. -- `app!` macro test: generated `ConfigStoreMetadata` registry matches - the manifest's `[stores.config].ids`. - -**Ship gate:** multi-store handlers in `app-demo` work on axum and -Cloudflare (the latter via mock or wasm-bindgen-test); existing -handlers' default-store reads keep working; `config push` flow is -runtime-ready (push command itself lands in sub-project #8). - -## 10. Sub-project 4 — App-config schema, derive macro, generic loader - -**Goal:** define the file format for per-service app config, the -`#[derive(AppConfig)]` macro that produces secret-field metadata, and -the generic loader the CLI uses. - -**Source changes:** - -- `crates/edgezero-core/src/app_config.rs` (new): `AppConfigMeta` trait - with `SECRET_FIELDS: &[SecretField]`, `SecretField` + `SecretKind` - enum, `load_app_config(path)`, - `load_app_config_raw(path) -> BTreeMap`. -- `crates/edgezero-macros/src/app_config.rs` (new): the `AppConfig` - derive. Implementation lives in the existing `edgezero-macros` - proc-macro crate (no new crate split). Parses input, scans for - `#[secret]` (KeyInDefault) and `#[secret(store_ref)]` (StoreRef), - enforces §6.7 constraints (compile errors on unsupported - combinations), emits `AppConfigMeta` impl with `SECRET_FIELDS`. -- `crates/edgezero-macros/src/lib.rs`: add the - `#[proc_macro_derive(AppConfig, attributes(secret))]` export. -- `crates/edgezero-cli/src/templates/app/.toml.hbs` (new): stub - app-config (greeting only). -- `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` (new): - `Config` with `#[derive(Deserialize, Serialize, - Validate, AppConfig)]` **and** `#[serde(deny_unknown_fields)]`. -- `examples/app-demo/app-demo.toml` (new) — typed values including one - `#[secret]` (`api_token`) and one `#[secret(store_ref)]` example - (`vault`). -- `examples/app-demo/crates/app-demo-core/src/config.rs` (new). -- Generator extension: emit `.toml` and - `-core/src/config.rs`. - -**Tests:** - -- `load_app_config` unit tests. -- Round-trip for `AppDemoConfig` against `app-demo.toml`. -- Macro tests in `crates/edgezero-macros/tests/app_config_derive.rs`: - - Empty `SECRET_FIELDS` when no annotation. - - Single `KeyInDefault` entry from `#[secret]`. - - Single `StoreRef` entry from `#[secret(store_ref)]`. - - Both kinds in one struct. - - Compile error on `#[secret]` + `#[serde(flatten)]`. - - Compile error on `#[secret]` + `#[serde(rename = ...)]`. - - Compile error on `#[secret(unknown)]`. - - Compile error on `#[secret]` on a non-scalar field - (e.g. `#[secret] pub api: Vec`). - -**Ship gate:** `AppDemoConfig::SECRET_FIELDS` matches the expected two -entries; `load_app_config::` succeeds against the -example. +**Source changes:** `manifest.rs` gains `Option` +per kind and `ManifestAdapter.stores: Option`; +validator rules (§6.6) fire only when the new fields are `Some`; +`STORES_SUPPORTED_ADAPTERS` allowlist drives completeness. Old fields +remain and keep deserialising. -## 11. Sub-project 5 — `config validate` command +**Tests:** new-schema round-trip; default resolution (omitted with one +id; omitted with many → error; explicit not-in-ids → error); +completeness (supported adapter omitting `stores` → error; +non-allowlisted adapter → skipped); Cloudflare JS-identifier check → +error; old-shape manifests parse with `None` and trigger no new +validation. -**Goal:** lint the project's TOML files locally with zero platform -calls. Validate the app config in its own right, not just as a source -of cross-references for the manifest. +**Ship gate:** existing app-demo runtime works unchanged; manifest +tests prove the new schema parses and validates. -**Public API additions:** +## 9. Sub-project 3 — Runtime rewrite (async ConfigStore, Bound handles, registries, extractors, Cloudflare KV, Hooks/macro) -```rust -pub use args::ConfigValidateArgs; -pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; -pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> -where C: DeserializeOwned + Validate + AppConfigMeta; // no Serialize -``` +**Goal:** the big runtime sub-project. After this, multi-store works +end-to-end on axum and Cloudflare. + +**Scope:** + +- `ConfigStore::get` becomes `async` (`#[async_trait(?Send)]`). +- `BoundKvStore` / `BoundConfigStore` / `BoundSecretStore` introduced; + `RequestContext` + `Hooks` accessors return them, id-keyed, with + `_default()` helpers resolving the §6.4 default. +- Each adapter's store setup builds a `StoreRegistry` from + `[adapters..stores.*]`. +- `CloudflareConfigStore` rewritten `[vars]` → KV (§6.9). +- `Kv` / `Secrets` extractors refactored to `default()` / `named()` + (§6.8); a `Config` extractor added. +- `ConfigStoreMetadata` becomes a registry; `app!` macro emits + id-keyed metadata from the new manifest schema. +- Old single-store manifest fields removed; `examples/app-demo/ + edgezero.toml` migrated; `app-demo` handlers updated to the new + accessors. Spin adapter omits `stores`. +- `docs/guide/manifest-store-migration.md` published. + +**Tests:** id-keyed contract-test factories; cross-adapter named-KV +test; Cloudflare config-from-KV async round-trip (wasm-bindgen-test); +`Kv`/`Secrets`/`Config` extractor tests for both `default()` and +`named()`; `app!` macro emits a metadata registry matching +`[stores.config].ids`. + +**Ship gate:** multi-store handlers work on axum and Cloudflare; +async config reads work; the `config push` runtime target exists. + +## 10. Sub-project 4 — 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` (trait, `SecretField`/ +`SecretKind`, `load_app_config` / `load_app_config_raw` with env +overlay); `edgezero-macros` `AppConfig` derive + +`#[proc_macro_derive]` export; generator templates for `.toml` +(includes 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` with +a nested section, one `#[secret]`, one `#[secret(store_ref)]`. + +**Tests:** `load_app_config` (valid, missing file, bad TOML, validator +failure, missing `[config]`); **env-overlay tests** — top-level +override, nested `__` override, type coercion, parse-failure error, +`--no-env` bypass; round-trip for `AppDemoConfig`; macro tests +including all compile-error constraints from §6.7. + +**Ship gate:** `AppDemoConfig::SECRET_FIELDS` matches expectations; +`load_app_config::` succeeds; an env var +`APP_DEMO__SERVICE__TIMEOUT_MS` demonstrably overrides the nested +value in a test. + +## 11. Sub-project 5 — `config validate` command + +**Goal:** lint TOML files locally; validate the app config in its own +right (TOML syntax, `[config]` present, deserialises into `C`, types, +`validator` rules, `deny_unknown_fields` when set, secret-field +checks) plus manifest cross-checks under `--strict`. ```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, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub app_config: Option, + #[arg(long)] pub strict: bool, + #[arg(long)] pub no_env: bool, // disable env overlay (§6.10) } ``` -**App-config validation (concrete checks):** - -| Check | Raw flavour | Typed flavour | -|------------------------------------|-------------|---------------| -| TOML syntax | yes | yes | -| `[config]` table exists | yes | yes | -| Deserialises into `C` | n/a | yes | -| Required fields present, types match `C` | n/a | yes (via serde) | -| Unknown fields rejected | n/a | only if `C` is `#[serde(deny_unknown_fields)]` (generator template sets this) | -| `C::validate()` business rules | n/a | yes | -| `#[secret]` field values non-empty | n/a | yes (via `--strict`) | -| `#[secret(store_ref)]` value in `[stores.secrets].ids` | n/a | yes (via `--strict`) | - -**Manifest validation (both flavours):** - -- TOML syntax + `ManifestLoader` schema checks. -- If `--strict`: - - Adapter-store completeness per §6.6 (Spin-skip honored). - - Handler paths in `[[triggers.http]]` well-formed. - -**Output:** human-readable diagnostics with file/line where possible; -exit 0 on success, 1 on failure. +Bound: `DeserializeOwned + Validate + AppConfigMeta` (no `Serialize`). -**Tests:** dedicated fixtures for every distinct failure mode. +**Tests:** dedicated fixtures per failure mode, including env-overlay +on/off. -**Ship gate:** `app-demo-cli config validate --strict` exits 0 against -the example; corrupted fixtures fail with expected messages. +**Ship gate:** `app-demo-cli config validate --strict` exits 0; +corrupted fixtures fail with expected messages. -## 12. Sub-project 6 — `auth` command (+ `CommandRunner` infrastructure) - -**Goal:** delegate per-adapter authentication to the native tool. -Introduces the `runner` module reused by later sub-projects. - -**Public API additions:** +## 12. Sub-project 6 — `auth` command (+ `CommandRunner`) ```rust -pub use args::{AuthArgs, AuthSub}; -pub fn run_auth(args: &AuthArgs) -> Result<(), String>; -``` - -```rust -#[derive(clap::Args, Default, Debug)] +#[derive(clap::Args, Debug)] // NO Default — see §6.11 #[non_exhaustive] pub struct AuthArgs { #[command(subcommand)] pub sub: AuthSub } @@ -968,294 +765,209 @@ pub enum AuthSub { } ``` -UX: `auth login --adapter cloudflare`. `Default` impl on `AuthArgs` -constructs a placeholder sub for trait completeness. +UX: `auth login --adapter cloudflare`. Per-adapter behaviour: axum +no-ops; cloudflare `wrangler login/logout/whoami`; fastly `fastly +profile create/delete/list`; spin `spin cloud login/logout/info`. All +via `CommandRunner`. -**Per-adapter behaviour:** - -| Adapter | Login | Logout | Status | -|------------|-------------------------|-------------------------|-----------------------| -| axum | no-op | no-op | always "ok" | -| cloudflare | `wrangler login` | `wrangler logout` | `wrangler whoami` | -| fastly | `fastly profile create` | `fastly profile delete` | `fastly profile list` | -| spin | `spin cloud login` | `spin cloud logout` | `spin cloud info` | - -All via `CommandRunner`. - -**Tests:** mock-runner expectations across the full matrix; error -cases (ENOENT, non-zero exit). - -**Ship gate:** mock-runner verification across the full matrix. +**Tests:** mock-runner matrix; ENOENT + non-zero-exit cases. +External `AuthArgs` construction uses `try_parse_from`. ## 13. Sub-project 7 — `provision` command -**Goal:** create platform resources for every logical id, writing -resulting IDs to the per-adapter native manifest. - -**Public API additions:** - -```rust -pub use args::ProvisionArgs; -pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; -``` - ```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, + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub adapter: String, + #[arg(long)] pub dry_run: bool, } ``` -**Behaviour:** iterate every id in `[stores.].ids` for kind ∈ -{kv, secrets, config}. For each, look up -`[adapters..stores..].name` and shell out: - -| Adapter | KV per id | Secrets per id | Config per id | -|------------|--------------------------------------------|---------------------------------------------|-----------------------------------------------------------------| -| axum | no-op (local; env-backed) | no-op | no-op | -| cloudflare | `wrangler kv namespace create ` | (no-op; secrets are runtime-managed) | `wrangler kv namespace create ` (config is a KV namespace) | -| fastly | `fastly kv-store create --name=` | `fastly secret-store create --name=` | `fastly config-store create --name=` | -| spin | error: "not yet supported" (no stores section in manifest, so this id wouldn't appear) | same | same | - -`--dry-run` prints would-be `CommandSpec`s without invocation. - -**Writeback to per-adapter native manifest:** - -- **Cloudflare:** patch `wrangler.toml`: - ```toml - [[kv_namespaces]] - binding = "" - id = "" - ``` - (Wrangler's `binding` is the same string as our - `[adapters.cloudflare.stores..].name`.) -- **Fastly:** patch `fastly.toml` with store IDs. - -`edgezero.toml` is not modified. - -**Tests:** per-(adapter, kind) `MockCommandRunner` with scripted -stdout; golden parser tests for ID extraction; temp-fixture writeback -verified; `--dry-run` invokes nothing. - -**Ship gate:** `app-demo-cli provision --adapter cloudflare --dry-run` -prints the expected create invocations for every id; non-dry-run -against the mock writes IDs to fixture `wrangler.toml`. +Iterate every id in `[stores.].ids`; look up +`[adapters..stores..].name`; shell out per the +adapter/kind table (`wrangler kv namespace create `, `fastly +kv-store create --name=`, etc.). `--dry-run` prints +`CommandSpec`s without invocation. + +**Writeback to native manifests:** + +- **Cloudflare:** patch `wrangler.toml` `[[kv_namespaces]]` with + `binding = ""`, `id = ""`. +- **Fastly:** the exact `fastly.toml` sections to patch are + **pinned in the implementation plan** by reading Fastly's current + manifest docs (Fastly distinguishes store names, resource-link + names/IDs, and `setup` vs `local_server` sections). The spec-level + contract: `provision` writes Fastly resource identifiers into + whatever `fastly.toml` section the Fastly Compute runtime resolves + stores from, and `config push` reads the identifier back from the + same section — read and write paths must agree. Sub-project #7's PR + ships the exact section names with golden-file tests. + +**Tests:** per-(adapter, kind) mock-runner with scripted stdout; +golden ID-extraction parsers; temp-fixture writeback verified; +`--dry-run` invokes nothing. ## 14. Sub-project 8 — `config push` command -**Goal:** upload `.toml`'s `[config]` values to the live config -store, skipping `#[secret]` / `#[secret(store_ref)]` fields. Targets -the default config store unless `--store` selects another. - -**Public API additions:** - -```rust -pub use args::ConfigPushArgs; -pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; -pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> -where C: DeserializeOwned + Validate + Serialize + AppConfigMeta; // adds Serialize -``` - ```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, - /// Logical id of the config store to push to. - /// Defaults to `[stores.config].default`. - #[arg(long)] - pub store: Option, - #[arg(long)] - pub app_config: Option, - #[arg(long)] - pub dry_run: bool, + #[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, // disable env overlay + #[arg(long)] pub dry_run: bool, } ``` -**Behaviour:** - -1. **Strict pre-flight validation.** Run the same checks as `config - validate --strict`. Abort before any runner call if it fails. -2. Load app-config (raw or typed) per §6.4. -3. Serialise per §6.4 (skipping `SECRET_FIELDS` in typed mode). -4. Resolve target id: `args.store.unwrap_or(stores.config.default_id)`. -5. Look up `[adapters..stores.config.].name`. -6. For platforms needing a resource ID, parse the adapter's native - manifest. Error with "did you run `provision` first?" if absent. -7. Shell out: - -| Adapter | Push | -|------------|---------------------------------------------------------------------------------------------------| -| axum | Write to `.edgezero/local-config-.env` (gitignored). No runner call. | -| cloudflare | `wrangler kv bulk put --namespace-id=` (Wrangler 3.60+ syntax) | -| fastly | Per key: `fastly config-store-entry create --store-id= --key= --value=` (`--stdin` for large values) | -| spin | error: "not yet supported" | - -**Tests:** typed + raw paths; per-adapter `MockCommandRunner` with -golden payloads; `#[secret]` and `#[secret(store_ref)]` fields absent -from pushed payload; missing native-manifest ID → clear error; -`--store` works; `--dry-run` invokes nothing. +Bound: `DeserializeOwned + Validate + Serialize + AppConfigMeta`. + +**Behaviour:** strict pre-flight validation; load app-config (env +overlay unless `--no-env`); serialise per §6.4 (skip `SECRET_FIELDS`); +resolve target id (`--store` or resolved default); look up the +per-adapter `name`; read the platform resource ID from the native +manifest (error "did you run `provision` first?" if absent); shell +out (`wrangler kv bulk put … --namespace-id=…`; `fastly +config-store-entry create …`; axum writes +`.edgezero/local-config-.env`; spin errors "not yet supported"). + +**Tests (MEDIUM #9 — push behaviour beyond validate):** typed + raw; +per-adapter mock-runner with golden payloads; secret fields absent +from payload; missing native-manifest ID error; `--store` selection; +`--dry-run` invokes nothing; **explicit "validate passes, push +serialization fails" cases** — non-object typed config +(`to_value` ≠ object), unsupported compound shape, `skip_serializing_if` +behaviour, `Option::None` omission, `#[serde(flatten)]` on a +non-secret field; env-overlay on vs `--no-env`. **Ship gate:** `app-demo-cli config push --adapter cloudflare ---dry-run` shows expected invocation; secret fields absent; namespace -ID from fixture `wrangler.toml`. - -## 15. Sub-project 9 — `app-demo` integration polish + drop `[stores.config.defaults]` - -**Goal:** prove the full system works end-to-end and remove the -deprecated `[stores.config.defaults]` schema. - -**Source changes (all in `examples/app-demo/` plus the deprecation):** - -- `crates/app-demo-cli/src/main.rs`: extend `Cmd` enum with - `Auth(AuthArgs)`, `Provision(ProvisionArgs)`, - `Config(ConfigCmd)`. Dispatch `Config::Validate` / - `Config::Push` to the **typed** variants with `AppDemoConfig`. -- `crates/app-demo-core/src/handlers.rs`: extend at least one handler - to read via `ctx.config_store_default()?.get("greeting")?` so the - push-then-read flow is exercised end-to-end against axum. - -**`[stores.config.defaults]` removal:** - -- Drop the `defaults` field from `ManifestConfigStoreConfig` in - `edgezero-core::manifest`. -- Drop the corresponding axum dev-server seeding code in - `dev_server.rs` (around line 349). -- Replace its behaviour: the **axum dev server seeds the local config - store from `.toml`**. The same file `config push` reads from - is now also the local-dev seed source. The allowlist behaviour - (only env-overridable keys) becomes "every key declared in - `.toml [config]`" — the typed struct's field names form the - allowlist. -- Update `examples/app-demo/edgezero.toml` to remove `[stores.config. - defaults]`. Values move to `app-demo.toml [config]`. - -**Documentation:** - -- `docs/guide/cli-walkthrough.md` finalised: full `myapp` loop. -- `docs/guide/manifest-store-migration.md` was introduced in #3; now - the navigation links resolve to a complete document. -- `.vitepress/config.ts` sidebar updated. - -**Tests:** - -- `app-demo-cli config validate --strict` exits 0. -- `app-demo-cli config push --adapter axum` writes the local file; a - running axum dev server reads `greeting` via - `config_store_default()` and returns it on `/config/greeting`. -- `--help` smoke test asserts all subcommands. - -**Ship gate:** end-to-end demo of the full loop in CI on axum. +--dry-run` shows the expected invocation; secret fields absent; +namespace ID from fixture `wrangler.toml`. + +## 15. Sub-project 9 — `app-demo` integration polish (exercises every new capability) + +**Goal:** `app-demo` must demonstrate the **full** feature set, not a +subset. Concretely it exercises: + +- **Extensible CLI:** `app-demo-cli` with all five built-ins plus + `Auth`, `Provision`, and `Config` (`Validate` / `Push`) subcommands, + the `Config` arm wired to the **typed** functions with + `AppDemoConfig`. +- **Multi-store manifest:** `edgezero.toml` declares ≥2 KV ids + (`sessions`, `cache`), one config id, one secrets id, with + per-adapter `name` mappings for axum / cloudflare / fastly; spin + omits the stores section. +- **Multi-store runtime:** one handler reads `sessions` KV, another + reads `cache` KV (via the refactored `Kv` extractor's `named()`), + proving the registry. +- **Async config + Cloudflare KV path:** a handler does + `ctx.config_store_default()?.get("greeting").await?`. +- **Typed app-config with a nested section:** `AppDemoConfig` has + `service: ServiceConfig { timeout_ms }`; a handler reads the nested + value. +- **Env-var override:** an integration test sets + `APP_DEMO__SERVICE__TIMEOUT_MS` and asserts the resolved config + reflects the override; the walkthrough doc shows + `APP_DEMO__GREETING=… cargo run`. +- **Secrets:** `AppDemoConfig` has one `#[secret]` field + (`api_token`) and one `#[secret(store_ref)]` field (`vault`); a + handler reads each via the matching runtime pattern. +- **`config validate` / `config push`:** CI runs `app-demo-cli config + validate --strict` (exit 0) and `app-demo-cli config push --adapter + axum` then reads the value back through a running axum dev server on + `/config/greeting`. +- **`auth` / `provision`:** exercised against the `MockCommandRunner` + in tests; the walkthrough doc shows the real invocations. + +**`[stores.config.defaults]` removal:** drop the `defaults` field from +`manifest.rs`; drop the axum dev-server seeding at +[dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349); +the axum config store now seeds local-dev values from `app-demo.toml` +(via `load_app_config_raw`, env overlay included) — the typed struct's +keys form the allowlist. `examples/app-demo/edgezero.toml` drops +`[stores.config.defaults]`. + +**Docs:** `docs/guide/cli-walkthrough.md` (full `myapp` loop including +an env-override example); `manifest-store-migration.md` finalised; +`.vitepress/config.ts` sidebar updated. + +**Ship gate:** CI runs the full loop on axum end-to-end, including the +env-override assertion. --- ## 16. Implementation order and milestones -Each sub-project ships as one PR. Order is §7–§15. All four CI gates -green; no skipping (`-D warnings` stays). - -| # | Title | Risk | -|---|--------------------------------------------------------------------------------------------------------|------| -| 1 | Extensible lib + scaffold | M | -| 2 | Manifest schema additions (purely additive) | L | -| 3 | RequestContext + Hooks + extractor + Cloudflare KV rewrite + app! macro + adapter store registries | H | -| 4 | App-config schema + derive macro | M | -| 5 | `config validate` | L | -| 6 | `auth` + `CommandRunner` | M | -| 7 | `provision` | H | -| 8 | `config push` | M | -| 9 | `app-demo` polish + drop `[stores.config.defaults]` | M | - -**Highest-risk sub-projects:** - -- **#3 (runtime rewrite):** every store-touching path in core, - adapters, the macro, and the extractor system changes. Cloudflare - config-store backend swap is the biggest single change. Mitigations: - every adapter has contract tests; the existing default-store - handler signatures keep working; in-tree app-demo is the canary. -- **#7 (provision):** shell-out + multi-file manifest writeback. - Golden parser tests + `--dry-run` available. +| # | Title | Risk | +|---|-------|------| +| 1 | Extensible lib + scaffold | M | +| 2 | Manifest schema additions (additive, `Option`-modelled) | L | +| 3 | Runtime rewrite (async ConfigStore, Bound handles, registries, extractors, Cloudflare KV, Hooks/macro) | H | +| 4 | App-config schema + derive macro + env-overlay loader | M | +| 5 | `config validate` | L | +| 6 | `auth` + `CommandRunner` | M | +| 7 | `provision` | H | +| 8 | `config push` | M | +| 9 | `app-demo` polish (exercises everything) + drop `[stores.config.defaults]` | M | + +**Highest-risk:** #3 (async trait change cascades through core, +adapters, handlers, extractors, macro; Cloudflare backend swap) and #7 +(shell-out + multi-file native-manifest writeback, Fastly section +details pinned at implementation time). ## 17. Risks and trade-offs -- **Manifest breaking change (#3):** every external user editing - `edgezero.toml` needs to update store sections when sub-project #3 - ships. The `manifest-store-migration.md` guide ships in that PR; - the validator emits a clear error pointing at the guide on the old - shape. -- **Cloudflare runtime config swap (#3):** workers deployed against - the old `[vars]` JSON-blob config need a one-time migration to the - new KV-backed config. Documented in the migration guide. -- **`[stores.config.defaults]` removal (#9):** in-tree app-demo seeded - local-dev values from this field. #9 replaces it with reading from - `.toml`; external projects relying on `defaults` follow the - same migration. -- **API stability:** every public `*Args` is `#[non_exhaustive]` + - `Default` so adding fields stays non-breaking and external - construction works via `Default + field mutation`. -- **Shell-out fragility:** platform CLI surfaces change. We pin - current syntax, surface clear errors on missing/failing tools, and - rely on `.tool-versions`. -- **ID writeback brittleness:** stdout parsing is version-sensitive. - Per-tool golden tests; `--dry-run` available. -- **Generator drift:** generator output structure tested for shape; - sub-projects #1 and #4 add tests. -- **Macro / serde-attribute scope (#4):** `#[secret]` constrained to - non-flattened, non-renamed scalar fields with compile-error - enforcement. Avoids drift from partial serde-attribute parsing. -- **Multi-environment app-config:** out of scope. Follow-up spec. -- **Spin support gap:** until the in-flight Spin stores PR lands, - Spin omits `[adapters.spin.stores]` and is skipped by the - completeness validator. `provision` / `config push` error for - `--adapter spin`. -- **Test relocation in #1:** ~10 tests move; mechanical diff. +- **Async `ConfigStore` cascade:** making `get` async touches the + trait, three adapter impls, `Hooks`, every handler reading config, + and the `Config` extractor. Contained to sub-project #3; the + in-tree `app-demo` is the canary; `#[async_trait(?Send)]` keeps + WASM compatibility. +- **Manifest breaking change (#3):** external `edgezero.toml` files + need migration; the guide ships in #3; the validator errors clearly + on the old shape. +- **Cloudflare runtime config swap (#3):** deployed workers migrate + `[vars]` → KV once; documented. +- **`[stores.config.defaults]` removal (#9):** replaced by seeding the + axum config store from `.toml`. +- **Env overlay surprising `config push` (§6.10):** push pushes + env-resolved values; `--no-env` is the escape hatch; documented. +- **Fastly writeback under-specification:** spec commits to a + read/write-path-agreement contract; exact `fastly.toml` sections + pinned in #7's implementation plan with golden tests. +- **API stability:** non-subcommand `*Args` are `#[non_exhaustive]` + + `Default`; `AuthArgs` is `#[non_exhaustive]` without `Default`. +- **Shell-out + ID-writeback fragility:** current platform syntax + pinned; golden parser tests; `--dry-run` available. +- **Extractor breaking change:** `Kv(handle)` destructure → `kv.default()`; + only in-tree consumer is `app-demo`, migrated in #3. +- **Macro / serde-attribute scope:** `#[secret]` constrained with + compile-error enforcement. +- **Spin gap:** Spin omits `[adapters.spin.stores]`; not in + `STORES_SUPPORTED_ADAPTERS`; `provision` / `config push` error for + `--adapter spin` until the Spin stores PR lands. ## 18. What this spec does not cover -- Anthropic credentials, edge-network DNS / TLS, observability / - metrics. -- Per-environment config. -- Restructuring `app-demo-core` handlers beyond the one demonstrating - push-then-read and the multi-store KV demo handler in #3. -- Changes to `edgezero-core` beyond `app_config`, the rewritten - `manifest` store schema, the rewritten `RequestContext` / - `Hooks` / `app!` macro / `ConfigStoreMetadata` / extractor surface, - and the Cloudflare adapter config-store backend. -- Migration tool for the old manifest schema. Manual via the - published guide. -- Spin-side store provisioning and config push: deferred until the - Spin stores PR lands. - -When all nine sub-projects ship: - -- `edgezero new myapp` produces a workspace with `myapp-cli`, a - typed `MyappConfig` (using `#[derive(AppConfig)]` + optional - `#[secret]` / `#[secret(store_ref)]` fields, and - `#[serde(deny_unknown_fields)]`), a `myapp.toml`, and an - `edgezero.toml` using the new logical-store schema. -- App code addresses stores by logical id: - `ctx.kv_store("sessions")`, `ctx.config_store_default()`, - `ctx.secret_store("default")`, plus handler-level `Kv` / `Secrets` / - `KvNamed<"sessions">` extractors. -- The Cloudflare config store reads from a KV namespace, so - `config push` updates values without a redeploy. -- The developer logs into their platforms (`myapp-cli auth login - --adapter X`), provisions stores (`myapp-cli provision --adapter X`), - validates and pushes their app config (`myapp-cli config validate - --strict && myapp-cli config push --adapter X`), and deploys - (`myapp-cli deploy --adapter X`). -- At runtime, the deployed service reads its config from the platform - config store via `ctx.config_store_default()` / `ctx.config_store(id)`, - and reads secret-annotated fields from the secret store (key in - default store for `#[secret]`, logical store id for - `#[secret(store_ref)]`). -- The default `edgezero` binary remains backwards-compatible. +- 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 for old manifests (manual via the guide). +- Spin-side store provisioning / config push. + +When all nine 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. 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, and `app-demo` demonstrates every one of +these capabilities in CI. From 1b41ad721f0dcd5bc92d8857898d56c2a1db0a2b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 19:51:22 -0700 Subject: [PATCH 07/38] Fourth-pass review: manifest discrimination, Hooks split, env coercion, Fastly contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH severity fixes: - Manifest old-vs-new discrimination corrected. Existing manifests already have [stores.kv/secrets/config] tables, so table-presence can't discriminate. Sub-project #2 now uses compatibility structs carrying legacy fields (name, legacy adapters) plus new logical fields (ids, default) side by side; the discriminator is ids.is_some(). The current app-demo edgezero.toml parses unchanged. - Hooks cannot return bound handles. Hooks / ConfigStoreMetadata are static compile-time app metadata; bound handles need per-request adapter state. Split: Hooks/app! emit store metadata registries; only RequestContext returns Bound*Store handles. Adapters consume the metadata at request setup to build the runtime registries. - Env overlay type coercion: with C: DeserializeOwned there is no pre-deserialization type reflection. Env vars now override existing keys only, coerced to the existing TOML value's type. Matches the current AxumConfigStore::from_env behavior. To make a key env-overridable it must appear in .toml. - Axum config push and runtime read agreed: the axum config store is backed by .edgezero/local-config-.json; config push --adapter axum writes that file; edgezero dev regenerates it at startup. No more disagreement between push target and dev-server source. MEDIUM severity fixes: - Fastly writeback contract made concrete from Fastly's docs: [setup._stores.] + [local_server._stores.] keyed by resource link name (== our `name`). provision creates the store and ensures both fastly.toml sections exist; config push resolves the store id on demand via `fastly config-store list --json` (Fastly has no stable persisted id slot). Read/write paths all key off [adapters.fastly.stores..].name. - Env key matching is deterministic and ambiguity-rejecting: keys transform to an env segment form (uppercase); two siblings mapping to the same segment is an AppConfigError. No case-insensitive fuzzy fallback. - Cloudflare KV eventual consistency: §6.9 no longer claims values are live "on the next request"; CI does not assert immediate global Cloudflare visibility. LOW severity: - BoundSecretStore keeps the existing bytes::Bytes API (get -> Option, require_str), not Vec. --- .../specs/2026-05-19-cli-extensions-design.md | 289 +++++++++++++----- 1 file changed, 214 insertions(+), 75 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index bb44314..9b37f96 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -207,9 +207,16 @@ 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>, SecretStoreError>; } +// Secret store keeps the existing bytes::Bytes API (no Vec, no extra alloc). +impl BoundSecretStore { + pub async fn get(&self, key: &str) -> Result, SecretError>; + pub async fn require_str(&self, key: &str) -> Result; +} -// RequestContext store API (rewritten in sub-project #3) +// RequestContext store API (rewritten in sub-project #3) — returns BOUND, +// per-request handles. This is the only surface that yields bound handles, +// because binding needs per-request adapter state (Cloudflare Env, Fastly +// runtime state, Axum local handles). impl RequestContext { pub fn kv_store(&self, id: &str) -> Option; pub fn kv_store_default(&self) -> Option; @@ -219,7 +226,12 @@ impl RequestContext { pub fn secret_store_default(&self) -> Option; } -// Hooks gains the same id-keyed accessors returning Bound*Store. +// Hooks does NOT return bound handles. Hooks is static, compile-time app +// metadata (the app! macro emits it). It exposes store *metadata* +// registries — logical ids, the resolved default, per-adapter names — +// keyed by store kind. Adapters consume that metadata at request setup to +// build the runtime registries that back RequestContext's bound handles. +// See §6.8 and §9. // Extractors (refactored in sub-project #3): see §6.8. pub struct Kv(/* per-request KV registry */); @@ -261,7 +273,7 @@ crates/edgezero-cli/ tests/lib_consumer.rs # NEW crates/edgezero-core/src/ - manifest.rs # REWRITTEN store schema (Option + per-adapter map) + manifest.rs # store schema: compat structs in #2 (legacy + ids), legacy fields dropped in #3 context.rs # REWRITTEN 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 @@ -403,14 +415,38 @@ crate = "crates/app-demo-adapter-spin" manifest = "crates/app-demo-adapter-spin/spin.toml" ``` -**Old-vs-new discrimination (HIGH #4 fix):** each `[stores.]` -deserialises into `Option`. An `edgezero.toml` -written before this effort has no `[stores.]` in the new shape → -`None` → no new-schema validation. A new manifest declaring -`[stores.] ids = [...]` → `Some(LogicalStoreConfig)` → fully -validated. This keeps sub-project #2 genuinely additive: old manifests -are distinguishable from new-but-incomplete ones, so empty `ids` is a -real error rather than an accidental old-manifest match. +**Old-vs-new discrimination — discriminate on the `ids` field, not the +table.** Pre-existing manifests *already have* `[stores.kv]`, +`[stores.secrets]`, `[stores.config]` tables (see the current +[examples/app-demo/edgezero.toml:108](examples/app-demo/edgezero.toml#L108)), +so "table present or not" cannot tell old from new. Instead, during +sub-project #2 each `ManifestStoreConfig` struct is a +**compatibility struct carrying both the old and new fields**: + +```rust +#[derive(Deserialize)] +#[non_exhaustive] +pub struct ManifestKvStoreConfig { + // --- legacy single-store fields (still parsed in #2; removed in #3) --- + #[serde(default)] pub name: Option, + #[serde(default)] pub adapters: BTreeMap, + // --- new logical-store fields --- + #[serde(default)] pub ids: Option>, + #[serde(default)] pub default: Option, +} +``` + +The discriminator is `ids.is_some()`: + +- `ids` absent → legacy shape → legacy validation only; new-schema + rules do not fire. +- `ids` present → new shape → new-schema validation fires (non-empty + `ids`, `default` resolution, per-adapter completeness). A new-shape + table with `ids = []` is a real error. + +This keeps sub-project #2 genuinely additive: every current manifest +keeps parsing and validating exactly as before. Sub-project #3 deletes +the legacy fields once the runtime no longer reads them. **Field reference:** @@ -558,9 +594,14 @@ id = "abc123def456" ``` `config push --adapter cloudflare` writes via `wrangler kv bulk put - --namespace-id=`. No redeploy; values live on the -next request after KV propagation. The `[vars]` model is removed; -existing deployed workers migrate once (documented in the guide). + --namespace-id=`. No redeploy is needed. **KV is +eventually consistent** — pushed values become visible after KV's +propagation window (typically seconds; Cloudflare documents up to ~60s +for global propagation). The spec, docs, and tests treat Cloudflare KV +visibility as eventual: CI does not assert immediate global visibility +for Cloudflare (CI exercises the axum and mock paths; see §15). The +`[vars]` model is removed; existing deployed workers migrate once +(documented in the guide). ### 6.10 App-config environment-variable resolution @@ -570,16 +611,43 @@ layers, lowest priority first: 1. The `[config]` table parsed from `.toml`. 2. Environment-variable overrides. +**Env vars override existing keys only.** An env var overrides a config +value **only if that key already exists in the parsed `[config]` +tree**. Keys absent from the file are not created by env vars. This is +a deliberate constraint: + +- With `C: DeserializeOwned` there is no pre-deserialization reflection + over `C`'s field types, so the loader cannot know the type of a key + supplied *only* via env. By restricting overrides to existing keys, + the loader infers the type from the **existing TOML value** at that + path and parses the env string accordingly. +- It also matches the existing `AxumConfigStore::from_env` behaviour, + which only reads env vars for keys declared in the manifest. +- Consequence: to make a key env-overridable, it must appear in + `.toml` (with a real value or a stub). The generator template + and `app-demo.toml` include every env-overridable key. + +This rule applies identically to the typed and raw loaders. + **Env var naming.** `__
__…__`: - `` is `[app].name` from `edgezero.toml`, uppercased, with `-` replaced by `_` (so `app-demo` → `APP_DEMO`). Passed to `load_app_config` as the `app_name` argument. - `__` (double underscore) separates **every** nesting level, - including app-name → first key. A single `_` is a literal character - within a name; only `__` is a separator. -- Each segment after the prefix is matched case-insensitively against - the config key at that level. + including app-name → first key. A single `_` is a literal character; + only `__` is a separator. + +**Deterministic, ambiguity-rejecting key matching.** TOML keys are +case-sensitive; env var names are conventionally uppercase. The loader +matches by transforming each config key at a level to its +**env segment form** — uppercase the key, leave `_` as-is — and +comparing against the env var's segment. If two sibling keys at the +same level transform to the same env segment (e.g. `foo` and `FOO`, or +`api_key` and `API_KEY`), the loader **errors** with +`AppConfigError` ("ambiguous env mapping: keys `foo` and `FOO` at +`config` both map to env segment `FOO`"). Matching is otherwise exact +on the transformed form — no fuzzy/case-insensitive fallback. Examples for `app-demo.toml`: @@ -595,18 +663,17 @@ timeout_ms = 1500 | `APP_DEMO__GREETING` | `config.greeting` | | `APP_DEMO__SERVICE__TIMEOUT_MS` | `config.service.timeout_ms` | -**Type coercion.** Env var values are strings. During overlay they are -parsed against the target field's TOML type (the overlay produces a -`toml::Value` tree; integers/bools are parsed from the string, parse -failure is an `AppConfigError`). For the typed loader this happens -before `serde` deserialization. +**Type coercion.** The env string is parsed against the type of the +**existing** TOML value at that path: string → as-is; integer/float/ +bool → parsed from the string (parse failure is an `AppConfigError`). +The overlay produces a `toml::Value` tree; for the typed loader this +happens before `serde` deserialization. -**Scope.** Resolution happens inside `load_app_config*`. Therefore -`config validate` and `config push` both see env-resolved values — -useful for injecting per-environment values from a deploy pipeline. A -`--no-env` flag on `validate` and `push` disables the overlay when the -raw file contents are wanted. The axum dev server also resolves via -this path, so `APP_DEMO__GREETING=hi cargo run …` overrides locally. +**Scope.** Resolution happens inside `load_app_config*`. `config +validate` and `config push` both see env-resolved values — useful for +injecting per-environment values from a deploy pipeline. A `--no-env` +flag on `validate` and `push` disables the overlay. The axum dev +server resolves via the same path. ### 6.11 `Default` on `*Args` @@ -645,24 +712,29 @@ succeeds. ## 8. Sub-project 2 — Manifest schema additions (purely additive) -**Goal:** add the new schema as `Option` + -`Option` so old-shape manifests are -distinguishable and validation only runs on new-shape declarations. -No runtime changes; nothing removed; `[stores.config.defaults]` -stays. - -**Source changes:** `manifest.rs` gains `Option` -per kind and `ManifestAdapter.stores: Option`; -validator rules (§6.6) fire only when the new fields are `Some`; -`STORES_SUPPORTED_ADAPTERS` allowlist drives completeness. Old fields -remain and keep deserialising. - -**Tests:** new-schema round-trip; default resolution (omitted with one -id; omitted with many → error; explicit not-in-ids → error); -completeness (supported adapter omitting `stores` → error; +**Goal:** add the new logical-store fields **alongside** the existing +single-store fields in the same structs (compatibility structs, §6.6), +discriminating on `ids` presence. Old-shape manifests keep parsing and +validating exactly as before. No runtime changes; nothing removed; +`[stores.config.defaults]` stays. + +**Source changes:** `manifest.rs` — each `ManifestStoreConfig` +becomes a compatibility struct carrying both the legacy fields +(`name`, legacy `adapters` overrides) and the new logical fields +(`ids: Option>`, `default: Option`). +`ManifestAdapter` gains `stores: Option` (the +per-adapter logical mapping). New-schema validator rules (§6.6) fire +only when `ids.is_some()`; `STORES_SUPPORTED_ADAPTERS` drives +completeness. Legacy fields and legacy validation are untouched. + +**Tests:** new-schema round-trip; `ids`-presence discrimination +(legacy table with `name` only → no new validation; table with +`ids` → new validation); default resolution (omitted with one id; +omitted with many → error; explicit not-in-ids → error; `ids = []` → +error); completeness (supported adapter omitting `stores` → error; non-allowlisted adapter → skipped); Cloudflare JS-identifier check → -error; old-shape manifests parse with `None` and trigger no new -validation. +error; the **current** `examples/app-demo/edgezero.toml` (legacy +shape) parses and validates unchanged. **Ship gate:** existing app-demo runtime works unchanged; manifest tests prove the new schema parses and validates. @@ -675,16 +747,26 @@ end-to-end on axum and Cloudflare. **Scope:** - `ConfigStore::get` becomes `async` (`#[async_trait(?Send)]`). -- `BoundKvStore` / `BoundConfigStore` / `BoundSecretStore` introduced; - `RequestContext` + `Hooks` accessors return them, id-keyed, with - `_default()` helpers resolving the §6.4 default. -- Each adapter's store setup builds a `StoreRegistry` from - `[adapters..stores.*]`. +- `BoundKvStore` / `BoundConfigStore` / `BoundSecretStore` introduced. + **Only `RequestContext` returns bound handles** — binding needs + per-request adapter state. `RequestContext` accessors are id-keyed + with `_default()` helpers resolving the §6.4 default. +- **`Hooks` does not return bound handles.** `Hooks` / + `ConfigStoreMetadata` are static, compile-time app metadata (emitted + by the `app!` macro). They are rewritten to expose store *metadata* + registries — per kind: logical ids, the resolved default, and the + per-adapter `name` map. Adapters consume that metadata at request + setup to build the runtime `StoreRegistry` that backs + `RequestContext`'s bound handles. So the split is: `Hooks`/`app!` = + metadata; `RequestContext` = bound runtime handles. +- Each adapter's store setup reads the `Hooks` metadata + injects a + `StoreRegistry` for each kind into the request context. - `CloudflareConfigStore` rewritten `[vars]` → KV (§6.9). - `Kv` / `Secrets` extractors refactored to `default()` / `named()` (§6.8); a `Config` extractor added. -- `ConfigStoreMetadata` becomes a registry; `app!` macro emits - id-keyed metadata from the new manifest schema. +- `ConfigStoreMetadata` becomes a metadata registry (one entry per + logical config id, each with its per-adapter names); `app!` macro + emits it from the new manifest schema. - Old single-store manifest fields removed; `examples/app-demo/ edgezero.toml` migrated; `app-demo` handlers updated to the new accessors. Spin adapter omits `stores`. @@ -791,19 +873,55 @@ adapter/kind table (`wrangler kv namespace create `, `fastly kv-store create --name=`, etc.). `--dry-run` prints `CommandSpec`s without invocation. -**Writeback to native manifests:** - -- **Cloudflare:** patch `wrangler.toml` `[[kv_namespaces]]` with - `binding = ""`, `id = ""`. -- **Fastly:** the exact `fastly.toml` sections to patch are - **pinned in the implementation plan** by reading Fastly's current - manifest docs (Fastly distinguishes store names, resource-link - names/IDs, and `setup` vs `local_server` sections). The spec-level - contract: `provision` writes Fastly resource identifiers into - whatever `fastly.toml` section the Fastly Compute runtime resolves - stores from, and `config push` reads the identifier back from the - same section — read and write paths must agree. Sub-project #7's PR - ships the exact section names with golden-file tests. +**Writeback to native manifests — concrete contract.** + +*Cloudflare* (IDs are stable and persisted): + +- After `wrangler kv namespace create `, parse the namespace ID + from stdout and patch `wrangler.toml`: + ```toml + [[kv_namespaces]] + binding = "" # == [adapters.cloudflare.stores..].name + id = "" + ``` +- `config push --adapter cloudflare` reads the `id` back from + `wrangler.toml` by matching `binding`. + +*Fastly* (resource-link model; IDs resolved on demand): + +Fastly's `fastly.toml` declares stores in two sections, both keyed by +the **resource link name** — which Fastly Compute code uses to access +the store, and which EdgeZero maps to +`[adapters.fastly.stores..].name`: + +- `[setup.kv_stores.]` / `[setup.config_stores.]` / + `[setup.secret_stores.]` — consumed by `fastly compute deploy` + to create and link resources on first deploy. +- `[local_server.kv_stores.]` / `[local_server.config_stores. + ]` / `[local_server.secret_stores.]` — consumed by + `fastly compute serve` for local testing. + +`provision --adapter fastly` for each logical id: + +1. `fastly -store create --name=` creates the store. +2. Ensures `fastly.toml` contains both `[setup._stores.]` + and `[local_server._stores.]` table entries (created if + absent) so deploy links the store and local serve can find it. + +The Fastly store *ID* is **not** persisted in `edgezero.toml` or +`fastly.toml` — Fastly's manifest has no stable ID slot outside the +transient `[setup]` section (which is ignored once the service +exists). Instead, `config push --adapter fastly` resolves the store +ID on demand: `fastly config-store list --json`, match by ``, +then `fastly config-store-entry create --store-id= --key=… --value=…` +(large values via `--stdin`). One extra authenticated CLI call per +push; no persistence problem. + +**Read/write-path agreement:** the runtime Fastly adapter accesses +each store by its resource link name (``); `provision` writes +that same `` into `[setup.*]` / `[local_server.*]`; `config +push` resolves the ID from `` via the list command. All three +paths key off `[adapters.fastly.stores..].name`. **Tests:** per-(adapter, kind) mock-runner with scripted stdout; golden ID-extraction parsers; temp-fixture writeback verified; @@ -831,9 +949,11 @@ overlay unless `--no-env`); serialise per §6.4 (skip `SECRET_FIELDS`); resolve target id (`--store` or resolved default); look up the per-adapter `name`; read the platform resource ID from the native manifest (error "did you run `provision` first?" if absent); shell -out (`wrangler kv bulk put … --namespace-id=…`; `fastly -config-store-entry create …`; axum writes -`.edgezero/local-config-.env`; spin errors "not yet supported"). +out (`wrangler kv bulk put … --namespace-id=…`; for Fastly, resolve +the store id via `fastly config-store list --json` then +`fastly config-store-entry create --store-id=… …` per §13; axum writes +the resolved values to `.edgezero/local-config-.json` — the file +the axum config store reads (§15); spin errors "not yet supported"). **Tests (MEDIUM #9 — push behaviour beyond validate):** typed + raw; per-adapter mock-runner with golden payloads; secret fields absent @@ -883,13 +1003,32 @@ subset. Concretely it exercises: - **`auth` / `provision`:** exercised against the `MockCommandRunner` in tests; the walkthrough doc shows the real invocations. +**Axum config store backing — push and runtime read the same file.** +For axum there is no remote store, so the axum config store is backed +by a single local file: `.edgezero/local-config-.json` (gitignored). + +- `config push --adapter axum` loads `.toml` (env overlay + applied), serialises the resolved `[config]` values, and writes them + to `.edgezero/local-config-.json`. +- The axum config store reads from `.edgezero/local-config-.json` + at request setup — the **same file** `config push` writes. No + disagreement: a running dev server observes pushed values. +- `edgezero dev` regenerates `.edgezero/local-config-.json` at + startup (running the same resolve-and-write step as `config push + --adapter axum`), so the dev workflow needs no manual push. If the + file is absent at request time (e.g. server started without `dev`), + the axum config store treats it as an empty store. + +This makes axum genuinely push-backed and consistent with the remote +adapters, and lets the §15 ship gate test a real push→read cycle. + **`[stores.config.defaults]` removal:** drop the `defaults` field from `manifest.rs`; drop the axum dev-server seeding at -[dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349); -the axum config store now seeds local-dev values from `app-demo.toml` -(via `load_app_config_raw`, env overlay included) — the typed struct's -keys form the allowlist. `examples/app-demo/edgezero.toml` drops -`[stores.config.defaults]`. +[dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349). +Its role is fully replaced by the file flow above: the source of +local-dev config values is `.toml` (resolved through `config +push --adapter axum` / `edgezero dev`), not a manifest section. +`examples/app-demo/edgezero.toml` drops `[stores.config.defaults]`. **Docs:** `docs/guide/cli-walkthrough.md` (full `myapp` loop including an env-override example); `manifest-store-migration.md` finalised; From f0aed202e5292b776b24b50d7d7ed0598414ec1f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 19 May 2026 23:50:18 -0700 Subject: [PATCH 08/38] Fifth-pass review: hard cutoff, Spin as first-class store adapter, one-PR delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard cutoff (per user directive — projects fully migrated, no compat): - Removed all old-vs-new manifest discrimination: no compat structs, no ids.is_some() check, no legacy-field parsing. The store schema is rewritten outright. Legacy fields (name, legacy adapters overrides, [stores.config.defaults]) are hard load errors pointing at the migration guide. Spin as a first-class store-capable adapter (PR #253 baseline): - Removed the "Spin deferred" non-goal. Spin participates fully. - New §6.7 Spin store semantics: KV is label-backed multi-store with a max_list_keys cap; config and secrets are both spin_sdk::variables — a single flat namespace, lowercase [a-z0-9_] keys, no dots. - Replaced the flat STORES_SUPPORTED_ADAPTERS allowlist with an adapter x kind capability matrix (Multi vs Single). Validation: if any target adapter is Single for a kind, [stores.].ids must have exactly one id (you cannot have two config stores if you also target Spin). - §6.4 config key model: nested config flattens to dotted keys; canonical handler form is dotted; Spin config store translates . -> __ internally; config push writes platform-native key form. - Spin wired into commit 2 (runtime registry, async ConfigStore now cascades across all FOUR adapters), commit 6 (provision: spin.toml writeback for key_value_stores / [variables] / [component..variables]), commit 7 (config push: Spin variables in spin.toml). - provision now has explicit axum (no-op, prints local-store note) and spin (manifest writeback, no CommandRunner) contracts; config push is split per adapter — no universal native-resource-ID assumption. Other review fixes: - Default resolution made strict: `default` required when ids.len() > 1. - Docs config path corrected to docs/.vitepress/config.mts (not .ts). Delivery: one PR with eight commits (one per sub-project), not eight PRs. CI gates the PR head; each commit should still build for bisectability. Sub-project count stays at 8 (manifest+runtime stay merged as the atomic commit 2). --- .../specs/2026-05-19-cli-extensions-design.md | 1212 ++++++++--------- 1 file changed, 532 insertions(+), 680 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 9b37f96..cc78bf6 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -3,33 +3,44 @@ **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 manifest schema rewrite introducing a logical-store / +- a **hard-cutoff manifest schema rewrite** introducing a logical-store / per-adapter-mapping model for KV / secrets / config, -- a runtime API rewrite supporting multiple stores per kind — including - making `ConfigStore` async, rewriting the Cloudflare config backend - from `[vars]` to KV, introducing bound store handles, refactoring the - `Kv` / `Secrets` extractors to support named stores, and updating - `Hooks`, `ConfigStoreMetadata`, and the `app!` macro, +- 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 - end-to-end. +- and an `app-demo` overhaul that exercises every new capability across + all four adapters (axum, cloudflare, fastly, spin) end-to-end. -The work is organised into nine sub-projects so it can ship in nine -incremental PRs, but the design decisions live here together. +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 commits** — one commit +per sub-project, in the §16 order. The design decisions live here +together. --- ## 1. Goal -Let downstream projects (e.g. a future `myapp` created by `edgezero new +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`, @@ -41,35 +52,33 @@ myapp`) build their own CLI binary that: Alongside the extensibility substrate, ship: - A **multi-store manifest model**: the app declares logical stores it - uses (`[stores.kv] ids = ["foo", "bar"]`) and each adapter declares the - platform-specific `name` for each logical id, with room for + uses (`[stores.kv] ids = ["foo", "bar"]`); each adapter maps every + logical id to a platform-specific `name`, with room for adapter-specific tuning. Stores are addressed in code by logical id. -- A **typed per-service app-config file** (e.g. `myapp.toml`) with a + 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. +- 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: values - in `.toml` can be overridden by env vars, with `__` separating - nesting levels (§6.10). -- **`ConfigStore` becomes async**, and the **Cloudflare config backend - moves from `[vars]` to KV** so `config push` reaches the runtime - without redeploying. -- **Bound store handles** (`BoundKvStore` / `BoundConfigStore` / - `BoundSecretStore`) so callers don't pass store names around. -- **Refactored `Kv` / `Secrets` extractors** that resolve either the - default store or a named store (§6.8). + `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`) - that shells out to each platform's native CLI, wrapped in a mockable + 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 that exercises all of the above end-to-end. +- An `app-demo` overhaul exercising all of the above across all four + adapters end-to-end. -The default `edgezero` binary keeps existing subcommands -backwards-compatible. The manifest schema rewrite is a **breaking -change** to the on-disk format; in-tree `examples/app-demo` is migrated, -and a published guide covers external users. +The default `edgezero` binary keeps its existing subcommands' names and +flags; new subcommands are added. ## 2. Non-goals @@ -83,11 +92,11 @@ and a published guide covers external users. 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 on-disk migration helper for old manifests. The migration guide - covers external users. -- No Spin-side `provision` / `config push`. Spin's stores schema lands - via a separate in-flight PR; `[adapters.spin]` omits the `stores` - section until then. +- **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 @@ -97,7 +106,7 @@ graph TB 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 name map
async ConfigStore + Bound*Store handles
RequestContext / Hooks: id-keyed store accessors
extractor: Kv / Secrets (default or named)"] + 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"] @@ -115,28 +124,23 @@ graph TB Key contracts: - **Substrate**: each built-in command is a `(pub *Args, pub run_*)` - pair. Downstream `Subcommand` enums opt in by listing variants. - Non-subcommand `*Args` derive `Default` (for external construction - despite `#[non_exhaustive]`); subcommand-wrapping `*Args` (e.g. - `AuthArgs`) do **not** derive `Default` (§6.11). -- **Multi-store manifest model**: §6.6. -- **Async `ConfigStore`**: `ConfigStore::get` becomes - `async fn get(...)` (via `#[async_trait(?Send)]`, matching the - project's WASM-compat rule). KV and secret stores are already async. -- **Bound store handles**: `RequestContext` / `Hooks` accessors return - `BoundKvStore` / `BoundConfigStore` / `BoundSecretStore` — each wraps - the provider handle plus the resolved platform name, so callers just - do `.get(key).await`. -- **Cloudflare config moves to KV**: `CloudflareConfigStore` reads from - a KV namespace (one per logical config id). With the now-async - trait, reads are real async KV gets; `config push` updates KV - without a redeploy. -- **Extractors**: `Kv` / `Secrets` are refactored to resolve the - default store or a named one (§6.8). -- **Typed app-config + secrets**: §6.7. -- **Env-var override**: §6.10. -- **Shell-out isolation**: private `CommandRunner` + `CommandSpec`; - `MockCommandRunner` in tests. + 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 @@ -160,14 +164,12 @@ pub fn run_dev() -> !; pub fn run_auth(args: &AuthArgs) -> Result<(), String>; pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; -// validate bound: no Serialize. pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> where C: serde::de::DeserializeOwned + validator::Validate + ::edgezero_core::app_config::AppConfigMeta; -// push bound: adds Serialize. pub fn run_config_push(args: &ConfigPushArgs) -> Result<(), String>; pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> where @@ -178,24 +180,18 @@ where From `edgezero-core`: ```rust -// app_config module (new in sub-project #4) -pub trait AppConfigMeta { - const SECRET_FIELDS: &'static [SecretField]; -} +// 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 } -/// Loads .toml, overlays environment variables (§6.10), then -/// deserializes + validates into C. pub fn load_app_config(path: &std::path::Path, app_name: &str) -> Result where C: serde::de::DeserializeOwned + validator::Validate + AppConfigMeta; - -/// Same env overlay, untyped — returns the merged tree. pub fn load_app_config_raw(path: &std::path::Path, app_name: &str) -> Result; -// async config store trait (sub-project #3) +// async config store trait #[async_trait(?Send)] pub trait ConfigStore { async fn get(&self, key: &str) -> Result, ConfigStoreError>; @@ -207,16 +203,12 @@ pub struct BoundConfigStore { /* ... */ } pub struct BoundSecretStore { /* ... */ } impl BoundConfigStore { pub async fn get(&self, key: &str) -> Result, ConfigStoreError>; } impl BoundKvStore { /* async CRUD */ } -// Secret store keeps the existing bytes::Bytes API (no Vec, no extra alloc). impl BoundSecretStore { pub async fn get(&self, key: &str) -> Result, SecretError>; pub async fn require_str(&self, key: &str) -> Result; } -// RequestContext store API (rewritten in sub-project #3) — returns BOUND, -// per-request handles. This is the only surface that yields bound handles, -// because binding needs per-request adapter state (Cloudflare Env, Fastly -// runtime state, Axum local handles). +// 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; @@ -226,30 +218,13 @@ impl RequestContext { pub fn secret_store_default(&self) -> Option; } -// Hooks does NOT return bound handles. Hooks is static, compile-time app -// metadata (the app! macro emits it). It exposes store *metadata* -// registries — logical ids, the resolved default, per-adapter names — -// keyed by store kind. Adapters consume that metadata at request setup to -// build the runtime registries that back RequestContext's bound handles. -// See §6.8 and §9. - -// Extractors (refactored in sub-project #3): see §6.8. -pub struct Kv(/* per-request KV registry */); -pub struct Secrets(/* per-request secret registry */); -impl Kv { - pub fn default(&self) -> Option; - pub fn named(&self, id: &str) -> Option; -} -impl Secrets { - pub fn default(&self) -> Option; - pub fn named(&self, id: &str) -> Option; -} +// Hooks / ConfigStoreMetadata: static, compile-time, id-keyed store +// metadata (no bound handles). ``` -From `edgezero-macros` (it IS the proc-macro crate): +From `edgezero-macros`: ```rust -// crates/edgezero-macros/src/lib.rs #[proc_macro_derive(AppConfig, attributes(secret))] pub fn derive_app_config(input: TokenStream) -> TokenStream { /* ... */ } ``` @@ -260,55 +235,48 @@ pub fn derive_app_config(input: TokenStream) -> TokenStream { /* ... */ } crates/edgezero-cli/ Cargo.toml src/ - lib.rs # public API; declares private modules - main.rs # thin wrapper for the default edgezero bin - args.rs # *Args structs (#[non_exhaustive]; Default only where meaningful) - adapter.rs # (unchanged, private) + lib.rs / main.rs / args.rs / adapter.rs / scaffold.rs / dev_server.rs generator.rs # extended: scaffolds -cli + .toml + -core/src/config.rs - scaffold.rs # (unchanged-ish, private) - dev_server.rs # (unchanged, private; feature-gated) 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 - tests/lib_consumer.rs # NEW crates/edgezero-core/src/ - manifest.rs # store schema: compat structs in #2 (legacy + ids), legacy fields dropped in #3 - context.rs # REWRITTEN store accessors (id-keyed; return Bound*Store) + 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 # (already async) - secret_store.rs # bound-handle wrapper added - extractor.rs # Kv / Secrets refactored to default-or-named - hooks.rs # REWRITTEN: id-keyed Hooks accessors - app.rs # ConfigStoreMetadata -> registry shape + 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 -# Adapter store impls rewritten for multi-store (sub-project #3): -crates/edgezero-adapter-{axum,cloudflare,fastly}/src/{config_store,key_value_store,secret_store}.rs -# Cloudflare config_store specifically: [vars] -> KV namespace, async reads. +# 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 new schema; spin omits stores section + 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 (default + env-overridden) and named kv + 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 + app-demo-adapter-*/ # store-setup rewrites (all four) docs/guide/{cli-walkthrough,manifest-store-migration}.md # NEW -.vitepress/config.ts # UPDATED sidebar +docs/.vitepress/config.mts # UPDATED sidebar (note: .mts, not .ts) ``` ## 6. Cross-cutting designs -### 6.1 `CommandSpec` + `CommandRunner` (sub-project #6) +### 6.1 `CommandSpec` + `CommandRunner` (sub-project #5) ```rust // crates/edgezero-cli/src/runner.rs (private) @@ -325,9 +293,6 @@ pub(crate) struct RealCommandRunner; #[cfg(test)] pub(crate) struct MockCommandRunner { /* recorded expectations */ } ``` -Public command functions use a private `*_with` inner so tests inject -the mock. - ### 6.2 Error model All public `run_*` return `Result<(), String>`. Binaries log and exit. @@ -338,56 +303,71 @@ All public `run_*` return `Result<(), String>`. Binaries log and exit. - `edgezero-adapter-{axum,fastly,cloudflare,spin}` (all default) gate each adapter's dispatch path. -### 6.4 Typed vs raw config serialization +### 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. +not require `Serialize` and performs no `to_value` check. **Push (both flavours):** all validate checks run first as a strict -pre-flight. Then each field is serialised to a string: -- `String` as-is; `bool`/numbers via `to_string()`; compound types via - `serde_json::to_string`; `Option::None` / `Value::Null` skipped. -- `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 scalar/compound rules, - no `Validate`, no secret skipping. - -**Unknown fields:** serde ignores them unless the struct has -`#[serde(deny_unknown_fields)]`. The generator template emits that -attribute; `config validate` therefore guarantees unknown-field -rejection only for structs that opt in. - -**Default-id resolution:** every reference to "the default config / -secret store" means the **resolved** default id — the explicit -`[stores.].default` if set, else the single `ids[0]` when -`ids.len() == 1`. Validation and `config push` resolve the default the -same way `ManifestLoader` does. - -### 6.5 Test strategy summary - -Existing tests move with their handlers; per-sub-project tests for each -new surface; every platform-touching test uses `MockCommandRunner`; -`tests/lib_consumer.rs` exercises the public API externally; manifest -contract tests cover multi-store, default resolution, Spin-skip, and -old-vs-new manifest discrimination. - -### 6.6 Multi-store manifest schema - -**App-level declaration (`edgezero.toml`):** +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 Multi-store manifest schema + capability rules + +The `[stores]` and `[adapters.*]` schema is **rewritten outright**. +There is no legacy shape. Legacy fields (`[stores.] name`, legacy +`[stores..adapters.*]` overrides, `[stores.config.defaults]`) +are removed; a manifest still using them is a **hard load error** +pointing at `docs/guide/manifest-store-migration.md`. + +**App-level declaration:** ```toml [stores.kv] -ids = ["foo", "bar"] -default = "foo" # optional when ids has exactly one entry +ids = ["sessions", "cache"] +default = "sessions" # REQUIRED when ids.len() > 1 [stores.config] -ids = ["app_config"] +ids = ["app_config"] # default optional: single id [stores.secrets] ids = ["default"] @@ -396,94 +376,113 @@ ids = ["default"] **Per-adapter mapping + tuning:** ```toml -[adapters.cloudflare.stores.kv.foo] -name = "FOO_CLOUDFLARE" +[adapters.cloudflare.stores.kv.sessions] +name = "SESSIONS_KV" -[adapters.fastly.stores.kv.foo] -name = "FOO_FASTLY" +[adapters.fastly.stores.kv.sessions] +name = "sessions_kv" max_value = "1MB" # adapter-specific tuning, free-form -[adapters.cloudflare.stores.config.app_config] -name = "APP_CONFIG_KV" # Cloudflare config is a KV namespace (§6.9) +[adapters.spin.stores.kv.sessions] +name = "sessions" # Spin KV store label -[adapters.cloudflare.stores.secrets.default] -name = "EDGEZERO_SECRETS" - -# spin omits the stores section until its in-flight PR lands: -[adapters.spin.adapter] -crate = "crates/app-demo-adapter-spin" -manifest = "crates/app-demo-adapter-spin/spin.toml" -``` - -**Old-vs-new discrimination — discriminate on the `ids` field, not the -table.** Pre-existing manifests *already have* `[stores.kv]`, -`[stores.secrets]`, `[stores.config]` tables (see the current -[examples/app-demo/edgezero.toml:108](examples/app-demo/edgezero.toml#L108)), -so "table present or not" cannot tell old from new. Instead, during -sub-project #2 each `ManifestStoreConfig` struct is a -**compatibility struct carrying both the old and new fields**: +[adapters.cloudflare.stores.config.app_config] +name = "APP_CONFIG_KV" -```rust -#[derive(Deserialize)] -#[non_exhaustive] -pub struct ManifestKvStoreConfig { - // --- legacy single-store fields (still parsed in #2; removed in #3) --- - #[serde(default)] pub name: Option, - #[serde(default)] pub adapters: BTreeMap, - // --- new logical-store fields --- - #[serde(default)] pub ids: Option>, - #[serde(default)] pub default: Option, -} +[adapters.spin.stores.config.app_config] +# name is accepted but vestigial for Spin config (flat variables, §6.7) ``` -The discriminator is `ids.is_some()`: - -- `ids` absent → legacy shape → legacy validation only; new-schema - rules do not fire. -- `ids` present → new shape → new-schema validation fires (non-empty - `ids`, `default` resolution, per-adapter completeness). A new-shape - table with `ids = []` is a real error. - -This keeps sub-project #2 genuinely additive: every current manifest -keeps parsing and validating exactly as before. Sub-project #3 deletes -the legacy fields once the runtime no longer reads them. - **Field reference:** | Field | Where | Role | |---|---|---| -| `[stores.].ids` | top level | logical ids (`Vec`, non-empty when the table is present) | -| `[stores.].default` | top level | resolved default; optional if `ids.len() == 1`; must be in `ids` | -| `[adapters..stores..].name` | per-adapter | platform name; required | -| other fields in that block | per-adapter | free-form `BTreeMap` tuning; opaque to core | - -**Provisioned platform resource IDs** live in each platform's native -manifest (`wrangler.toml`, `fastly.toml`), not `edgezero.toml`. -`provision` writes them; `config push` reads them. - -**Validation rules:** - -- `ids` non-empty when `[stores.]` is present. -- `default` in `ids`, or absent (then resolved to `ids[0]`). -- **Adapter store completeness with an explicit allowlist (MEDIUM #6 - fix):** `STORES_SUPPORTED_ADAPTERS = ["axum", "cloudflare", "fastly"]`. - Every adapter in `[adapters.*]` **that is in this allowlist** must - declare an `[adapters..stores]` section mapping every id of every - declared store kind. A supported adapter omitting `stores` is an - **error** (it cannot silently opt out). Adapters not in the allowlist - (currently only `spin`) are skipped — this is how Spin participates - before its stores PR lands. When the Spin PR ships, `spin` joins the - allowlist. +| `[stores.].ids` | top level | logical ids (`Vec`, non-empty) | +| `[stores.].default` | top level | resolved default; **required when `ids.len() > 1`**, optional (resolves to `ids[0]`) when exactly one id; must be in `ids` | +| `[adapters..stores..].name` | per-adapter | platform name (see capability rules for whether required) | +| other fields in that block | per-adapter | free-form `BTreeMap` tuning | + +**Adapter × kind capability matrix.** A single flat +`STORES_SUPPORTED_ADAPTERS` list is too coarse. 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. + A per-id `name` mapping is **required** for every id. +- **Single**: the adapter has exactly one flat store of that kind. The + per-id `name` is accepted but vestigial. + +**Validation rules (in `ManifestLoader`):** + +- `[stores.].ids` non-empty when present. +- `default` present iff `ids.len() > 1`; when present, must be in `ids`. +- **Capability check:** for each declared kind, compute the minimum + capability across the adapters declared in `[adapters.*]`. If any + declared adapter is `Single` for that kind, `[stores.].ids` + **must have exactly one id** — you cannot declare two config stores + in a project that also targets Spin, because Spin config is a single + flat namespace. The error names the offending adapter and kind. +- For each (adapter, kind) that is `Multi`, every id must have a + `[adapters..stores..]` block with a `name`. For + `Single` (adapter, kind) pairs, the block is optional. - `name` under `[adapters.cloudflare.stores.*]` must be a JavaScript - identifier (Wrangler binding constraint); invalid names are - **errors**. + identifier; `name` under `[adapters.spin.stores.kv.*]` must be a + valid Spin KV label. Invalid names are errors. **Runtime resolution:** each adapter builds a `StoreRegistry { by_id: BTreeMap, default_id: String }` -at request setup. `ctx.kv_store("foo")` → `Some` / `None`; -`ctx.kv_store_default()` → the `default_id` handle. - -### 6.7 Secret annotations via `#[derive(AppConfig)]` +at request setup. For `Single` (adapter, kind) pairs the registry has +one entry mapped to the adapter's single 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. Constraints: no TTL; **listing is capped** (`SpinKvStore` +has a `max_list_keys` cap and returns `KvError::Validation` rather +than silently truncating when the cap is exceeded). The runtime +adapter opens each configured label and registers it by logical id. + +**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). +`[adapters.spin.stores.config.].name` is accepted but vestigial. +**Spin variable names must match `[a-z][a-z0-9_]*`** — lowercase, no +dots, no uppercase. The config-store 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, shared namespace.** +`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 it is +non-empty). `[stores.secrets].ids` must have exactly one id for a +Spin project. Because config and secret variables share one +namespace, their effective key spaces must not collide; this is +guaranteed within a single `AppConfig` struct (config fields and +`#[secret]` fields are distinct sibling fields → distinct variable +names). + +**Implication for app config targeting Spin.** If the project's +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)] @@ -508,37 +507,32 @@ pub struct ServiceConfig { } ``` -The derive emits `impl AppConfigMeta` with a `SECRET_FIELDS` array of -`SecretField { name, kind }`. +The derive emits `impl AppConfigMeta` with a `SECRET_FIELDS` array. -**Constraints (compile errors from the derive):** `#[secret]` / -`#[secret(store_ref)]` only on scalar string fields; error if combined -with `#[serde(flatten)]` / `#[serde(rename)]` / `#[serde(skip*)]`; +**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 (resolved default exists). `StoreRef` — value appears in -`[stores.secrets].ids`. **Push:** both kinds skipped. +declared. `StoreRef` — value appears in `[stores.secrets].ids`. +**Push:** both kinds skipped. **Runtime usage:** ```rust // #[secret] (KeyInDefault): -let token = ctx.secret_store_default()?.get(&cfg.api_token).await?; +let token = ctx.secret_store_default()?.require_str(&cfg.api_token).await?; // #[secret(store_ref)] (StoreRef): -let token = ctx.secret_store(&cfg.vault)?.get("active").await?; +let token = ctx.secret_store(&cfg.vault)?.require_str("active").await?; ``` -### 6.8 Extractor design - -The existing `Kv` / `Secrets` extractors are **refactored to resolve -either the default store or a named one** (the user-chosen approach — -no const-generic `&'static str`, which doesn't compile on stable -Rust 1.95). +### 6.9 Extractor design -The extractor yields a small per-request registry handle; the handler -picks the store by id at the call site: +`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); @@ -546,146 +540,48 @@ impl Kv { pub fn default(&self) -> Option; pub fn named(&self, id: &str) -> Option; } -// Secrets is identical in shape. - -#[action] -async fn handler(kv: Kv) -> Result { - let sessions = kv.named("sessions").ok_or_else(|| EdgeError::internal("no sessions kv"))?; - let cache = kv.default().ok_or_else(|| EdgeError::internal("no default kv"))?; - let v = sessions.get("k").await?; - // ... -} +// Secrets / Config identical in shape. ``` -This is a **breaking change** to handlers that currently destructure -`Kv(handle)` for a single store. The only in-tree consumers are the -`app-demo` handlers, updated in sub-project #3. External handlers -migrate from `Kv(handle)` to `kv.default()`. - -A `Config` extractor with the same shape (`default()` / `named()`, -returning `BoundConfigStore`) is added for symmetry. - -### 6.9 Cloudflare config store rewrite (`[vars]` → KV, async) - -Current `CloudflareConfigStore` -([config_store.rs:1-12](crates/edgezero-adapter-cloudflare/src/config_store.rs#L1-L12)) -reads one `[vars]` JSON blob, parsed once at construction — which is -why the trait could be synchronous. Updating config required a worker -redeploy. - -**Rewrite (sub-project #3):** `CloudflareConfigStore` reads from a KV -namespace, one per logical config id. Because KV reads are async, the -`ConfigStore` trait becomes async (`#[async_trait(?Send)]`). The -adapter's `get` performs a real `env..get(key)` await. - -On-disk shape after this ships: - -```toml -# edgezero.toml -[stores.config] -ids = ["app_config"] -[adapters.cloudflare.stores.config.app_config] -name = "APP_CONFIG_KV" +The only in-tree consumers of the old single-store extractors are the +`app-demo` handlers, updated in sub-project #2. -# wrangler.toml (written by provision) -[[kv_namespaces]] -binding = "APP_CONFIG_KV" -id = "abc123def456" -``` +### 6.10 App-config environment-variable resolution -`config push --adapter cloudflare` writes via `wrangler kv bulk put - --namespace-id=`. No redeploy is needed. **KV is -eventually consistent** — pushed values become visible after KV's -propagation window (typically seconds; Cloudflare documents up to ~60s -for global propagation). The spec, docs, and tests treat Cloudflare KV -visibility as eventual: CI does not assert immediate global visibility -for Cloudflare (CI exercises the axum and mock paths; see §15). The -`[vars]` model is removed; existing deployed workers migrate once -(documented in the guide). +`load_app_config` / `load_app_config_raw` resolve in two layers: +(1) the `[config]` table from `.toml`; (2) env-var overrides. -### 6.10 App-config environment-variable resolution +**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`. -`load_app_config` / `load_app_config_raw` resolve values in two -layers, lowest priority first: - -1. The `[config]` table parsed from `.toml`. -2. Environment-variable overrides. - -**Env vars override existing keys only.** An env var overrides a config -value **only if that key already exists in the parsed `[config]` -tree**. Keys absent from the file are not created by env vars. This is -a deliberate constraint: - -- With `C: DeserializeOwned` there is no pre-deserialization reflection - over `C`'s field types, so the loader cannot know the type of a key - supplied *only* via env. By restricting overrides to existing keys, - the loader infers the type from the **existing TOML value** at that - path and parses the env string accordingly. -- It also matches the existing `AxumConfigStore::from_env` behaviour, - which only reads env vars for keys declared in the manifest. -- Consequence: to make a key env-overridable, it must appear in - `.toml` (with a real value or a stub). The generator template - and `app-demo.toml` include every env-overridable key. - -This rule applies identically to the typed and raw loaders. - -**Env var naming.** `__
__…__`: - -- `` is `[app].name` from `edgezero.toml`, uppercased, with - `-` replaced by `_` (so `app-demo` → `APP_DEMO`). Passed to - `load_app_config` as the `app_name` argument. -- `__` (double underscore) separates **every** nesting level, - including app-name → first key. A single `_` is a literal character; - only `__` is a separator. - -**Deterministic, ambiguity-rejecting key matching.** TOML keys are -case-sensitive; env var names are conventionally uppercase. The loader -matches by transforming each config key at a level to its -**env segment form** — uppercase the key, leave `_` as-is — and -comparing against the env var's segment. If two sibling keys at the -same level transform to the same env segment (e.g. `foo` and `FOO`, or -`api_key` and `API_KEY`), the loader **errors** with -`AppConfigError` ("ambiguous env mapping: keys `foo` and `FOO` at -`config` both map to env segment `FOO`"). Matching is otherwise exact -on the transformed form — no fuzzy/case-insensitive fallback. - -Examples for `app-demo.toml`: +**Env var naming.** `__
__…__`. `` is +`[app].name` uppercased with `-`→`_`. `__` separates every nesting +level; a single `_` is literal. -```toml -[config] -greeting = "hello" -[config.service] -timeout_ms = 1500 -``` +**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`. -| Env var | Overrides | -|---|---| -| `APP_DEMO__GREETING` | `config.greeting` | -| `APP_DEMO__SERVICE__TIMEOUT_MS` | `config.service.timeout_ms` | +**Type coercion.** The env string is parsed against the existing TOML +value's type; parse failure → `AppConfigError`. -**Type coercion.** The env string is parsed against the type of the -**existing** TOML value at that path: string → as-is; integer/float/ -bool → parsed from the string (parse failure is an `AppConfigError`). -The overlay produces a `toml::Value` tree; for the typed loader this -happens before `serde` deserialization. +**Scope.** `config validate` and `config push` both see env-resolved +values; `--no-env` disables the overlay. The axum dev server resolves +via the same path. -**Scope.** Resolution happens inside `load_app_config*`. `config -validate` and `config push` both see env-resolved values — useful for -injecting per-environment values from a deploy pipeline. A `--no-env` -flag on `validate` and `push` disables the overlay. The axum dev -server 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` (`BuildArgs`, `DeployArgs`, `NewArgs`, -`ServeArgs`, `ProvisionArgs`, `ConfigValidateArgs`, `ConfigPushArgs`) -derive `Default` so external tests/wrappers construct them via -`Default::default()` + field mutation despite `#[non_exhaustive]`. - -Subcommand-wrapping `*Args` (`AuthArgs`) do **not** derive `Default` — -a defaulted required subcommand could leak into a test and run a real -auth path. External tests construct `AuthArgs` via -`clap::Parser::try_parse_from`. +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`. --- @@ -703,115 +599,90 @@ app-demo-cli` parallel. **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 -(backwards-compatible — new subcommands are added by later -sub-projects, so help output is *not* frozen forever, only the -existing commands' shape); `app-demo-cli --help` shows the five -built-ins; `edgezero new throwaway-app && cargo check --workspace` -succeeds. - -## 8. Sub-project 2 — Manifest schema additions (purely additive) - -**Goal:** add the new logical-store fields **alongside** the existing -single-store fields in the same structs (compatibility structs, §6.6), -discriminating on `ids` presence. Old-shape manifests keep parsing and -validating exactly as before. No runtime changes; nothing removed; -`[stores.config.defaults]` stays. - -**Source changes:** `manifest.rs` — each `ManifestStoreConfig` -becomes a compatibility struct carrying both the legacy fields -(`name`, legacy `adapters` overrides) and the new logical fields -(`ids: Option>`, `default: Option`). -`ManifestAdapter` gains `stores: Option` (the -per-adapter logical mapping). New-schema validator rules (§6.6) fire -only when `ids.is_some()`; `STORES_SUPPORTED_ADAPTERS` drives -completeness. Legacy fields and legacy validation are untouched. - -**Tests:** new-schema round-trip; `ids`-presence discrimination -(legacy table with `name` only → no new validation; table with -`ids` → new validation); default resolution (omitted with one id; -omitted with many → error; explicit not-in-ids → error; `ids = []` → -error); completeness (supported adapter omitting `stores` → error; -non-allowlisted adapter → skipped); Cloudflare JS-identifier check → -error; the **current** `examples/app-demo/edgezero.toml` (legacy -shape) parses and validates unchanged. - -**Ship gate:** existing app-demo runtime works unchanged; manifest -tests prove the new schema parses and validates. - -## 9. Sub-project 3 — Runtime rewrite (async ConfigStore, Bound handles, registries, extractors, Cloudflare KV, Hooks/macro) - -**Goal:** the big runtime sub-project. After this, multi-store works -end-to-end on axum and Cloudflare. +**Ship gate:** existing `edgezero` commands keep the same flags; +`app-demo-cli --help` shows the five built-ins; `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. Manifest schema and runtime store +API are coupled; with a hard cutoff they ship together as one commit +(commit 2 of the eight-commit PR). **Scope:** -- `ConfigStore::get` becomes `async` (`#[async_trait(?Send)]`). -- `BoundKvStore` / `BoundConfigStore` / `BoundSecretStore` introduced. - **Only `RequestContext` returns bound handles** — binding needs - per-request adapter state. `RequestContext` accessors are id-keyed - with `_default()` helpers resolving the §6.4 default. -- **`Hooks` does not return bound handles.** `Hooks` / - `ConfigStoreMetadata` are static, compile-time app metadata (emitted - by the `app!` macro). They are rewritten to expose store *metadata* - registries — per kind: logical ids, the resolved default, and the - per-adapter `name` map. Adapters consume that metadata at request - setup to build the runtime `StoreRegistry` that backs - `RequestContext`'s bound handles. So the split is: `Hooks`/`app!` = - metadata; `RequestContext` = bound runtime handles. -- Each adapter's store setup reads the `Hooks` metadata + injects a - `StoreRegistry` for each kind into the request context. -- `CloudflareConfigStore` rewritten `[vars]` → KV (§6.9). -- `Kv` / `Secrets` extractors refactored to `default()` / `named()` - (§6.8); a `Config` extractor added. -- `ConfigStoreMetadata` becomes a metadata registry (one entry per - logical config id, each with its per-adapter names); `app!` macro - emits it from the new manifest schema. -- Old single-store manifest fields removed; `examples/app-demo/ - edgezero.toml` migrated; `app-demo` handlers updated to the new - accessors. Spin adapter omits `stores`. -- `docs/guide/manifest-store-migration.md` published. - -**Tests:** id-keyed contract-test factories; cross-adapter named-KV -test; Cloudflare config-from-KV async round-trip (wasm-bindgen-test); -`Kv`/`Secrets`/`Config` extractor tests for both `default()` and -`named()`; `app!` macro emits a metadata registry matching -`[stores.config].ids`. - -**Ship gate:** multi-store handlers work on axum and Cloudflare; -async config reads work; the `config push` runtime target exists. - -## 10. Sub-project 4 — App-config schema, derive macro, env-overlay loader +- **Manifest:** rewrite `ManifestStores` / `ManifestAdapter` to the + §6.6 schema outright. Legacy fields are removed; using them is a hard + load error. Validation includes the §6.6 capability matrix. +- **`ConfigStore` async:** `get` becomes `async` + (`#[async_trait(?Send)]`). +- **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 new schema. +- **Adapter store rewrites — ALL FOUR adapters:** + - **axum:** in-memory KV registry; config from + `.edgezero/local-config-.json` (§15); secrets from env vars. + - **cloudflare:** KV registry; **config rewritten `[vars]` → KV** + (§6.x) 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, + `store_name` ignored) into the multi-store registry; stop relying + on hardcoded default labels — labels come from + `[adapters.spin.stores.kv.*].name`. +- **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 seeding at + [dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349) + is removed. +- **Migrate in-tree:** `examples/app-demo/edgezero.toml` rewritten to + the new schema with all four adapters declaring stores + (≥2 KV ids `sessions`+`cache`; exactly one config id and one + secrets id, as the Spin capability rule requires); `app-demo` + handlers updated to id-keyed accessors. +- **`docs/guide/manifest-store-migration.md`** published. + +**Tests:** manifest round-trip + validation (non-empty ids; default +required when `ids.len() > 1`; capability check — declaring two config +ids with spin present → error; per-adapter completeness for `Multi` +pairs; Cloudflare JS-identifier + Spin KV-label checks; pre-rewrite +manifest → hard error with migration message); 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; `Kv`/`Secrets`/`Config` extractor tests; `app!` +macro metadata registry test. + +**Ship gate:** multi-store handlers work on axum, cloudflare, fastly, +and spin; async config reads work; all four 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` (trait, `SecretField`/ -`SecretKind`, `load_app_config` / `load_app_config_raw` with env -overlay); `edgezero-macros` `AppConfig` derive + -`#[proc_macro_derive]` export; generator templates for `.toml` -(includes a nested `[config.service]` section) and -`-core/src/config.rs` (with `#[serde(deny_unknown_fields)]`); +**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` with a nested section, one `#[secret]`, one `#[secret(store_ref)]`. **Tests:** `load_app_config` (valid, missing file, bad TOML, validator -failure, missing `[config]`); **env-overlay tests** — top-level -override, nested `__` override, type coercion, parse-failure error, -`--no-env` bypass; round-trip for `AppDemoConfig`; macro tests -including all compile-error constraints from §6.7. +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 expectations; -`load_app_config::` succeeds; an env var -`APP_DEMO__SERVICE__TIMEOUT_MS` demonstrably overrides the nested -value in a test. +**Ship gate:** `AppDemoConfig::SECRET_FIELDS` matches; `load_app_config` +succeeds; `APP_DEMO__SERVICE__TIMEOUT_MS` overrides the nested value +in a test. -## 11. Sub-project 5 — `config validate` command - -**Goal:** lint TOML files locally; validate the app config in its own -right (TOML syntax, `[config]` present, deserialises into `C`, types, -`validator` rules, `deny_unknown_fields` when set, secret-field -checks) plus manifest cross-checks under `--strict`. +## 10. Sub-project 4 — `config validate` command ```rust #[derive(clap::Args, Default, Debug)] @@ -820,22 +691,30 @@ 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, // disable env overlay (§6.10) + #[arg(long)] pub no_env: bool, } ``` Bound: `DeserializeOwned + Validate + AppConfigMeta` (no `Serialize`). -**Tests:** dedicated fixtures per failure mode, including env-overlay -on/off. +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:** every +flattened config key, `.`→`__` translated, must match `[a-z][a-z0-9_]*` +(§6.7). Manifest: `ManifestLoader` checks; under `--strict`, +capability-aware completeness and well-formed handler paths. + +**Tests:** dedicated fixtures per failure mode incl. the Spin +key-syntax check; env-overlay on/off. **Ship gate:** `app-demo-cli config validate --strict` exits 0; corrupted fixtures fail with expected messages. -## 12. Sub-project 6 — `auth` command (+ `CommandRunner`) +## 11. Sub-project 5 — `auth` command (+ `CommandRunner`) ```rust -#[derive(clap::Args, Debug)] // NO Default — see §6.11 +#[derive(clap::Args, Debug)] // NO Default — §6.11 #[non_exhaustive] pub struct AuthArgs { #[command(subcommand)] pub sub: AuthSub } @@ -847,15 +726,14 @@ pub enum AuthSub { } ``` -UX: `auth login --adapter cloudflare`. Per-adapter behaviour: axum -no-ops; cloudflare `wrangler login/logout/whoami`; fastly `fastly -profile create/delete/list`; spin `spin cloud login/logout/info`. All -via `CommandRunner`. +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. -External `AuthArgs` construction uses `try_parse_from`. -## 13. Sub-project 7 — `provision` command +## 12. Sub-project 6 — `provision` command ```rust #[derive(clap::Args, Default, Debug)] @@ -867,67 +745,44 @@ pub struct ProvisionArgs { } ``` -Iterate every id in `[stores.].ids`; look up -`[adapters..stores..].name`; shell out per the -adapter/kind table (`wrangler kv namespace create `, `fastly -kv-store create --name=`, etc.). `--dry-run` prints -`CommandSpec`s without invocation. - -**Writeback to native manifests — concrete contract.** - -*Cloudflare* (IDs are stable and persisted): - -- After `wrangler kv namespace create `, parse the namespace ID - from stdout and patch `wrangler.toml`: - ```toml - [[kv_namespaces]] - binding = "" # == [adapters.cloudflare.stores..].name - id = "" - ``` -- `config push --adapter cloudflare` reads the `id` back from - `wrangler.toml` by matching `binding`. - -*Fastly* (resource-link model; IDs resolved on demand): - -Fastly's `fastly.toml` declares stores in two sections, both keyed by -the **resource link name** — which Fastly Compute code uses to access -the store, and which EdgeZero maps to -`[adapters.fastly.stores..].name`: - -- `[setup.kv_stores.]` / `[setup.config_stores.]` / - `[setup.secret_stores.]` — consumed by `fastly compute deploy` - to create and link resources on first deploy. -- `[local_server.kv_stores.]` / `[local_server.config_stores. - ]` / `[local_server.secret_stores.]` — consumed by - `fastly compute serve` for local testing. - -`provision --adapter fastly` for each logical id: - -1. `fastly -store create --name=` creates the store. -2. Ensures `fastly.toml` contains both `[setup._stores.]` - and `[local_server._stores.]` table entries (created if - absent) so deploy links the store and local serve can find it. - -The Fastly store *ID* is **not** persisted in `edgezero.toml` or -`fastly.toml` — Fastly's manifest has no stable ID slot outside the -transient `[setup]` section (which is ignored once the service -exists). Instead, `config push --adapter fastly` resolves the store -ID on demand: `fastly config-store list --json`, match by ``, -then `fastly config-store-entry create --store-id= --key=… --value=…` -(large values via `--stdin`). One extra authenticated CLI call per -push; no persistence problem. - -**Read/write-path agreement:** the runtime Fastly adapter accesses -each store by its resource link name (``); `provision` writes -that same `` into `[setup.*]` / `[local_server.*]`; `config -push` resolves the ID from `` via the list command. All three -paths key off `[adapters.fastly.stores..].name`. - -**Tests:** per-(adapter, kind) mock-runner with scripted stdout; -golden ID-extraction parsers; temp-fixture writeback verified; -`--dry-run` invokes nothing. - -## 14. Sub-project 8 — `config push` command +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 `spin.toml` writeback: +- KV: ensure each label appears in the component's + `key_value_stores` list (`[component..key_value_stores]`). +- Config + secrets: ensure each Spin variable is declared in the + top-level `[variables]` table and bound in + `[component..variables]`. (The component name comes from + the Spin adapter's `[adapters.spin.adapter]` manifest reference.) +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 `spin.toml`; axum +no-op output asserted; `--dry-run` performs nothing. + +## 13. Sub-project 7 — `config push` command ```rust #[derive(clap::Args, Default, Debug)] @@ -937,7 +792,7 @@ pub struct ConfigPushArgs { #[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, // disable env overlay + #[arg(long)] pub no_env: bool, #[arg(long)] pub dry_run: bool, } ``` @@ -945,168 +800,165 @@ pub struct ConfigPushArgs { Bound: `DeserializeOwned + Validate + Serialize + AppConfigMeta`. **Behaviour:** strict pre-flight validation; load app-config (env -overlay unless `--no-env`); serialise per §6.4 (skip `SECRET_FIELDS`); -resolve target id (`--store` or resolved default); look up the -per-adapter `name`; read the platform resource ID from the native -manifest (error "did you run `provision` first?" if absent); shell -out (`wrangler kv bulk put … --namespace-id=…`; for Fastly, resolve -the store id via `fastly config-store list --json` then -`fastly config-store-entry create --store-id=… …` per §13; axum writes -the resolved values to `.edgezero/local-config-.json` — the file -the axum config store reads (§15); spin errors "not yet supported"). - -**Tests (MEDIUM #9 — push behaviour beyond validate):** typed + raw; -per-adapter mock-runner with golden payloads; secret fields absent -from payload; missing native-manifest ID error; `--store` selection; -`--dry-run` invokes nothing; **explicit "validate passes, push -serialization fails" cases** — non-object typed config -(`to_value` ≠ object), unsupported compound shape, `skip_serializing_if` -behaviour, `Option::None` omission, `#[serde(flatten)]` on a -non-secret field; env-overlay on vs `--no-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 | Write each value as a Spin variable into `spin.toml`'s `[variables]` table (static default values), keys in `.`→`__` lowercase form (§6.7). No remote call — live Fermyon Cloud variable push is out of scope (§2). | + +**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; `--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. **Ship gate:** `app-demo-cli config push --adapter cloudflare ---dry-run` shows the expected invocation; secret fields absent; -namespace ID from fixture `wrangler.toml`. +--dry-run` and `--adapter spin --dry-run` each show the expected +output; secret fields absent; Spin keys `__`-encoded. -## 15. Sub-project 9 — `app-demo` integration polish (exercises every new capability) +## 14. (reserved — sub-project numbering uses the `#` column in §16) -**Goal:** `app-demo` must demonstrate the **full** feature set, not a -subset. Concretely it exercises: +## 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 all five built-ins plus - `Auth`, `Provision`, and `Config` (`Validate` / `Push`) subcommands, - the `Config` arm wired to the **typed** functions with - `AppDemoConfig`. -- **Multi-store manifest:** `edgezero.toml` declares ≥2 KV ids - (`sessions`, `cache`), one config id, one secrets id, with - per-adapter `name` mappings for axum / cloudflare / fastly; spin - omits the stores section. -- **Multi-store runtime:** one handler reads `sessions` KV, another - reads `cache` KV (via the refactored `Kv` extractor's `named()`), - proving the registry. -- **Async config + Cloudflare KV path:** a handler does + `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?`. -- **Typed app-config with a nested section:** `AppDemoConfig` has - `service: ServiceConfig { timeout_ms }`; a handler reads the nested - value. +- **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 resolved config - reflects the override; the walkthrough doc shows - `APP_DEMO__GREETING=… cargo run`. -- **Secrets:** `AppDemoConfig` has one `#[secret]` field - (`api_token`) and one `#[secret(store_ref)]` field (`vault`); a - handler reads each via the matching runtime pattern. -- **`config validate` / `config push`:** CI runs `app-demo-cli config - validate --strict` (exit 0) and `app-demo-cli config push --adapter - axum` then reads the value back through a running axum dev server on - `/config/greeting`. -- **`auth` / `provision`:** exercised against the `MockCommandRunner` - in tests; the walkthrough doc shows the real invocations. - -**Axum config store backing — push and runtime read the same file.** -For axum there is no remote store, so the axum config store is backed -by a single local file: `.edgezero/local-config-.json` (gitignored). - -- `config push --adapter axum` loads `.toml` (env overlay - applied), serialises the resolved `[config]` values, and writes them - to `.edgezero/local-config-.json`. -- The axum config store reads from `.edgezero/local-config-.json` - at request setup — the **same file** `config push` writes. No - disagreement: a running dev server observes pushed values. -- `edgezero dev` regenerates `.edgezero/local-config-.json` at - startup (running the same resolve-and-write step as `config push - --adapter axum`), so the dev workflow needs no manual push. If the - file is absent at request time (e.g. server started without `dev`), - the axum config store treats it as an empty store. - -This makes axum genuinely push-backed and consistent with the remote -adapters, and lets the §15 ship gate test a real push→read cycle. - -**`[stores.config.defaults]` removal:** drop the `defaults` field from -`manifest.rs`; drop the axum dev-server seeding at -[dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349). -Its role is fully replaced by the file flow above: the source of -local-dev config values is `.toml` (resolved through `config -push --adapter axum` / `edgezero dev`), not a manifest section. -`examples/app-demo/edgezero.toml` drops `[stores.config.defaults]`. - -**Docs:** `docs/guide/cli-walkthrough.md` (full `myapp` loop including -an env-override example); `manifest-store-migration.md` finalised; -`.vitepress/config.ts` sidebar updated. - -**Ship gate:** CI runs the full loop on axum end-to-end, including the -env-override assertion. + `APP_DEMO__SERVICE__TIMEOUT_MS` and asserts the override. +- **Secrets:** one `#[secret]` (`api_token`) and one + `#[secret(store_ref)]` (`vault`); a handler reads each. +- **`config validate` / `config push`:** CI runs `config validate + --strict` (exit 0) then `config push --adapter axum` and reads the + value back through a running axum dev server on `/config/greeting`. + `config push --adapter spin --dry-run` is asserted to produce + `__`-encoded keys. +- **`auth` / `provision`:** exercised against `MockCommandRunner` (and, + for spin/axum provision, against temp-fixture manifests) in tests. + +**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 dev` regenerates +it at startup. If absent, the axum config store is empty. + +**Docs:** `docs/guide/cli-walkthrough.md` (full `myapp` loop, env +override, all four adapters); `docs/.vitepress/config.mts` sidebar +updated. + +**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. --- ## 16. Implementation order and milestones -| # | Title | Risk | -|---|-------|------| -| 1 | Extensible lib + scaffold | M | -| 2 | Manifest schema additions (additive, `Option`-modelled) | L | -| 3 | Runtime rewrite (async ConfigStore, Bound handles, registries, extractors, Cloudflare KV, Hooks/macro) | H | -| 4 | App-config schema + derive macro + env-overlay loader | M | -| 5 | `config validate` | L | -| 6 | `auth` + `CommandRunner` | M | -| 7 | `provision` | H | -| 8 | `config push` | M | -| 9 | `app-demo` polish (exercises everything) + drop `[stores.config.defaults]` | M | - -**Highest-risk:** #3 (async trait change cascades through core, -adapters, handlers, extractors, macro; Cloudflare backend swap) and #7 -(shell-out + multi-file native-manifest writeback, Fastly section -details pinned at implementation time). +The whole effort is **a single pull request containing eight commits**, +one per sub-project, applied in this order: + +| Commit | § | 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) | M | + +**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 commits should nonetheless compile and pass tests on its +own so the history stays bisectable — commit boundaries are chosen so +that each is a self-contained, buildable increment. Commit 2 is the one +unavoidably large commit (the atomic manifest+runtime rewrite); the +other seven are individually small. + +**Review note.** Because this is one PR, the reviewer sees all eight +commits together. The PR description should list the eight commits and +point at this spec. Reviewing commit-by-commit is recommended. + +**Highest-risk:** commit 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 commit. +Large by necessity under the hard-cutoff decision. Mitigated by +per-adapter contract tests and `app-demo` as the in-tree canary. +Commit 6 (`provision`) — shell-out + multi-file native-manifest +writeback across four adapters (`wrangler.toml`, `fastly.toml`, +`spin.toml`). ## 17. Risks and trade-offs -- **Async `ConfigStore` cascade:** making `get` async touches the - trait, three adapter impls, `Hooks`, every handler reading config, - and the `Config` extractor. Contained to sub-project #3; the - in-tree `app-demo` is the canary; `#[async_trait(?Send)]` keeps - WASM compatibility. -- **Manifest breaking change (#3):** external `edgezero.toml` files - need migration; the guide ships in #3; the validator errors clearly - on the old shape. -- **Cloudflare runtime config swap (#3):** deployed workers migrate - `[vars]` → KV once; documented. -- **`[stores.config.defaults]` removal (#9):** replaced by seeding the - axum config store from `.toml`. -- **Env overlay surprising `config push` (§6.10):** push pushes - env-resolved values; `--no-env` is the escape hatch; documented. -- **Fastly writeback under-specification:** spec commits to a - read/write-path-agreement contract; exact `fastly.toml` sections - pinned in #7's implementation plan with golden tests. -- **API stability:** non-subcommand `*Args` are `#[non_exhaustive]` + - `Default`; `AuthArgs` is `#[non_exhaustive]` without `Default`. +- **Hard manifest cutoff:** a pre-rewrite `edgezero.toml` fails to + load with a migration-guide error. All in-tree projects migrated in + commit 2; external projects migrate once. +- **Large atomic commit (commit 2):** unavoidable without a + compatibility layer, which the hard-cutoff decision rejects. It is + one commit, 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). +- **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)` destructure → `kv.default()`; - only in-tree consumer is `app-demo`, migrated in #3. -- **Macro / serde-attribute scope:** `#[secret]` constrained with - compile-error enforcement. -- **Spin gap:** Spin omits `[adapters.spin.stores]`; not in - `STORES_SUPPORTED_ADAPTERS`; `provision` / `config push` error for - `--adapter spin` until the Spin stores PR lands. +- **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). +- 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 for old manifests (manual via the guide). -- Spin-side store provisioning / config push. +- A migration *tool*; migration is manual via the published guide. +- Dynamic Spin variable providers (Fermyon Cloud variable push, Vault). -When all nine sub-projects ship, `edgezero new myapp` produces a +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. 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, and `app-demo` demonstrates every one of -these capabilities in CI. +`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. From 27a6169348d63c3dc05d1552d2673dfac078d4db Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 00:12:55 -0700 Subject: [PATCH 09/38] Sixth-pass review: close Spin integration design holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine findings against the current (f0aed20) spec, all Spin-integration depth: - Spin provision cannot know config/secret variable keys (manifest has store ids, not field keys). Fix: Spin provision does KV-label spin.toml writeback ONLY. Config-variable declaration moves to config push (which loads .toml). Secret-variable declaration is manual. - config push --adapter spin must write BOTH [variables] (declaration + default) and [component..variables] (binding) — a Spin variable is unreadable without the component binding. Errors rather than writing a half-configured manifest. - Spin component discovery specified: parse spin.toml; single component resolves implicitly; multi-component requires [adapters.spin.adapter].component; config validate --strict surfaces failures early. - Secret variables are not inferable (#[secret(store_ref)] runtime keys are code-local). Spin secret variables are declared manually by the developer; the CLI never writes them. - Config/secret namespace collision guarantee was wrong: #[secret] field VALUES (not Rust field names) are the secret keys. config validate now computes the effective Spin variable set ({flattened config keys} u {#[secret] values}) and errors on duplicates. - Spin KV TTL: BoundKvStore exposes put_*_with_ttl (verified in key_value_store.rs). On Spin these return a deterministic KvError::Unsupported, never silent store-without-expiry. - Spin KV listing-cap error variant flagged as an open reconciliation point with PR #253 (Validation -> a limit/server error); resolved in commit 2, not a blocker. - Single (adapter, kind) per-id mapping blocks are now FORBIDDEN (validation error), not "accepted but vestigial". Fixes the §1 vs §6.6 contradiction. - Spin variable naming rule pinned as Spin's own ^[a-z][a-z0-9_]*$ (cites spinframework.dev/manifest-reference), not an EdgeZero rule. app-demo (§15) updated: manually declares Spin secret variables, single-component spin.toml, asserts Spin provision writes only key_value_stores and config push writes both spin.toml tables. --- .../specs/2026-05-19-cli-extensions-design.md | 335 ++++++++++++------ 1 file changed, 235 insertions(+), 100 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index cc78bf6..db67aa0 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -52,12 +52,14 @@ myapp`) build their own CLI binary that: Alongside the extensibility substrate, ship: - A **multi-store manifest model**: the app declares logical stores it - uses (`[stores.kv] ids = ["foo", "bar"]`); each adapter 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. + 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 @@ -89,8 +91,8 @@ flags; new subcommands are added. - 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.) + 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. @@ -134,7 +136,7 @@ Key contracts: - **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 + 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). @@ -310,17 +312,17 @@ App config can be nested (`service: ServiceConfig { timeout_ms }`). 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 +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) | +| 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 @@ -389,34 +391,38 @@ name = "sessions" # Spin KV store label [adapters.cloudflare.stores.config.app_config] name = "APP_CONFIG_KV" -[adapters.spin.stores.config.app_config] -# name is accepted but vestigial for Spin config (flat variables, §6.7) +# NOTE: there is deliberately no [adapters.spin.stores.config.*] block. +# Spin config is Single-capability (flat variables) — a per-id mapping +# block for a Single (adapter, kind) pair is a validation error (§6.6). ``` **Field reference:** -| Field | Where | Role | -|---|---|---| -| `[stores.].ids` | top level | logical ids (`Vec`, non-empty) | -| `[stores.].default` | top level | resolved default; **required when `ids.len() > 1`**, optional (resolves to `ids[0]`) when exactly one id; must be in `ids` | -| `[adapters..stores..].name` | per-adapter | platform name (see capability rules for whether required) | -| other fields in that block | per-adapter | free-form `BTreeMap` tuning | +| Field | Where | Role | +| ---------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- | +| `[stores.].ids` | top level | logical ids (`Vec`, non-empty) | +| `[stores.].default` | top level | resolved default; **required when `ids.len() > 1`**, optional (resolves to `ids[0]`) when exactly one id; must be in `ids` | +| `[adapters..stores..].name` | per-adapter | platform name (see capability rules for whether required) | +| other fields in that block | per-adapter | free-form `BTreeMap` tuning | **Adapter × kind capability matrix.** A single flat `STORES_SUPPORTED_ADAPTERS` list is too coarse. 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. - A per-id `name` mapping is **required** for every id. -- **Single**: the adapter has exactly one flat store of that kind. The - per-id `name` is accepted but vestigial. + A per-id `[adapters..stores..]` block with a `name` is + **required** for every id. +- **Single**: the adapter has exactly one flat store of that kind. + A per-id `[adapters..stores..]` block is **forbidden** — + there is nothing to configure per id, and a vestigial no-op block is + misleading. Its presence is a validation error. **Validation rules (in `ManifestLoader`):** @@ -430,7 +436,8 @@ pair has a capability: flat namespace. The error names the offending adapter and kind. - For each (adapter, kind) that is `Multi`, every id must have a `[adapters..stores..]` block with a `name`. For - `Single` (adapter, kind) pairs, the block is optional. + `Single` (adapter, kind) pairs, **any such block is a validation + error** — the runtime ignores per-id naming there. - `name` under `[adapters.cloudflare.stores.*]` must be a JavaScript identifier; `name` under `[adapters.spin.stores.kv.*]` must be a valid Spin KV label. Invalid names are errors. @@ -448,38 +455,97 @@ 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. Constraints: no TTL; **listing is capped** (`SpinKvStore` -has a `max_list_keys` cap and returns `KvError::Validation` rather -than silently truncating when the cap is exceeded). The runtime -adapter opens each configured label and registers it by logical id. +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** (`KvError::Unsupported`), never silently store the value + without expiry — generic code must not believe an expiry was applied + when it was not. The Spin KV contract test asserts this error. +- **Listing is capped.** `SpinKvStore` carries a `max_list_keys` cap + and returns an error rather than silently truncating when exceeded. + *Open concern inherited from PR #253:* #253 currently uses + `KvError::Validation` for this. A store growing beyond a cap is a + server/limit condition, not a malformed client request, so + `Validation` (which an adapter may map to HTTP 400) is arguably the + wrong variant. This spec does not block on it, but flags it: the + implementation of commit 2 should reconcile the listing-cap error + with PR #253 — prefer a limit/server-side error variant — and test + the pagination logic directly. If #253's variant is kept, that is a + conscious decision recorded in the commit. **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). -`[adapters.spin.stores.config.].name` is accepted but vestigial. -**Spin variable names must match `[a-z][a-z0-9_]*`** — lowercase, no -dots, no uppercase. The config-store 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, shared namespace.** +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 it is -non-empty). `[stores.secrets].ids` must have exactly one id for a -Spin project. Because config and secret variables share one -namespace, their effective key spaces must not collide; this is -guaranteed within a single `AppConfig` struct (config fields and -`#[secret]` fields are distinct sibling fields → distinct variable -names). - -**Implication for app config targeting Spin.** If the project's -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 +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)]` @@ -649,12 +715,15 @@ API are coupled; with a hard cutoff they ship together as one commit **Tests:** manifest round-trip + validation (non-empty ids; default required when `ids.len() > 1`; capability check — declaring two config ids with spin present → error; per-adapter completeness for `Multi` -pairs; Cloudflare JS-identifier + Spin KV-label checks; pre-rewrite -manifest → hard error with migration message); 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; `Kv`/`Secrets`/`Config` extractor tests; `app!` -macro metadata registry test. +pairs; a per-id block on a `Single` (adapter, kind) pair → error; +Cloudflare JS-identifier + Spin KV-label checks; pre-rewrite manifest → +hard error with migration message); 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 (and its error-variant decision, +§6.7); `Kv`/`Secrets`/`Config` extractor tests; `app!` macro metadata +registry test. **Ship gate:** multi-store handlers work on axum, cloudflare, fastly, and spin; async config reads work; all four CI gates green (including @@ -700,13 +769,24 @@ 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:** every -flattened config key, `.`→`__` translated, must match `[a-z][a-z0-9_]*` -(§6.7). Manifest: `ManifestLoader` checks; under `--strict`, -capability-aware completeness and well-formed handler paths. +`[stores.secrets].ids`. **When `spin` is in the adapter set**, three +additional Spin checks (all per §6.7): -**Tests:** dedicated fixtures per failure mode incl. the Spin -key-syntax check; env-overlay on/off. +1. every flattened config key, `.`→`__` translated, matches + `^[a-z][a-z0-9_]*$`; +2. the effective Spin variable name set — {flattened config keys} ∪ + {`#[secret]` field values}, after `.`→`__` translation — has no + duplicate (config/secret namespace collision check); +3. Spin component discovery resolves (exactly one `[component.*]` in + `spin.toml`, or an explicit, matching `[adapters.spin.adapter] + .component`). + +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. @@ -765,22 +845,31 @@ 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 `spin.toml` writeback: -- KV: ensure each label appears in the component's - `key_value_stores` list (`[component..key_value_stores]`). -- Config + secrets: ensure each Spin variable is declared in the - top-level `[variables]` table and bound in - `[component..variables]`. (The component name comes from - the Spin adapter's `[adapters.spin.adapter]` manifest reference.) -No `CommandRunner` calls for Spin — it is pure manifest editing. +--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 `spin.toml`; axum -no-op output asserted; `--dry-run` performs nothing. +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 @@ -804,17 +893,38 @@ 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. | +| 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 | Write each value as a Spin variable into `spin.toml`'s `[variables]` table (static default values), keys in `.`→`__` lowercase form (§6.7). No remote call — live Fermyon Cloud variable push is out of scope (§2). | +| 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; `--store` selection; `--dry-run` +`.`→`__` 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`, @@ -843,19 +953,30 @@ all four adapters. - **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 `.`→`__` +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. + `#[secret(store_ref)]` (`vault`); a handler reads each. `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) then `config push --adapter axum` and reads the - value back through a running axum dev server on `/config/greeting`. - `config push --adapter spin --dry-run` is asserted to produce - `__`-encoded keys. +--strict` (exit 0 — including the three Spin checks of §10) then + `config push --adapter axum` and reads the value back through a + running axum dev server on `/config/greeting`. `config push + --adapter spin --dry-run` is asserted to produce `__`-encoded keys + and to write **both** `spin.toml` tables. - **`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 @@ -878,16 +999,16 @@ contract + mock tests. The whole effort is **a single pull request containing eight commits**, one per sub-project, applied in this order: -| Commit | § | 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) | M | +| Commit | § | 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) | M | **CI and bisectability.** CI gates the PR as a whole on its head commit; all four gates (`fmt`, `clippy -D warnings`, `cargo test`, @@ -931,6 +1052,20 @@ writeback across four adapters (`wrangler.toml`, `fastly.toml`, - **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:** TTL writes return + `KvError::Unsupported` on Spin (deterministic, not silent). The + listing-cap error variant is an open reconciliation point with + PR #253 (§6.7) — resolved in commit 2, not a blocker. +- **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 @@ -943,13 +1078,13 @@ writeback across four adapters (`wrangler.toml`, `fastly.toml`, ## 18. What this spec does not cover - Anthropic credentials, edge DNS / TLS, observability / metrics. -- Per-environment config *files* (env-var override is in scope). +- 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. +- 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 From fd2ef436131868f4dc8fa30099715bee36224e46 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 00:39:52 -0700 Subject: [PATCH 10/38] =?UTF-8?q?Seventh-pass=20review=20+=20dev=E2=86=92d?= =?UTF-8?q?emo=20rename=20+=20documentation=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh-pass review fixes (against 27a6169): - KvError::Unsupported does not exist today — spec now states commit 2 adds the variant with a 5xx-class EdgeError mapping (Spin TTL writes). - Spin listing-cap error resolved in-spec, not left open: commit 2 adds KvError::LimitExceeded (5xx-class), and the Spin listing path returns it past max_list_keys, replacing PR #253's KvError::Validation. - run_dev() -> ! corrected: the dev server may return. Now run_demo() -> Result<(), String>; commit 1 adjusts the dev-server boundary (today it returns ()). - Commit 2 bisectability: added a config-seeding story — the axum config store's backing-file contract lands in commit 2, but commit-2 tests seed the .edgezero/local-config-.json fixture directly; config push / demo-regeneration that produce the file land in commits 7/8. - Spin config/secret collision check clarified as typed-only (needs AppConfigMeta::SECRET_FIELDS); raw validation does the key-syntax and component-discovery checks but not the collision check, and says so in its diagnostics. - Spin variable-name rule kept pinned to spinframework.dev docs. dev → demo subcommand rename (per user): - The subcommand that runs the example app locally on axum is now `demo`; `dev` is reserved for a future dev-workflow command. - run_dev → run_demo, Command::Dev → Command::Demo, the CLI's dev_server module → demo_server. The edgezero-adapter-axum crate's own internal dev_server module is left as-is (not user-facing). Documentation update step (per user): - New §6.12 makes documentation part of every commit's definition-of-done, with a page→commit ownership table (cli-reference, configuration, kv, handlers, getting-started, adapters/cloudflare, adapters/overview, architecture). - Commit 8 ends with a documentation audit: grep docs/ for stale references (old manifest keys, dev subcommand, old store API), confirm none remain, confirm the .vitepress/config.mts sidebar is complete, docs CI green. --- .../specs/2026-05-19-cli-extensions-design.md | 171 ++++++++++++++---- 1 file changed, 138 insertions(+), 33 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index db67aa0..7648348 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -44,8 +44,11 @@ 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`, - `dev`, `new`, `serve`; after this effort also `auth`, `provision`, - `config validate`, `config push`). + `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. @@ -161,7 +164,7 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String>; pub fn run_new(args: &NewArgs) -> Result<(), String>; pub fn run_serve(args: &ServeArgs) -> Result<(), String>; #[cfg(feature = "edgezero-adapter-axum")] -pub fn run_dev() -> !; +pub fn run_demo() -> Result<(), String>; // `demo` subcommand; Ok on graceful shutdown pub fn run_auth(args: &AuthArgs) -> Result<(), String>; pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; @@ -237,7 +240,7 @@ pub fn derive_app_config(input: TokenStream) -> TokenStream { /* ... */ } crates/edgezero-cli/ Cargo.toml src/ - lib.rs / main.rs / args.rs / adapter.rs / scaffold.rs / dev_server.rs + 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 @@ -461,20 +464,23 @@ 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** (`KvError::Unsupported`), never silently store the value - without expiry — generic code must not believe an expiry was applied - when it was not. The Spin KV contract test asserts this error. + error**, never silently store the value without expiry. The current + `KvError` enum has **no `Unsupported` variant** — **commit 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 commit 2). The Spin KV contract + test asserts this error. - **Listing is capped.** `SpinKvStore` carries a `max_list_keys` cap - and returns an error rather than silently truncating when exceeded. - *Open concern inherited from PR #253:* #253 currently uses - `KvError::Validation` for this. A store growing beyond a cap is a - server/limit condition, not a malformed client request, so - `Validation` (which an adapter may map to HTTP 400) is arguably the - wrong variant. This spec does not block on it, but flags it: the - implementation of commit 2 should reconcile the listing-cap error - with PR #253 — prefer a limit/server-side error variant — and test - the pagination logic directly. If #253's variant is kept, that is a - conscious decision recorded in the commit. + 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: commit 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. Commit 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 @@ -636,8 +642,8 @@ compared exactly. Two sibling keys mapping to the same segment is an value's type; parse failure → `AppConfigError`. **Scope.** `config validate` and `config push` both see env-resolved -values; `--no-env` disables the overlay. The axum dev server resolves -via the same path. +values; `--no-env` disables the overlay. 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). @@ -649,6 +655,42 @@ Non-subcommand `*Args` derive `Default` (external construction despite 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 commit) + +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 commit's +definition-of-done** — a commit that changes user-facing behaviour +updates the affected `docs/guide/` pages *in the same commit*, so the +PR never has a docs-lag window. The docs CI (ESLint + Prettier on +`docs/`) must pass. + +Affected existing pages and the commit that owns each update: + +| Page | What changes | Commit | +|------|--------------|--------| +| `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 commit): + +- `docs/guide/manifest-store-migration.md` — commit 2 (how to migrate a + pre-rewrite `edgezero.toml`). +- `docs/guide/cli-walkthrough.md` — commit 8 (full `myapp` loop). + +Commit 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 commit 8's ship gate. + --- ## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton @@ -662,6 +704,19 @@ 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. Commit 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 `()`; commit 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. @@ -682,6 +737,9 @@ API are coupled; with a hard cutoff they ship together as one commit load error. Validation includes the §6.6 capability matrix. - **`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. @@ -725,6 +783,26 @@ Spin KV listing-cap pagination test (and its error-variant decision, §6.7); `Kv`/`Secrets`/`Config` extractor tests; `app!` macro metadata registry test. +**Bisectability — config seeding before `config push` exists.** Commit +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 commit 7, and `edgezero demo`'s +auto-regeneration of the file depends on the commit-3 loader and the +commit-7 resolve-and-write step. So between commit 2 and commit 7: + +- The axum config store's backing-file **contract** is what commit 2 + establishes; commit 2 does not need anything to *produce* the file. +- Commit 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 commit-2 state: if no fixture file is present the axum + config store is empty (the documented "absent → empty" behaviour). + Any commit-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 commit 8. + +This keeps commit 2 independently buildable and testable. + **Ship gate:** multi-store handlers work on axum, cloudflare, fastly, and spin; async config reads work; all four CI gates green (including the wasm32 spin gate). @@ -773,13 +851,21 @@ opts in; `#[secret]` non-empty; `#[secret(store_ref)]` in additional Spin checks (all per §6.7): 1. every flattened config key, `.`→`__` translated, matches - `^[a-z][a-z0-9_]*$`; + `^[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); + 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`). + .component`) — **typed and raw** (manifest-based, no struct + needed). Manifest: `ManifestLoader` checks; under `--strict`, capability-aware completeness and well-formed handler paths. @@ -970,7 +1056,7 @@ timeout_ms` is read at runtime; the Spin path proves `.`→`__` - **`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 dev server on `/config/greeting`. `config push + running axum demo server on `/config/greeting`. `config push --adapter spin --dry-run` is asserted to produce `__`-encoded keys and to write **both** `spin.toml` tables. - **`auth` / `provision`:** exercised against `MockCommandRunner` (and, @@ -981,16 +1067,27 @@ timeout_ms` is read at runtime; the Spin path proves `.`→`__` **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 dev` regenerates +the axum config store reads the same file; `edgezero demo` regenerates it at startup. If absent, the axum config store is empty. -**Docs:** `docs/guide/cli-walkthrough.md` (full `myapp` loop, env -override, all four adapters); `docs/.vitepress/config.mts` sidebar -updated. +**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).** Commit 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 commit; 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. +contract + mock tests; the documentation audit passes with zero stale +references. --- @@ -1008,7 +1105,12 @@ one per sub-project, applied in this order: | 5 | §11 | `auth` + `CommandRunner` | M | | 6 | §12 | `provision` | H | | 7 | §13 | `config push` | M | -| 8 | §15 | `app-demo` polish (all four adapters) | M | +| 8 | §15 | `app-demo` polish (all four adapters) + docs audit | M | + +Every commit also updates the `docs/guide/` pages it makes stale +(§6.12) — documentation is part of each commit's definition-of-done, +not a deferred afterthought. Commit 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`, @@ -1058,10 +1160,13 @@ writeback across four adapters (`wrangler.toml`, `fastly.toml`, 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:** TTL writes return - `KvError::Unsupported` on Spin (deterministic, not silent). The - listing-cap error variant is an open reconciliation point with - PR #253 (§6.7) — resolved in commit 2, not a blocker. +- **Spin KV TTL / listing-cap:** commit 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] From 1533464c2e58cecf07ed423950b430a6e7b3f104 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 08:34:48 -0700 Subject: [PATCH 11/38] Eighth-pass review: three minor fixes (no blockers remain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commit 2 bisectability vs AppDemoConfig: §8 now states commit 2's app-demo handler migration is store-accessor-only (ctx.kv_store(id), config_store, the refactored extractors). AppDemoConfig and any typed-app-config handler work are commit 3 — commit 2 never references a type that lands in commit 3. - #[secret(store_ref)] vs Single-secrets capability: §6.8 spells out that axum/cloudflare/spin are all Single for secrets, so any app including one of them has exactly one secrets id, and every #[secret(store_ref)] field must resolve to it. store_ref only buys multiple secret stores on a Fastly-only project. §15 / the walkthrough show this for the all-four-adapter app-demo. - Spin variable-name rule drift guard: commit 7 gets a golden-file test on the generated spin.toml — asserts every variable name matches ^[a-z][a-z0-9_]*$ and that the generated manifest parses (round-trips through the same parser the runtime uses), so the rule cannot drift from Spin's actual manifest behaviour. Reviewer confirms no blocking design issues remain. --- .../specs/2026-05-19-cli-extensions-design.md | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 7648348..5c87397 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -591,12 +591,25 @@ only on scalar string fields; error if combined with 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): +// #[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?; ``` @@ -766,8 +779,15 @@ API are coupled; with a hard cutoff they ship together as one commit - **Migrate in-tree:** `examples/app-demo/edgezero.toml` rewritten to the new schema with all four adapters declaring stores (≥2 KV ids `sessions`+`cache`; exactly one config id and one - secrets id, as the Spin capability rule requires); `app-demo` - handlers updated to id-keyed accessors. + secrets id, as the Spin capability rule requires). `app-demo` + handlers are migrated **only for the store-accessor change** in + commit 2 — `ctx.kv_store(id)` / `config_store` / the refactored + `Kv` / `Secrets` / `Config` extractors. Commit 2 does **not** + introduce `AppDemoConfig` or any typed-app-config handler work: + that type is created in commit 3 (§9), and `examples/app-demo/ + app-demo.toml` does not exist yet. This keeps commit 2 + independently buildable — no commit-2 code references a type that + lands in commit 3. - **`docs/guide/manifest-store-migration.md`** published. **Tests:** manifest round-trip + validation (non-empty ids; default @@ -1016,6 +1036,17 @@ 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. If `spin_sdk` exposes a manifest-validation entry +point, the test calls it; otherwise it parses with `toml` and checks +the variable-name regex. 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. @@ -1044,12 +1075,17 @@ timeout_ms` is read at runtime; the Spin path proves `.`→`__` - **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`'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. + `#[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. From d7eff52954e8a728f759645965259add4bd979ad Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 08:51:49 -0700 Subject: [PATCH 12/38] Ninth-pass review: three minor notes (reviewer sign-off, no blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spin manifest validation strength: the spin.toml golden test now specifies a strongest-first ladder — (1) the spin CLI's own manifest validation when present (the wasm32 spin CI job already installs it), (2) a spin_sdk validation entry point if exposed, (3) toml + regex as the weakest acceptable fallback. The regex is the floor, not the ceiling; real Spin validation is preferred wherever reachable. - Generated template vs app-demo example made explicit: `edgezero new` scaffolds the common case — greeting, nested service section, a single plain #[secret] — and deliberately does NOT include #[secret(store_ref)] (a commented line shows how to add it). store_ref only helps Fastly-only projects, so it should not be the default in every fresh scaffold. app-demo remains the full-capability showcase that exercises both secret forms. - Commit 2 flagged as the explicit review hotspot in §16: the atomic manifest+runtime rewrite warrants the most reviewer attention; its per-adapter contract tests are the primary mitigation and should be reviewed alongside the code. Reviewer confirms no blocking issues; spec is implementation-ready. --- .../specs/2026-05-19-cli-extensions-design.md | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 5c87397..af05ebb 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -836,8 +836,22 @@ generic loader with env-var overlay (§6.10). `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` with -a nested section, one `#[secret]`, one `#[secret(store_ref)]`. +`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 @@ -1040,12 +1054,20 @@ unsupported compound shape, `skip_serializing_if`, `Option::None`, 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. If `spin_sdk` exposes a manifest-validation entry -point, the test calls it; otherwise it parses with `toml` and checks -the variable-name regex. The golden file is regenerated only on an -intentional format change. +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 @@ -1160,6 +1182,11 @@ other seven are individually small. **Review note.** Because this is one PR, the reviewer sees all eight commits together. The PR description should list the eight commits and point at this spec. Reviewing commit-by-commit is recommended. +**Commit 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:** commit 2 — atomic manifest+runtime rewrite touching the schema, `ConfigStore` (async), **all four** adapters' store impls, the From fe5dce141fa3bc3f4264e189df60db2e64aa07bc Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 09:06:42 -0700 Subject: [PATCH 13/38] Add implementation plan for CLI extensions (8-commit PR) --- .../plans/2026-05-20-cli-extensions.md | 560 ++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-cli-extensions.md 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..6c0fd4c --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -0,0 +1,560 @@ +# 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 commits. Commit 1 extracts the CLI library substrate. Commit 2 is an atomic manifest + runtime rewrite (hard cutoff — no backward compatibility). Commits 3–7 add app-config and the four commands. Commit 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 commit 2) + +- [ ] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** The current branch has **no** Spin store support — `crates/edgezero-adapter-spin/src/` has no `config_store.rs` / `key_value_store.rs` / `secret_store.rs`, and `lib.rs` explicitly rejects `[stores.*]` for spin. Commit 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime; they must exist first. Commit 1 does **not** need PR #253. Verify with: `ls crates/edgezero-adapter-spin/src/` shows the three store files before starting commit 2. +- [ ] Working on branch `docs/extensible-cli-library-spec` (or a fresh feature branch off it). The spec lives in `docs/superpowers/`, which is gitignored — keep using `git add -f` for spec/plan files only. + +## 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` runs `cargo test --workspace --all-targets`, `cargo check --workspace --all-features`, and per-adapter wasm `--test contract`. `.github/workflows/format.yml` runs `cargo fmt --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, and ESLint/Prettier on `docs/`. + +## File structure (created / modified across the 8 commits) + +``` +crates/edgezero-cli/ + Cargo.toml # M: lib target implicit via src/lib.rs; new deps + src/lib.rs # C (commit 1): public API + src/main.rs # M (commit 1): thin wrapper + src/args.rs # M: standalone *Args structs; commits 4-7 add args + src/demo_server.rs # M (commit 1): renamed from dev_server.rs + src/runner.rs # C (commit 5): CommandSpec + CommandRunner + src/auth.rs # C (commit 5) + src/provision.rs # C (commit 6) + src/config.rs # C (commit 7): validate + push + src/generator.rs # M (commits 1, 3): scaffold -cli, .toml + src/templates/cli/ # C (commit 1) + src/templates/app/ # C (commit 3) + src/templates/root/edgezero.toml.hbs # M (commit 2): new store schema + src/templates/core/src/config.rs.hbs # C (commit 3) + tests/lib_consumer.rs # C (commit 1) +crates/edgezero-core/src/ + manifest.rs # M (commit 2): store schema rewrite + capability rules + config_store.rs # M (commit 2): async trait + key_value_store.rs # M (commit 2): KvError::Unsupported + LimitExceeded + secret_store.rs # M (commit 2): bound-handle wrapper + context.rs # M (commit 2): id-keyed Bound*Store accessors + extractor.rs # M (commit 2): Kv/Secrets/Config default()/named() + hooks.rs / app.rs # M (commit 2): id-keyed metadata + app_config.rs # C (commit 3) +crates/edgezero-macros/src/ + lib.rs # M (commit 3): AppConfig derive export + app_config.rs # C (commit 3): derive impl + app.rs # M (commit 2): emit id-keyed metadata +crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/ + {config_store,key_value_store,secret_store}.rs # M (commit 2): multi-store registries +examples/app-demo/ + Cargo.toml # M (commit 1): add app-demo-cli member + edgezero.toml # M (commit 2): new schema + app-demo.toml # C (commit 3) + crates/app-demo-cli/ # C (commit 1, extended 4-8) + crates/app-demo-core/src/config.rs # C (commit 3) + crates/app-demo-core/src/handlers.rs # M (commits 2, 8) +docs/guide/ # M: many pages per §6.12 +docs/guide/manifest-store-migration.md # C (commit 2) +docs/guide/cli-walkthrough.md # C (commit 8) +docs/.vitepress/config.mts # M (commits 2, 8): sidebar +``` + +--- + +# Commit 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton + +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 — commit 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 the same five subcommands (with `demo` instead of `dev`). + +### 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; `./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 all five built-ins (`Build(BuildArgs)`, `Deploy(DeployArgs)`, `Demo`, `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:** `cargo run -p edgezero-cli -- new throwaway && cd /tmp/throwaway && cargo check --workspace` succeeds; clean up. + +### 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]`. + +- [ ] **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 — all five 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`, `demo`, `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). + +- [ ] **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: Commit-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: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace --all-targets`, and `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" +``` + +--- + +# Commit 2 — Manifest + runtime rewrite (atomic, all four adapters) + +Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit and the review hotspot. Hard cutoff — legacy store schema is removed outright. + +### Task 2.1: Rewrite the manifest store schema + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` + +- [ ] **Step 1: Write failing tests** for the new schema in `manifest.rs` tests: a manifest with `[stores.kv] ids = ["a","b"]\ndefault = "a"` plus `[adapters.cloudflare.stores.kv.a] name = "A"` etc. parses; `ids = []` errors; `default` missing with two ids errors; `default` not in `ids` errors; a `Single`-pair per-id block errors; a legacy `[stores.kv] name = "X"` errors with a message containing `manifest-store-migration`. + +- [ ] **Step 2: Run** — expect FAIL. + +- [ ] **Step 3: Implement** per §6.6. Replace `ManifestStores`, `ManifestConfigStoreConfig`, `ManifestKvConfig`, `ManifestSecretsConfig`, and the `Manifest*AdapterConfig` types with: + - `ManifestStores { kv: Option, config: Option, secrets: Option }` where `LogicalStore { ids: Vec, default: Option }`. + - `ManifestAdapter` gains `stores: Option` and `component: Option` (Spin component, §6.7). `AdapterStoresConfig { kv/config/secrets: BTreeMap }`, `AdapterStoreMapping { name: String, #[serde(flatten)] extras: BTreeMap }`. + - A `Capability { Multi, Single }` and a const fn `capability(adapter: &str, kind: StoreKind) -> Capability` encoding the §6.6 matrix. + - Validation in `ManifestLoader`: non-empty `ids`; `default` rules; capability check (any `Single` adapter for a kind ⇒ `ids.len() == 1`); per-id mapping required for `Multi` pairs / forbidden for `Single` pairs; Cloudflare `name` JS-identifier check; Spin KV label check. + - Detect legacy keys (`name`/`enabled`/`defaults`/`adapters` under `[stores.*]`) via a `#[serde(deny_unknown_fields)]` or an explicit reject, emitting an error pointing at `docs/guide/manifest-store-migration.md`. + - Add resolver helpers: `resolved_default(kind) -> &str`, `store_name(adapter, kind, id) -> Option<&str>`. + +- [ ] **Step 4: Run** `cargo test -p edgezero-core manifest` — expect PASS. Existing manifest tests that used the old schema are rewritten to the new schema (this is a hard cutoff — old-schema tests are replaced, not kept). + +### Task 2.2: New `KvError` variants + +**Files:** +- Modify: `crates/edgezero-core/src/key_value_store.rs` + +- [ ] **Step 1: Write failing test:** assert `KvError::Unsupported` and `KvError::LimitExceeded` exist and that their `EdgeError` conversion yields a 5xx status. + +- [ ] **Step 2: Run** — expect FAIL. + +- [ ] **Step 3: Implement.** Add `Unsupported { message: String }` and `LimitExceeded { message: String }` to `KvError`. Map both to a 5xx-class `EdgeError` in the existing `KvError → EdgeError` conversion (an unsupported op / a store-too-large condition is not a client error). + +- [ ] **Step 4: Run** — expect PASS. + +### Task 2.3: Make `ConfigStore` async + +**Files:** +- Modify: `crates/edgezero-core/src/config_store.rs`, and every `ConfigStore` impl (all four adapters + any in-core test stores) + +- [ ] **Step 1: Implement.** Change the trait to `#[async_trait(?Send)] pub trait ConfigStore: Send + Sync { async fn get(&self, key: &str) -> Result, ConfigStoreError>; }`. Make `ConfigStoreHandle::get` async. Update the `config_store_contract_tests!` macro so generated tests `.await` the calls (they already run under `futures::executor::block_on` per project convention). + +- [ ] **Step 2:** Update every `ConfigStore` impl in the four adapters to `async fn get` (the bodies stay; only the signature + any awaits change). This is mechanical but compile-driven — `cargo build` will list every site. + +- [ ] **Step 3: Run** `cargo build --workspace` — drive to zero errors. + +### Task 2.4: Bound store handles + id-keyed `RequestContext` + `StoreRegistry` + +**Files:** +- Modify: `crates/edgezero-core/src/context.rs`, `config_store.rs`, `key_value_store.rs`, `secret_store.rs` + +- [ ] **Step 1: Implement** per §4. Add `BoundKvStore`, `BoundConfigStore`, `BoundSecretStore` — each wraps the provider handle plus the resolved platform name; `BoundConfigStore::get` async; `BoundSecretStore::get -> Result, SecretError>` + `require_str`. Add `StoreRegistry { by_id: BTreeMap, default_id: String }`. Replace `RequestContext::config_store()/kv_handle()/secret_handle()` with `kv_store(id)/kv_store_default()`, `config_store(id)/config_store_default()`, `secret_store(id)/secret_store_default()` returning `Option`. The context stores three `StoreRegistry` values in its `Extensions`. + +- [ ] **Step 2: Write tests** in `context.rs`: a registry with two ids returns `Some` for each, `None` for an unknown id; `*_default()` resolves the `default_id`. + +- [ ] **Step 3: Run** `cargo test -p edgezero-core context` — expect PASS. + +### Task 2.5: Id-keyed `Hooks` / `ConfigStoreMetadata` + `app!` macro + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs`, `crates/edgezero-core/src/hooks.rs` (if separate), `crates/edgezero-macros/src/app.rs`, `crates/edgezero-macros/src/manifest_definitions.rs` + +- [ ] **Step 1: Implement.** `ConfigStoreMetadata` becomes a registry: one entry per logical config id, each carrying the per-adapter `name` map. `Hooks` exposes store **metadata** (ids, resolved default, per-adapter names) per kind — **not** bound handles. Update the `app!` macro to emit the id-keyed metadata from the new manifest schema (`manifest_definitions.rs` is where the macro reads the manifest). + +- [ ] **Step 2: Write a macro test:** the generated `ConfigStoreMetadata` registry matches a fixture manifest's `[stores.config].ids`. + +- [ ] **Step 3: Run** `cargo test -p edgezero-core && cargo test -p edgezero-macros` — expect PASS. + +### Task 2.6: Refactor `Kv` / `Secrets` extractors + add `Config` + +**Files:** +- Modify: `crates/edgezero-core/src/extractor.rs` + +- [ ] **Step 1: Implement** per §6.9. `Kv` / `Secrets` / new `Config` each become a per-request registry handle with `.default() -> Option` and `.named(id) -> Option`. Update their `FromRequest` impls to extract the corresponding `StoreRegistry` from the context. + +- [ ] **Step 2: Write tests:** a handler-style test resolving `kv.default()` and `kv.named("sessions")`. + +- [ ] **Step 3: Run** `cargo test -p edgezero-core extractor` — expect PASS. + +### Task 2.7: Rewrite all four adapter store impls for multi-store + +**Files:** +- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{config_store,key_value_store,secret_store}.rs` and each adapter's request-setup code. + +- [ ] **Step 1: axum.** Build `StoreRegistry` for each kind from `[adapters.axum.stores.*]`. KV stays `PersistentKvStore` (redb), one per id. Config store reads `.edgezero/local-config-.json` (the file commit 7 writes); absent ⇒ empty. Secrets from env vars (Single). + +- [ ] **Step 2: cloudflare.** KV registry. **Config rewritten from `[vars]` to KV** (§6.9) — `CloudflareConfigStore` does an async `env..get(key)`; one namespace per config id. Secrets from worker secrets (Single). + +- [ ] **Step 3: fastly.** KV / config / secret store registries (all `Multi`). + +- [ ] **Step 4: spin.** Wire `SpinKvStore` (label registry, honor `max_list_keys`, return `KvError::LimitExceeded` past the cap, `KvError::Unsupported` for TTL writes), `SpinConfigStore` (single flat-variable store, `.`→`__` lowercase key translation), `SpinSecretStore` (single flat-variable store, `store_name` ignored). Stop rejecting `[stores.*]` for spin in `lib.rs`. Labels come from `[adapters.spin.stores.kv.*].name`. + +- [ ] **Step 5:** Update each adapter's contract-test invocations to the id-keyed factory shape; add a Spin TTL→`Unsupported` contract test and a Spin listing-cap→`LimitExceeded` test; add a Cloudflare config-from-KV async round-trip test (wasm-bindgen-test). + +- [ ] **Step 6: Run** `cargo test --workspace --all-targets`, then the per-adapter wasm contract tests (`cargo test -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --test contract`, fastly + spin on `wasm32-wasip1`). All green. + +### Task 2.8: Migrate `app-demo` + write the migration guide + +**Files:** +- Modify: `examples/app-demo/edgezero.toml`, `examples/app-demo/crates/app-demo-core/src/handlers.rs`, `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` +- Create: `docs/guide/manifest-store-migration.md` + +- [ ] **Step 1:** Rewrite `examples/app-demo/edgezero.toml` to the new schema: `[stores.kv] ids = ["sessions","cache"]\ndefault = "sessions"`; one config id (`app_config`); one secrets id (`default`); per-adapter `[adapters..stores.kv.]` blocks for axum/cloudflare/fastly/spin; no Spin per-id blocks for config/secrets (Single). Remove `[stores.config.defaults]`. + +- [ ] **Step 2:** Migrate `app-demo` handlers to id-keyed accessors — **store-accessor change only** (`ctx.kv_store("sessions")`, `ctx.config_store_default()`, the refactored `Kv`/`Secrets`/`Config` extractors). Do **not** introduce `AppDemoConfig` here (commit 3). + +- [ ] **Step 3:** Rewrite `templates/root/edgezero.toml.hbs` to the new schema so `edgezero new` produces a valid manifest. + +- [ ] **Step 4:** Write `docs/guide/manifest-store-migration.md` — old shape → new shape, worked example, the capability matrix. + +- [ ] **Step 5: Run** `cd examples/app-demo && cargo test && cargo build --workspace` — green. + +### Task 2.9: Commit-2 docs + commit + +**Files:** +- Modify: `docs/guide/configuration.md`, `kv.md`, `handlers.md`, `adapters/cloudflare.md`, `adapters/overview.md`, `architecture.md`, `docs/.vitepress/config.mts` + +- [ ] **Step 1:** Update each page per §6.12 — new `[stores]` schema + capability rules + the removal of `[stores.config.defaults]` (`configuration.md`); multi-store + bound handles + extractor `default()/named()` (`kv.md`, `handlers.md`); `[vars]`→KV config (`adapters/cloudflare.md`); Spin store semantics (`adapters/overview.md`); light review (`architecture.md`). Add `manifest-store-migration.md` to the sidebar in `config.mts`. + +- [ ] **Step 2: Run** the full gate (all of `.github/workflows/test.yml` + `format.yml` commands, including the docs ESLint/Prettier and the wasm gates) — green. + +- [ ] **Step 3: Commit:** `git commit -m "Manifest + runtime rewrite: multi-store schema, async ConfigStore, all four adapters"` + +--- + +# Commit 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: `AppConfigMeta` trait with `const SECRET_FIELDS: &'static [SecretField]`; `SecretField { name, kind }`; `SecretKind { KeyInDefault, StoreRef }`; `AppConfigError`; `load_app_config(path, app_name)` and `load_app_config_raw(path, app_name)`. `load_app_config` parses the `[config]` table, applies the env overlay (Task 3.3), then 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` + +- [ ] **Step 1: 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; `trybuild`-style compile-fail for `#[secret]` + `#[serde(flatten)]`, `#[secret]` + `#[serde(rename)]`, `#[secret(bogus)]`, `#[secret]` on a non-scalar field. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement.** `#[proc_macro_derive(AppConfig, attributes(secret))]` in `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). + +- [ ] **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; `--no-env` (a bool param to the loader) bypasses. + +- [ ] **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/generator.rs`, `scaffold.rs` + +- [ ] **Step 1:** `app/.toml.hbs` — a `[config]` table with `greeting` and a nested `[config.service]` section. `core/src/config.rs.hbs` — `Config` with `#[derive(Deserialize, Serialize, Validate, 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 2:** Render both in `generate_new`; register in `scaffold.rs`. + +- [ ] **Step 3: Write/extend the generator test** to assert `.toml` and `-core/src/config.rs` are produced. + +- [ ] **Step 4: 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`, `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)]`). Export it from `lib.rs`. + +- [ ] **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"` + +--- + +# Commit 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 `app-demo-cli config validate` + docs + commit + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` + +- [ ] **Step 1:** Add a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with a `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in commit 7). Dispatch `Validate` to `edgezero_cli::run_config_validate_typed::`. + +- [ ] **Step 2:** Document `config validate` in `cli-reference.md`. + +- [ ] **Step 3: Run** the full gate; `cd examples/app-demo && cargo run -p app-demo-cli -- config validate --strict` exits 0. **Commit:** `git commit -m "config validate command (raw + typed)"` + +--- + +# Commit 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. Add `Auth(AuthArgs)` to `app-demo-cli`'s `Cmd`. + +- [ ] **Step 4: Run** — PASS. Document `auth` in `cli-reference.md`. + +- [ ] **Step 5: Run** the full gate. **Commit:** `git commit -m "auth command + CommandRunner infrastructure"` + +--- + +# Commit 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. Add `Provision(ProvisionArgs)` to `app-demo-cli`'s `Cmd`. Document `provision` in `cli-reference.md`. + +- [ ] **Step 5: Run** the full gate. **Commit:** `git commit -m "provision command (cloudflare/fastly/spin writeback, axum no-op)"` + +--- + +# Commit 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 `app-demo-cli config push` + docs + commit + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md`, `configuration.md` + +- [ ] **Step 1:** Extend `ConfigCmd` with `Push(ConfigPushArgs)`; dispatch to `run_config_push_typed::`. + +- [ ] **Step 2:** Document `config push` in `cli-reference.md`; cross-reference from `configuration.md`. + +- [ ] **Step 3: Run** the full gate. **Commit:** `git commit -m "config push command (per-adapter, secret-skipping, env overlay)"` + +--- + +# Commit 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 all five 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` produces `__`-encoded keys and writes both `spin.toml` tables; an env-override test asserts `APP_DEMO__SERVICE__TIMEOUT_MS` takes effect. + +- [ ] **Step 3: Run** `cd examples/app-demo && cargo test` — PASS. + +### Task 8.2: 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` and the end-to-end axum loop (`cargo run -p app-demo-cli -- config validate --strict`, `... config push --adapter axum`, start the demo server, curl `/config/greeting`). Keep it off the wasm matrix — axum only, no live external calls. + +- [ ] **Step 2: Run** the workflow logic locally to confirm the loop passes. + +### Task 8.3: 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 complete gate: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace --all-targets`, `cargo check --workspace --all-features`, all three wasm contract jobs, `cd examples/app-demo && cargo test`, and the docs ESLint/Prettier. 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 commit's final task. +- **Precondition:** PR #253 is a hard precondition for commit 2 — called out at the top and in the commit-2 header. +- **Bisectability:** each commit ends with a green-gate step before its commit step; commit 1 needs no PR #253; commit 2's axum config tests seed the JSON fixture directly (Task 2.7 step 1 — "absent ⇒ empty"; tests write the file). +- **Known drift risk:** commits 3–8's exact code depends on the `Bound*Store` / `StoreRegistry` shapes finalized in commit 2. Re-read commit 2's actual output before executing each later commit; adjust signatures to match. +- **`app-demo` in CI:** Task 8.2 adds the missing CI wiring — the spec's §15 ship gate assumed CI exercises `app-demo`, which it does not today. From a542f28644f98d91ab10a3c2070eea8aa2dd1e99 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 10:53:57 -0700 Subject: [PATCH 14/38] Fix six plan-review findings (plan + spec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spin config-push --dry-run never mutates: plan Task 8.1 and spec §15 reworded — dry-run PRINTS the would-be both-table content and the test asserts spin.toml is unchanged on disk. (The real push writing both tables is covered by commit 7's non-dry-run tests.) - Spin `component` field location: it belongs on the [adapters..adapter] definition struct (with `crate`/`manifest`), not the top-level ManifestAdapter — otherwise the accepted TOML would wrongly be [adapters.spin] component = ... - load_app_config API made consistent: AppConfigLoadOptions { env_overlay } struct; simple load_app_config / _raw apply the overlay (default); load_app_config_with_options / _raw_with_options take the struct; --no-env calls the _with_options form with env_overlay: false. No hidden bool param. Updated spec §4 + §6.10 and plan Tasks 3.1 / 3.3. - Axum multi-KV path rule: one redb file per logical id, file stem from [adapters.axum.stores.kv.].name -> .edgezero/kv-.redb. Prevents multi-store collapsing into one backing file. - Generator manual check: stop assuming the project lands in CWD or /tmp/throwaway; generate into an explicit mktemp dir via --dir. - Removed references to a non-existent crates/edgezero-core/src/ hooks.rs — Hooks + ConfigStoreMetadata both live in app.rs. --- .../plans/2026-05-20-cli-extensions.md | 37 +++++++++++++++---- .../specs/2026-05-19-cli-extensions-design.md | 30 +++++++++++++-- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 6c0fd4c..70729fa 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -53,7 +53,7 @@ crates/edgezero-core/src/ secret_store.rs # M (commit 2): bound-handle wrapper context.rs # M (commit 2): id-keyed Bound*Store accessors extractor.rs # M (commit 2): Kv/Secrets/Config default()/named() - hooks.rs / app.rs # M (commit 2): id-keyed metadata + app.rs # M (commit 2): Hooks + id-keyed ConfigStoreMetadata (Hooks lives in app.rs, no separate hooks.rs) app_config.rs # C (commit 3) crates/edgezero-macros/src/ lib.rs # M (commit 3): AppConfig derive export @@ -149,7 +149,19 @@ fn build_args_default_and_mutate() { - [ ] **Step 4: Run** the generator test — expect PASS. -- [ ] **Step 5: Manual check:** `cargo run -p edgezero-cli -- new throwaway && cd /tmp/throwaway && cargo check --workspace` succeeds; clean up. +- [ ] **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 @@ -209,7 +221,8 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit - [ ] **Step 3: Implement** per §6.6. Replace `ManifestStores`, `ManifestConfigStoreConfig`, `ManifestKvConfig`, `ManifestSecretsConfig`, and the `Manifest*AdapterConfig` types with: - `ManifestStores { kv: Option, config: Option, secrets: Option }` where `LogicalStore { ids: Vec, default: Option }`. - - `ManifestAdapter` gains `stores: Option` and `component: Option` (Spin component, §6.7). `AdapterStoresConfig { kv/config/secrets: BTreeMap }`, `AdapterStoreMapping { name: String, #[serde(flatten)] extras: BTreeMap }`. + - `ManifestAdapter` (the `[adapters.]` struct) gains `stores: Option`. `AdapterStoresConfig { kv/config/secrets: BTreeMap }`, `AdapterStoreMapping { name: String, #[serde(flatten)] extras: BTreeMap }`. + - The Spin `component` field goes on the **`[adapters..adapter]` definition struct** — the one that already carries `crate` and `manifest` — **not** on the top-level `ManifestAdapter`. Adding it to `ManifestAdapter` would make the accepted TOML `[adapters.spin] component = "..."`, which is wrong; it must be `[adapters.spin.adapter] component = "..."` (§6.7). Confirm the struct name by reading `manifest.rs` (the struct deserialized from `[adapters..adapter]`); add `component: Option` there. - A `Capability { Multi, Single }` and a const fn `capability(adapter: &str, kind: StoreKind) -> Capability` encoding the §6.6 matrix. - Validation in `ManifestLoader`: non-empty `ids`; `default` rules; capability check (any `Single` adapter for a kind ⇒ `ids.len() == 1`); per-id mapping required for `Multi` pairs / forbidden for `Single` pairs; Cloudflare `name` JS-identifier check; Spin KV label check. - Detect legacy keys (`name`/`enabled`/`defaults`/`adapters` under `[stores.*]`) via a `#[serde(deny_unknown_fields)]` or an explicit reject, emitting an error pointing at `docs/guide/manifest-store-migration.md`. @@ -255,7 +268,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.5: Id-keyed `Hooks` / `ConfigStoreMetadata` + `app!` macro **Files:** -- Modify: `crates/edgezero-core/src/app.rs`, `crates/edgezero-core/src/hooks.rs` (if separate), `crates/edgezero-macros/src/app.rs`, `crates/edgezero-macros/src/manifest_definitions.rs` +- Modify: `crates/edgezero-core/src/app.rs` (`Hooks` + `ConfigStoreMetadata` both live here — there is no separate `hooks.rs`), `crates/edgezero-macros/src/app.rs`, `crates/edgezero-macros/src/manifest_definitions.rs` - [ ] **Step 1: Implement.** `ConfigStoreMetadata` becomes a registry: one entry per logical config id, each carrying the per-adapter `name` map. `Hooks` exposes store **metadata** (ids, resolved default, per-adapter names) per kind — **not** bound handles. Update the `app!` macro to emit the id-keyed metadata from the new manifest schema (`manifest_definitions.rs` is where the macro reads the manifest). @@ -279,7 +292,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit **Files:** - Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{config_store,key_value_store,secret_store}.rs` and each adapter's request-setup code. -- [ ] **Step 1: axum.** Build `StoreRegistry` for each kind from `[adapters.axum.stores.*]`. KV stays `PersistentKvStore` (redb), one per id. Config store reads `.edgezero/local-config-.json` (the file commit 7 writes); absent ⇒ empty. Secrets from env vars (Single). +- [ ] **Step 1: axum.** Build `StoreRegistry` for each kind from `[adapters.axum.stores.*]`. KV stays `PersistentKvStore` (redb) — **one separate redb file per logical id**, file stem from the per-adapter mapping `[adapters.axum.stores.kv.].name`: `.edgezero/kv-.redb`. (Axum KV is `Multi`, so every id has a `name`.) Distinct files prevent multi-store collapsing into one backing file. Config store reads `.edgezero/local-config-.json` (the file commit 7 writes); absent ⇒ empty. Secrets from env vars (Single). - [ ] **Step 2: cloudflare.** KV registry. **Config rewritten from `[vars]` to KV** (§6.9) — `CloudflareConfigStore` does an async `env..get(key)`; one namespace per config id. Secrets from worker secrets (Single). @@ -333,7 +346,15 @@ Spec §9, §6.7, §6.8, §6.10. - [ ] **Step 2: Run** — FAIL. -- [ ] **Step 3: Implement** per §4: `AppConfigMeta` trait with `const SECRET_FIELDS: &'static [SecretField]`; `SecretField { name, kind }`; `SecretKind { KeyInDefault, StoreRef }`; `AppConfigError`; `load_app_config(path, app_name)` and `load_app_config_raw(path, app_name)`. `load_app_config` parses the `[config]` table, applies the env overlay (Task 3.3), then deserializes + `validate()`. `pub mod app_config;` in `lib.rs`. +- [ ] **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. @@ -355,7 +376,7 @@ Spec §9, §6.7, §6.8, §6.10. **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; `--no-env` (a bool param to the loader) bypasses. +- [ ] **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. @@ -523,7 +544,7 @@ Spec §15, §6.12. - [ ] **Step 1:** Confirm `app-demo-cli`'s `Cmd` has all five 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` produces `__`-encoded keys and writes both `spin.toml` tables; an env-override test asserts `APP_DEMO__SERVICE__TIMEOUT_MS` takes effect. +- [ ] **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. - [ ] **Step 3: Run** `cd examples/app-demo && cargo test` — PASS. diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index af05ebb..ffa1033 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -190,12 +190,28 @@ 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 { @@ -655,8 +671,11 @@ compared exactly. Two sibling keys mapping to the same segment is an value's type; parse failure → `AppConfigError`. **Scope.** `config validate` and `config push` both see env-resolved -values; `--no-env` disables the overlay. The axum demo server (the -`demo` subcommand) resolves via the same path. +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). @@ -1115,8 +1134,11 @@ timeout_ms` is read at runtime; the Spin path proves `.`→`__` --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 produce `__`-encoded keys - and to write **both** `spin.toml` tables. + --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 commit 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` From 1e6f3f3c871bbb4798e253d336e0fc8b4c7cc5f8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 11:21:21 -0700 Subject: [PATCH 15/38] Tighten plan: four review findings before execution - Macro compile-fail tests: Task 3.2 now adds `trybuild = "1"` to edgezero-macros [dev-dependencies] explicitly (only `tempfile` was there), with a tests/ui/*.rs fixture + .stderr golden per rejected case. - External-consumer test env guard: tests/lib_consumer.rs must restore EDGEZERO_MANIFEST via an RAII EnvOverride guard and stay a single #[test] (no in-binary parallelism); a shared Mutex guard is required if more env-touching tests are ever added. - WASM contract test commands pinned: Task 2.7 step 6 names the exact target / features / runner per adapter (cloudflare wasm32-unknown- unknown + wasm-bindgen; fastly wasm32-wasip1 + Viceroy; spin wasm32-wasip1 + Wasmtime), deferring to test.yml as source of truth. - app-demo e2e lifecycle: Task 8.1/8.2 now require an ephemeral port (no hard-coded 8787), a readiness poll (no bare sleep), and RAII teardown that kills the demo server even on assertion failure; the loop is preferably a Rust integration test, not shell-in-YAML. --- .../plans/2026-05-20-cli-extensions.md | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 70729fa..65837fa 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -186,6 +186,8 @@ Expected: `cargo check --workspace` in the generated project succeeds. - [ ] **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: Commit-1 documentation + commit @@ -302,7 +304,15 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit - [ ] **Step 5:** Update each adapter's contract-test invocations to the id-keyed factory shape; add a Spin TTL→`Unsupported` contract test and a Spin listing-cap→`LimitExceeded` test; add a Cloudflare config-from-KV async round-trip test (wasm-bindgen-test). -- [ ] **Step 6: Run** `cargo test --workspace --all-targets`, then the per-adapter wasm contract tests (`cargo test -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --test contract`, fastly + spin on `wasm32-wasip1`). All green. +- [ ] **Step 6: Run** `cargo test --workspace --all-targets`, then the per-adapter wasm contract tests with the **exact** runner / target / feature each adapter's CI job uses (`.github/workflows/test.yml` `adapter-wasm-tests` matrix — match it, do not improvise): + - **cloudflare:** target `wasm32-unknown-unknown`, runner `wasm-bindgen-test-runner` — + `cargo test -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare --test contract` + - **fastly:** target `wasm32-wasip1`, runner Viceroy (version pinned in `.tool-versions`) — + `cargo test -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly --test contract` + - **spin:** target `wasm32-wasip1`, runner Wasmtime — + `cargo test -p edgezero-adapter-spin --target wasm32-wasip1 --features spin --test contract` + + The runner for each target is configured in the workspace `.cargo/config.toml`. If the exact feature flags or runner config differ from the above, defer to `.github/workflows/test.yml` as the source of truth and update this step to match. All green. ### Task 2.8: Migrate `app-demo` + write the migration guide @@ -363,7 +373,9 @@ Spec §9, §6.7, §6.8, §6.10. **Files:** - Create: `crates/edgezero-macros/src/app_config.rs`; Modify: `crates/edgezero-macros/src/lib.rs` -- [ ] **Step 1: 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; `trybuild`-style compile-fail for `#[secret]` + `#[serde(flatten)]`, `#[secret]` + `#[serde(rename)]`, `#[secret(bogus)]`, `#[secret]` on a non-scalar field. +- [ ] **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. @@ -546,6 +558,11 @@ Spec §15, §6.12. - [ ] **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: CI wiring for the `app-demo` loop @@ -553,9 +570,11 @@ Spec §15, §6.12. **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` and the end-to-end axum loop (`cargo run -p app-demo-cli -- config validate --strict`, `... config push --adapter axum`, start the demo server, curl `/config/greeting`). Keep it off the wasm matrix — axum only, no live external calls. +- [ ] **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 2: Run** the workflow logic locally to confirm the loop passes. +- [ ] **Step 3: Run** the workflow logic locally to confirm the loop passes and leaves no orphan processes or `.edgezero/` artifacts. ### Task 8.3: Walkthrough doc + documentation audit + commit From 23b76cae39dbcbb530aaba1efdf343fc0e61e394 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 11:33:39 -0700 Subject: [PATCH 16/38] Plan: wire new commands into the default binary, upgrade scaffold, align gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default `edgezero` binary wiring (High): commits 4-7 now have explicit steps to add Auth / Provision / Config(Validate|Push) to the default edgezero-cli `Command` enum and `main.rs` dispatch (raw run_* — the default binary has no app struct), with `edgezero --help` / parse tests. Previously only the original five commands and app-demo-cli were wired; the spec requires the new subcommands on the default binary too. New Task 4.2 covers `config`; Task 5.2/6.1/7.2 extended. - Generated `-cli` template upgrade (Medium): new Task 8.2 updates templates/cli/src/main.rs.hbs to the full eight-command set once auth/provision/config exist, wiring the scaffold's config arm to the typed functions with the generated project's config struct. Generator test asserts it. - Full-gate alignment (Medium): added a canonical "## The full gate" section with the exact five CI commands from CLAUDE.md / the workflows (cargo check uses --features "fastly cloudflare spin", not --all-features). Every "run the full gate" step references it; fixed the commit-1 and commit-8 gate steps and the Codebase-facts CI line that had drifted to --all-features. Commit-8 tasks renumbered (8.2 CI wiring -> 8.3; walkthrough/audit -> 8.4). --- .../plans/2026-05-20-cli-extensions.md | 139 +++++++++++++++--- 1 file changed, 117 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 65837fa..c84640c 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -25,7 +25,29 @@ - `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` runs `cargo test --workspace --all-targets`, `cargo check --workspace --all-features`, and per-adapter wasm `--test contract`. `.github/workflows/format.yml` runs `cargo fmt --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, and ESLint/Prettier on `docs/`. +- 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 (commands in Task 2.7 step 6), +`cd examples/app-demo && cargo test`, and — for doc changes — the docs +ESLint/Prettier job. Each commit's final task runs the full gate before +its `git commit`. ## File structure (created / modified across the 8 commits) @@ -33,15 +55,15 @@ crates/edgezero-cli/ Cargo.toml # M: lib target implicit via src/lib.rs; new deps src/lib.rs # C (commit 1): public API - src/main.rs # M (commit 1): thin wrapper - src/args.rs # M: standalone *Args structs; commits 4-7 add args + src/main.rs # M (commit 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 (commit 1): renamed from dev_server.rs src/runner.rs # C (commit 5): CommandSpec + CommandRunner src/auth.rs # C (commit 5) src/provision.rs # C (commit 6) src/config.rs # C (commit 7): validate + push src/generator.rs # M (commits 1, 3): scaffold -cli, .toml - src/templates/cli/ # C (commit 1) + src/templates/cli/ # C (commit 1); M (commit 8): full command set src/templates/app/ # C (commit 3) src/templates/root/edgezero.toml.hbs # M (commit 2): new store schema src/templates/core/src/config.rs.hbs # C (commit 3) @@ -83,6 +105,7 @@ Spec §7. No PR #253 dependency. Goal: `edgezero-cli` becomes lib + bin; the `de ### 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: @@ -107,6 +130,7 @@ fn build_args_default_and_mutate() { ### 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` @@ -123,6 +147,7 @@ fn build_args_default_and_mutate() { ### 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` @@ -137,6 +162,7 @@ fn build_args_default_and_mutate() { ### 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` @@ -166,6 +192,7 @@ 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` @@ -182,6 +209,7 @@ Expected: `cargo check --workspace` in the generated project succeeds. ### 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). @@ -193,11 +221,12 @@ Expected: `cargo check --workspace` in the generated project succeeds. ### Task 1.7: Commit-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: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace --all-targets`, and `cd examples/app-demo && cargo test`. All green. +- [ ] **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:** @@ -215,6 +244,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.1: Rewrite the manifest store schema **Files:** + - Modify: `crates/edgezero-core/src/manifest.rs` - [ ] **Step 1: Write failing tests** for the new schema in `manifest.rs` tests: a manifest with `[stores.kv] ids = ["a","b"]\ndefault = "a"` plus `[adapters.cloudflare.stores.kv.a] name = "A"` etc. parses; `ids = []` errors; `default` missing with two ids errors; `default` not in `ids` errors; a `Single`-pair per-id block errors; a legacy `[stores.kv] name = "X"` errors with a message containing `manifest-store-migration`. @@ -235,6 +265,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.2: New `KvError` variants **Files:** + - Modify: `crates/edgezero-core/src/key_value_store.rs` - [ ] **Step 1: Write failing test:** assert `KvError::Unsupported` and `KvError::LimitExceeded` exist and that their `EdgeError` conversion yields a 5xx status. @@ -248,6 +279,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.3: Make `ConfigStore` async **Files:** + - Modify: `crates/edgezero-core/src/config_store.rs`, and every `ConfigStore` impl (all four adapters + any in-core test stores) - [ ] **Step 1: Implement.** Change the trait to `#[async_trait(?Send)] pub trait ConfigStore: Send + Sync { async fn get(&self, key: &str) -> Result, ConfigStoreError>; }`. Make `ConfigStoreHandle::get` async. Update the `config_store_contract_tests!` macro so generated tests `.await` the calls (they already run under `futures::executor::block_on` per project convention). @@ -259,6 +291,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.4: Bound store handles + id-keyed `RequestContext` + `StoreRegistry` **Files:** + - Modify: `crates/edgezero-core/src/context.rs`, `config_store.rs`, `key_value_store.rs`, `secret_store.rs` - [ ] **Step 1: Implement** per §4. Add `BoundKvStore`, `BoundConfigStore`, `BoundSecretStore` — each wraps the provider handle plus the resolved platform name; `BoundConfigStore::get` async; `BoundSecretStore::get -> Result, SecretError>` + `require_str`. Add `StoreRegistry { by_id: BTreeMap, default_id: String }`. Replace `RequestContext::config_store()/kv_handle()/secret_handle()` with `kv_store(id)/kv_store_default()`, `config_store(id)/config_store_default()`, `secret_store(id)/secret_store_default()` returning `Option`. The context stores three `StoreRegistry` values in its `Extensions`. @@ -270,6 +303,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.5: Id-keyed `Hooks` / `ConfigStoreMetadata` + `app!` macro **Files:** + - Modify: `crates/edgezero-core/src/app.rs` (`Hooks` + `ConfigStoreMetadata` both live here — there is no separate `hooks.rs`), `crates/edgezero-macros/src/app.rs`, `crates/edgezero-macros/src/manifest_definitions.rs` - [ ] **Step 1: Implement.** `ConfigStoreMetadata` becomes a registry: one entry per logical config id, each carrying the per-adapter `name` map. `Hooks` exposes store **metadata** (ids, resolved default, per-adapter names) per kind — **not** bound handles. Update the `app!` macro to emit the id-keyed metadata from the new manifest schema (`manifest_definitions.rs` is where the macro reads the manifest). @@ -281,6 +315,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.6: Refactor `Kv` / `Secrets` extractors + add `Config` **Files:** + - Modify: `crates/edgezero-core/src/extractor.rs` - [ ] **Step 1: Implement** per §6.9. `Kv` / `Secrets` / new `Config` each become a per-request registry handle with `.default() -> Option` and `.named(id) -> Option`. Update their `FromRequest` impls to extract the corresponding `StoreRegistry` from the context. @@ -292,6 +327,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.7: Rewrite all four adapter store impls for multi-store **Files:** + - Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{config_store,key_value_store,secret_store}.rs` and each adapter's request-setup code. - [ ] **Step 1: axum.** Build `StoreRegistry` for each kind from `[adapters.axum.stores.*]`. KV stays `PersistentKvStore` (redb) — **one separate redb file per logical id**, file stem from the per-adapter mapping `[adapters.axum.stores.kv.].name`: `.edgezero/kv-.redb`. (Axum KV is `Multi`, so every id has a `name`.) Distinct files prevent multi-store collapsing into one backing file. Config store reads `.edgezero/local-config-.json` (the file commit 7 writes); absent ⇒ empty. Secrets from env vars (Single). @@ -317,6 +353,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.8: Migrate `app-demo` + write the migration guide **Files:** + - Modify: `examples/app-demo/edgezero.toml`, `examples/app-demo/crates/app-demo-core/src/handlers.rs`, `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` - Create: `docs/guide/manifest-store-migration.md` @@ -333,6 +370,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit ### Task 2.9: Commit-2 docs + commit **Files:** + - Modify: `docs/guide/configuration.md`, `kv.md`, `handlers.md`, `adapters/cloudflare.md`, `adapters/overview.md`, `architecture.md`, `docs/.vitepress/config.mts` - [ ] **Step 1:** Update each page per §6.12 — new `[stores]` schema + capability rules + the removal of `[stores.config.defaults]` (`configuration.md`); multi-store + bound handles + extractor `default()/named()` (`kv.md`, `handlers.md`); `[vars]`→KV config (`adapters/cloudflare.md`); Spin store semantics (`adapters/overview.md`); light review (`architecture.md`). Add `manifest-store-migration.md` to the sidebar in `config.mts`. @@ -350,6 +388,7 @@ 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`. @@ -371,6 +410,7 @@ Spec §9, §6.7, §6.8, §6.10. ### Task 3.2: `AppConfig` derive macro **Files:** + - Create: `crates/edgezero-macros/src/app_config.rs`; Modify: `crates/edgezero-macros/src/lib.rs` - [ ] **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). @@ -386,6 +426,7 @@ Spec §9, §6.7, §6.8, §6.10. ### 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. @@ -399,6 +440,7 @@ Spec §9, §6.7, §6.8, §6.10. ### 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/generator.rs`, `scaffold.rs` @@ -413,6 +455,7 @@ Spec §9, §6.7, §6.8, §6.10. ### 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`, `docs/guide/configuration.md`, `getting-started.md` @@ -433,6 +476,7 @@ Spec §10. New: `ConfigValidateArgs`, `run_config_validate`, `run_config_validat ### 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` @@ -444,16 +488,34 @@ Spec §10. New: `ConfigValidateArgs`, `run_config_validate`, `run_config_validat - [ ] **Step 4: Run** — PASS. -### Task 4.2: Wire `app-demo-cli config validate` + docs + commit +### 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 commit 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/src/main.rs`, `docs/guide/cli-reference.md` -- [ ] **Step 1:** Add a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with a `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in commit 7). Dispatch `Validate` to `edgezero_cli::run_config_validate_typed::`. +- [ ] **Step 1:** Add a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in commit 7). Dispatch `Validate` to `edgezero_cli::run_config_validate_typed::` — the **typed** validator (`app-demo-cli` knows `AppDemoConfig`). -- [ ] **Step 2:** Document `config validate` in `cli-reference.md`. +- [ ] **Step 2:** Document `config validate` in `cli-reference.md` — note the default `edgezero` binary runs the raw validator, downstream CLIs the typed one. -- [ ] **Step 3: Run** the full gate; `cd examples/app-demo && cargo run -p app-demo-cli -- config validate --strict` exits 0. **Commit:** `git commit -m "config validate command (raw + typed)"` +- [ ] **Step 3: 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)"` --- @@ -464,6 +526,7 @@ 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, ... }`. @@ -477,6 +540,7 @@ Spec §11, §6.1. ### 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` @@ -485,11 +549,13 @@ Spec §11, §6.1. - [ ] **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. Add `Auth(AuthArgs)` to `app-demo-cli`'s `Cmd`. +- [ ] **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: Run** the full gate. **Commit:** `git commit -m "auth command + CommandRunner infrastructure"` +- [ ] **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"` --- @@ -500,6 +566,7 @@ 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` @@ -509,9 +576,11 @@ Spec §12, §13 (Fastly contract). - [ ] **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. Add `Provision(ProvisionArgs)` to `app-demo-cli`'s `Cmd`. Document `provision` in `cli-reference.md`. +- [ ] **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 5: Run** the full gate. **Commit:** `git commit -m "provision command (cloudflare/fastly/spin writeback, axum no-op)"` +- [ ] **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)"` --- @@ -522,6 +591,7 @@ 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). @@ -532,16 +602,21 @@ Spec §13, §6.4, §6.5. - [ ] **Step 4: Run** — PASS. -### Task 7.2: Wire `app-demo-cli config push` + docs + commit +### Task 7.2: Wire `config push` into both binaries + docs + commit **Files:** -- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md`, `configuration.md` -- [ ] **Step 1:** Extend `ConfigCmd` with `Push(ConfigPushArgs)`; dispatch to `run_config_push_typed::`. +- 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 2:** Document `config push` in `cli-reference.md`; cross-reference from `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 3: Run** the full gate. **Commit:** `git commit -m "config push command (per-adapter, secret-skipping, env overlay)"` +- [ ] **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)"` --- @@ -552,6 +627,7 @@ 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 all five 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]`). @@ -565,9 +641,27 @@ Spec §15, §6.12. - [ ] **Step 3: Run** `cd examples/app-demo && cargo test` — PASS. -### Task 8.2: CI wiring for the `app-demo` loop +### Task 8.2: Upgrade the generated `-cli` template to the full command set **Files:** +- Modify: `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`, `crates/edgezero-cli/src/generator.rs` (tests) + +Commit 1 created the `-cli` template with only the five base +built-ins (`auth` / `provision` / `config` did not exist yet). Now that +commits 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:** Update `templates/cli/src/main.rs.hbs` so the generated `Cmd` enum lists **all eight** built-ins: `Build`, `Deploy`, `Demo`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/demo/new/serve/auth/provision` to the raw `edgezero_cli::run_*`. 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 `{{name}}-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 2:** Extend the generator structure test (from Task 1.4 / 3.4): the scaffolded `-cli/src/main.rs` contains `Auth`, `Provision`, and `Config` variants and references the typed config functions with the project's config type. + +- [ ] **Step 3: 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 eight commands. + +### 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. @@ -576,16 +670,17 @@ Spec §15, §6.12. - [ ] **Step 3: Run** the workflow logic locally to confirm the loop passes and leaves no orphan processes or `.edgezero/` artifacts. -### Task 8.3: Walkthrough doc + documentation audit + commit +### 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 complete gate: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace --all-targets`, `cargo check --workspace --all-features`, all three wasm contract jobs, `cd examples/app-demo && cargo test`, and the docs ESLint/Prettier. All green. +- [ ] **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"` @@ -597,4 +692,4 @@ Spec §15, §6.12. - **Precondition:** PR #253 is a hard precondition for commit 2 — called out at the top and in the commit-2 header. - **Bisectability:** each commit ends with a green-gate step before its commit step; commit 1 needs no PR #253; commit 2's axum config tests seed the JSON fixture directly (Task 2.7 step 1 — "absent ⇒ empty"; tests write the file). - **Known drift risk:** commits 3–8's exact code depends on the `Bound*Store` / `StoreRegistry` shapes finalized in commit 2. Re-read commit 2's actual output before executing each later commit; adjust signatures to match. -- **`app-demo` in CI:** Task 8.2 adds the missing CI wiring — the spec's §15 ship gate assumed CI exercises `app-demo`, which it does not today. +- **`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. From c63dad06545cbd79c5618db00934f477d89452f7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 11:38:33 -0700 Subject: [PATCH 17/38] Plan: fix three crate-dependency gaps for typed-config wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app-demo-cli missing app-demo-core dep (High): Task 4.3 now adds `app-demo-core = { path = "../app-demo-core" }` to app-demo-cli/Cargo.toml — it references AppDemoConfig once typed `config validate` / `config push` are wired, but its deps were only edgezero-cli/clap/log. - Generated -cli template missing core-crate dep (High): Task 8.2 now also updates templates/cli/Cargo.toml.hbs to depend on `{{name}}-core` (path dep), and the generator test asserts the scaffold builds with that dependency and resolves the typed config type. - AppConfig macro + validator availability (Medium): chosen route stated explicitly — `edgezero-core` re-exports the `AppConfig` derive (matching the existing `action`/`app` re-exports), so a config crate needs only `edgezero-core` for the macro, no direct edgezero-macros dep. Task 3.4 updates templates/core/Cargo.toml.hbs to add `validator` (with derive); Task 3.5 verifies app-demo-core already carries edgezero-core + validator + serde. Generator test checks the scaffolded core crate builds. Task 3.4 / 4.3 / 8.2 steps renumbered to fit the inserted dependency steps. --- .../plans/2026-05-20-cli-extensions.md | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index c84640c..22a5281 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -411,7 +411,18 @@ Spec §9, §6.7, §6.8, §6.10. **Files:** -- Create: `crates/edgezero-macros/src/app_config.rs`; Modify: `crates/edgezero-macros/src/lib.rs` +- 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). @@ -419,7 +430,7 @@ Spec §9, §6.7, §6.8, §6.10. - [ ] **Step 2: Run** — FAIL. -- [ ] **Step 3: Implement.** `#[proc_macro_derive(AppConfig, attributes(secret))]` in `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). +- [ ] **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. @@ -442,13 +453,15 @@ Spec §9, §6.7, §6.8, §6.10. **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/generator.rs`, `scaffold.rs` +- Modify: `crates/edgezero-cli/src/templates/core/Cargo.toml.hbs`, `crates/edgezero-cli/src/generator.rs`, `scaffold.rs` -- [ ] **Step 1:** `app/.toml.hbs` — a `[config]` table with `greeting` and a nested `[config.service]` section. `core/src/config.rs.hbs` — `Config` with `#[derive(Deserialize, Serialize, Validate, 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 1:** `app/.toml.hbs` — a `[config]` table with `greeting` and a nested `[config.service]` section. `core/src/config.rs.hbs` — `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 2:** Render both in `generate_new`; register in `scaffold.rs`. +- [ ] **Step 2: Update `templates/core/Cargo.toml.hbs` deps.** 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 = { ... , features = ["derive"] }` to `templates/core/Cargo.toml.hbs` (it currently lacks it); confirm `serde` has `features = ["derive"]`. Use whatever version/workspace-pin convention the existing template deps use. -- [ ] **Step 3: Write/extend the generator test** to assert `.toml` and `-core/src/config.rs` are produced. +- [ ] **Step 3:** Render both in `generate_new`; register in `scaffold.rs`. + +- [ ] **Step 4: Write/extend the generator test** to assert `.toml` and `-core/src/config.rs` are produced **and** that the generated `-core` builds (the `validator` dep resolves and `edgezero_core::AppConfig` is in scope) — `cargo check -p -core` in the scaffolded project. - [ ] **Step 4: Run** the generator test — PASS. @@ -457,9 +470,9 @@ Spec §9, §6.7, §6.8, §6.10. **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`, `docs/guide/configuration.md`, `getting-started.md` +- 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)]`). Export it from `lib.rs`. +- [ ] **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. @@ -509,13 +522,15 @@ binary has no app-config struct, so it uses the **raw** functions. **Files:** -- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` +- 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 a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in commit 7). Dispatch `Validate` to `edgezero_cli::run_config_validate_typed::` — the **typed** validator (`app-demo-cli` knows `AppDemoConfig`). +- [ ] **Step 1: Add the `app-demo-core` dependency.** `app-demo-cli` is about to reference `AppDemoConfig`, which lives in `app-demo-core` (created in commit 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:** Document `config validate` in `cli-reference.md` — note the default `edgezero` binary runs the raw validator, downstream CLIs the typed one. +- [ ] **Step 2:** Add a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in commit 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: 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)"` +- [ ] **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)"` --- @@ -644,7 +659,7 @@ Spec §15, §6.12. ### Task 8.2: Upgrade the generated `-cli` template to the full command set **Files:** -- Modify: `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`, `crates/edgezero-cli/src/generator.rs` (tests) +- 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) Commit 1 created the `-cli` template with only the five base built-ins (`auth` / `provision` / `config` did not exist yet). Now that @@ -652,11 +667,13 @@ commits 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:** Update `templates/cli/src/main.rs.hbs` so the generated `Cmd` enum lists **all eight** built-ins: `Build`, `Deploy`, `Demo`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/demo/new/serve/auth/provision` to the raw `edgezero_cli::run_*`. 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 `{{name}}-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 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 commit 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 eight** built-ins: `Build`, `Deploy`, `Demo`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/demo/new/serve/auth/provision` to the raw `edgezero_cli::run_*`. `use {{name}}_core::{{NameUpperCamel}}Config;` and 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 `{{name}}-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 2:** Extend the generator structure test (from Task 1.4 / 3.4): the scaffolded `-cli/src/main.rs` contains `Auth`, `Provision`, and `Config` variants and references the typed config functions with the project's config type. +- [ ] **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 3: 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 eight commands. +- [ ] **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 eight commands **and** resolves `{{NameUpperCamel}}Config` from its core crate. ### Task 8.3: CI wiring for the `app-demo` loop From df7751e69a2c17143c45a14533575d5645a15e37 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 12:08:27 -0700 Subject: [PATCH 18/38] Plan: generator context for config type name + validator workspace seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generated config type placeholder (Medium): Task 3.4 step 1 now explicitly adds a `NameUpperCamel` key to the generator Handlebars context (derived from `name`: split on -/_, upper-case each segment, join — `my-app` -> `MyApp`), with a unit test. Templates reference `{{NameUpperCamel}}Config`; the key was previously unset (generator data only had name/proj_core/proj_core_mod/proj_mod). - validator workspace-dep plumbing (Medium/Low): Task 3.4 step 3 now names the generator change explicitly — `templates/core/Cargo.toml.hbs` uses `validator = { workspace = true }`, so `validator` must also be added to the generator's workspace-dependency seed (`seed_workspace_dependencies` in generator.rs), which omits it today. - Duplicate Step 4 in Task 3.4 (Low): Task 3.4 renumbered cleanly to Steps 1-6. --- docs/superpowers/plans/2026-05-20-cli-extensions.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 22a5281..c8237fc 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -455,15 +455,17 @@ Task 3.4 / 3.5.) - 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:** `app/.toml.hbs` — a `[config]` table with `greeting` and a nested `[config.service]` section. `core/src/config.rs.hbs` — `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 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 commit 8 reuses the same key. The generator's Handlebars data today exposes only `name`, `proj_core`, `proj_core_mod`, `proj_mod` (`generator.rs`). Add a stable derivation: split `name` on `-` and `_`, upper-case the first letter of each segment and lower-case the rest, then join — insert it under the context key `NameUpperCamel`. Add a unit test for the exact rule: `my-app` → `MyApp`, `foo` → `Foo`, `a_b-c` → `ABC`. This key lands here in commit 3 because `config.rs.hbs` is its first consumer; commit 8's `templates/cli/` reuses it. -- [ ] **Step 2: Update `templates/core/Cargo.toml.hbs` deps.** 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 = { ... , features = ["derive"] }` to `templates/core/Cargo.toml.hbs` (it currently lacks it); confirm `serde` has `features = ["derive"]`. Use whatever version/workspace-pin convention the existing template deps use. +- [ ] **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:** Render both in `generate_new`; register in `scaffold.rs`. +- [ ] **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: Write/extend the generator test** to assert `.toml` and `-core/src/config.rs` are produced **and** that the generated `-core` builds (the `validator` dep resolves and `edgezero_core::AppConfig` is in scope) — `cargo check -p -core` in the scaffolded project. +- [ ] **Step 4:** Render both new templates in `generate_new`; register them in `scaffold.rs`. -- [ ] **Step 4: Run** the generator test — PASS. +- [ ] **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 From 82be804717cfdc9aa34a9b74a4b0222e62db2266 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 13:22:47 -0700 Subject: [PATCH 19/38] Plan: fix generated-CLI import path + guarantee valid NameUpperCamel ident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generated CLI import (Medium): the cli template's `use` must reference the core crate's Rust module name, not the package name. `use {{name}}_core::...` renders `my-app_core` for `my-app` (invalid Rust). Task 8.2 now uses `{{proj_core_mod}}` — the hyphen-to- underscore module form the generator already exposes. - NameUpperCamel validity (Medium/Low): Task 3.4 step 1 derivation now guarantees a valid Rust type identifier — derive from the sanitized crate name, drop empty segments (absorbs a leading `_`), and prefix with `App` when the result would start with a non-letter (digit- leading project names). Unit test covers `123-app` -> `App123App`, `_foo` -> `Foo`, etc. --- docs/superpowers/plans/2026-05-20-cli-extensions.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index c8237fc..2e0bddb 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -455,7 +455,15 @@ Task 3.4 / 3.5.) - 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 commit 8 reuses the same key. The generator's Handlebars data today exposes only `name`, `proj_core`, `proj_core_mod`, `proj_mod` (`generator.rs`). Add a stable derivation: split `name` on `-` and `_`, upper-case the first letter of each segment and lower-case the rest, then join — insert it under the context key `NameUpperCamel`. Add a unit test for the exact rule: `my-app` → `MyApp`, `foo` → `Foo`, `a_b-c` → `ABC`. This key lands here in commit 3 because `config.rs.hbs` is its first consumer; commit 8's `templates/cli/` reuses it. +- [ ] **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 commit 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 commit 3 because `config.rs.hbs` is its first consumer; commit 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). @@ -671,7 +679,7 @@ 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 commit 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 eight** built-ins: `Build`, `Deploy`, `Demo`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/demo/new/serve/auth/provision` to the raw `edgezero_cli::run_*`. `use {{name}}_core::{{NameUpperCamel}}Config;` and 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 `{{name}}-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 2:** Update `templates/cli/src/main.rs.hbs` so the generated `Cmd` enum lists **all eight** built-ins: `Build`, `Deploy`, `Demo`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/demo/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. From 166c2dfab1e69574fa79869b39f27d2b288cd15e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 13:26:54 -0700 Subject: [PATCH 20/38] Fixed formatting --- .claude/settings.json | 36 ++++++------- .gitignore | 3 -- .../plans/2026-05-20-cli-extensions.md | 4 +- .../specs/2026-05-19-cli-extensions-design.md | 52 +++++++++---------- 4 files changed, 46 insertions(+), 49 deletions(-) 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/.gitignore b/.gitignore index e25d20e..27607f6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,6 @@ target/ # Worktrees .worktrees/ -# Superpowers plans -docs/superpowers/ - # Editors .claude/* !.claude/settings.json diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 2e0bddb..632061a 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -416,7 +416,7 @@ Spec §9, §6.7, §6.8, §6.10. **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 +`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 @@ -514,6 +514,7 @@ Spec §10. New: `ConfigValidateArgs`, `run_config_validate`, `run_config_validat ### 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 @@ -669,6 +670,7 @@ Spec §15, §6.12. ### 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) Commit 1 created the `-cli` template with only the five base diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index ffa1033..e309e80 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -56,7 +56,7 @@ 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 + 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 @@ -535,7 +535,7 @@ 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. +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 @@ -694,22 +694,22 @@ 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 commit's definition-of-done** — a commit that changes user-facing behaviour -updates the affected `docs/guide/` pages *in the same commit*, so the +updates the affected `docs/guide/` pages _in the same commit_, so the PR never has a docs-lag window. The docs CI (ESLint + Prettier on `docs/`) must pass. Affected existing pages and the commit that owns each update: -| Page | What changes | Commit | -|------|--------------|--------| -| `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 | +| Page | What changes | Commit | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `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 commit): @@ -804,7 +804,7 @@ API are coupled; with a hard cutoff they ship together as one commit `Kv` / `Secrets` / `Config` extractors. Commit 2 does **not** introduce `AppDemoConfig` or any typed-app-config handler work: that type is created in commit 3 (§9), and `examples/app-demo/ - app-demo.toml` does not exist yet. This keeps commit 2 +app-demo.toml` does not exist yet. This keeps commit 2 independently buildable — no commit-2 code references a type that lands in commit 3. - **`docs/guide/manifest-store-migration.md`** published. @@ -825,12 +825,12 @@ registry test. **Bisectability — config seeding before `config push` exists.** Commit 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 commit 7, and `edgezero demo`'s +_writes_ that file) does not land until commit 7, and `edgezero demo`'s auto-regeneration of the file depends on the commit-3 loader and the commit-7 resolve-and-write step. So between commit 2 and commit 7: - The axum config store's backing-file **contract** is what commit 2 - establishes; commit 2 does not need anything to *produce* the file. + establishes; commit 2 does not need anything to _produce_ the file. - Commit 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`. @@ -859,7 +859,7 @@ and `-core/src/config.rs` (with `#[serde(deny_unknown_fields)]`); **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 +`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)]` — @@ -867,7 +867,7 @@ secret pattern. It does **not** include `#[secret(store_ref)]` — (§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 +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. @@ -917,7 +917,7 @@ additional Spin checks (all per §6.7): 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 +.component`) — **typed and raw** (manifest-based, no struct needed). Manifest: `ManifestLoader` checks; under `--strict`, capability-aware @@ -990,10 +990,10 @@ provisioned by the Spin runtime / Fermyon at deploy). `provision 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 + 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 +--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. @@ -1037,11 +1037,11 @@ Push is **split by adapter** — there is no single "resource-ID" model: | 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 | 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: +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 = ""`. @@ -1052,7 +1052,7 @@ is not readable by a component unless it is both *declared* and 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* +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 @@ -1255,7 +1255,7 @@ writeback across four adapters (`wrangler.toml`, `fastly.toml`, - **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. +.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 From 1d582dd26de6df1d8651ba979bcebbc8e8e2b26b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 14:12:29 -0700 Subject: [PATCH 21/38] Commit 1: extensible edgezero-cli library + generator + app-demo-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn edgezero-cli into lib + bin so downstream projects can build their own CLI binary reusing any subset of the built-in commands. - Promote Command variant fields into standalone #[derive(clap::Args)] structs (BuildArgs / DeployArgs / ServeArgs; NewArgs already standalone), each #[non_exhaustive] + Default for external construction. - Add src/lib.rs exposing the public API: run_build / run_deploy / run_serve / run_new / run_demo, init_cli_logger, and the args module (pub mod, not pub use — restriction lint). main.rs becomes a thin wrapper over the library. - Rename the `dev` subcommand to `demo` (dev is reserved for a future dev-workflow command): dev_server.rs -> demo_server.rs, run_dev -> run_demo (now Result<(), String>), Command::Dev -> Command::Demo. - Extend the generator to scaffold a crates/-cli crate from new templates/cli/ Handlebars templates; seed clap + edgezero-cli as workspace dependencies; add crates/-cli to the workspace members. - Add the handwritten examples/app-demo/crates/app-demo-cli crate as the canonical downstream consumer, with a --help smoke test. - Add crates/edgezero-cli/tests/lib_consumer.rs: external-consumer integration test proving the public API is usable from outside. - Docs: cli-reference.md (demo rename + "Building Your Own CLI"), getting-started.md, CLAUDE.md. All gates green: fmt, clippy -D warnings, cargo test --workspace, feature cargo check, spin wasm32; app-demo workspace fmt/clippy/test. --- CLAUDE.md | 6 +- crates/edgezero-cli/src/args.rs | 68 ++- .../src/{dev_server.rs => demo_server.rs} | 31 +- crates/edgezero-cli/src/generator.rs | 151 +++++-- crates/edgezero-cli/src/lib.rs | 410 ++++++++++++++++++ crates/edgezero-cli/src/main.rs | 392 +---------------- crates/edgezero-cli/src/scaffold.rs | 13 + .../src/templates/cli/Cargo.toml.hbs | 13 + .../src/templates/cli/src/main.rs.hbs | 45 ++ .../src/templates/root/Cargo.toml.hbs | 1 + crates/edgezero-cli/tests/lib_consumer.rs | 68 +++ docs/guide/cli-reference.md | 54 ++- docs/guide/getting-started.md | 7 +- examples/app-demo/Cargo.lock | 343 ++++++++++++++- examples/app-demo/Cargo.toml | 3 + .../app-demo/crates/app-demo-cli/Cargo.toml | 14 + .../app-demo/crates/app-demo-cli/src/main.rs | 46 ++ .../crates/app-demo-cli/tests/help.rs | 28 ++ 18 files changed, 1231 insertions(+), 462 deletions(-) rename crates/edgezero-cli/src/{dev_server.rs => demo_server.rs} (76%) create mode 100644 crates/edgezero-cli/src/lib.rs create mode 100644 crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs create mode 100644 crates/edgezero-cli/src/templates/cli/src/main.rs.hbs create mode 100644 crates/edgezero-cli/tests/lib_consumer.rs create mode 100644 examples/app-demo/crates/app-demo-cli/Cargo.toml create mode 100644 examples/app-demo/crates/app-demo-cli/src/main.rs create mode 100644 examples/app-demo/crates/app-demo-cli/tests/help.rs diff --git a/CLAUDE.md b/CLAUDE.md index 849b738..304f26d 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 dev-example -- demo # Docs site cd docs && npm ci && npm run dev diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 9256233..d010327 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -10,30 +10,42 @@ 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 example app locally on the axum demo server. + 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), } +/// 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)] pub struct NewArgs { /// Directory to create the app in (default: current dir). @@ -46,10 +58,26 @@ pub struct NewArgs { 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 missing_required_adapter_returns_error() { Args::try_parse_from(["edgezero", "build"]).expect_err("missing --adapter"); @@ -67,10 +95,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"); }; diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/demo_server.rs similarity index 76% rename from crates/edgezero-cli/src/dev_server.rs rename to crates/edgezero-cli/src/demo_server.rs index ceac39d..6a5328c 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/demo_server.rs @@ -26,33 +26,36 @@ struct EchoParams { name: String, } -pub fn run_dev() { +/// Run the example app locally on the axum demo server. +/// +/// Returns `Ok(())` on graceful shutdown, `Err` on startup failure. +pub fn run_demo() -> Result<(), String> { match try_run_manifest_axum() { - Ok(true) => return, + Ok(true) => return Ok(()), Ok(false) => {} - Err(err) => log::error!("[edgezero] dev manifest error: {err}"), + Err(err) => log::error!("[edgezero] demo manifest error: {err}"), } let addr = SocketAddr::from(([127, 0, 0, 1], 8787)); log::info!( - "[edgezero] dev: starting local server on http://{}:{}", + "[edgezero] demo: starting local server on http://{}:{}", addr.ip(), addr.port() ); - let router = build_dev_router(); + let router = build_demo_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}"); - } + server + .run() + .map_err(|err| format!("demo server error: {err}")) } -fn build_dev_router() -> RouterService { +fn build_demo_router() -> RouterService { #[cfg(feature = "dev-example")] { let demo_app = App::build_app(); @@ -68,20 +71,20 @@ fn build_dev_router() -> RouterService { #[cfg(not(feature = "dev-example"))] fn default_router() -> RouterService { RouterService::builder() - .get("/", dev_root) - .get("/echo/{name}", dev_echo) + .get("/", demo_root) + .get("/echo/{name}", demo_echo) .build() } #[cfg(not(feature = "dev-example"))] #[action] -async fn dev_root() -> Text<&'static str> { - Text::new("EdgeZero dev server") +async fn demo_root() -> Text<&'static str> { + Text::new("EdgeZero demo server") } #[cfg(not(feature = "dev-example"))] #[action] -async fn dev_echo(Path(params): Path) -> Text { +async fn demo_echo(Path(params): Path) -> Text { Text::new(format!("hello {}", params.name)) } diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 979bfd5..6e3b137 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, @@ -124,12 +133,14 @@ pub fn generate_new(args: &NewArgs) -> Result<(), GeneratorError> { 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); + let cli_crate_line = resolve_cli_dependency(&layout, &cwd, &mut workspace_dependencies); let adapter_artifacts = collect_adapter_data(&layout, &cwd, &mut workspace_dependencies)?; let mut data_map = build_base_data( &layout, &core_crate_line, + &cli_crate_line, &adapter_artifacts, &workspace_dependencies, ); @@ -163,6 +174,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,6 +206,27 @@ fn seed_workspace_dependencies() -> BTreeMap { deps } +fn resolve_cli_dependency( + layout: &ProjectLayout, + cwd: &Path, + workspace_dependencies: &mut BTreeMap, +) -> String { + let ResolvedDependency { + name, + workspace_line, + crate_line, + } = resolve_dep_line( + &layout.out_dir, + cwd, + "crates/edgezero-cli", + "edgezero-cli = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-cli\" }", + &[], + ); + + workspace_dependencies.entry(name).or_insert(workspace_line); + crate_line +} + fn resolve_core_dependency( layout: &ProjectLayout, cwd: &Path, @@ -429,12 +465,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 +482,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 +584,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,58 +693,67 @@ 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")); - assert!( - cargo_toml.contains("crates/demo-app-adapter-spin"), - "workspace Cargo.toml should include spin adapter" - ); + 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\"")); let manifest = fs::read_to_string(project_dir.join("edgezero.toml")).expect("read edgezero.toml"); @@ -698,25 +763,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,4 +789,25 @@ mod tests { ); } } + + #[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()), + local_core: false, + }; + + 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..cb9c83a --- /dev/null +++ b/crates/edgezero-cli/src/lib.rs @@ -0,0 +1,410 @@ +//! `EdgeZero` CLI library. +//! +//! Exposes the built-in command handlers (`run_build`, `run_deploy`, +//! `run_new`, `run_serve`, `run_demo`) 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. + +#[cfg(feature = "cli")] +mod adapter; +#[cfg(all(feature = "cli", feature = "edgezero-adapter-axum"))] +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 example app locally on the axum demo server. +/// +/// # Errors +/// +/// Returns an error if the demo server fails to start. +#[cfg(all(feature = "cli", feature = "edgezero-adapter-axum"))] +#[inline] +pub fn run_demo() -> Result<(), String> { + demo_server::run_demo() +} + +/// Run the example app locally on the axum demo server. +/// +/// # Errors +/// +/// Always errors: this build was compiled without `edgezero-adapter-axum`. +#[cfg(all(feature = "cli", not(feature = "edgezero-adapter-axum")))] +#[inline] +pub fn run_demo() -> Result<(), String> { + Err( + "edgezero-cli built without `edgezero-adapter-axum`; rebuild with that feature to use `edgezero demo`." + .to_owned(), + ) +} + +#[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] +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/main.rs b/crates/edgezero-cli/src/main.rs index afdde45..de76218 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -1,92 +1,22 @@ -//! `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), + 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 +30,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..b8c044e 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 { @@ -222,6 +233,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}"); } 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..d36231d --- /dev/null +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -0,0 +1,45 @@ +//! {{name}} CLI — built on the `edgezero-cli` library. +//! +//! This binary reuses every built-in `edgezero` command 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), + /// Run the example app locally on the axum demo server. + Demo, + /// 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::Demo => edgezero_cli::run_demo(), + 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/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/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/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 2c0238f..b11c8de 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -50,23 +50,26 @@ my-app/ The scaffolder includes all adapters registered at CLI build time. -### edgezero dev +### edgezero demo -Start the local development server: +Run the example app locally on the axum demo server: ```bash -edgezero dev +edgezero demo ``` **Example:** ```bash -edgezero dev +edgezero 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). +If `edgezero.toml` defines an Axum adapter command, `edgezero demo` delegates to it. Otherwise it +starts the built-in demo server (default routes). + +> The subcommand is named `demo` — the name `dev` is reserved for a future +> dev-workflow command. ### edgezero build @@ -220,6 +223,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 built-in +command is exposed as a `(*Args, run_*)` pair (`BuildArgs` / `run_build`, +`DeployArgs` / `run_deploy`, `NewArgs` / `run_new`, `ServeArgs` / `run_serve`, +`run_demo`), 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..00f56a4 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -28,17 +28,18 @@ 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 - `edgezero.toml` - Manifest describing routes, middleware, and adapter config -## Start the Dev Server +## Start the Demo Server -Run the local Axum-powered development server: +Run the example app locally on the axum demo server: ```bash -edgezero dev +edgezero demo ``` Your app is now running at `http://127.0.0.1:8787`. Try the generated endpoints: 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-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..859a3ee --- /dev/null +++ b/examples/app-demo/crates/app-demo-cli/src/main.rs @@ -0,0 +1,46 @@ +//! `app-demo` CLI — built on the `edgezero-cli` library. +//! +//! Reuses every built-in `edgezero` command 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), + /// Run the example app locally on the axum demo server. + Demo, + /// 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::Demo => edgezero_cli::run_demo(), + 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..41a0edc --- /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 every built-in command. + +#[cfg(test)] +mod tests { + use std::process::Command; + + #[test] + fn help_lists_all_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", "demo", "new", "serve"] { + assert!( + stdout.contains(command), + "`--help` output should list the `{command}` command" + ); + } + } +} From 06f4b72ddd39aca8bae9239c41035175af2bde30 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 14:20:35 -0700 Subject: [PATCH 22/38] Make `demo` example-only; `serve --adapter axum` runs the axum adapter After the dev->demo rename, `demo` should mean "run the bundled example", not "run the project's axum adapter". Drop `try_run_manifest_axum` (and its `load_manifest_optional` helper) from `demo_server`: `edgezero demo` now always starts the built-in example server on 127.0.0.1:8787 and never reads `edgezero.toml`. `edgezero serve --adapter axum` is now the single, unambiguous way to run a project's axum adapter (it runs `[adapters.axum.commands].serve`). This removes the demo / serve --adapter axum behavioral overlap. Docs updated. --- crates/edgezero-cli/src/demo_server.rs | 46 ++++---------------------- docs/guide/cli-reference.md | 14 +++----- 2 files changed, 11 insertions(+), 49 deletions(-) diff --git a/crates/edgezero-cli/src/demo_server.rs b/crates/edgezero-cli/src/demo_server.rs index 6a5328c..e653d33 100644 --- a/crates/edgezero-cli/src/demo_server.rs +++ b/crates/edgezero-cli/src/demo_server.rs @@ -1,17 +1,10 @@ #![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::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}; @@ -26,19 +19,17 @@ struct EchoParams { name: String, } -/// Run the example app locally on the axum demo server. +/// Run the bundled example app locally on the axum demo server. +/// +/// This always runs the built-in example — it does **not** read +/// `edgezero.toml` or delegate to a project's axum adapter. To run your +/// own project's axum adapter, use `edgezero serve --adapter axum`. /// /// Returns `Ok(())` on graceful shutdown, `Err` on startup failure. pub fn run_demo() -> Result<(), String> { - match try_run_manifest_axum() { - Ok(true) => return Ok(()), - Ok(false) => {} - Err(err) => log::error!("[edgezero] demo manifest error: {err}"), - } - let addr = SocketAddr::from(([127, 0, 0, 1], 8787)); log::info!( - "[edgezero] demo: starting local server on http://{}:{}", + "[edgezero] demo: starting example server on http://{}:{}", addr.ip(), addr.port() ); @@ -87,28 +78,3 @@ async fn demo_root() -> Text<&'static str> { async fn demo_echo(Path(params): Path) -> Text { Text::new(format!("hello {}", params.name)) } - -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/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index b11c8de..5538176 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -52,21 +52,17 @@ The scaffolder includes all adapters registered at CLI build time. ### edgezero demo -Run the example app locally on the axum demo server: - -```bash -edgezero demo -``` - -**Example:** +Run the bundled example app locally on the axum demo server: ```bash edgezero demo # Server starts at http://127.0.0.1:8787 ``` -If `edgezero.toml` defines an Axum adapter command, `edgezero demo` delegates to it. Otherwise it -starts the built-in demo server (default routes). +`edgezero demo` always runs the built-in example — it does not read `edgezero.toml` +or delegate to your project's 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. From 3f6151d4d728d40f2397dc383569c16d64e9134b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 14:28:53 -0700 Subject: [PATCH 23/38] Plan: mark Commit 1 done, fix stale branch/path, expand Fastly in Commit 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commit 1 marked DONE (landed 1d582dd + follow-up 06f4b72) with a Status section, so workers don't redo already-landed work. - Working-branch reference corrected: feature/extensible-cli (was the stale docs/extensible-cli-library-spec). - app-demo edgezero-cli dep path fixed to ../../crates/edgezero-cli (relative to the workspace manifest; the four-up path was wrong and would break the demo workspace). - Task 2.7 Fastly step expanded from one line to explicit per-kind registry steps + contract tests: Fastly is Multi for KV/config/ secrets, two logical stores per kind, per-id name resolution, id-keyed contract coverage under Viceroy — parity with the cloudflare/spin acceptance criteria. --- .../plans/2026-05-20-cli-extensions.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 632061a..2716f0f 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -15,7 +15,15 @@ ## Preconditions (do before commit 2) - [ ] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** The current branch has **no** Spin store support — `crates/edgezero-adapter-spin/src/` has no `config_store.rs` / `key_value_store.rs` / `secret_store.rs`, and `lib.rs` explicitly rejects `[stores.*]` for spin. Commit 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime; they must exist first. Commit 1 does **not** need PR #253. Verify with: `ls crates/edgezero-adapter-spin/src/` shows the three store files before starting commit 2. -- [ ] Working on branch `docs/extensible-cli-library-spec` (or a fresh feature branch off it). The spec lives in `docs/superpowers/`, which is gitignored — keep using `git add -f` for spec/plan files only. +- [ ] 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 + +- **Commit 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. +- **Commits 2–8 — pending.** Commit 2 is gated on PR #253. ## Codebase facts this plan relies on @@ -98,7 +106,7 @@ docs/.vitepress/config.mts # M (commits 2, 8): sidebar --- -# Commit 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton +# Commit 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. @@ -196,7 +204,7 @@ Expected: `cargo check --workspace` in the generated project succeeds. - 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]`. +- [ ] **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 }`. @@ -334,7 +342,13 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit - [ ] **Step 2: cloudflare.** KV registry. **Config rewritten from `[vars]` to KV** (§6.9) — `CloudflareConfigStore` does an async `env..get(key)`; one namespace per config id. Secrets from worker secrets (Single). -- [ ] **Step 3: fastly.** KV / config / secret store registries (all `Multi`). +- [ ] **Step 3: fastly.** Fastly is `Multi` for **all three** kinds (KV, config, secrets) — the only adapter that is. Build a `StoreRegistry` per kind from `[adapters.fastly.stores..*]`: + - **KV:** one Fastly KV store per logical id, opened by the per-id `name`. The existing `FastlyKvStore` is constructed once per id; the registry maps `` → handle. + - **Config:** one Fastly config store per logical id, opened by the per-id `name`. The existing `FastlyConfigStore` becomes per-id; `get` stays async after the §6.4 trait change. + - **Secrets:** one Fastly secret store per logical id, opened by the per-id `name`. + - For every kind, an absent per-id `name` mapping is already a manifest-validation error (§6.6); the adapter setup can rely on each declared id having a `name`. + - Resolution: at request setup the adapter reads the `Hooks` store metadata, opens each `(kind, id)` Fastly resource by its `name`, and inserts the three `StoreRegistry` values into the context. + - **Tests:** the Fastly contract suite must cover **two logical stores of each kind** (e.g. `[stores.kv] ids = ["a", "b"]`) and assert `ctx.kv_store("a")` / `ctx.kv_store("b")` resolve to distinct stores, `ctx.kv_store("missing")` is `None`, and `kv_store_default()` resolves the manifest default — same id-keyed contract-factory shape as the other adapters (Step 5). Run under Viceroy on `wasm32-wasip1`. - [ ] **Step 4: spin.** Wire `SpinKvStore` (label registry, honor `max_list_keys`, return `KvError::LimitExceeded` past the cap, `KvError::Unsupported` for TTL writes), `SpinConfigStore` (single flat-variable store, `.`→`__` lowercase key translation), `SpinSecretStore` (single flat-variable store, `store_name` ignored). Stop rejecting `[stores.*]` for spin in `lib.rs`. Labels come from `[adapters.spin.stores.kv.*].name`. From e5a9a4f22b438f2430243778e593b297e4db9f92 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 14:56:53 -0700 Subject: [PATCH 24/38] Spec: document the namespaced args API (edgezero_cli::args::*) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 1 shipped `pub mod args` rather than crate-root re-exports: a root `pub use args::{...}` trips clippy::pub_use (the restriction group is -D-denied workspace-wide). §4 now documents the supported API as edgezero_cli::args::BuildArgs etc., with run_* staying at the crate root, and updates every run_* signature to &args::. Matches what 1d582dd actually exposes and what lib_consumer.rs / cli-reference.md already use. (Reviewer's second finding — demo overlapping serve --adapter axum in 1d582dd — was already resolved by 06f4b72; no action.) --- .../specs/2026-05-19-cli-extensions-design.md | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index e309e80..5663946 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -149,34 +149,42 @@ Key contracts: ## 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") -pub use args::{ - AuthArgs, AuthSub, BuildArgs, ConfigPushArgs, ConfigValidateArgs, - DeployArgs, NewArgs, ProvisionArgs, ServeArgs, -}; +/// 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: &BuildArgs) -> Result<(), String>; -pub fn run_deploy(args: &DeployArgs) -> Result<(), String>; -pub fn run_new(args: &NewArgs) -> Result<(), String>; -pub fn run_serve(args: &ServeArgs) -> Result<(), String>; +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: &AuthArgs) -> Result<(), String>; -pub fn run_provision(args: &ProvisionArgs) -> Result<(), String>; +pub fn run_auth(args: &args::AuthArgs) -> Result<(), String>; +pub fn run_provision(args: &args::ProvisionArgs) -> Result<(), String>; -pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String>; -pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> 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: &ConfigPushArgs) -> Result<(), String>; -pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> +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 247cb74680a736111fcb4cdb9593d4aab02546c9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 19:06:13 -0700 Subject: [PATCH 25/38] demo: run app-demo via run_app for full manifest setup `edgezero demo` now delegates to `edgezero_adapter_axum::dev_server::run_app`, running the bundled app-demo example the same way its own axum adapter does. This wires the complete manifest setup (routing, KV/config/secret stores, logging, host/port) instead of a hand-rolled echo router. The demo path requires the `dev-example` feature; without it `run_demo` returns an actionable error. --- crates/edgezero-cli/src/demo_server.rs | 112 +++++++------------------ 1 file changed, 29 insertions(+), 83 deletions(-) diff --git a/crates/edgezero-cli/src/demo_server.rs b/crates/edgezero-cli/src/demo_server.rs index 14411e0..2128562 100644 --- a/crates/edgezero-cli/src/demo_server.rs +++ b/crates/edgezero-cli/src/demo_server.rs @@ -1,94 +1,40 @@ #![cfg(feature = "edgezero-adapter-axum")] -use std::env; -use std::net::SocketAddr; - -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; -use edgezero_core::addr; -use edgezero_core::router::RouterService; - -#[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, -} - -/// Run the bundled example app locally on the axum demo server. +//! 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). The example is only +//! compiled in under the `dev-example` feature. + +/// 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`. /// -/// This always runs the built-in example — it does **not** read -/// `edgezero.toml` or delegate to a project's axum adapter. To run your -/// own project's axum adapter, use `edgezero serve --adapter axum`. +/// # Errors /// -/// Returns `Ok(())` on graceful shutdown, `Err` on startup failure. +/// Returns an error if the demo server fails to start. +#[cfg(feature = "dev-example")] pub fn run_demo() -> Result<(), String> { - let addr = resolve_demo_addr(); - log::info!( - "[edgezero] demo: starting example server on http://{}:{}", - addr.ip(), - addr.port() - ); - - let router = build_demo_router(); - let config = AxumDevServerConfig { - addr, - ..AxumDevServerConfig::default() - }; + use app_demo_core::App; + use edgezero_adapter_axum::dev_server::run_app; - let server = AxumDevServer::with_config(router, config); - server - .run() + run_app::(include_str!("../../../examples/app-demo/edgezero.toml")) .map_err(|err| format!("demo server error: {err}")) } -/// Resolve the demo server bind address from `EDGEZERO_HOST` / -/// `EDGEZERO_PORT` environment variables, falling back to `127.0.0.1:8787`. -fn resolve_demo_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 build_demo_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("/", demo_root) - .get("/echo/{name}", demo_echo) - .build() -} - -#[cfg(not(feature = "dev-example"))] -#[action] -async fn demo_root() -> Text<&'static str> { - Text::new("EdgeZero demo server") -} - +/// Stand-in for builds without the `dev-example` feature. +/// +/// # Errors +/// +/// Always errors: the `app-demo` example is not bundled in this build. #[cfg(not(feature = "dev-example"))] -#[action] -async fn demo_echo(Path(params): Path) -> Text { - Text::new(format!("hello {}", params.name)) +pub fn run_demo() -> Result<(), String> { + Err( + "edgezero demo requires the `dev-example` feature (the app-demo example is not bundled in this build); rebuild with `--features dev-example`." + .to_owned(), + ) } From 0b0c914f185aba3b8a7e6cd810b7a1dd426f1831 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 19:22:21 -0700 Subject: [PATCH 26/38] Plan/spec: rename "Commit N" to "Stage N" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight numbered work units are now "stages" rather than "commits" — each stage may span multiple git commits. Literal git-commit actions (commit steps, `git commit -m`, the PR head commit) keep the "commit" wording. --- .../plans/2026-05-20-cli-extensions.md | 134 +++++++++--------- .../specs/2026-05-19-cli-extensions-design.md | 118 +++++++-------- 2 files changed, 126 insertions(+), 126 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 2716f0f..e7f3c68 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -4,7 +4,7 @@ **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 commits. Commit 1 extracts the CLI library substrate. Commit 2 is an atomic manifest + runtime rewrite (hard cutoff — no backward compatibility). Commits 3–7 add app-config and the four commands. Commit 8 makes `app-demo` the full-capability showcase and audits docs. +**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. @@ -12,18 +12,18 @@ --- -## Preconditions (do before commit 2) +## Preconditions (do before stage 2) -- [ ] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** The current branch has **no** Spin store support — `crates/edgezero-adapter-spin/src/` has no `config_store.rs` / `key_value_store.rs` / `secret_store.rs`, and `lib.rs` explicitly rejects `[stores.*]` for spin. Commit 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime; they must exist first. Commit 1 does **not** need PR #253. Verify with: `ls crates/edgezero-adapter-spin/src/` shows the three store files before starting commit 2. +- [ ] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** The current branch has **no** Spin store support — `crates/edgezero-adapter-spin/src/` has no `config_store.rs` / `key_value_store.rs` / `secret_store.rs`, and `lib.rs` explicitly rejects `[stores.*]` for spin. Stage 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime; they must exist first. Stage 1 does **not** need PR #253. Verify with: `ls crates/edgezero-adapter-spin/src/` shows the three store files before starting stage 2. - [ ] 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 -- **Commit 1 — DONE.** Landed as `1d582dd` (extensible `edgezero-cli` +- **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. -- **Commits 2–8 — pending.** Commit 2 is gated on PR #253. +- **Stages 2–8 — pending.** Stage 2 is gated on PR #253. ## Codebase facts this plan relies on @@ -54,59 +54,59 @@ 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 (commands in Task 2.7 step 6), `cd examples/app-demo && cargo test`, and — for doc changes — the docs -ESLint/Prettier job. Each commit's final task runs the full gate before +ESLint/Prettier job. Each stage's final task runs the full gate before its `git commit`. -## File structure (created / modified across the 8 commits) +## 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 (commit 1): public API - src/main.rs # M (commit 1): thin wrapper; M (4-7): dispatch arms for new commands + 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 (commit 1): renamed from dev_server.rs - src/runner.rs # C (commit 5): CommandSpec + CommandRunner - src/auth.rs # C (commit 5) - src/provision.rs # C (commit 6) - src/config.rs # C (commit 7): validate + push - src/generator.rs # M (commits 1, 3): scaffold -cli, .toml - src/templates/cli/ # C (commit 1); M (commit 8): full command set - src/templates/app/ # C (commit 3) - src/templates/root/edgezero.toml.hbs # M (commit 2): new store schema - src/templates/core/src/config.rs.hbs # C (commit 3) - tests/lib_consumer.rs # C (commit 1) + 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 (commit 2): store schema rewrite + capability rules - config_store.rs # M (commit 2): async trait - key_value_store.rs # M (commit 2): KvError::Unsupported + LimitExceeded - secret_store.rs # M (commit 2): bound-handle wrapper - context.rs # M (commit 2): id-keyed Bound*Store accessors - extractor.rs # M (commit 2): Kv/Secrets/Config default()/named() - app.rs # M (commit 2): Hooks + id-keyed ConfigStoreMetadata (Hooks lives in app.rs, no separate hooks.rs) - app_config.rs # C (commit 3) + 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 (commit 3): AppConfig derive export - app_config.rs # C (commit 3): derive impl - app.rs # M (commit 2): emit id-keyed metadata + 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 (commit 2): multi-store registries + {config_store,key_value_store,secret_store}.rs # M (stage 2): multi-store registries examples/app-demo/ - Cargo.toml # M (commit 1): add app-demo-cli member - edgezero.toml # M (commit 2): new schema - app-demo.toml # C (commit 3) - crates/app-demo-cli/ # C (commit 1, extended 4-8) - crates/app-demo-core/src/config.rs # C (commit 3) - crates/app-demo-core/src/handlers.rs # M (commits 2, 8) + 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 (commit 2) -docs/guide/cli-walkthrough.md # C (commit 8) -docs/.vitepress/config.mts # M (commits 2, 8): sidebar +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 ``` --- -# Commit 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton ✅ DONE (`1d582dd`, `06f4b72`) +# 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. @@ -133,7 +133,7 @@ fn build_args_default_and_mutate() { - [ ] **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 — commit 1 lands as one commit after Task 1.7. Stage progress only. +- [ ] **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` @@ -226,7 +226,7 @@ Expected: `cargo check --workspace` in the generated project succeeds. - [ ] **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: Commit-1 documentation + commit +### Task 1.7: Stage-1 documentation + commit **Files:** @@ -245,9 +245,9 @@ git commit -m "Extensible edgezero-cli library + generator + app-demo-cli; renam --- -# Commit 2 — Manifest + runtime rewrite (atomic, all four adapters) +# Stage 2 — Manifest + runtime rewrite (atomic, all four adapters) -Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit and the review hotspot. Hard cutoff — legacy store schema is removed outright. +Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest stage and the review hotspot. Hard cutoff — legacy store schema is removed outright. ### Task 2.1: Rewrite the manifest store schema @@ -338,7 +338,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit - Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{config_store,key_value_store,secret_store}.rs` and each adapter's request-setup code. -- [ ] **Step 1: axum.** Build `StoreRegistry` for each kind from `[adapters.axum.stores.*]`. KV stays `PersistentKvStore` (redb) — **one separate redb file per logical id**, file stem from the per-adapter mapping `[adapters.axum.stores.kv.].name`: `.edgezero/kv-.redb`. (Axum KV is `Multi`, so every id has a `name`.) Distinct files prevent multi-store collapsing into one backing file. Config store reads `.edgezero/local-config-.json` (the file commit 7 writes); absent ⇒ empty. Secrets from env vars (Single). +- [ ] **Step 1: axum.** Build `StoreRegistry` for each kind from `[adapters.axum.stores.*]`. KV stays `PersistentKvStore` (redb) — **one separate redb file per logical id**, file stem from the per-adapter mapping `[adapters.axum.stores.kv.].name`: `.edgezero/kv-.redb`. (Axum KV is `Multi`, so every id has a `name`.) Distinct files prevent multi-store collapsing into one backing file. Config store reads `.edgezero/local-config-.json` (the file stage 7 writes); absent ⇒ empty. Secrets from env vars (Single). - [ ] **Step 2: cloudflare.** KV registry. **Config rewritten from `[vars]` to KV** (§6.9) — `CloudflareConfigStore` does an async `env..get(key)`; one namespace per config id. Secrets from worker secrets (Single). @@ -373,7 +373,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit - [ ] **Step 1:** Rewrite `examples/app-demo/edgezero.toml` to the new schema: `[stores.kv] ids = ["sessions","cache"]\ndefault = "sessions"`; one config id (`app_config`); one secrets id (`default`); per-adapter `[adapters..stores.kv.]` blocks for axum/cloudflare/fastly/spin; no Spin per-id blocks for config/secrets (Single). Remove `[stores.config.defaults]`. -- [ ] **Step 2:** Migrate `app-demo` handlers to id-keyed accessors — **store-accessor change only** (`ctx.kv_store("sessions")`, `ctx.config_store_default()`, the refactored `Kv`/`Secrets`/`Config` extractors). Do **not** introduce `AppDemoConfig` here (commit 3). +- [ ] **Step 2:** Migrate `app-demo` handlers to id-keyed accessors — **store-accessor change only** (`ctx.kv_store("sessions")`, `ctx.config_store_default()`, the refactored `Kv`/`Secrets`/`Config` extractors). Do **not** introduce `AppDemoConfig` here (stage 3). - [ ] **Step 3:** Rewrite `templates/root/edgezero.toml.hbs` to the new schema so `edgezero new` produces a valid manifest. @@ -381,7 +381,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit - [ ] **Step 5: Run** `cd examples/app-demo && cargo test && cargo build --workspace` — green. -### Task 2.9: Commit-2 docs + commit +### Task 2.9: Stage-2 docs + commit **Files:** @@ -395,7 +395,7 @@ Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest commit --- -# Commit 3 — App-config schema, derive macro, env-overlay loader +# Stage 3 — App-config schema, derive macro, env-overlay loader Spec §9, §6.7, §6.8, §6.10. @@ -469,7 +469,7 @@ Task 3.4 / 3.5.) - 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 commit 8 reuses the same key. The generator's Handlebars data today exposes only `name`, `proj_core`, `proj_core_mod`, `proj_mod` (`generator.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). @@ -477,7 +477,7 @@ Task 3.4 / 3.5.) 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 commit 3 because `config.rs.hbs` is its first consumer; commit 8's `templates/cli/` reuses it. + 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). @@ -506,7 +506,7 @@ Task 3.4 / 3.5.) --- -# Commit 4 — `config validate` command +# Stage 4 — `config validate` command Spec §10. New: `ConfigValidateArgs`, `run_config_validate`, `run_config_validate_typed`. @@ -535,7 +535,7 @@ 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 commit 7). +- [ ] **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`). @@ -549,9 +549,9 @@ binary has no app-config struct, so it uses the **raw** functions. - 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 commit 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 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 commit 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 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. @@ -559,7 +559,7 @@ binary has no app-config struct, so it uses the **raw** functions. --- -# Commit 5 — `auth` command (+ `CommandRunner`) +# Stage 5 — `auth` command (+ `CommandRunner`) Spec §11, §6.1. @@ -599,7 +599,7 @@ Spec §11, §6.1. --- -# Commit 6 — `provision` command +# Stage 6 — `provision` command Spec §12, §13 (Fastly contract). @@ -624,7 +624,7 @@ Spec §12, §13 (Fastly contract). --- -# Commit 7 — `config push` command +# Stage 7 — `config push` command Spec §13, §6.4, §6.5. @@ -660,7 +660,7 @@ Spec §13, §6.4, §6.5. --- -# Commit 8 — `app-demo` integration polish + docs audit +# Stage 8 — `app-demo` integration polish + docs audit Spec §15, §6.12. @@ -687,13 +687,13 @@ Spec §15, §6.12. - 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) -Commit 1 created the `-cli` template with only the five base +Stage 1 created the `-cli` template with only the five base built-ins (`auth` / `provision` / `config` did not exist yet). Now that -commits 4–7 have landed them, a freshly-scaffolded project must expose +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 commit 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 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 eight** built-ins: `Build`, `Deploy`, `Demo`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/demo/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. @@ -731,8 +731,8 @@ post-effort built-ins). ## 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 commit's final task. -- **Precondition:** PR #253 is a hard precondition for commit 2 — called out at the top and in the commit-2 header. -- **Bisectability:** each commit ends with a green-gate step before its commit step; commit 1 needs no PR #253; commit 2's axum config tests seed the JSON fixture directly (Task 2.7 step 1 — "absent ⇒ empty"; tests write the file). -- **Known drift risk:** commits 3–8's exact code depends on the `Bound*Store` / `StoreRegistry` shapes finalized in commit 2. Re-read commit 2's actual output before executing each later commit; adjust signatures to match. +- **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 index 5663946..3119f73 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -32,7 +32,7 @@ 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 commits** — one commit +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. @@ -489,21 +489,21 @@ registers it by logical id. `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** — **commit 2 adds + `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 commit 2). The Spin KV contract + 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: commit 2 adds `KvError::LimitExceeded`** (5xx-class + 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. Commit 2 also tests the pagination logic + `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 @@ -695,20 +695,20 @@ Non-subcommand `*Args` derive `Default` (external construction despite 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 commit) +### 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 commit's -definition-of-done** — a commit that changes user-facing behaviour -updates the affected `docs/guide/` pages _in the same commit_, so the +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 commit that owns each update: +Affected existing pages and the stage that owns each update: -| Page | What changes | Commit | +| 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 | @@ -719,17 +719,17 @@ Affected existing pages and the commit that owns each update: | `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 commit): +New pages (created in their owning stage): -- `docs/guide/manifest-store-migration.md` — commit 2 (how to migrate a +- `docs/guide/manifest-store-migration.md` — stage 2 (how to migrate a pre-rewrite `edgezero.toml`). -- `docs/guide/cli-walkthrough.md` — commit 8 (full `myapp` loop). +- `docs/guide/cli-walkthrough.md` — stage 8 (full `myapp` loop). -Commit 8 additionally performs a **documentation audit**: grep the +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 commit 8's ship gate. +sidebar. The audit is a checklist item in stage 8's ship gate. --- @@ -746,14 +746,14 @@ 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. Commit 1 +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 `()`; commit 1 adjusts that boundary. +`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.) @@ -767,8 +767,8 @@ throwaway-app && cargo check --workspace` succeeds. ## 8. Sub-project 2 — Manifest + runtime rewrite (atomic, all four adapters) **Goal:** the big atomic sub-project. Manifest schema and runtime store -API are coupled; with a hard cutoff they ship together as one commit -(commit 2 of the eight-commit PR). +API are coupled; with a hard cutoff they ship together as one stage +(stage 2 of the eight-stage PR). **Scope:** @@ -808,13 +808,13 @@ API are coupled; with a hard cutoff they ship together as one commit (≥2 KV ids `sessions`+`cache`; exactly one config id and one secrets id, as the Spin capability rule requires). `app-demo` handlers are migrated **only for the store-accessor change** in - commit 2 — `ctx.kv_store(id)` / `config_store` / the refactored - `Kv` / `Secrets` / `Config` extractors. Commit 2 does **not** + stage 2 — `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 type is created in commit 3 (§9), and `examples/app-demo/ -app-demo.toml` does not exist yet. This keeps commit 2 - independently buildable — no commit-2 code references a type that - lands in commit 3. + that type is created in stage 3 (§9), and `examples/app-demo/ +app-demo.toml` does not exist yet. This keeps stage 2 + independently buildable — no stage-2 code references a type that + lands in stage 3. - **`docs/guide/manifest-store-migration.md`** published. **Tests:** manifest round-trip + validation (non-empty ids; default @@ -830,25 +830,25 @@ Spin KV listing-cap pagination test (and its error-variant decision, §6.7); `Kv`/`Secrets`/`Config` extractor tests; `app!` macro metadata registry test. -**Bisectability — config seeding before `config push` exists.** Commit +**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 commit 7, and `edgezero demo`'s -auto-regeneration of the file depends on the commit-3 loader and the -commit-7 resolve-and-write step. So between commit 2 and commit 7: +_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 commit 2 - establishes; commit 2 does not need anything to _produce_ the file. -- Commit 2's axum config-store tests **write the JSON fixture file +- 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 commit-2 state: if no fixture file is present the axum +- `app-demo`'s stage-2 state: if no fixture file is present the axum config store is empty (the documented "absent → empty" behaviour). - Any commit-2 `app-demo` test that asserts a config value seeds the + 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 commit 8. + read-back end-to-end test lands in stage 8. -This keeps commit 2 independently buildable and testable. +This keeps stage 2 independently buildable and testable. **Ship gate:** multi-store handlers work on axum, cloudflare, fastly, and spin; async config reads work; all four CI gates green (including @@ -1146,7 +1146,7 @@ timeout_ms` is read at runtime; the Spin path proves `.`→`__` `__`-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 commit 7's tests, not the dry-run assertion. + 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` @@ -1166,11 +1166,11 @@ 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).** Commit 8 finishes with a docs audit: +**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 commit; confirm the docs CI (ESLint + Prettier) passes. +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 @@ -1181,10 +1181,10 @@ references. ## 16. Implementation order and milestones -The whole effort is **a single pull request containing eight commits**, +The whole effort is **a single pull request containing eight stages**, one per sub-project, applied in this order: -| Commit | § | Title | Risk | +| Stage | § | Title | Risk | | ------ | --- | ------------------------------------------------------ | ---- | | 1 | §7 | Extensible lib + scaffold | M | | 2 | §8 | Manifest + runtime rewrite (atomic, all four adapters) | H | @@ -1195,36 +1195,36 @@ one per sub-project, applied in this order: | 7 | §13 | `config push` | M | | 8 | §15 | `app-demo` polish (all four adapters) + docs audit | M | -Every commit also updates the `docs/guide/` pages it makes stale -(§6.12) — documentation is part of each commit's definition-of-done, -not a deferred afterthought. Commit 8 closes with a documentation +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 commits should nonetheless compile and pass tests on its -own so the history stays bisectable — commit boundaries are chosen so -that each is a self-contained, buildable increment. Commit 2 is the one -unavoidably large commit (the atomic manifest+runtime rewrite); the +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 -commits together. The PR description should list the eight commits and -point at this spec. Reviewing commit-by-commit is recommended. -**Commit 2 is the review hotspot** — the atomic manifest+runtime +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:** commit 2 — atomic manifest+runtime rewrite touching the +**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 commit. +`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. -Commit 6 (`provision`) — shell-out + multi-file native-manifest +Stage 6 (`provision`) — shell-out + multi-file native-manifest writeback across four adapters (`wrangler.toml`, `fastly.toml`, `spin.toml`). @@ -1232,10 +1232,10 @@ writeback across four adapters (`wrangler.toml`, `fastly.toml`, - **Hard manifest cutoff:** a pre-rewrite `edgezero.toml` fails to load with a migration-guide error. All in-tree projects migrated in - commit 2; external projects migrate once. -- **Large atomic commit (commit 2):** unavoidable without a + 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 commit, not one PR — the PR carries all eight. + 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. @@ -1253,7 +1253,7 @@ writeback across four adapters (`wrangler.toml`, `fastly.toml`, 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:** commit 2 adds two new `KvError` +- **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` From 4565b30340b6b1f35b87459b79ee633e1f4257ba Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 19:22:50 -0700 Subject: [PATCH 27/38] Formatting --- .../specs/2026-05-19-cli-extensions-design.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 3119f73..5deabf6 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -1184,16 +1184,16 @@ references. 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 | +| 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, From 6463ba106eeb62c7fd6ed1fba5c226c55a92d412 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 20:02:22 -0700 Subject: [PATCH 28/38] Make demo a contributor-only command; rename feature to demo-example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the demo subcommand: - demo is exposed only when built with the new `demo-example` feature. Generated CLIs and app-demo-cli no longer expose `Demo` at all — a downstream project has no bundled app-demo to run. The default `edgezero` binary gates `Command::Demo` on `demo-example`, so the advertised `--help` surface matches what actually works. - `demo-example` (renamed from `dev-example`) now also pulls in `edgezero-adapter-axum`, making the feature self-contained. - getting-started.md points generated projects at `edgezero serve --adapter axum`; cli-reference.md documents `demo` as contributor-only. - NewArgs now derives Default and is #[non_exhaustive], matching the other public *Args structs. - Generated handler tests serialize API_BASE_URL access behind a mutex + RAII env guard. - Refreshed README, CLAUDE.md, architecture docs, and agent docs for the dev->demo / dev-example->demo-example rename. --- .claude/agents/build-validator.md | 2 +- .claude/agents/verify-app.md | 2 +- CLAUDE.md | 2 +- TODO.md | 4 +- crates/edgezero-cli/Cargo.toml | 2 +- crates/edgezero-cli/README.md | 9 ++-- crates/edgezero-cli/src/args.rs | 14 +++++- crates/edgezero-cli/src/demo_server.rs | 23 +++------- crates/edgezero-cli/src/lib.rs | 35 ++++++--------- crates/edgezero-cli/src/main.rs | 1 + .../src/templates/cli/src/main.rs.hbs | 5 +-- .../src/templates/core/src/handlers.rs.hbs | 45 ++++++++++++++++--- docs/guide/architecture.md | 2 +- docs/guide/cli-reference.md | 15 ++++--- docs/guide/getting-started.md | 6 +-- .../app-demo/crates/app-demo-cli/src/main.rs | 5 +-- .../crates/app-demo-cli/tests/help.rs | 6 +-- 17 files changed, 100 insertions(+), 78 deletions(-) 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.md b/CLAUDE.md index 304f26d..7bc481b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ cargo check --workspace --all-targets --features "fastly cloudflare spin" cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin # Run the demo server -cargo run -p edgezero-cli --features dev-example -- demo +cargo run -p edgezero-cli --features demo-example -- demo # Docs site cd docs && npm ci && npm run dev 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-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 801e316..59b212c 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -41,4 +41,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 d010327..b82bc55 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -11,7 +11,8 @@ pub struct Args { pub enum Command { /// Build the project for a target edge. Build(BuildArgs), - /// Run the example app locally on the axum demo server. + /// Run the bundled `app-demo` example locally (contributor-only). + #[cfg(feature = "demo-example")] Demo, /// Deploy to a target edge. Deploy(DeployArgs), @@ -46,7 +47,8 @@ pub struct DeployArgs { } /// Arguments for the `new` command. -#[derive(clap::Args, Debug)] +#[derive(clap::Args, Debug, Default)] +#[non_exhaustive] pub struct NewArgs { /// Directory to create the app in (default: current dir). #[arg(long)] @@ -78,6 +80,14 @@ mod tests { 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()); + assert!(!args.local_core); + } + #[test] fn missing_required_adapter_returns_error() { Args::try_parse_from(["edgezero", "build"]).expect_err("missing --adapter"); diff --git a/crates/edgezero-cli/src/demo_server.rs b/crates/edgezero-cli/src/demo_server.rs index 2128562..a1b89b4 100644 --- a/crates/edgezero-cli/src/demo_server.rs +++ b/crates/edgezero-cli/src/demo_server.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "edgezero-adapter-axum")] +#![cfg(feature = "demo-example")] //! The `edgezero demo` subcommand. //! @@ -6,8 +6,11 @@ //! `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). The example is only -//! compiled in under the `dev-example` feature. +//! 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. /// @@ -17,7 +20,6 @@ /// # Errors /// /// Returns an error if the demo server fails to start. -#[cfg(feature = "dev-example")] pub fn run_demo() -> Result<(), String> { use app_demo_core::App; use edgezero_adapter_axum::dev_server::run_app; @@ -25,16 +27,3 @@ pub fn run_demo() -> Result<(), String> { run_app::(include_str!("../../../examples/app-demo/edgezero.toml")) .map_err(|err| format!("demo server error: {err}")) } - -/// Stand-in for builds without the `dev-example` feature. -/// -/// # Errors -/// -/// Always errors: the `app-demo` example is not bundled in this build. -#[cfg(not(feature = "dev-example"))] -pub fn run_demo() -> Result<(), String> { - Err( - "edgezero demo requires the `dev-example` feature (the app-demo example is not bundled in this build); rebuild with `--features dev-example`." - .to_owned(), - ) -} diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index cb9c83a..82c55ea 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -1,14 +1,18 @@ //! `EdgeZero` CLI library. //! //! Exposes the built-in command handlers (`run_build`, `run_deploy`, -//! `run_new`, `run_serve`, `run_demo`) 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_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 = "edgezero-adapter-axum"))] +#[cfg(all(feature = "cli", feature = "demo-example"))] mod demo_server; #[cfg(feature = "cli")] mod generator; @@ -117,31 +121,20 @@ pub fn run_new(args: &NewArgs) -> Result<(), String> { generator::generate_new(args).map_err(|err| err.to_string()) } -/// Run the example app locally on the axum demo server. +/// 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 = "edgezero-adapter-axum"))] +#[cfg(all(feature = "cli", feature = "demo-example"))] #[inline] pub fn run_demo() -> Result<(), String> { demo_server::run_demo() } -/// Run the example app locally on the axum demo server. -/// -/// # Errors -/// -/// Always errors: this build was compiled without `edgezero-adapter-axum`. -#[cfg(all(feature = "cli", not(feature = "edgezero-adapter-axum")))] -#[inline] -pub fn run_demo() -> Result<(), String> { - Err( - "edgezero-cli built without `edgezero-adapter-axum`; rebuild with that feature to use `edgezero demo`." - .to_owned(), - ) -} - #[cfg(feature = "cli")] fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Option { let manifest_data = manifest.manifest(); diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index de76218..f4a095c 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -10,6 +10,7 @@ fn main() { 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), diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs index d36231d..9278eb2 100644 --- a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -1,6 +1,6 @@ //! {{name}} CLI — built on the `edgezero-cli` library. //! -//! This binary reuses every built-in `edgezero` command via the +//! 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}; @@ -17,8 +17,6 @@ struct Args { enum Cmd { /// Build the project for a target edge. Build(BuildArgs), - /// Run the example app locally on the axum demo server. - Demo, /// Deploy to a target edge. Deploy(DeployArgs), /// Create a new `EdgeZero` app skeleton. @@ -34,7 +32,6 @@ fn main() { let result = match Args::parse().cmd { Cmd::Build(args) => edgezero_cli::run_build(&args), Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), - Cmd::Demo => edgezero_cli::run_demo(), Cmd::New(args) => edgezero_cli::run_new(&args), Cmd::Serve(args) => edgezero_cli::run_serve(&args), }; 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/docs/guide/architecture.md b/docs/guide/architecture.md index 8096b81..5793a59 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -125,7 +125,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 5538176..9fb0177 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -52,17 +52,20 @@ The scaffolder includes all adapters registered at CLI build time. ### edgezero demo -Run the bundled example app locally on the axum demo 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 demo +cargo run -p edgezero-cli --features demo-example -- demo # Server starts at http://127.0.0.1:8787 ``` -`edgezero demo` always runs the built-in example — it does not read `edgezero.toml` -or delegate to your project's adapters. To run **your project's** axum adapter, use -`edgezero serve --adapter axum` (which runs `[adapters.axum.commands].serve` from -`edgezero.toml`). +`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. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 00f56a4..9c697f9 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -34,12 +34,12 @@ This generates a workspace with: - `crates/my-app-adapter-axum` - Native Axum entrypoint - `edgezero.toml` - Manifest describing routes, middleware, and adapter config -## Start the Demo Server +## Run Your App Locally -Run the example app locally on the axum demo server: +Run your generated app on the native Axum adapter: ```bash -edgezero demo +edgezero serve --adapter axum ``` Your app is now running at `http://127.0.0.1:8787`. Try the generated endpoints: diff --git a/examples/app-demo/crates/app-demo-cli/src/main.rs b/examples/app-demo/crates/app-demo-cli/src/main.rs index 859a3ee..4429a49 100644 --- a/examples/app-demo/crates/app-demo-cli/src/main.rs +++ b/examples/app-demo/crates/app-demo-cli/src/main.rs @@ -1,6 +1,6 @@ //! `app-demo` CLI — built on the `edgezero-cli` library. //! -//! Reuses every built-in `edgezero` command via the `edgezero_cli` +//! 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. @@ -18,8 +18,6 @@ struct Args { enum Cmd { /// Build the project for a target edge. Build(BuildArgs), - /// Run the example app locally on the axum demo server. - Demo, /// Deploy to a target edge. Deploy(DeployArgs), /// Create a new `EdgeZero` app skeleton. @@ -35,7 +33,6 @@ fn main() { let result = match Args::parse().cmd { Cmd::Build(args) => edgezero_cli::run_build(&args), Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), - Cmd::Demo => edgezero_cli::run_demo(), Cmd::New(args) => edgezero_cli::run_new(&args), Cmd::Serve(args) => edgezero_cli::run_serve(&args), }; diff --git a/examples/app-demo/crates/app-demo-cli/tests/help.rs b/examples/app-demo/crates/app-demo-cli/tests/help.rs index 41a0edc..8fd393a 100644 --- a/examples/app-demo/crates/app-demo-cli/tests/help.rs +++ b/examples/app-demo/crates/app-demo-cli/tests/help.rs @@ -1,12 +1,12 @@ //! Smoke test: the `app-demo-cli` binary parses its CLI without panicking -//! and `--help` lists every built-in command. +//! and `--help` lists the built-in commands. #[cfg(test)] mod tests { use std::process::Command; #[test] - fn help_lists_all_builtin_commands() { + fn help_lists_builtin_commands() { let output = Command::new(env!("CARGO_BIN_EXE_app-demo-cli")) .arg("--help") .output() @@ -18,7 +18,7 @@ mod tests { ); let stdout = String::from_utf8_lossy(&output.stdout); - for command in ["build", "deploy", "demo", "new", "serve"] { + for command in ["build", "deploy", "new", "serve"] { assert!( stdout.contains(command), "`--help` output should list the `{command}` command" From 8d16ba943b7bc430c5a98bb08e962b65a2283951 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 22:31:05 -0700 Subject: [PATCH 29/38] Fix binary name, stale dev docs, and scaffold drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the Stage 1 surface: - Add a `[[bin]] name = "edgezero"` target so `cargo build` produces `target/debug/edgezero` — the name every doc and the clap `about` already use. - Remove the inert `--local-core` flag from `NewArgs`; it was never read by the generator. - Warn when `edgezero new` falls back to a Git dependency for `edgezero-cli`: the generated CLI crate needs `edgezero-cli` as a published library, so an out-of-repo scaffold only builds once that is available on the referenced remote. In-repo generation uses a path dependency and is unaffected. - Replace removed `edgezero dev` references with `edgezero serve --adapter axum` in the root README, architecture, and axum adapter docs. - Drop `run_demo` from the "build your own CLI" surface (it is contributor-only), and add the generated `*-cli` and Spin adapter crates to the scaffold structure docs. --- README.md | 6 +++--- crates/edgezero-cli/Cargo.toml | 4 ++++ crates/edgezero-cli/src/args.rs | 5 ----- crates/edgezero-cli/src/generator.rs | 11 +++++++++-- docs/guide/adapters/axum.md | 6 +++--- docs/guide/architecture.md | 3 +-- docs/guide/cli-reference.md | 16 ++++++++++------ docs/guide/getting-started.md | 12 ++++++++++-- 8 files changed, 40 insertions(+), 23 deletions(-) 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/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 59b212c..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" } diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index b82bc55..7fd7ee6 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -53,9 +53,6 @@ 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, } @@ -85,7 +82,6 @@ mod tests { let args = NewArgs::default(); assert!(args.name.is_empty()); assert!(args.dir.is_none()); - assert!(!args.local_core); } #[test] @@ -124,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/generator.rs b/crates/edgezero-cli/src/generator.rs index 4cf0a45..4d9d3a6 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -211,6 +211,8 @@ fn resolve_cli_dependency( cwd: &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, @@ -219,10 +221,16 @@ fn resolve_cli_dependency( &layout.out_dir, cwd, "crates/edgezero-cli", - "edgezero-cli = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"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 } @@ -840,7 +848,6 @@ mod tests { 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"); 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 5793a59..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 diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 9fb0177..e70f97a 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -43,12 +43,16 @@ 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 demo @@ -224,11 +228,11 @@ Install the provider CLI: ## Building Your Own CLI -`edgezero-cli` is published as a library as well as a binary. Every built-in +`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`, -`run_demo`), so a downstream project can build its own CLI binary that reuses -any subset of the built-ins and adds its own subcommands: +`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}; diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9c697f9..62b61a3 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -32,6 +32,7 @@ This generates a workspace with: - `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 ## Run Your App Locally @@ -71,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 @@ -79,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 ``` From e29b749fccc70586968546ad1d1648a31d27cf25 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 20 May 2026 23:43:39 -0700 Subject: [PATCH 30/38] Generate path dependencies to the local edgezero checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes fresh `edgezero new` projects failing to build outside the repo. The generated CLI crate imports `edgezero_cli`, but dependency resolution fell back to a Git dependency whenever the output directory was outside the repo root — and the published `edgezero-cli` has no library target, so every `edgezero_cli::...` import failed. - Locate the edgezero checkout via `CARGO_MANIFEST_DIR` (baked in at build time) instead of the current directory, so generation finds the checkout regardless of where the project is created or where the command runs. - When the output directory is outside the checkout, emit an absolute path dependency rather than the Git fallback. The Git fallback now only applies to a binary detached from its source tree. - Assert in the generator test that the scaffold resolves edgezero crates to path dependencies, so a regression to the Git fallback is caught by `cargo test -p edgezero-cli`. - Add an opt-in (`#[ignore]`) integration test that runs `cargo check` on the generated CLI crate, proving it compiles against the local `edgezero-cli` library. - Drop the stale `--local-core` option from the CLI reference docs. --- crates/edgezero-cli/src/generator.rs | 58 +++++++++++++++---- crates/edgezero-cli/src/scaffold.rs | 5 ++ .../tests/generated_project_builds.rs | 43 ++++++++++++++ docs/guide/cli-reference.md | 1 - 4 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 crates/edgezero-cli/tests/generated_project_builds.rs diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 4d9d3a6..8e87422 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -124,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. @@ -131,11 +146,18 @@ 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); - let cli_crate_line = resolve_cli_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, @@ -208,7 +230,7 @@ fn seed_workspace_dependencies() -> BTreeMap { fn resolve_cli_dependency( layout: &ProjectLayout, - cwd: &Path, + 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\" }"; @@ -219,7 +241,7 @@ fn resolve_cli_dependency( crate_line, } = resolve_dep_line( &layout.out_dir, - cwd, + repo_root, "crates/edgezero-cli", CLI_GIT_FALLBACK, &[], @@ -237,7 +259,7 @@ fn resolve_cli_dependency( fn resolve_core_dependency( layout: &ProjectLayout, - cwd: &Path, + repo_root: &Path, workspace_dependencies: &mut BTreeMap, ) -> String { let ResolvedDependency { @@ -246,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 }", &[], @@ -258,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(); @@ -280,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, @@ -325,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, @@ -345,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, @@ -763,6 +785,18 @@ mod tests { 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("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 = fs::read_to_string(project_dir.join("edgezero.toml")).expect("read edgezero.toml"); assert!(manifest.contains("[adapters.cloudflare.adapter]")); diff --git a/crates/edgezero-cli/src/scaffold.rs b/crates/edgezero-cli/src/scaffold.rs index b8c044e..30282a6 100644 --- a/crates/edgezero-cli/src/scaffold.rs +++ b/crates/edgezero-cli/src/scaffold.rs @@ -146,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() } 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..169bd5f --- /dev/null +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -0,0 +1,43 @@ +//! Opt-in integration test: a freshly scaffolded project compiles. +//! +//! Ignored by default — it runs `cargo check` on a generated workspace, +//! which recompiles the edgezero stack (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 CLI crate compiles against the `edgezero-cli` library. +//! +//! Run it explicitly (and in CI): +//! +//! ```sh +//! cargo test -p edgezero-cli --test generated_project_builds -- --ignored +//! ``` + +#[cfg(test)] +mod tests { + use std::process::Command; + + #[test] + #[ignore = "compiles a generated workspace; run explicitly"] + fn generated_cli_crate_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"); + let check_status = Command::new(env!("CARGO")) + .args(["check", "-p", "scaffold-probe-cli", "--offline"]) + .current_dir(&project) + .status() + .expect("run `cargo check` on the generated CLI crate"); + assert!( + check_status.success(), + "generated CLI crate should compile against the local edgezero-cli library", + ); + } +} diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index e70f97a..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:** From cc7ff45a3f17e25f37e5d6dae967618e287756f9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 10:34:18 -0700 Subject: [PATCH 31/38] Verify the full generated workspace compiles, not just the CLI crate Broaden the opt-in scaffold test to `cargo check --workspace` and drop `--offline`: a freshly generated project has no lockfile, so offline resolution of transitive registry crates is unreliable (true of any scaffolded project). Online, the full generated workspace compiles. --- .../tests/generated_project_builds.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs index 169bd5f..cbd5d5f 100644 --- a/crates/edgezero-cli/tests/generated_project_builds.rs +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -1,10 +1,11 @@ //! Opt-in integration test: a freshly scaffolded project compiles. //! //! Ignored by default — it runs `cargo check` on a generated workspace, -//! which recompiles the edgezero stack (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 CLI crate compiles against the `edgezero-cli` library. +//! 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 — including the CLI crate +//! that imports `edgezero_cli` — compiles end to end. //! //! Run it explicitly (and in CI): //! @@ -17,8 +18,8 @@ mod tests { use std::process::Command; #[test] - #[ignore = "compiles a generated workspace; run explicitly"] - fn generated_cli_crate_compiles() { + #[ignore = "compiles a generated workspace and may fetch crates; run explicitly"] + fn generated_workspace_compiles() { let temp = tempfile::tempdir().expect("temp dir"); let new_status = Command::new(env!("CARGO_BIN_EXE_edgezero")) .arg("new") @@ -31,13 +32,13 @@ mod tests { let project = temp.path().join("scaffold-probe"); let check_status = Command::new(env!("CARGO")) - .args(["check", "-p", "scaffold-probe-cli", "--offline"]) + .args(["check", "--workspace"]) .current_dir(&project) .status() - .expect("run `cargo check` on the generated CLI crate"); + .expect("run `cargo check` on the generated workspace"); assert!( check_status.success(), - "generated CLI crate should compile against the local edgezero-cli library", + "generated workspace should compile against the local edgezero crates", ); } } From ca357c4668b37853a446ce610f41d1ac474a8ef6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 10:58:09 -0700 Subject: [PATCH 32/38] Fix generated-README serve command; align plan/spec with 4-command CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adapter README `dev_steps` snippets advised `edgezero-cli serve --adapter ...`, but the binary is `edgezero` (the `edgezero-cli` package builds `target/debug/edgezero`). Corrected all four adapters (axum, cloudflare, fastly, spin) so generated-project READMEs show a working command. - Updated the plan and spec acceptance notes: generated and app-demo CLIs expose the four downstream built-ins (build/deploy/new/serve), not five — `demo` is contributor-only and absent from downstream CLIs. Also corrected the Stage 8 generated-CLI command count. --- crates/edgezero-adapter-axum/src/cli.rs | 2 +- crates/edgezero-adapter-cloudflare/src/cli.rs | 2 +- crates/edgezero-adapter-fastly/src/cli.rs | 2 +- crates/edgezero-adapter-spin/src/cli.rs | 2 +- docs/superpowers/plans/2026-05-20-cli-extensions.md | 12 ++++++------ .../specs/2026-05-19-cli-extensions-design.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) 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-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-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/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index e7f3c68..99a70ee 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -179,7 +179,7 @@ fn build_args_default_and_mutate() { - [ ] **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 all five built-ins (`Build(BuildArgs)`, `Deploy(DeployArgs)`, `Demo`, `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 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. @@ -208,9 +208,9 @@ Expected: `cargo check --workspace` in the generated project succeeds. - [ ] **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 — all five built-ins, no custom subcommands yet. `#[command(name = "app-demo-cli", about = "app-demo edge CLI")]`. +- [ ] **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`, `demo`, `new`, `serve`. +- [ ] **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. @@ -670,7 +670,7 @@ Spec §15, §6.12. - 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 all five 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 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. @@ -695,11 +695,11 @@ 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 eight** built-ins: `Build`, `Deploy`, `Demo`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/demo/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 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 eight commands **and** resolves `{{NameUpperCamel}}Config` from its core crate. +- [ ] **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 diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 5deabf6..83ed3ab 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -761,7 +761,7 @@ is not user-facing and is left as-is.) `app-demo-cli/tests/help.rs`; generator structure test. **Ship gate:** existing `edgezero` commands keep the same flags; -`app-demo-cli --help` shows the five built-ins; `edgezero new +`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) @@ -1107,7 +1107,7 @@ output; secret fields absent; Spin keys `__`-encoded. **Goal:** `app-demo` demonstrates the **full** feature set in CI across all four adapters. -- **Extensible CLI:** `app-demo-cli` with all five built-ins plus +- **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 From 22770723f0e490bd68c1c39332973681c7c5379e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 11:13:45 -0700 Subject: [PATCH 33/38] Wire generated-project compile check into CI; fix stale plan lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a CI step that runs the `generated_project_builds` test (`-- --ignored`), so the Stage 1 scaffold regression — a fresh `edgezero new` project failing to compile — is caught by CI rather than only by manual runs. - Correct two stale Stage 1 plan steps: a default `cargo build -p edgezero-cli` exposes four subcommands, not five; `demo` is gated behind the `demo-example` feature. --- .github/workflows/test.yml | 3 +++ docs/superpowers/plans/2026-05-20-cli-extensions.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 99a70ee..a2817d6 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -150,7 +150,7 @@ fn build_args_default_and_mutate() { - [ ] **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 the same five subcommands (with `demo` instead of `dev`). +- [ ] **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` @@ -165,7 +165,7 @@ fn build_args_default_and_mutate() { - [ ] **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; `./target/debug/edgezero demo --help` works. +- [ ] **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` From 1d9534561c6c3bb3ba7fdfb7adf3d13035df0b6d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 12:25:20 -0700 Subject: [PATCH 34/38] Fix generated wasm adapters and project-name sanitisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 review findings on generated projects: - Cloudflare adapter template called `run_app(req, env, ctx)` but the API takes `manifest_src` first — generated Cloudflare crates failed to compile for wasm32. Aligned the template with the other three adapters and the handwritten app-demo crate. - The Spin `#[http_component]` macro expands to an unsafe wasm export, which trips the generated workspace's `unsafe_code = "deny"` gate. Added a narrow wasm-only `#[allow(unsafe_code)]` with a reason to the Spin entrypoint, in the template and in app-demo. - `sanitize_crate_name` mangled uppercase letters to `-`, so `edgezero new MyApp` produced the invalid package name `-y-pp-core`. It now lower-cases ASCII letters, keeps `-`/`_`, collapses other characters, and trims leading/trailing separators; added unit tests. - The opt-in `generated_project_builds` test only checked the host target. It now also runs `cargo check` for each adapter's wasm target (skipping a target that is not installed), which is where the two failures above lived. Plan: marked PR #253 merged, and recorded two post-review Stage 2 design inputs — downstream binaries must build without an `edgezero.toml`, and the manifest holds only non-adapter-specific config. --- .../src/templates/src/lib.rs.hbs | 8 ++- .../src/templates/src/lib.rs.hbs | 8 +++ crates/edgezero-cli/src/scaffold.rs | 57 ++++++++++++--- .../tests/generated_project_builds.rs | 72 ++++++++++++++++--- .../plans/2026-05-20-cli-extensions.md | 23 +++++- .../crates/app-demo-adapter-spin/src/lib.rs | 8 +++ 6 files changed, 156 insertions(+), 20 deletions(-) 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-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/src/scaffold.rs b/crates/edgezero-cli/src/scaffold.rs index 30282a6..969ee7d 100644 --- a/crates/edgezero-cli/src/scaffold.rs +++ b/crates/edgezero-cli/src/scaffold.rs @@ -177,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 } @@ -254,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/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs index cbd5d5f..2e424c8 100644 --- a/crates/edgezero-cli/tests/generated_project_builds.rs +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -1,11 +1,12 @@ //! Opt-in integration test: a freshly scaffolded project compiles. //! -//! Ignored by default — it runs `cargo check` on a generated workspace, -//! 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 — including the CLI crate -//! that imports `edgezero_cli` — compiles end to end. +//! 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): //! @@ -15,10 +16,28 @@ #[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")) @@ -31,14 +50,49 @@ mod tests { assert!(new_status.success(), "`edgezero new` should succeed"); let project = temp.path().join("scaffold-probe"); - let check_status = Command::new(env!("CARGO")) + + // 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!( - check_status.success(), - "generated workspace should compile against the local edgezero crates", + 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/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index a2817d6..ff53dfa 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -14,7 +14,7 @@ ## Preconditions (do before stage 2) -- [ ] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** The current branch has **no** Spin store support — `crates/edgezero-adapter-spin/src/` has no `config_store.rs` / `key_value_store.rs` / `secret_store.rs`, and `lib.rs` explicitly rejects `[stores.*]` for spin. Stage 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime; they must exist first. Stage 1 does **not** need PR #253. Verify with: `ls crates/edgezero-adapter-spin/src/` shows the three store files before starting 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 @@ -247,7 +247,26 @@ git commit -m "Extensible edgezero-cli library + generator + app-demo-cli; renam # Stage 2 — Manifest + runtime rewrite (atomic, all four adapters) -Spec §8, §6.6, §6.7, §6.9. **Requires PR #253.** This is the largest stage and the review hotspot. Hard cutoff — legacy store schema is removed outright. +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: Rewrite the manifest store schema 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")] From 72ec5ee51139661bed99c5c771a60571a49bcd45 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 13:38:54 -0700 Subject: [PATCH 35/38] Plan: clear stale PR #253 gating and five-built-ins references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status block no longer calls Stage 2 "gated on PR #253" — the precondition is met (PR #253 merged). - Task 8.2 now says Stage 1 created the generated CLI template with four downstream built-ins, not five (demo is contributor-only). --- docs/superpowers/plans/2026-05-20-cli-extensions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index ff53dfa..43bcedf 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -23,7 +23,7 @@ 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 gated on PR #253. +- **Stages 2–8 — pending.** Stage 2 is next; its PR #253 precondition is met. ## Codebase facts this plan relies on @@ -706,7 +706,7 @@ Spec §15, §6.12. - 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 five base +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 From 58c8e9142790ef44cb3bc2b6d920060b62332cc5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 14:35:39 -0700 Subject: [PATCH 36/38] Spec/plan: revise Stage 2 to the portable-manifest + EDGEZERO__ design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks spec §6.6/§8 and the plan's Stage 2 tasks for the design agreed in review: - edgezero.toml is portable and non-adapter-specific — [app], routes, [environment], and [stores.] logical ids/default only. No [adapters.*] table. - The manifest is never compiled into the binary; the app! macro bakes the portable config into the App/Hooks type at compile time, and run_app::() drops its manifest_src parameter (no include_str!). - Adapter-specific runtime config — store platform names, tuning, host/port, logging — comes from EDGEZERO__* environment variables at runtime, with defaults when absent. - An adapter binary builds and runs with no edgezero.toml and zero env vars. Plan Task 2.1–2.9 rewritten accordingly (adds the EDGEZERO__ env-config layer task; drops the in-manifest per-adapter mapping). --- .../plans/2026-05-20-cli-extensions.md | 192 ++++++--------- .../specs/2026-05-19-cli-extensions-design.md | 230 +++++++++--------- 2 files changed, 202 insertions(+), 220 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md index 43bcedf..5319376 100644 --- a/docs/superpowers/plans/2026-05-20-cli-extensions.md +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -52,7 +52,7 @@ 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 (commands in Task 2.7 step 6), +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`. @@ -268,149 +268,119 @@ implementing** — do not bolt them on piecemeal: the adapter layer (per-adapter manifests / adapter crate config), not the shared manifest. -### Task 2.1: Rewrite the manifest store schema +### Task 2.1: Portable manifest schema -**Files:** - -- Modify: `crates/edgezero-core/src/manifest.rs` - -- [ ] **Step 1: Write failing tests** for the new schema in `manifest.rs` tests: a manifest with `[stores.kv] ids = ["a","b"]\ndefault = "a"` plus `[adapters.cloudflare.stores.kv.a] name = "A"` etc. parses; `ids = []` errors; `default` missing with two ids errors; `default` not in `ids` errors; a `Single`-pair per-id block errors; a legacy `[stores.kv] name = "X"` errors with a message containing `manifest-store-migration`. - -- [ ] **Step 2: Run** — expect FAIL. - -- [ ] **Step 3: Implement** per §6.6. Replace `ManifestStores`, `ManifestConfigStoreConfig`, `ManifestKvConfig`, `ManifestSecretsConfig`, and the `Manifest*AdapterConfig` types with: - - `ManifestStores { kv: Option, config: Option, secrets: Option }` where `LogicalStore { ids: Vec, default: Option }`. - - `ManifestAdapter` (the `[adapters.]` struct) gains `stores: Option`. `AdapterStoresConfig { kv/config/secrets: BTreeMap }`, `AdapterStoreMapping { name: String, #[serde(flatten)] extras: BTreeMap }`. - - The Spin `component` field goes on the **`[adapters..adapter]` definition struct** — the one that already carries `crate` and `manifest` — **not** on the top-level `ManifestAdapter`. Adding it to `ManifestAdapter` would make the accepted TOML `[adapters.spin] component = "..."`, which is wrong; it must be `[adapters.spin.adapter] component = "..."` (§6.7). Confirm the struct name by reading `manifest.rs` (the struct deserialized from `[adapters..adapter]`); add `component: Option` there. - - A `Capability { Multi, Single }` and a const fn `capability(adapter: &str, kind: StoreKind) -> Capability` encoding the §6.6 matrix. - - Validation in `ManifestLoader`: non-empty `ids`; `default` rules; capability check (any `Single` adapter for a kind ⇒ `ids.len() == 1`); per-id mapping required for `Multi` pairs / forbidden for `Single` pairs; Cloudflare `name` JS-identifier check; Spin KV label check. - - Detect legacy keys (`name`/`enabled`/`defaults`/`adapters` under `[stores.*]`) via a `#[serde(deny_unknown_fields)]` or an explicit reject, emitting an error pointing at `docs/guide/manifest-store-migration.md`. - - Add resolver helpers: `resolved_default(kind) -> &str`, `store_name(adapter, kind, id) -> Option<&str>`. +**Files:** `crates/edgezero-core/src/manifest.rs` (+ `manifest_definitions.rs`) -- [ ] **Step 4: Run** `cargo test -p edgezero-core manifest` — expect PASS. Existing manifest tests that used the old schema are rewritten to the new schema (this is a hard cutoff — old-schema tests are replaced, not kept). +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`. -### Task 2.2: New `KvError` variants +- [ ] Tests: round-trip; non-empty ids; default required when >1 id; + legacy manifest → hard error with migration message. +- [ ] Full gate. -**Files:** +### Task 2.2: `EDGEZERO__*` environment-config layer -- Modify: `crates/edgezero-core/src/key_value_store.rs` +**Files:** `crates/edgezero-core/src/env_config.rs` (new) -- [ ] **Step 1: Write failing test:** assert `KvError::Unsupported` and `KvError::LimitExceeded` exist and that their `EdgeError` conversion yields a 5xx status. +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). -- [ ] **Step 2: Run** — expect FAIL. +- [ ] Tests: nesting, defaults, store-name resolution; zero-env case. +- [ ] Full gate. -- [ ] **Step 3: Implement.** Add `Unsupported { message: String }` and `LimitExceeded { message: String }` to `KvError`. Map both to a 5xx-class `EdgeError` in the existing `KvError → EdgeError` conversion (an unsupported op / a store-too-large condition is not a client error). +### Task 2.3: `app!` macro bakes portable config into `Hooks` -- [ ] **Step 4: Run** — expect PASS. +**Files:** `crates/edgezero-macros/src/app.rs`, `crates/edgezero-core/src/app.rs` -### Task 2.3: Make `ConfigStore` async +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`. -**Files:** +- [ ] Tests: `app!` macro metadata-registry test. +- [ ] Full gate. -- Modify: `crates/edgezero-core/src/config_store.rs`, and every `ConfigStore` impl (all four adapters + any in-core test stores) +### Task 2.4: `run_app::()` drops `manifest_src` (all four adapters) -- [ ] **Step 1: Implement.** Change the trait to `#[async_trait(?Send)] pub trait ConfigStore: Send + Sync { async fn get(&self, key: &str) -> Result, ConfigStoreError>; }`. Make `ConfigStoreHandle::get` async. Update the `config_store_contract_tests!` macro so generated tests `.await` the calls (they already run under `futures::executor::block_on` per project convention). +**Files:** `run_app` in each adapter crate; the four entrypoint templates; `edgezero-cli/src/demo_server.rs` -- [ ] **Step 2:** Update every `ConfigStore` impl in the four adapters to `async fn get` (the bodies stay; only the signature + any awaits change). This is mechanical but compile-driven — `cargo build` will list every site. +`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`. -- [ ] **Step 3: Run** `cargo build --workspace` — drive to zero errors. +- [ ] Tests: `run_app` builds and runs with no manifest file / zero env. +- [ ] Full gate. -### Task 2.4: Bound store handles + id-keyed `RequestContext` + `StoreRegistry` +### Task 2.5: Async `ConfigStore`, `KvError` variants, bound handles, id-keyed context -**Files:** +**Files:** `config_store.rs`, `key_value_store.rs`, `secret_store.rs`, `context.rs`, `error.rs` -- Modify: `crates/edgezero-core/src/context.rs`, `config_store.rs`, `key_value_store.rs`, `secret_store.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. -- [ ] **Step 1: Implement** per §4. Add `BoundKvStore`, `BoundConfigStore`, `BoundSecretStore` — each wraps the provider handle plus the resolved platform name; `BoundConfigStore::get` async; `BoundSecretStore::get -> Result, SecretError>` + `require_str`. Add `StoreRegistry { by_id: BTreeMap, default_id: String }`. Replace `RequestContext::config_store()/kv_handle()/secret_handle()` with `kv_store(id)/kv_store_default()`, `config_store(id)/config_store_default()`, `secret_store(id)/secret_store_default()` returning `Option`. The context stores three `StoreRegistry` values in its `Extensions`. +- [ ] Tests: async config round-trip; new `KvError` mappings; registry. +- [ ] Full gate. -- [ ] **Step 2: Write tests** in `context.rs`: a registry with two ids returns `Some` for each, `None` for an unknown id; `*_default()` resolves the `default_id`. +### Task 2.6: Adapter store registries — all four adapters -- [ ] **Step 3: Run** `cargo test -p edgezero-core context` — expect PASS. +**Files:** `{config_store,key_value_store,secret_store}.rs` in each adapter crate -### Task 2.5: Id-keyed `Hooks` / `ConfigStoreMetadata` + `app!` macro - -**Files:** +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`. -- Modify: `crates/edgezero-core/src/app.rs` (`Hooks` + `ConfigStoreMetadata` both live here — there is no separate `hooks.rs`), `crates/edgezero-macros/src/app.rs`, `crates/edgezero-macros/src/manifest_definitions.rs` +- [ ] 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`. -- [ ] **Step 1: Implement.** `ConfigStoreMetadata` becomes a registry: one entry per logical config id, each carrying the per-adapter `name` map. `Hooks` exposes store **metadata** (ids, resolved default, per-adapter names) per kind — **not** bound handles. Update the `app!` macro to emit the id-keyed metadata from the new manifest schema (`manifest_definitions.rs` is where the macro reads the manifest). +### Task 2.7: `Kv` / `Secrets` / `Config` extractors -- [ ] **Step 2: Write a macro test:** the generated `ConfigStoreMetadata` registry matches a fixture manifest's `[stores.config].ids`. +**Files:** `crates/edgezero-core/src/extractor.rs` -- [ ] **Step 3: Run** `cargo test -p edgezero-core && cargo test -p edgezero-macros` — expect PASS. +Refactor `Kv` / `Secrets` to `default()` / `named()`; add the `Config` +extractor (§6.9). -### Task 2.6: Refactor `Kv` / `Secrets` extractors + add `Config` - -**Files:** - -- Modify: `crates/edgezero-core/src/extractor.rs` - -- [ ] **Step 1: Implement** per §6.9. `Kv` / `Secrets` / new `Config` each become a per-request registry handle with `.default() -> Option` and `.named(id) -> Option`. Update their `FromRequest` impls to extract the corresponding `StoreRegistry` from the context. - -- [ ] **Step 2: Write tests:** a handler-style test resolving `kv.default()` and `kv.named("sessions")`. - -- [ ] **Step 3: Run** `cargo test -p edgezero-core extractor` — expect PASS. - -### Task 2.7: Rewrite all four adapter store impls for multi-store - -**Files:** +- [ ] Tests: extractor tests for all three. +- [ ] Full gate. -- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{config_store,key_value_store,secret_store}.rs` and each adapter's request-setup code. +### Task 2.8: Migrate `app-demo`, templates, docs -- [ ] **Step 1: axum.** Build `StoreRegistry` for each kind from `[adapters.axum.stores.*]`. KV stays `PersistentKvStore` (redb) — **one separate redb file per logical id**, file stem from the per-adapter mapping `[adapters.axum.stores.kv.].name`: `.edgezero/kv-.redb`. (Axum KV is `Multi`, so every id has a `name`.) Distinct files prevent multi-store collapsing into one backing file. Config store reads `.edgezero/local-config-.json` (the file stage 7 writes); absent ⇒ empty. Secrets from env vars (Single). - -- [ ] **Step 2: cloudflare.** KV registry. **Config rewritten from `[vars]` to KV** (§6.9) — `CloudflareConfigStore` does an async `env..get(key)`; one namespace per config id. Secrets from worker secrets (Single). - -- [ ] **Step 3: fastly.** Fastly is `Multi` for **all three** kinds (KV, config, secrets) — the only adapter that is. Build a `StoreRegistry` per kind from `[adapters.fastly.stores..*]`: - - **KV:** one Fastly KV store per logical id, opened by the per-id `name`. The existing `FastlyKvStore` is constructed once per id; the registry maps `` → handle. - - **Config:** one Fastly config store per logical id, opened by the per-id `name`. The existing `FastlyConfigStore` becomes per-id; `get` stays async after the §6.4 trait change. - - **Secrets:** one Fastly secret store per logical id, opened by the per-id `name`. - - For every kind, an absent per-id `name` mapping is already a manifest-validation error (§6.6); the adapter setup can rely on each declared id having a `name`. - - Resolution: at request setup the adapter reads the `Hooks` store metadata, opens each `(kind, id)` Fastly resource by its `name`, and inserts the three `StoreRegistry` values into the context. - - **Tests:** the Fastly contract suite must cover **two logical stores of each kind** (e.g. `[stores.kv] ids = ["a", "b"]`) and assert `ctx.kv_store("a")` / `ctx.kv_store("b")` resolve to distinct stores, `ctx.kv_store("missing")` is `None`, and `kv_store_default()` resolves the manifest default — same id-keyed contract-factory shape as the other adapters (Step 5). Run under Viceroy on `wasm32-wasip1`. - -- [ ] **Step 4: spin.** Wire `SpinKvStore` (label registry, honor `max_list_keys`, return `KvError::LimitExceeded` past the cap, `KvError::Unsupported` for TTL writes), `SpinConfigStore` (single flat-variable store, `.`→`__` lowercase key translation), `SpinSecretStore` (single flat-variable store, `store_name` ignored). Stop rejecting `[stores.*]` for spin in `lib.rs`. Labels come from `[adapters.spin.stores.kv.*].name`. - -- [ ] **Step 5:** Update each adapter's contract-test invocations to the id-keyed factory shape; add a Spin TTL→`Unsupported` contract test and a Spin listing-cap→`LimitExceeded` test; add a Cloudflare config-from-KV async round-trip test (wasm-bindgen-test). - -- [ ] **Step 6: Run** `cargo test --workspace --all-targets`, then the per-adapter wasm contract tests with the **exact** runner / target / feature each adapter's CI job uses (`.github/workflows/test.yml` `adapter-wasm-tests` matrix — match it, do not improvise): - - **cloudflare:** target `wasm32-unknown-unknown`, runner `wasm-bindgen-test-runner` — - `cargo test -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare --test contract` - - **fastly:** target `wasm32-wasip1`, runner Viceroy (version pinned in `.tool-versions`) — - `cargo test -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly --test contract` - - **spin:** target `wasm32-wasip1`, runner Wasmtime — - `cargo test -p edgezero-adapter-spin --target wasm32-wasip1 --features spin --test contract` - - The runner for each target is configured in the workspace `.cargo/config.toml`. If the exact feature flags or runner config differ from the above, defer to `.github/workflows/test.yml` as the source of truth and update this step to match. All green. - -### Task 2.8: Migrate `app-demo` + write the migration guide - -**Files:** - -- Modify: `examples/app-demo/edgezero.toml`, `examples/app-demo/crates/app-demo-core/src/handlers.rs`, `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` -- Create: `docs/guide/manifest-store-migration.md` - -- [ ] **Step 1:** Rewrite `examples/app-demo/edgezero.toml` to the new schema: `[stores.kv] ids = ["sessions","cache"]\ndefault = "sessions"`; one config id (`app_config`); one secrets id (`default`); per-adapter `[adapters..stores.kv.]` blocks for axum/cloudflare/fastly/spin; no Spin per-id blocks for config/secrets (Single). Remove `[stores.config.defaults]`. - -- [ ] **Step 2:** Migrate `app-demo` handlers to id-keyed accessors — **store-accessor change only** (`ctx.kv_store("sessions")`, `ctx.config_store_default()`, the refactored `Kv`/`Secrets`/`Config` extractors). Do **not** introduce `AppDemoConfig` here (stage 3). - -- [ ] **Step 3:** Rewrite `templates/root/edgezero.toml.hbs` to the new schema so `edgezero new` produces a valid manifest. - -- [ ] **Step 4:** Write `docs/guide/manifest-store-migration.md` — old shape → new shape, worked example, the capability matrix. - -- [ ] **Step 5: Run** `cd examples/app-demo && cargo test && cargo build --workspace` — green. - -### Task 2.9: Stage-2 docs + commit - -**Files:** +**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 -- Modify: `docs/guide/configuration.md`, `kv.md`, `handlers.md`, `adapters/cloudflare.md`, `adapters/overview.md`, `architecture.md`, `docs/.vitepress/config.mts` +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. -- [ ] **Step 1:** Update each page per §6.12 — new `[stores]` schema + capability rules + the removal of `[stores.config.defaults]` (`configuration.md`); multi-store + bound handles + extractor `default()/named()` (`kv.md`, `handlers.md`); `[vars]`→KV config (`adapters/cloudflare.md`); Spin store semantics (`adapters/overview.md`); light review (`architecture.md`). Add `manifest-store-migration.md` to the sidebar in `config.mts`. +- [ ] Full gate + `cd examples/app-demo && cargo test` + docs CI. -- [ ] **Step 2: Run** the full gate (all of `.github/workflows/test.yml` + `format.yml` commands, including the docs ESLint/Prettier and the wasm gates) — green. +### Task 2.9: Stage-2 ship gate + commit -- [ ] **Step 3: Commit:** `git commit -m "Manifest + runtime rewrite: multi-store schema, async ConfigStore, all four adapters"` +- [ ] 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. --- diff --git a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md index 83ed3ab..043a362 100644 --- a/docs/superpowers/specs/2026-05-19-cli-extensions-design.md +++ b/docs/superpowers/specs/2026-05-19-cli-extensions-design.md @@ -380,61 +380,78 @@ 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 Multi-store manifest schema + capability rules +### 6.6 Manifest schema, environment config, and capability rules -The `[stores]` and `[adapters.*]` schema is **rewritten outright**. -There is no legacy shape. Legacy fields (`[stores.] name`, legacy -`[stores..adapters.*]` overrides, `[stores.config.defaults]`) -are removed; a manifest still using them is a **hard load error** -pointing at `docs/guide/manifest-store-migration.md`. +`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`. -**App-level declaration:** +**`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 +default = "sessions" # REQUIRED when ids.len() > 1 [stores.config] -ids = ["app_config"] # default optional: single id +ids = ["app_config"] # default optional when exactly one id [stores.secrets] ids = ["default"] ``` -**Per-adapter mapping + tuning:** - -```toml -[adapters.cloudflare.stores.kv.sessions] -name = "SESSIONS_KV" - -[adapters.fastly.stores.kv.sessions] -name = "sessions_kv" -max_value = "1MB" # adapter-specific tuning, free-form - -[adapters.spin.stores.kv.sessions] -name = "sessions" # Spin KV store label - -[adapters.cloudflare.stores.config.app_config] -name = "APP_CONFIG_KV" - -# NOTE: there is deliberately no [adapters.spin.stores.config.*] block. -# Spin config is Single-capability (flat variables) — a per-id mapping -# block for a Single (adapter, kind) pair is a validation error (§6.6). -``` - -**Field reference:** - -| Field | Where | Role | -| ---------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- | -| `[stores.].ids` | top level | logical ids (`Vec`, non-empty) | -| `[stores.].default` | top level | resolved default; **required when `ids.len() > 1`**, optional (resolves to `ids[0]`) when exactly one id; must be in `ids` | -| `[adapters..stores..].name` | per-adapter | platform name (see capability rules for whether required) | -| other fields in that block | per-adapter | free-form `BTreeMap` tuning | - -**Adapter × kind capability matrix.** A single flat -`STORES_SUPPORTED_ADAPTERS` list is too coarse. Each (adapter, kind) -pair has a capability: +`[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 | | ---------- | ---------------- | ----------------------- | ----------------------- | @@ -443,36 +460,23 @@ pair has a capability: | 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. - A per-id `[adapters..stores..]` block with a `name` is - **required** for every id. -- **Single**: the adapter has exactly one flat store of that kind. - A per-id `[adapters..stores..]` block is **forbidden** — - there is nothing to configure per id, and a vestigial no-op block is - misleading. Its presence is a validation error. - -**Validation rules (in `ManifestLoader`):** - -- `[stores.].ids` non-empty when present. -- `default` present iff `ids.len() > 1`; when present, must be in `ids`. -- **Capability check:** for each declared kind, compute the minimum - capability across the adapters declared in `[adapters.*]`. If any - declared adapter is `Single` for that kind, `[stores.].ids` - **must have exactly one id** — you cannot declare two config stores - in a project that also targets Spin, because Spin config is a single - flat namespace. The error names the offending adapter and kind. -- For each (adapter, kind) that is `Multi`, every id must have a - `[adapters..stores..]` block with a `name`. For - `Single` (adapter, kind) pairs, **any such block is a validation - error** — the runtime ignores per-id naming there. -- `name` under `[adapters.cloudflare.stores.*]` must be a JavaScript - identifier; `name` under `[adapters.spin.stores.kv.*]` must be a - valid Spin KV label. Invalid names are errors. +- **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. For `Single` (adapter, kind) pairs the registry has -one entry mapped to the adapter's single flat store. +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 @@ -766,15 +770,27 @@ throwaway-app && cargo check --workspace` succeeds. ## 8. Sub-project 2 — Manifest + runtime rewrite (atomic, all four adapters) -**Goal:** the big atomic sub-project. Manifest schema and runtime store -API are coupled; with a hard cutoff they ship together as one stage -(stage 2 of the eight-stage PR). +**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:** rewrite `ManifestStores` / `ManifestAdapter` to the - §6.6 schema outright. Legacy fields are removed; using them is a hard - load error. Validation includes the §6.6 capability matrix. +- **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 @@ -784,51 +800,46 @@ API are coupled; with a hard cutoff they ship together as one stage `BoundSecretStore`; `RequestContext` accessors id-keyed, with `_default()` helpers. - **Static metadata:** `Hooks` / `ConfigStoreMetadata` rewritten to - id-keyed metadata; `app!` macro emits them from the new schema. -- **Adapter store rewrites — ALL FOUR adapters:** - - **axum:** in-memory KV registry; config from + 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** - (§6.x) with async reads; secrets from worker secrets. + 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, - `store_name` ignored) into the multi-store registry; stop relying - on hardcoded default labels — labels come from - `[adapters.spin.stores.kv.*].name`. + 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 seeding at - [dev_server.rs:349](crates/edgezero-adapter-axum/src/dev_server.rs#L349) + 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 new schema with all four adapters declaring stores - (≥2 KV ids `sessions`+`cache`; exactly one config id and one - secrets id, as the Spin capability rule requires). `app-demo` - handlers are migrated **only for the store-accessor change** in - stage 2 — `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 type is created in stage 3 (§9), and `examples/app-demo/ -app-demo.toml` does not exist yet. This keeps stage 2 - independently buildable — no stage-2 code references a type that - lands in stage 3. + 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`; capability check — declaring two config -ids with spin present → error; per-adapter completeness for `Multi` -pairs; a per-id block on a `Single` (adapter, kind) pair → error; -Cloudflare JS-identifier + Spin KV-label checks; pre-rewrite manifest → -hard error with migration message); 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 (and its error-variant decision, -§6.7); `Kv`/`Secrets`/`Config` extractor tests; `app!` macro metadata -registry test. +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 @@ -851,8 +862,9 @@ stage-7 resolve-and-write step. So between stage 2 and stage 7: This keeps stage 2 independently buildable and testable. **Ship gate:** multi-store handlers work on axum, cloudflare, fastly, -and spin; async config reads work; all four CI gates green (including -the wasm32 spin gate). +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 From f5bd4320eacaf1d56a33fd5faba92e802a5a58ca Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 21:44:43 -0700 Subject: [PATCH 37/38] Stage 2 Task 2.1: portable manifest store schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the manifest store model to the §6.6 portable schema: - `[stores.]` now carries only logical `ids` (non-empty) and an optional `default` (required when >1 id, must be a declared id). The five per-adapter store config types collapse into one reusable `StoreDeclaration`. - The pre-rewrite store schema (`[stores.] name`, `[stores.config.defaults]`, `[stores..adapters.*]`, `enabled`) is a hard load error whose message points at the migration guide. - Store helper methods resolve a store's name to its logical default id (interim — `EDGEZERO__*` env overrides arrive in Task 2.2). - `[stores.config.defaults]` and its axum dev-server seeding are gone. - Migrated `examples/app-demo/edgezero.toml` and the generated `edgezero.toml.hbs` template to the new schema. Scoped to store types only; `[adapters.*]`, the env layer, and adapter store registries are later Stage 2 tasks. --- .../edgezero-adapter-axum/src/config_store.rs | 6 +- .../edgezero-adapter-axum/src/dev_server.rs | 11 +- crates/edgezero-adapter-spin/src/lib.rs | 14 +- crates/edgezero-cli/src/lib.rs | 13 +- .../src/templates/root/edgezero.toml.hbs | 14 + crates/edgezero-core/src/manifest.rs | 604 ++++++------------ crates/edgezero-macros/src/app.rs | 29 +- examples/app-demo/edgezero.toml | 31 +- 8 files changed, 229 insertions(+), 493 deletions(-) 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-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-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 82c55ea..2bebc39 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -356,7 +356,7 @@ name = "demo-app" entry = "crates/demo-core" [stores.secrets] -name = "MY_SECRETS" +ids = ["MY_SECRETS"] [adapters.fastly.commands] build = "echo build" @@ -376,7 +376,7 @@ serve = "echo serve" let loader = ManifestLoader::load_from_str( r#" [stores.secrets] -name = "MY_SECRETS" +ids = ["MY_SECRETS"] "#, ); @@ -391,13 +391,8 @@ name = "MY_SECRETS" } #[test] - fn store_bindings_message_respects_secret_store_enabled() { - let loader = ManifestLoader::load_from_str( - " -[stores.secrets] -enabled = false -", - ); + 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/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-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/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" From 46248f9722223c47c3423681192a59d0f57a12b9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 21 May 2026 21:48:39 -0700 Subject: [PATCH 38/38] Stage 2 Task 2.2: EDGEZERO__* environment-config layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `edgezero-core::env_config` module: parses `EDGEZERO__`-prefixed environment variables (`__` = key-path separator, segments lower-cased) into an `EnvConfig` value with accessors for store platform names + tuning, bind host/port, and logging level. - `from_env()` reads the process environment; `from_vars()` lets the Cloudflare adapter supply its `Env` binding (no `std::env` there). - `store_name(kind, id)` falls back to the logical id when unset. Additive only — wired into the runtime in later Stage 2 tasks. --- crates/edgezero-core/src/env_config.rs | 220 +++++++++++++++++++++++++ crates/edgezero-core/src/lib.rs | 1 + 2 files changed, 221 insertions(+) create mode 100644 crates/edgezero-core/src/env_config.rs 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;