mirror of
https://github.com/hanzoai/cli.git
synced 2026-10-30 15:00:27 +00:00
`hanzo status` was not a command. There was no `status` in the subcommand enum, so it parsed as the flattened coding-session TASK and started a headless coding agent — typing a read-only word launched a writer. It is now a real command, and it shows the cloud rather than one corner of it: clusters, the deployed applications and their health, and the machines on the fleet. COMPOSED, never a new API. Three routes cloud already serves, read concurrently through the one authenticated seam: GET /v1/k8s/clusters, GET /v1/deploy/applications, GET /v1/fleet/workers. No fourth wire, no CLI-only side channel. That seam is now a value (`product::Seam`) lifted out of the product tree's own `call` — WHERE and WHO resolved once, then fanned out — so this reads three surfaces without re-deriving the origin or the credential and without a second HTTP client. Two laws it exists to enforce: Most important first. Anything unhealthy leads the page: an application that is not Healthy+Synced, a cluster not running, a node not online. The rest is grouped and COUNTED, so a fleet of 336 healthy applications is three lines rather than 336 rows. A surface that did not answer is UNAVAILABLE, never zero. /v1/deploy/gitops answers 403 to a non-console identity while /v1/k8s/clusters legitimately answers an empty list — those are different facts, and rendering the refusal as "0" reads exactly like a healthy fleet. A refusal prints its status and the server's own words (collapsed to one clipped line, so an ingress error page cannot eat the page it was meant to explain); an empty 200 prints "none reported"; an answer with no list prints "unreadable". "all clear" is claimed only when every surface was actually read. One failing surface never fails the command; failing to read all of them does. Nothing is invented: every field is read through one reader, and a value the server did not state is omitted or reported unknown — never defaulted to a number that looks like health. 1.9.20 was taken by the spec refresh already on main, so this is the next patch. Co-authored-by: Hanzo Dev <[email protected]>
294 lines
10 KiB
Rust
294 lines
10 KiB
Rust
//! Native functionality tests for the SHIPPED `hanzo` binary — the real
|
|
//! executable, real argv, real exit codes. Hermetic tests isolate ALL persisted
|
|
//! state behind `--config <tempdir>` (the global flag, placed after the
|
|
//! subcommand); nothing here reads or writes the developer's own config.
|
|
//!
|
|
//! Live tests (real login against hanzo.id / api.hanzo.ai) are gated on
|
|
//! `HANZO_E2E_TOKEN` — a real hanzo.id bearer. Absent → the test SKIPS with the
|
|
//! env var named on stderr (honest skip, never a fake green). NOTE: a live
|
|
//! login stores a credential through the real vault (the OS keychain on a
|
|
//! desktop; the file vault in CI) for the token's OWN identity — the same slot
|
|
//! a developer's real login for that identity uses — so the suite never runs
|
|
//! `logout` and never removes credentials.
|
|
|
|
use assert_cmd::Command;
|
|
use predicates::prelude::*;
|
|
|
|
/// A `hanzo` invocation with clean, non-interactive output.
|
|
fn hanzo() -> Command {
|
|
let mut cmd = Command::cargo_bin("hanzo").expect("hanzo binary builds");
|
|
cmd.env("NO_COLOR", "1");
|
|
cmd.env("HANZO_NO_ANIMATION", "1");
|
|
cmd
|
|
}
|
|
|
|
/// The `--config <file>` tail every isolated invocation appends.
|
|
fn cfg_args(cfg: &tempfile::TempDir) -> [String; 2] {
|
|
[
|
|
"--config".into(),
|
|
cfg.path().join("config.toml").display().to_string(),
|
|
]
|
|
}
|
|
|
|
fn tmp() -> tempfile::TempDir {
|
|
tempfile::tempdir().expect("tempdir")
|
|
}
|
|
|
|
// ── Version + surface ────────────────────────────────────────────────────────
|
|
|
|
/// `--version` reports the ONE version (Cargo.toml via CARGO_PKG_VERSION).
|
|
#[test]
|
|
fn version_is_the_cargo_version() {
|
|
hanzo()
|
|
.arg("--version")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
|
|
}
|
|
|
|
/// THE DEFECT: `hanzo --version` printed clap's own line and exited, so it never
|
|
/// reached `commands::version::run` — the only thing that reports an install
|
|
/// skew — and it disagreed with `hanzo version` on format. All three spellings
|
|
/// must be ONE function, so their STDOUT must be byte-identical: exactly one
|
|
/// line, `hanzo <semver>`, which is what the Go control binary's delegate parser
|
|
/// reads. A skew report is a warning, so it may never land on stdout.
|
|
#[test]
|
|
fn every_version_spelling_prints_the_one_line() {
|
|
let out = |args: &[&str]| {
|
|
let a = hanzo().args(args).assert().success();
|
|
String::from_utf8_lossy(&a.get_output().stdout).to_string()
|
|
};
|
|
let one = format!("hanzo {}\n", env!("CARGO_PKG_VERSION"));
|
|
assert_eq!(out(&["--version"]), one);
|
|
assert_eq!(out(&["-V"]), one);
|
|
assert_eq!(out(&["version"]), one);
|
|
}
|
|
|
|
/// THE DEFECT: there was no `status` command, so `hanzo status` was parsed as the
|
|
/// flattened coding-session TASK and launched a headless agent. It must be a real
|
|
/// command with its own help.
|
|
#[test]
|
|
fn status_is_a_command_not_a_coding_session() {
|
|
hanzo()
|
|
.args(["status", "--help"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("unhealthy").and(predicate::str::contains("Usage: hanzo status")));
|
|
}
|
|
|
|
/// Signed out, `status` REFUSES and names the way in — it never renders an empty
|
|
/// cloud as though that were the truth.
|
|
#[test]
|
|
fn status_signed_out_refuses_and_never_shows_an_empty_fleet() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["status"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("not signed in").and(predicate::str::contains("hanzo auth login")))
|
|
.stdout(predicate::str::contains("all clear").not());
|
|
}
|
|
|
|
/// `--help` names every major resource the CLI ships (the resource-noun tree),
|
|
/// and the old top-level verbs are GONE.
|
|
#[test]
|
|
fn help_lists_the_resource_nouns() {
|
|
let out = hanzo().arg("--help").assert().success();
|
|
let help = String::from_utf8_lossy(&out.get_output().stdout).to_string();
|
|
for surface in [
|
|
"agent", "auth", "cluster", "config", "model", "node", "runner", "secret",
|
|
"serve", "version", "fabric", "network", "wallet", "billing", "connector", "usage",
|
|
] {
|
|
assert!(help.contains(surface), "`hanzo --help` must list the `{surface}` resource");
|
|
}
|
|
// The relocated verbs are not top-level names any more.
|
|
for gone in ["login", "whoami", "switch", "deploy"] {
|
|
assert!(
|
|
!help.contains(&format!(" {gone} ")),
|
|
"`hanzo --help` must not list `{gone}` as a top-level command"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Network model (hermetic, config-isolated) ────────────────────────────────
|
|
|
|
/// `network list` shows the four built-ins, mainnet active by default, and the
|
|
/// sovereign L1 law (network_id == chain_id) on each.
|
|
#[test]
|
|
fn network_list_shows_builtins_with_mainnet_active() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["network", "list"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("* mainnet")
|
|
.and(predicate::str::contains("testnet"))
|
|
.and(predicate::str::contains("devnet"))
|
|
.and(predicate::str::contains("local"))
|
|
.and(predicate::str::contains("36963"))
|
|
.and(predicate::str::contains("sovereign")),
|
|
);
|
|
}
|
|
|
|
/// `network use` switches and PERSISTS: a later invocation on the same config
|
|
/// reads the selection back. This proves the config write path end to end.
|
|
#[test]
|
|
fn network_use_persists_across_invocations() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["network", "use", "testnet"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success();
|
|
hanzo()
|
|
.args(["network", "current"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("testnet").and(predicate::str::contains("36962")));
|
|
}
|
|
|
|
/// An unknown network is refused with a useful hint, non-zero.
|
|
#[test]
|
|
fn network_use_unknown_fails_with_hint() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["network", "use", "bogus"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("unknown network").and(predicate::str::contains("network add")));
|
|
}
|
|
|
|
/// `network add` registers a sovereign L1 (chain-id defaults to network-id),
|
|
/// `--activate` selects it, and it round-trips through `current`.
|
|
#[test]
|
|
fn network_add_sovereign_roundtrip() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args([
|
|
"network", "add", "my-l1",
|
|
"--network-id", "424242",
|
|
"--rpc", "https://rpc.my-l1.example",
|
|
"--api", "https://api.my-l1.example",
|
|
"--activate",
|
|
])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success();
|
|
hanzo()
|
|
.args(["network", "current"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("my-l1")
|
|
.and(predicate::str::contains("424242")) // chain_id defaulted == network_id
|
|
.and(predicate::str::contains("https://rpc.my-l1.example")),
|
|
);
|
|
}
|
|
|
|
// ── Identity + secrets law (hermetic) ────────────────────────────────────────
|
|
|
|
/// `auth show` with no identity is an honest refusal naming `hanzo auth login`.
|
|
#[test]
|
|
fn auth_show_signed_out_refuses_with_login_hint() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["auth", "show"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("not signed in").and(predicate::str::contains("hanzo auth login")),
|
|
);
|
|
}
|
|
|
|
/// The stdin-secret law: a literal token on argv is REFUSED (it would land in
|
|
/// `ps` and shell history) — the grammar itself demands stdin.
|
|
#[test]
|
|
fn login_refuses_a_literal_token_on_argv() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["auth", "login", "--provider", "hanzo", "--token", "not-a-real-token"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.failure();
|
|
}
|
|
|
|
/// A garbage bearer on stdin fails CLEANLY: no identity can be derived from a
|
|
/// non-JWT, so nothing is stored and the exit is non-zero.
|
|
#[test]
|
|
fn login_rejects_a_garbage_token_from_stdin() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["auth", "login", "--provider", "hanzo", "--token", "-"])
|
|
.args(cfg_args(&cfg))
|
|
.write_stdin("garbage-not-a-jwt\n")
|
|
.assert()
|
|
.failure();
|
|
}
|
|
|
|
/// `wallet list` with no wallets is a clean empty state (exit 0) that names the
|
|
/// create path — never an error, never an invented wallet.
|
|
#[test]
|
|
fn wallet_list_empty_state() {
|
|
let cfg = tmp();
|
|
hanzo()
|
|
.args(["wallet", "list"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("no wallets").and(predicate::str::contains("hanzo wallet create")));
|
|
}
|
|
|
|
// ── Live auth flow (gated on HANZO_E2E_TOKEN) ────────────────────────────────
|
|
|
|
/// Skip helper: honest skip on stderr naming the exact env var.
|
|
fn e2e_token() -> Option<String> {
|
|
match std::env::var("HANZO_E2E_TOKEN") {
|
|
Ok(t) if !t.trim().is_empty() => Some(t.trim().to_string()),
|
|
_ => {
|
|
eprintln!("SKIP: set HANZO_E2E_TOKEN (a real hanzo.id bearer) to run the live auth flow");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The REAL auth flow with a token from env: `login --token -` (stdin) files the
|
|
/// identity, `whoami` reports it, and `usage` reads the live per-account
|
|
/// balances from api.hanzo.ai with that credential. One flow, live.
|
|
#[test]
|
|
fn live_login_whoami_usage_with_env_token() {
|
|
let Some(token) = e2e_token() else { return };
|
|
let cfg = tmp();
|
|
|
|
hanzo()
|
|
.args(["auth", "login", "--provider", "hanzo", "--token", "-"])
|
|
.args(cfg_args(&cfg))
|
|
.write_stdin(format!("{token}\n"))
|
|
.assert()
|
|
.success();
|
|
|
|
hanzo()
|
|
.args(["auth", "show"])
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success()
|
|
// auth show prints the active identity as owner/name — never "not signed in".
|
|
.stdout(predicate::str::contains("/"))
|
|
.stdout(predicate::str::contains("not signed in").not());
|
|
|
|
// `usage` fans out to api.hanzo.ai with each held account's own token. A
|
|
// stacked view always renders; a broke/expired account is a row state, not
|
|
// a crash — so success + at least one row is the honest assertion.
|
|
hanzo()
|
|
.arg("usage")
|
|
.args(cfg_args(&cfg))
|
|
.assert()
|
|
.success();
|
|
}
|