From 0f5e490d09e2b49e7d4ba221870215241b234bab Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 11:42:15 -0700 Subject: [PATCH 1/2] api: handlers hold no logic, and one generated contract (plan 2.3, 2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.4 — choreography the daemon's handlers were carrying moves into `springtale-runtime::operations`, so a second surface cannot drift from it. New operations: `authors` (the `trusted-author:` prefix and the 32-byte Ed25519 check in one place), `chat::ingest`, `bot::{status, memory_summary}`, `sessions::list`, `approvals::{pending, resolve}` through the runtime `ApprovalGate` trait, and `heartbeat::{get, set, boot_interval}` — one operation that persists the interval and applies it to the monitor, with boot reading the key back so a restart keeps the setting. The events limit clamp moves into `operations::events::list`. Rule create/update/delete already went through `rules::{create_and_ activate, update_and_reactivate, delete_and_deactivate}`; the desktop holds no runtime any more, so the duplicates the plan names are gone. Raw `serde_json::Value` bodies become typed request structs with no silent defaults: `CreateFormationRequest` (intent and connectors are required — a formation whose posture nobody chose is not a default), `CompactMemoryRequest`, `Set/StepAutonomyRequest`, `AddAuthorRequest`, `SetHeartbeatRequest`, `IncomingMessage`, `ResolveRequest`. 2.3 — 138 handlers carry `#[utoipa::path]` with a unique operation id, 85 response/request types derive `ToSchema`, and `GET /openapi.json` serves the document unauthenticated (it is a schema, not data). `springtaled --dump-openapi` prints it before any vault or store exists, so CI regenerates it and diffs it against the committed copy. `pnpm build` runs `openapi-typescript` over that copy into `packages/types/src/api.ts`; 30 wire shapes drop out of the hand-maintained `dashboard/types.ts`, which shrinks to the provider contract and view models. `scripts/check-surface.sh` proves every route has a command-line verb and a provider method and that nothing under /formations sits outside the four orchestration verb groups. An empty list is a failure, not a pass. `springtale --help --json` cannot answer the CLI half — clap emits no machine-readable help and a verb name does not carry its route — so the check reads the CLI's own path literals instead. The CLI's build script emits completions for five shells and a man page, and every subcommand now honours `--json` through `output::emit`. That work surfaced a real bug: `springtale safety disguise ` panicked in any debug build because a positional `bool` derives `SetTrue`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .github/workflows/ci.yml | 31 + Cargo.lock | 54 + Cargo.toml | 4 + apps/springtale-cli/Cargo.toml | 7 + apps/springtale-cli/build.rs | 94 + apps/springtale-cli/src/cli.rs | 16 + apps/springtale-cli/src/commands/author.rs | 41 +- apps/springtale-cli/src/commands/bot.rs | 32 +- apps/springtale-cli/src/commands/canvas.rs | 17 +- apps/springtale-cli/src/commands/connector.rs | 28 +- .../src/commands/cooperation.rs | 17 +- apps/springtale-cli/src/commands/crypto.rs | 10 +- apps/springtale-cli/src/commands/data.rs | 33 +- apps/springtale-cli/src/commands/doctor.rs | 43 +- apps/springtale-cli/src/commands/fix.rs | 83 +- .../src/commands/healthcheck.rs | 16 +- apps/springtale-cli/src/commands/init.rs | 5 + apps/springtale-cli/src/commands/login.rs | 45 +- apps/springtale-cli/src/commands/new.rs | 49 +- apps/springtale-cli/src/commands/panic.rs | 9 +- apps/springtale-cli/src/commands/server.rs | 24 +- apps/springtale-cli/src/commands/trace.rs | 24 +- apps/springtale-cli/src/commands/travel.rs | 26 +- apps/springtale-cli/src/commands/vault.rs | 17 +- apps/springtale-cli/src/main.rs | 43 +- apps/springtale-cli/src/output.rs | 20 + apps/springtaled/Cargo.toml | 1 + apps/springtaled/src/api/agents.rs | 47 +- apps/springtaled/src/api/approvals.rs | 105 +- apps/springtaled/src/api/auth.rs | 6 + apps/springtaled/src/api/authors.rs | 82 +- apps/springtaled/src/api/bot.rs | 82 +- apps/springtaled/src/api/canvas.rs | 12 + apps/springtaled/src/api/chat.rs | 68 +- apps/springtaled/src/api/config_api.rs | 107 +- apps/springtaled/src/api/connectors.rs | 88 + apps/springtaled/src/api/dashboard.rs | 14 + apps/springtaled/src/api/data.rs | 22 +- apps/springtaled/src/api/diagnostics.rs | 6 + apps/springtaled/src/api/drift.rs | 14 + apps/springtaled/src/api/events.rs | 64 +- apps/springtaled/src/api/executions.rs | 22 +- apps/springtaled/src/api/fixes.rs | 20 + apps/springtaled/src/api/formations.rs | 163 +- apps/springtaled/src/api/health.rs | 14 + apps/springtaled/src/api/login.rs | 38 +- apps/springtaled/src/api/memory.rs | 25 +- apps/springtaled/src/api/mod.rs | 7 +- apps/springtaled/src/api/onboarding.rs | 16 +- apps/springtaled/src/api/openapi.rs | 291 + apps/springtaled/src/api/recipes.rs | 125 +- apps/springtaled/src/api/rules.rs | 85 + apps/springtaled/src/api/safety.rs | 62 +- apps/springtaled/src/api/send.rs | 7 + apps/springtaled/src/api/sessions.rs | 39 +- apps/springtaled/src/api/stream.rs | 6 + apps/springtaled/src/api/templates.rs | 13 + apps/springtaled/src/api/utterances.rs | 12 + apps/springtaled/src/api/webhooks.rs | 8 + apps/springtaled/src/api/workspaces.rs | 54 +- apps/springtaled/src/cli.rs | 8 + apps/springtaled/src/main.rs | 18 +- apps/springtaled/src/runtime/boot/mod.rs | 9 + crates/springtale-runtime/Cargo.toml | 1 + .../src/operations/agent.rs | 15 +- .../src/operations/approvals.rs | 86 + .../src/operations/authors.rs | 98 + .../springtale-runtime/src/operations/bot.rs | 86 + .../src/operations/bot_settings.rs | 5 +- .../springtale-runtime/src/operations/chat.rs | 72 + .../src/operations/config.rs | 11 +- .../src/operations/cross_channel.rs | 4 +- .../springtale-runtime/src/operations/data.rs | 5 +- .../src/operations/diagnostics.rs | 6 +- .../src/operations/error_fixes.rs | 4 +- .../src/operations/events.rs | 65 +- .../src/operations/executions/drift.rs | 12 +- .../src/operations/executions/query.rs | 8 +- .../src/operations/formations.rs | 17 +- .../src/operations/heartbeat.rs | 80 + .../src/operations/memory.rs | 10 + .../springtale-runtime/src/operations/mod.rs | 6 + .../src/operations/onboarding.rs | 5 +- .../src/operations/preflight/types.rs | 8 +- .../src/operations/preview.rs | 4 +- .../src/operations/recipes/pieces.rs | 4 +- .../src/operations/recipes/types.rs | 40 +- .../src/operations/rules/create.rs | 5 +- .../src/operations/sessions.rs | 39 + .../src/operations/templates.rs | 8 +- .../src/operations/test_step.rs | 4 +- .../src/operations/workspaces/query.rs | 2 +- scripts/check-surface.sh | 100 + scripts/cli-routes.sh | 14 + scripts/provider-methods.mjs | 39 + scripts/surface-exemptions.txt | 100 + tauri/biome.json | 8 +- tauri/packages/types/openapi.json | 7122 +++++++++++++++++ tauri/packages/types/package.json | 9 + tauri/packages/types/src/api.ts | 6553 +++++++++++++++ tauri/packages/types/src/index.ts | 12 + tauri/packages/ui/src/colony/DriftBadge.tsx | 8 +- .../ui/src/colony/ExecutionsPanel.tsx | 15 +- tauri/packages/ui/src/dashboard/types.ts | 272 +- tauri/pnpm-lock.yaml | 204 +- 105 files changed, 16836 insertions(+), 878 deletions(-) create mode 100644 apps/springtale-cli/build.rs create mode 100644 apps/springtaled/src/api/openapi.rs create mode 100644 crates/springtale-runtime/src/operations/approvals.rs create mode 100644 crates/springtale-runtime/src/operations/authors.rs create mode 100644 crates/springtale-runtime/src/operations/bot.rs create mode 100644 crates/springtale-runtime/src/operations/chat.rs create mode 100644 crates/springtale-runtime/src/operations/heartbeat.rs create mode 100644 crates/springtale-runtime/src/operations/sessions.rs create mode 100755 scripts/check-surface.sh create mode 100755 scripts/cli-routes.sh create mode 100755 scripts/provider-methods.mjs create mode 100644 scripts/surface-exemptions.txt create mode 100644 tauri/packages/types/openapi.json create mode 100644 tauri/packages/types/src/api.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9db972f..248436f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,6 +149,37 @@ jobs: - run: pnpm -C tauri typecheck - run: pnpm -C tauri build + # ── API surface check (plan 2.3) ────────────────────────────────── + # + # One generated contract: the OpenAPI document springtaled derives + # from its own handlers must stay in step with the checked-in copy the + # frontend generates types from, and every route it declares must be + # reachable from the command line and from the web DataProvider. + surface: + name: API surface check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2 + with: + egress-policy: audit + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - name: Regenerate the contract and prove it matches the committed one + run: | + cargo run -q -p springtaled -- --dump-openapi > /tmp/openapi.json + diff -u tauri/packages/types/openapi.json /tmp/openapi.json \ + || { echo "openapi.json is stale — run: cargo run -p springtaled -- --dump-openapi > tauri/packages/types/openapi.json"; exit 1; } + - name: Every route has a CLI verb and a provider method + run: sh scripts/check-surface.sh + # ── TypeScript Lint (Biome) ─────────────────────────────────────── # # Biome is the workspace's TS/JSX linter + formatter (2026 idiom for diff --git a/Cargo.lock b/Cargo.lock index 6c83638a..e890d0ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,6 +976,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -994,6 +1003,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "cmake" version = "0.1.58" @@ -4891,6 +4910,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rpassword" version = "5.0.1" @@ -5745,6 +5770,8 @@ dependencies = [ "anyhow", "chrono", "clap", + "clap_complete", + "clap_mangen", "connector-telegram", "figment", "futures-util", @@ -5972,6 +5999,7 @@ dependencies = [ "tokio", "toml 0.8.23", "tracing", + "utoipa", "uuid", ] @@ -6123,6 +6151,7 @@ dependencies = [ "twilight-gateway", "twilight-http", "twilight-model", + "utoipa", "uuid", "zeroize", ] @@ -7136,6 +7165,31 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "uuid", +] + [[package]] name = "uuid" version = "1.26.0" diff --git a/Cargo.toml b/Cargo.toml index 67cb312f..3f1643fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -203,6 +203,10 @@ pyo3 = { version = "0.29", features = ["extension-module", "abi3-py3 # ── CLI ──────────────────────────────────────────────────────────────────────── clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4" +clap_mangen = "0.2" +utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } +utoipa-axum = "0.2" indicatif = "0.17" tabled = "0.17" rpassword = "5" diff --git a/apps/springtale-cli/Cargo.toml b/apps/springtale-cli/Cargo.toml index 69b43064..c23fe78a 100644 --- a/apps/springtale-cli/Cargo.toml +++ b/apps/springtale-cli/Cargo.toml @@ -44,6 +44,13 @@ springtale-ai = { workspace = true } springtale-sentinel = { workspace = true } connector-telegram = { workspace = true } +# Shell completions + man page are generated at build time from the same +# `src/cli.rs` the binary compiles (see `build.rs` for the include! mechanism). +[build-dependencies] +clap = { workspace = true } +clap_complete = { workspace = true } +clap_mangen = { workspace = true } + [dev-dependencies] tempfile = { workspace = true } # `tests/daemon_client.rs` runs the real CLI binary against a real diff --git a/apps/springtale-cli/build.rs b/apps/springtale-cli/build.rs new file mode 100644 index 00000000..8ed2fb3f --- /dev/null +++ b/apps/springtale-cli/build.rs @@ -0,0 +1,94 @@ +//! Build-time generation of shell completions and the `springtale(1)` man page. +//! +//! # Mechanism +//! +//! A build script cannot `use` items from the binary crate it builds, so the +//! clap definition has to reach this file some other way. `src/cli.rs` is +//! deliberately standalone — it imports nothing but `std::path::PathBuf` and +//! `clap` — so it is `include!`d here into a private `cli` module. The binary +//! keeps `mod cli;` unchanged; both compile the same source, and the +//! `#[cfg(test)]` block inside it is compiled out for the build script. +//! +//! If `src/cli.rs` ever grows a `crate::`/`super::` reference the include stops +//! compiling, and the fix is to move that reference out of `cli.rs` rather than +//! to weaken this script — the CLI surface is meant to be declarable on its own. +//! +//! # Output +//! +//! Everything lands under `$OUT_DIR/assets/`: +//! +//! ```text +//! assets/completions/springtale.bash +//! assets/completions/_springtale (zsh) +//! assets/completions/springtale.fish +//! assets/completions/_springtale.ps1 (powershell) +//! assets/completions/springtale.elv (elvish) +//! assets/man/springtale.1 +//! ``` +//! +//! `$OUT_DIR` is buried under `target/`, so the absolute path is also exported +//! as the `SPRINGTALE_ASSETS_DIR` compile-time env var for packaging scripts to +//! read back with `cargo build --message-format=json`. Setting the +//! `SPRINGTALE_ASSET_DIR` environment variable at build time mirrors every file +//! into that directory as well (used by the release packaging job). + +use std::io::Result; +use std::path::{Path, PathBuf}; + +use clap::CommandFactory; +use clap_complete::Shell; + +mod cli { + include!("src/cli.rs"); +} + +fn main() -> Result<()> { + println!("cargo::rerun-if-changed=src/cli.rs"); + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-env-changed=SPRINGTALE_ASSET_DIR"); + + let Some(out_dir) = std::env::var_os("OUT_DIR") else { + // Not running under cargo (rust-analyzer probes, doc tooling). + return Ok(()); + }; + let assets = PathBuf::from(out_dir).join("assets"); + generate_into(&assets)?; + + if let Some(extra) = std::env::var_os("SPRINGTALE_ASSET_DIR") { + generate_into(Path::new(&extra))?; + } + + println!( + "cargo::rustc-env=SPRINGTALE_ASSETS_DIR={}", + assets.display() + ); + Ok(()) +} + +/// Write every completion script and the man page under `root`. +fn generate_into(root: &Path) -> Result<()> { + let completions = root.join("completions"); + let man = root.join("man"); + std::fs::create_dir_all(&completions)?; + std::fs::create_dir_all(&man)?; + + let mut command = cli::Cli::command(); + command.build(); + + for shell in [ + Shell::Bash, + Shell::Zsh, + Shell::Fish, + Shell::PowerShell, + Shell::Elvish, + ] { + clap_complete::generate_to(shell, &mut command, "springtale", &completions)?; + } + + let rendered = { + let mut buf = Vec::new(); + clap_mangen::Man::new(command).render(&mut buf)?; + buf + }; + std::fs::write(man.join("springtale.1"), rendered) +} diff --git a/apps/springtale-cli/src/cli.rs b/apps/springtale-cli/src/cli.rs index 8076487b..5627c5c1 100644 --- a/apps/springtale-cli/src/cli.rs +++ b/apps/springtale-cli/src/cli.rs @@ -353,6 +353,12 @@ pub enum SafetyAction { /// Turn the disguise overlay on or off. Disguise { /// `true` to activate the disguise, `false` to clear it. + /// + /// A positional `bool` derives `ArgAction::SetTrue` by default, + /// which clap rejects for a positional (it would take no value). + /// `Set` makes it the value-taking positional the help text + /// describes. + #[arg(action = clap::ArgAction::Set)] active: bool, }, /// Set how many rapid title-bar taps trigger the panic wipe. @@ -636,6 +642,16 @@ mod tests { use super::*; use clap::CommandFactory; + /// clap's own consistency check over the whole tree. `build.rs` + /// generates completions and the man page from this same definition, + /// so a malformed arg (e.g. a positional `bool`, which derives + /// `SetTrue` and takes no value) breaks the build rather than + /// panicking the first user who runs the subcommand. + #[test] + fn test_cli_definition_passes_clap_debug_assert() { + Cli::command().debug_assert(); + } + /// Walk the whole clap tree, collecting `parent/child` verb paths. fn verb_paths(cmd: &clap::Command, prefix: &str, out: &mut Vec) { for sub in cmd.get_subcommands() { diff --git a/apps/springtale-cli/src/commands/author.rs b/apps/springtale-cli/src/commands/author.rs index 07f9273f..e81e7f61 100644 --- a/apps/springtale-cli/src/commands/author.rs +++ b/apps/springtale-cli/src/commands/author.rs @@ -60,12 +60,14 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res .await .map_err(|e| anyhow::anyhow!("{e}"))?; - if json { - output::print_json(&serde_json::json!({ "name": name, "pubkey": pubkey_hex }))?; - } else { - println!("Trusted author added: {name}"); - println!(" pubkey: {pubkey_hex}"); - } + let added = serde_json::json!({ "name": name, "pubkey": pubkey_hex }); + output::emit(json, &added, |v| { + format!( + "Trusted author added: {}\n pubkey: {}", + output::cell(v, "name"), + output::cell(v, "pubkey") + ) + })?; } AuthorAction::List => { let configs = store @@ -88,17 +90,17 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res }) .collect(); - if json { - let authors: Vec = rows - .iter() - .map(|r| serde_json::json!({ "name": r.name, "pubkey": r.pubkey })) - .collect(); - output::print_json(&authors)?; - } else if rows.is_empty() { - println!("No trusted authors."); - } else { - println!("{}", Table::new(rows)); - } + let authors: Vec = rows + .iter() + .map(|r| serde_json::json!({ "name": r.name, "pubkey": r.pubkey })) + .collect(); + output::emit(json, &authors, |_| { + if rows.is_empty() { + "No trusted authors.".to_owned() + } else { + Table::new(rows).to_string() + } + })?; } AuthorAction::Remove { name } => { let key = format!("{TRUSTED_AUTHOR_PREFIX}{name}"); @@ -106,7 +108,10 @@ pub async fn run(action: AuthorAction, store: &SqliteBackend, json: bool) -> Res .delete_config(&key) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - println!("Removed trusted author: {name}"); + let removed = serde_json::json!({ "name": name, "removed": true }); + output::emit(json, &removed, |v| { + format!("Removed trusted author: {}", output::cell(v, "name")) + })?; } } Ok(()) diff --git a/apps/springtale-cli/src/commands/bot.rs b/apps/springtale-cli/src/commands/bot.rs index 755b4a70..d9681f09 100644 --- a/apps/springtale-cli/src/commands/bot.rs +++ b/apps/springtale-cli/src/commands/bot.rs @@ -12,32 +12,36 @@ use crate::output; use crate::store::PassphraseOpts; use springtale_runtime::operations::pairing; -pub async fn pair_init(opts: &PassphraseOpts) -> Result<()> { +pub async fn pair_init(opts: &PassphraseOpts, json_out: bool) -> Result<()> { let store = crate::store::open_store(opts)?; let code = pairing::generate_pairing_code(&store) .await .context("failed to generate pairing code")?; - println!("Pairing code (give this to the user, do NOT send via chat):\n"); - println!(" {code}\n"); - println!("The user types this code into their chat with the bot."); - println!("Code expires in 10 minutes. Single-use."); - Ok(()) + let body = serde_json::json!({ "pairing_code": code, "single_use": true }); + output::emit(json_out, &body, |v| { + format!( + "Pairing code (give this to the user, do NOT send via chat):\n\n {}\n\nThe user types this code into their chat with the bot.\nCode expires in 10 minutes. Single-use.", + output::cell(v, "pairing_code") + ) + }) } -pub async fn panic_unpair(opts: &PassphraseOpts) -> Result<()> { +pub async fn panic_unpair(opts: &PassphraseOpts, json_out: bool) -> Result<()> { let store = crate::store::open_store(opts)?; let removed = pairing::panic_unpair(&store) .await .context("failed to revoke paired users")?; - println!("Removed {removed} pairing/paired entries."); - if removed > 0 { - println!("All users must re-pair to regain access."); - } else { - println!("No paired users were found."); - } - Ok(()) + let body = serde_json::json!({ "removed": removed }); + output::emit(json_out, &body, |_| { + let tail = if removed > 0 { + "All users must re-pair to regain access." + } else { + "No paired users were found." + }; + format!("Removed {removed} pairing/paired entries.\n{tail}") + }) } /// `springtale bot settings …` — plan 6.3. Goes through the daemon so the diff --git a/apps/springtale-cli/src/commands/canvas.rs b/apps/springtale-cli/src/commands/canvas.rs index 764d5fc8..246b4b32 100644 --- a/apps/springtale-cli/src/commands/canvas.rs +++ b/apps/springtale-cli/src/commands/canvas.rs @@ -26,11 +26,13 @@ pub async fn run(stream: bool, json_out: bool) -> Result<()> { .and_then(|t| t.as_str()) .ok_or_else(|| anyhow::anyhow!("daemon did not issue a stream ticket"))?; let response = client.stream(&format!("/stream?ticket={ticket}")).await?; - follow(response).await + follow(response, json_out).await } -/// Print each SSE `data:` payload as it arrives. -async fn follow(response: reqwest::Response) -> Result<()> { +/// Print each SSE `data:` payload as it arrives. The payloads are already +/// JSON, so `--json` only decides pretty vs. one-line — but it still goes +/// through `output::emit`, so the flag has exactly one implementation. +async fn follow(response: reqwest::Response, json_out: bool) -> Result<()> { use anyhow::Context; use futures_util::StreamExt; @@ -48,8 +50,13 @@ async fn follow(response: reqwest::Response) -> Result<()> { data.push_str(d); } } - if !data.is_empty() { - println!("{data}"); + if data.is_empty() { + continue; + } + match serde_json::from_str::(&data) { + Ok(event) => output::emit(json_out, &event, |v| v.to_string())?, + // Unparseable frame: pass it through rather than drop it. + Err(_) => output::emit(json_out, &data, |raw| raw.clone())?, } } } diff --git a/apps/springtale-cli/src/commands/connector.rs b/apps/springtale-cli/src/commands/connector.rs index 2d0e302e..3bbb0f3d 100644 --- a/apps/springtale-cli/src/commands/connector.rs +++ b/apps/springtale-cli/src/commands/connector.rs @@ -15,7 +15,7 @@ pub async fn run(action: ConnectorAction, json_out: bool) -> Result<()> { // `sign` is a local file + vault operation with no daemon route, so // it must not require a reachable daemon or an API token. if let ConnectorAction::Sign { path } = &action { - return sign(path); + return sign(path, json_out); } let client = Client::from_config()?; @@ -69,7 +69,7 @@ pub async fn run(action: ConnectorAction, json_out: bool) -> Result<()> { } /// Sign a connector manifest with the local identity, in place. -fn sign(path: &std::path::Path) -> Result<()> { +fn sign(path: &std::path::Path, json_out: bool) -> Result<()> { let contents = std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("failed to read manifest at {}: {e}", path.display()))?; let mut manifest: springtale_connector::ConnectorManifest = toml::from_str(&contents) @@ -87,13 +87,19 @@ fn sign(path: &std::path::Path) -> Result<()> { .map_err(|e| anyhow::anyhow!("failed to write manifest at {}: {e}", path.display()))?; let pubkey_hex = hex::encode(keypair.verifying_key().to_bytes()); - println!("Signed {}", path.display()); - println!(" author: {}", manifest.author); - println!(" pubkey: {pubkey_hex}"); - println!(" signature: {signature}"); - println!( - " Install verifies against `trusted-author:{}` — register it with `springtale author add {} --self`.", - manifest.author, manifest.author - ); - Ok(()) + let body = json!({ + "path": path.display().to_string(), + "author": manifest.author, + "pubkey": pubkey_hex, + "signature": signature, + }); + output::emit(json_out, &body, |v| { + let author = output::cell(v, "author"); + format!( + "Signed {}\n author: {author}\n pubkey: {}\n signature: {}\n Install verifies against `trusted-author:{author}` — register it with `springtale author add {author} --self`.", + output::cell(v, "path"), + output::cell(v, "pubkey"), + output::cell(v, "signature"), + ) + }) } diff --git a/apps/springtale-cli/src/commands/cooperation.rs b/apps/springtale-cli/src/commands/cooperation.rs index 2fb885cf..e70b1223 100644 --- a/apps/springtale-cli/src/commands/cooperation.rs +++ b/apps/springtale-cli/src/commands/cooperation.rs @@ -14,6 +14,8 @@ use anyhow::{Context, Result, anyhow}; use springtale_cooperation::utterance::UtteranceDefs; use springtale_cooperation::utterance::defs::{ALL_CODEPOINT_CONSTS, NAMED_CODEPOINTS}; +use crate::output; + /// Nerd Fonts' Material Design Icons block, `F0001–F1AF0`. const PUA_START: u32 = 0xE000; @@ -25,15 +27,20 @@ fn all_codepoints() -> BTreeSet { cps } -pub fn glyphs(check: Option<&Path>) -> Result<()> { +pub fn glyphs(check: Option<&Path>, json_out: bool) -> Result<()> { let cps = all_codepoints(); if let Some(path) = check { check_against(path, &cps)?; } - for c in &cps { - println!("U+{:04X}", u32::from(*c)); - } - Ok(()) + // The plain listing is `pyftsubset --unicodes-file` input, so the + // human form stays one bare `U+XXXX` per line; `--json` wraps the + // same list in an envelope for anything that wants to parse it. + let listed: Vec = cps + .iter() + .map(|c| format!("U+{:04X}", u32::from(*c))) + .collect(); + let body = serde_json::json!({ "codepoints": &listed }); + output::emit(json_out, &body, |_| listed.join("\n")) } /// `glyphnames.json` is `{ "METADATA": {...}, "-": { "char", "code" }, ... }`. diff --git a/apps/springtale-cli/src/commands/crypto.rs b/apps/springtale-cli/src/commands/crypto.rs index 593712b4..baf0ff22 100644 --- a/apps/springtale-cli/src/commands/crypto.rs +++ b/apps/springtale-cli/src/commands/crypto.rs @@ -1,10 +1,12 @@ use anyhow::{Context, Result}; +use crate::output; + /// Re-encrypt the vault with a new passphrase. /// /// Opens the vault with the current passphrase, reads all entries, /// creates a new vault with the new passphrase, copies entries, and saves. -pub fn rotate_vault_key() -> Result<()> { +pub fn rotate_vault_key(json_out: bool) -> Result<()> { let vault_path = springtale_store::paths::default_vault_path(); if !vault_path.exists() { anyhow::bail!("no vault found at {}", vault_path.display()); @@ -58,6 +60,8 @@ pub fn rotate_vault_key() -> Result<()> { new_vault.save().context("failed to save new vault")?; - eprintln!("Vault key rotated successfully."); - Ok(()) + let body = serde_json::json!({ "rotated": true, "entries": keys.len() }); + output::emit_status(json_out, &body, |_| { + "Vault key rotated successfully.".to_owned() + }) } diff --git a/apps/springtale-cli/src/commands/data.rs b/apps/springtale-cli/src/commands/data.rs index 3f6e5bfe..c5595765 100644 --- a/apps/springtale-cli/src/commands/data.rs +++ b/apps/springtale-cli/src/commands/data.rs @@ -5,9 +5,10 @@ use serde_json::{Value, json}; use crate::cli::DataAction; use crate::client::Client; +use crate::output; /// Handle data subcommands. -pub async fn run(action: DataAction) -> Result<()> { +pub async fn run(action: DataAction, json_out: bool) -> Result<()> { let client = Client::from_config()?; match action { DataAction::Export { output, encrypt } => { @@ -15,7 +16,6 @@ pub async fn run(action: DataAction) -> Result<()> { anyhow::bail!("encrypted export requires travel mode (springtale travel prepare)"); } let data: Value = client.post("/data/export", &json!({})).await?; - let json = serde_json::to_string_pretty(&data)?; if let Some(path) = output { // Write with 0o600 permissions (architecture doc §8.2) use std::io::Write; @@ -27,10 +27,17 @@ pub async fn run(action: DataAction) -> Result<()> { .mode(0o600) .open(&path)?; let mut writer = std::io::BufWriter::new(file); - writer.write_all(json.as_bytes())?; - eprintln!("Exported to: {}", path.display()); + writer.write_all(serde_json::to_string_pretty(&data)?.as_bytes())?; + let done = json!({ "exported_to": path.display().to_string() }); + output::emit_status(json_out, &done, |v| { + format!("Exported to: {}", output::cell(v, "exported_to")) + })?; } else { - println!("{json}"); + // The export *is* the payload, so both forms print it — + // the flag still routes through the one helper. + output::emit(json_out, &data, |v| { + serde_json::to_string_pretty(v).unwrap_or_default() + })?; } } DataAction::Import { input } => { @@ -39,10 +46,12 @@ pub async fn run(action: DataAction) -> Result<()> { let export: Value = serde_json::from_str(&text) .map_err(|e| anyhow::anyhow!("invalid export file: {e}"))?; let stats: Value = client.post("/data/import", &export).await?; - eprintln!( - "Imported: {} rules, {} connectors, {} events", - stats["rules_inserted"], stats["connectors_inserted"], stats["events_inserted"] - ); + output::emit_status(json_out, &stats, |v| { + format!( + "Imported: {} rules, {} connectors, {} events", + v["rules_inserted"], v["connectors_inserted"], v["events_inserted"] + ) + })?; } DataAction::Purge { yes } => { // Irreversible. The flag is required here and the route @@ -53,10 +62,12 @@ pub async fn run(action: DataAction) -> Result<()> { "refusing to purge without --yes (this deletes every rule, event, and session)" ); } - let _: Value = client + let body: Value = client .post("/data/purge", &json!({ "confirm": true })) .await?; - eprintln!("All user data purged. Vault intact."); + output::emit_status(json_out, &body, |_| { + "All user data purged. Vault intact.".to_owned() + })?; } } Ok(()) diff --git a/apps/springtale-cli/src/commands/doctor.rs b/apps/springtale-cli/src/commands/doctor.rs index c808187a..84b7f9a8 100644 --- a/apps/springtale-cli/src/commands/doctor.rs +++ b/apps/springtale-cli/src/commands/doctor.rs @@ -9,9 +9,10 @@ use springtale_runtime::operations::diagnostics::{ self, CallerContext, Check, DiagnosticPaths, Report, Severity, }; +use crate::output; use crate::store::{PassphraseOpts, derive_db_key_hex}; -pub async fn run(opts: &PassphraseOpts) -> Result<()> { +pub async fn run(opts: &PassphraseOpts, json_out: bool) -> Result<()> { // The integrity check needs the store key; derive it from the // passphrase rather than reporting "vault locked". A first run has // no database yet, so do not prompt for one then. @@ -22,43 +23,43 @@ pub async fn run(opts: &PassphraseOpts) -> Result<()> { None }; - println!("Springtale Doctor"); - println!("=================\n"); - + // The whole report is rendered in one go so `--json` can hand back + // the serialized `Report` instead — the header used to be printed + // before the checks even ran, which left JSON output unparseable. let report = diagnostics::run_checks(&paths, key.as_deref(), CallerContext::Cli).await; - render(&report); + output::emit(json_out, &report, render) +} - println!(); +fn render(report: &Report) -> String { + let mut out = String::from("Springtale Doctor\n=================\n\n"); + for check in &report.checks { + out.push_str(&render_check(check)); + } + out.push('\n'); let issues = report.issue_count(); if issues == 0 { - println!("All checks passed. Springtale is ready to run."); + out.push_str("All checks passed. Springtale is ready to run."); } else { - println!( + out.push_str(&format!( "{issues} issue{} found. Fix the items above and run `springtale doctor` again.", if issues == 1 { "" } else { "s" } - ); - } - - Ok(()) -} - -fn render(report: &Report) { - for check in &report.checks { - print_check(check); + )); } + out } -fn print_check(check: &Check) { +fn render_check(check: &Check) -> String { let tag = match check.severity { Severity::Ok => "[OK] ", Severity::Warn => "[WARN]", Severity::Fail => "[FAIL]", }; - println!("{tag} {}", check.label); + let mut out = format!("{tag} {}\n", check.label); if let Some(detail) = &check.detail { - println!(" {detail}"); + out.push_str(&format!(" {detail}\n")); } if let Some(hint) = &check.fix_hint { - println!(" {hint}"); + out.push_str(&format!(" {hint}\n")); } + out } diff --git a/apps/springtale-cli/src/commands/fix.rs b/apps/springtale-cli/src/commands/fix.rs index a840c5a2..24bf17ed 100644 --- a/apps/springtale-cli/src/commands/fix.rs +++ b/apps/springtale-cli/src/commands/fix.rs @@ -7,59 +7,76 @@ use anyhow::Result; use springtale_runtime::operations::error_fixes::{self, FixGuide}; +use crate::output; use crate::store::{PassphraseOpts, derive_db_key_hex}; -pub async fn run(error_id: &str, opts: &PassphraseOpts) -> Result<()> { +pub async fn run(error_id: &str, opts: &PassphraseOpts, json_out: bool) -> Result<()> { let Some(guide) = error_fixes::lookup(error_id) else { - print_unknown(error_id); - return Ok(()); + // Not an error: an unknown id lists the known ones. Both forms go + // through the same helper so `--json` is machine-readable here too. + let known = error_fixes::all_guides(); + let body = serde_json::json!({ "error_id": error_id, "known": false, "known_ids": known }); + return output::emit(json_out, &body, |_| { + render_unknown(error_id, known) + .trim_end_matches('\n') + .to_owned() + }); }; - print_guide(guide); - - if guide.has_auto_fix { + // The auto-fix runs before anything is printed, so `--json` gets one + // object — guide plus outcome — instead of prose interleaved with it. + let outcome = if guide.has_auto_fix { // Fixers that open the store need the key; the user has the // passphrase at hand, so ask now instead of reporting "locked". let key = derive_db_key_hex(opts)?; - println!("\nAttempting automated fix...\n"); - let outcome = error_fixes::auto_fix_with_key(guide.id, Some(&key)).await; - for msg in &outcome.messages { - println!(" {msg}"); - } - println!( - "\nResult: {}", - if outcome.success { - "success" - } else { - "no change" - } - ); - } + Some(error_fixes::auto_fix_with_key(guide.id, Some(&key)).await) + } else { + None + }; - Ok(()) + let body = serde_json::json!({ "guide": guide, "outcome": outcome }); + output::emit(json_out, &body, |_| { + let mut out = render_guide(guide); + if let Some(outcome) = &outcome { + out.push_str("\nAttempting automated fix...\n\n"); + for msg in &outcome.messages { + out.push_str(&format!(" {msg}\n")); + } + out.push_str(&format!( + "\nResult: {}", + if outcome.success { + "success" + } else { + "no change" + } + )); + } + out.trim_end_matches('\n').to_owned() + }) } -fn print_guide(guide: &FixGuide) { - println!("{}: {}\n", guide.id, guide.title); +fn render_guide(guide: &FixGuide) -> String { + let mut out = format!("{}: {}\n\n", guide.id, guide.title); if !guide.causes.is_empty() { - println!("Common causes:"); + out.push_str("Common causes:\n"); for cause in guide.causes { - println!(" - {cause}"); + out.push_str(&format!(" - {cause}\n")); } - println!(); + out.push('\n'); } if !guide.suggestions.is_empty() { - println!("Suggestions:"); + out.push_str("Suggestions:\n"); for suggestion in guide.suggestions { - println!(" - {suggestion}"); + out.push_str(&format!(" - {suggestion}\n")); } } + out } -fn print_unknown(error_id: &str) { - println!("Unknown error ID: {error_id}\n"); - println!("Known error IDs:"); - for guide in error_fixes::all_guides() { - println!(" {} — {}", guide.id, guide.title); +fn render_unknown(error_id: &str, known: &[FixGuide]) -> String { + let mut out = format!("Unknown error ID: {error_id}\n\nKnown error IDs:\n"); + for guide in known { + out.push_str(&format!(" {} — {}\n", guide.id, guide.title)); } + out } diff --git a/apps/springtale-cli/src/commands/healthcheck.rs b/apps/springtale-cli/src/commands/healthcheck.rs index e700bf8c..8e5a219d 100644 --- a/apps/springtale-cli/src/commands/healthcheck.rs +++ b/apps/springtale-cli/src/commands/healthcheck.rs @@ -11,7 +11,9 @@ use std::time::Duration; use anyhow::{Result, anyhow}; -pub async fn run(base_url: &str) -> Result<()> { +use crate::output; + +pub async fn run(base_url: &str, json_out: bool) -> Result<()> { let client = springtale_transport::safe_http::builder() .timeout(Duration::from_secs(3)) .build() @@ -24,12 +26,14 @@ pub async fn run(base_url: &str) -> Result<()> { .await .map_err(|e| anyhow!("healthcheck request: {e}"))?; - if response.status().is_success() { - Ok(()) - } else { - Err(anyhow!( + if !response.status().is_success() { + return Err(anyhow!( "healthcheck failed: HTTP {}", response.status().as_u16() - )) + )); } + // A healthy probe stays silent for the container runtime; `--json` + // gives a scriptable body without changing the exit-code contract. + let body = serde_json::json!({ "healthy": true, "url": url }); + output::emit_status(json_out, &body, |_| String::new()) } diff --git a/apps/springtale-cli/src/commands/init.rs b/apps/springtale-cli/src/commands/init.rs index 81a3e874..ee42ef6c 100644 --- a/apps/springtale-cli/src/commands/init.rs +++ b/apps/springtale-cli/src/commands/init.rs @@ -10,6 +10,11 @@ //! //! The old version appended bot tokens to `springtale.toml` directly. //! That's banned — secrets never land in user-editable TOML files. +//! +//! `--json` is deliberately not honoured here: `init` is an interactive +//! wizard whose stdout is prompts the user answers on stdin, not a result +//! document. Scripted setup goes through `springtale new