From fc1e43464c1fee9be468d8de6b9298caf2244bf9 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:33:52 +0800 Subject: [PATCH 1/2] feat(cli): consolidate maintenance surface under wright update Group update operations around user intent: plain 'wright update' covers every Wright-managed component (standalone self-update plus installed first-party providers), 'update self' keeps the standalone contract, and 'update provider [NAME]' replaces the top-level provider namespace. Retire the top-level provider, help, and version commands in favor of --help/--version. Closes #439 --- .github/workflows/ci.yml | 2 +- crates/wright-cli/src/cli.rs | 50 +--- crates/wright-cli/src/main.rs | 33 +-- crates/wright-cli/src/provider.rs | 10 + crates/wright-cli/src/update.rs | 193 +++++++++++++- crates/wright-cli/tests/cli.rs | 18 +- crates/wright-cli/tests/update.rs | 307 ++++++++++++++++++++++- crates/wright-driver/src/opy_provider.rs | 156 +++++++++++- docs/cli.md | 4 +- docs/cli/commands.md | 11 +- docs/cli/machine-contract.md | 2 +- docs/cli/update.md | 82 +++--- docs/release.md | 3 +- scripts/release.sh | 2 +- scripts/smoke-native.py | 4 +- 15 files changed, 733 insertions(+), 144 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c61f95c..135a68b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -334,7 +334,7 @@ jobs: && cargo test --locked -p wright-consumer --test consumer - name: Install pinned OPY provider - run: target/debug/wright provider update opy --version 0.1.38 + run: target/debug/wright update provider opy --version 0.1.38 - name: Compile OPY via provider run: >- diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index 0ff414a9..66496694 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use crate::serve::ServeArgs; +use crate::update::UpdateArgs; use clap::{Args, Parser, Subcommand, ValueEnum}; /// The top-level command model used by parsing, help, and completion. @@ -61,8 +62,8 @@ FINDING SELECTION (check, analyze, lint): --max Report at most N findings (withheld counts are shown) UPDATE OPTIONS: - --check Check for an update without modifying the installation - --version Install an exact version instead of the latest stable release"; + --check Resolve update targets and report availability without modifying anything + --version Install an exact version (`update self`, `update provider `)"; #[derive(Debug, Subcommand)] pub(crate) enum Command { @@ -82,16 +83,11 @@ pub(crate) enum Command { Inspect(CommonArgs), /// Generate static shell completion from the command model. Completion(CompletionArgs), - /// Update a standalone installation. + /// Update Wright-managed components: a standalone installation and + /// installed first-party providers. Update(UpdateArgs), - /// Manage first-party language providers. - Provider(ProviderArgs), /// Serve the versioned agent contract over stdio or JSON-RPC 2.0. Serve(ServeArgs), - /// Show the top-level help. - Help, - /// Show version and result-contract metadata. - Version, /// Compare two Workshop texts using canonical WIR semantics (internal gate command). #[command(name = "semantic-compare", hide = true)] SemanticCompare(SemanticCompareArgs), @@ -256,42 +252,6 @@ impl CompletionInstallArgs { } } -#[derive(Debug, Args)] -pub(crate) struct UpdateArgs { - /// Check for an update without modifying the installation. - #[arg(long)] - pub(crate) check: bool, - /// Install an exact version instead of the latest stable release. - #[arg(long, value_name = "VERSION")] - pub(crate) version: Option, -} - -#[derive(Debug, Args)] -pub(crate) struct ProviderArgs { - #[command(subcommand)] - pub(crate) command: ProviderCommand, -} - -#[derive(Debug, Subcommand)] -pub(crate) enum ProviderCommand { - /// Install or update a first-party provider. - Update(ProviderUpdateArgs), -} - -#[derive(Debug, Args)] -pub(crate) struct ProviderUpdateArgs { - /// The provider to install. - pub(crate) provider: ProviderNameArg, - /// Install an exact release instead of the latest stable release. - #[arg(long, value_name = "VERSION")] - pub(crate) version: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -pub(crate) enum ProviderNameArg { - Opy, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub(crate) enum SourceKindArg { Auto, diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index ab5f219c..86621212 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -15,9 +15,7 @@ use wright_driver::config::{InputSpec, LintConfig, OutputFormat, SessionConfig, use wright_driver::result::exit; use wright_driver::source_provider::SourceBackend; -use crate::cli::{ - Cli, Command, CommonArgs, ConvertTargetArg, OutputFormatArg, ProviderCommand, ProviderNameArg, -}; +use crate::cli::{Cli, Command, CommonArgs, ConvertTargetArg, OutputFormatArg}; /// The CLI name and version banner. pub const CLI_NAME: &str = "wright"; @@ -52,16 +50,12 @@ fn main() -> ExitCode { } match cli.command { - None | Some(Command::Help) => { + None => { let mut command = Cli::command(); let _ = command.print_help(); println!(); ExitCode::SUCCESS } - Some(Command::Version) => { - println!("{}", version_banner()); - ExitCode::SUCCESS - } Some(Command::SemanticCompare(args)) => run_semantic_compare(args), Some(Command::Serve(args)) => serve::run(args), Some(Command::Completion(args)) => match args.subcommand { @@ -88,31 +82,13 @@ fn main() -> ExitCode { } }, }, - Some(Command::Update(args)) => match update::run(args.check, args.version.as_deref()) { + Some(Command::Update(args)) => match update::run(&args) { Ok(code) => ExitCode::from(code), Err(error) => { eprintln!("wright: {}", error.message()); ExitCode::from(error.exit_code()) } }, - Some(Command::Provider(args)) => match args.command { - ProviderCommand::Update(update) => match update.provider { - ProviderNameArg::Opy => match provider::update(update.version.as_deref()) { - Ok(resolved) => { - println!( - "installed first-party OPY provider {} at {}", - resolved.version.as_deref().unwrap_or("local"), - resolved.executable.display() - ); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("wright: {}: {}", error.code(), error); - ExitCode::from(error.exit_code()) - } - }, - }, - }, Some(command) => run_workflow(command), } } @@ -204,10 +180,7 @@ fn run_workflow(command: Command) -> ExitCode { ), Command::Completion(_) | Command::Update(_) - | Command::Provider(_) | Command::Serve(_) - | Command::Help - | Command::Version | Command::SemanticCompare(_) => { unreachable!("non-workflow command handled before run_workflow") } diff --git a/crates/wright-cli/src/provider.rs b/crates/wright-cli/src/provider.rs index 2412a1c0..8a38dfd8 100644 --- a/crates/wright-cli/src/provider.rs +++ b/crates/wright-cli/src/provider.rs @@ -1,5 +1,15 @@ use wright_driver::{OpyProviderConfig, OpyProviderError, ResolvedOpyProvider}; +/// The first-party OPY provider recorded in the provider store, if usable. +pub(crate) fn installed() -> Result, OpyProviderError> { + OpyProviderConfig::default().installed() +} + +/// The latest published OPY provider version (pointer fetch only). +pub(crate) fn latest_version() -> Result { + OpyProviderConfig::default().latest_version() +} + /// Explicitly install/update the first-party OPY provider. pub(crate) fn update(version: Option<&str>) -> Result { OpyProviderConfig::default().update(version) diff --git a/crates/wright-cli/src/update.rs b/crates/wright-cli/src/update.rs index ee923dc5..678e9491 100644 --- a/crates/wright-cli/src/update.rs +++ b/crates/wright-cli/src/update.rs @@ -4,7 +4,8 @@ use std::path::{Path, PathBuf}; use std::process::Command as Process; use std::time::Duration; -use wright_driver::sha256_hex; +use clap::{Args, Subcommand, ValueEnum}; +use wright_driver::{OpyProviderError, ResolvedOpyProvider, sha256_hex}; const DEFAULT_BASE_URL: &str = "https://github.com/wrightkit/wright/releases/download"; const DEFAULT_API_URL: &str = "https://api.github.com/repos/wrightkit/wright/releases/latest"; @@ -77,7 +78,191 @@ impl Provenance { } } -pub(crate) fn run(check_only: bool, requested: Option<&str>) -> Result { +#[derive(Debug, Args)] +pub(crate) struct UpdateArgs { + /// Resolve update targets and report available updates without modifying + /// the installation. + #[arg(long)] + pub(crate) check: bool, + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum UpdateCommand { + /// Update the standalone Wright installation only. + #[command(name = "self")] + SelfUpdate(UpdateSelfArgs), + /// Update installed first-party providers, or install/update one provider + /// by name. + Provider(UpdateProviderArgs), +} + +#[derive(Debug, Args)] +pub(crate) struct UpdateSelfArgs { + /// Install an exact version instead of the latest stable release. + #[arg(long, value_name = "VERSION")] + pub(crate) version: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct UpdateProviderArgs { + /// Provider to install or update; omitted updates installed providers only. + pub(crate) provider: Option, + /// Install an exact release instead of the latest stable release. + #[arg(long, value_name = "VERSION", requires = "provider")] + pub(crate) version: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ProviderNameArg { + Opy, +} + +pub(crate) fn run(args: &UpdateArgs) -> Result { + match &args.command { + Some(UpdateCommand::SelfUpdate(sub)) => self_update(args.check, sub.version.as_deref()), + Some(UpdateCommand::Provider(sub)) => Ok(provider_target( + args.check, + sub.version.as_deref(), + sub.provider.is_some(), + )), + None => Ok(update_all(args.check)), + } +} + +/// Plain `wright update`: every target Wright manages — the standalone +/// installation plus already-installed first-party providers. A skipped or +/// failed target never blocks the remaining ones. +fn update_all(check_only: bool) -> u8 { + let mut code = exit::SUCCESS; + match self_update(check_only, None) { + Ok(_) => {} + Err(error @ UpdateError::Unsupported(_)) => { + println!("skipped wright self-update: {}", error.message()); + } + Err(error) => { + eprintln!("wright: {}", error.message()); + code = fold(code, error.exit_code()); + } + } + fold(code, provider_target(check_only, None, false)); + code +} + +fn fold(code: u8, next: u8) -> u8 { + if code == exit::SUCCESS { next } else { code } +} + +/// The first-party provider update target. `may_install` distinguishes +/// `update provider ` (install or update) from the installed-only +/// enumeration used by `update` and `update provider`. +fn provider_target(check_only: bool, requested: Option<&str>, may_install: bool) -> u8 { + let installed = match crate::provider::installed() { + Ok(installed) => installed, + // A platform without provider artifacts has no provider target; an + // explicit request still reports the refusal as a failure. + Err(error @ OpyProviderError::UnsupportedPlatform(_)) if !may_install => { + println!("skipped opy provider: {error}"); + return exit::SUCCESS; + } + Err(error) => return provider_failure(&error), + }; + if check_only { + return provider_check(installed.as_ref(), requested, may_install); + } + if installed.is_none() && !may_install { + println!("opy provider is not installed; skipping"); + return exit::SUCCESS; + } + let previous = installed + .as_ref() + .and_then(|p| p.version.as_deref()) + .map(str::to_string); + match crate::provider::update(requested) { + Ok(resolved) => { + provider_report(previous.as_deref(), &resolved); + exit::SUCCESS + } + Err(error) => provider_failure(&error), + } +} + +fn provider_report(previous: Option<&str>, resolved: &ResolvedOpyProvider) { + let Some(version) = resolved.version.as_deref() else { + println!( + "installed opy provider at {}", + resolved.executable.display() + ); + return; + }; + match previous { + None => println!( + "installed opy provider {version} at {}", + resolved.executable.display() + ), + Some(current) => match compare_versions(current, version) { + Ordering::Equal => println!("opy provider {version} is already up to date"), + Ordering::Less => println!("updated opy provider {current} -> {version}"), + Ordering::Greater => println!("downgraded opy provider {current} -> {version}"), + }, + } +} + +/// `--check` resolution for the provider target: report availability without +/// downloading or modifying anything. +fn provider_check( + installed: Option<&ResolvedOpyProvider>, + requested: Option<&str>, + may_install: bool, +) -> u8 { + let current = installed.and_then(|p| p.version.as_deref()); + // An absent provider is not a target of `update --check`, so it is skipped + // without spending a release-pointer fetch on a version nobody would read. + if current.is_none() && !may_install { + println!("opy provider is not installed; skipping"); + return exit::SUCCESS; + } + let target = match requested { + Some(version) => match parse_version(version) { + Ok(_) => version.trim_start_matches('v').to_string(), + Err(error) => { + eprintln!("wright: {}", error.message()); + return error.exit_code(); + } + }, + None => match crate::provider::latest_version() { + Ok(version) => version, + Err(error) => return provider_failure(&error), + }, + }; + let Some(current) = current else { + println!( + "opy provider is not installed; `wright update provider opy` would install {target}" + ); + return exit::SUCCESS; + }; + match compare_versions(current, &target) { + Ordering::Equal => println!("opy provider {current} is already up to date"), + Ordering::Less => println!( + "update available: opy provider {current} -> {target} (run `wright update provider opy` to install)" + ), + Ordering::Greater if requested.is_some() => println!( + "opy provider {target} is older than the installed {current}; `wright update provider opy --version {target}` would downgrade" + ), + Ordering::Greater => println!( + "installed opy provider {current} is newer than the latest release {target}; keeping {current}" + ), + } + exit::SUCCESS +} + +fn provider_failure(error: &OpyProviderError) -> u8 { + eprintln!("wright: {}: {}", error.code(), error); + error.exit_code() +} + +fn self_update(check_only: bool, requested: Option<&str>) -> Result { let platform = detect_platform()?; let exe = std::env::current_exe() .map_err(|e| UpdateError::failed(format!("could not locate the wright binary: {e}")))?; @@ -219,7 +404,7 @@ fn resolve_latest( )) })?; let tag = val.get("tag_name").and_then(serde_json::Value::as_str).ok_or_else(|| { - UpdateError::failed(format!("could not find the latest release tag in the response from {api_url}; pin a version with `wright update --version`")) + UpdateError::failed(format!("could not find the latest release tag in the response from {api_url}; pin a version with `wright update self --version`")) })?; let version = tag.trim_start_matches('v'); parse_version(version)?; @@ -408,7 +593,7 @@ fn fetch_text(client: &reqwest::blocking::Client, url: &str) -> Result Result, UpdateError> { - let response = client.get(url).send().map_err(|e| UpdateError::failed(format!("could not download {url} ({e}); check the network connection or pin a version with `wright update --version`")))?; + let response = client.get(url).send().map_err(|e| UpdateError::failed(format!("could not download {url} ({e}); check the network connection or pin a version with `wright update self --version`")))?; if !response.status().is_success() { return Err(UpdateError::failed(format!( "could not download {url} (HTTP {})", diff --git a/crates/wright-cli/tests/cli.rs b/crates/wright-cli/tests/cli.rs index 3516ed73..f50033a9 100644 --- a/crates/wright-cli/tests/cli.rs +++ b/crates/wright-cli/tests/cli.rs @@ -920,7 +920,7 @@ fn explicit_locale_override_is_accepted() { #[test] fn version_and_help_are_documented_contract_surfaces() { - let output = run(&["version"]); + let output = run(&["--version"]); assert!(output.status.success()); let banner = String::from_utf8_lossy(&output.stdout); assert!(banner.starts_with("wright "), "{banner}"); @@ -930,11 +930,6 @@ fn version_and_help_are_documented_contract_surfaces() { ); assert!(banner.contains("wright-driver"), "{banner}"); - let output = run(&["--version"]); - assert!(output.status.success()); - let flag_banner = String::from_utf8_lossy(&output.stdout); - assert_eq!(flag_banner, banner, "--version matches `version`"); - let output = run(&["--help"]); assert!(output.status.success()); let help = String::from_utf8_lossy(&output.stdout); @@ -956,6 +951,17 @@ fn version_and_help_are_documented_contract_surfaces() { assert!(help.contains(option), "top-level help documents {option}"); } assert!(help.contains("EXIT CODES")); + + // The consolidated maintenance surface (#439) retires the dedicated + // meta/provider commands; `--help` and `--version` are canonical. + for removed in ["version", "help", "provider"] { + let output = run(&[removed]); + assert_eq!( + output.status.code(), + Some(2), + "{removed} is a usage error now" + ); + } } #[test] diff --git a/crates/wright-cli/tests/update.rs b/crates/wright-cli/tests/update.rs index 2d9d7c64..ede6acb4 100644 --- a/crates/wright-cli/tests/update.rs +++ b/crates/wright-cli/tests/update.rs @@ -1,10 +1,11 @@ -//! End-to-end `wright update` tests (#116) against a mock release server. +//! End-to-end `wright update` tests (#116, #439) against mock release servers. //! //! Serves fake release archives and checksums over a local HTTP server (the //! same shape `scripts/test-install.sh` uses for `install.sh`) and exercises -//! the real `wright` binary: version resolution, `--check` without -//! modification, checksum-verified installs, atomic replacement of both -//! binaries, and the refusal paths. +//! the real `wright` binary: the consolidated update surface (`update`, +//! `update self`, `update provider [opy]`), version resolution, `--check` +//! without modification, checksum-verified installs, atomic replacement of +//! both binaries, and the refusal/skip paths. use std::collections::HashMap; use std::io::{Read, Write}; @@ -20,6 +21,9 @@ use sha2::Digest; /// The fake release version served by the mock server. const RELEASE: &str = "9.9.9"; const TRIPLE: &str = "x86_64-unknown-linux-gnu"; +/// The OPY provider versions used by the provider update tests. +const PROVIDER_OLD: &str = "1.0.0"; +const PROVIDER_RELEASE: &str = "1.4.0"; /// A minimal HTTP/1.1 server serving a fixed path -> body map. struct MockServer { @@ -182,6 +186,94 @@ fn build_archive(version: &str) -> Vec { encoder.finish().unwrap() } +/// The first-party provider release target for the host running the tests; +/// `None` when no provider artifacts exist for this platform. +fn provider_target() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), + ("macos", "x86_64") => Some("x86_64-apple-darwin"), + ("macos", "aarch64") => Some("aarch64-apple-darwin"), + ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), + _ => None, + } +} + +fn provider_binary() -> &'static str { + if provider_target() == Some("x86_64-pc-windows-msvc") { + "opy-provider.exe" + } else { + "opy-provider" + } +} + +/// The OPY provider store under the `WRIGHT_PROVIDER_DATA_DIR` each child gets. +fn provider_store(dir: &Path) -> PathBuf { + dir.join("provider-data").join("providers").join("opy") +} + +/// Record an installed provider in the store without exercising install paths. +fn seed_provider(dir: &Path, version: &str) { + let target = provider_target().expect("provider tests need a supported host"); + let store = provider_store(dir); + let executable = store.join(version).join(target).join(provider_binary()); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::write(&executable, provider_body(version)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::fs::write(store.join("active"), format!("{version}\n")).unwrap(); +} + +fn provider_body(version: &str) -> Vec { + format!("#!/bin/sh\necho \"fake opy-provider {version}\"\n").into_bytes() +} + +/// Provider release files for the mock server: the version pointer, the +/// archive, and its checksum under the canonical `opy-rs` routes. +fn provider_release(version: &str, files: &mut HashMap>) { + let target = provider_target().expect("provider tests need a supported host"); + let binary = provider_binary(); + let name = format!("opy-provider-{version}-{target}.tar.gz"); + let mut builder = tar::Builder::new(Vec::new()); + let body = provider_body(version); + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, binary, &body[..]).unwrap(); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &builder.into_inner().unwrap()).unwrap(); + let archive = encoder.finish().unwrap(); + files.insert( + "/opy-rs/latest/version".to_string(), + format!("{version}\n").into_bytes(), + ); + files.insert( + format!("/opy-rs/releases/{version}/{name}"), + archive.clone(), + ); + files.insert( + format!("/opy-rs/releases/{version}/{name}.sha256"), + format!("{} {name}\n", sha256_hex(&archive)).into_bytes(), + ); +} + +/// Environment overrides that point the child at this server's provider routes. +fn provider_env(server: &MockServer) -> Vec<(&'static str, String)> { + let mut env = server.env(); + env.push(( + "WRIGHT_OPY_PROVIDER_LATEST_URL", + format!("{}/opy-rs/latest/version", server.base_url()), + )); + env.push(( + "WRIGHT_OPY_PROVIDER_BASE_URL", + format!("{}/opy-rs/releases", server.base_url()), + )); + env +} + /// A fresh install directory holding copies of the real test binary. fn install_dir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("wright-update-it-{}-{tag}", std::process::id())); @@ -198,7 +290,8 @@ fn run_update(dir: &Path, args: &[&str], env: &[(&'static str, String)]) -> std: command .args(args) .env("WRIGHT_INSTALL_OS", "linux") - .env("WRIGHT_INSTALL_ARCH", "x86_64"); + .env("WRIGHT_INSTALL_ARCH", "x86_64") + .env("WRIGHT_PROVIDER_DATA_DIR", dir.join("provider-data")); for (key, value) in env { command.env(key, value); } @@ -245,7 +338,7 @@ fn check_with_pinned_version_reports_availability_without_network() { let before = read(&dir.join("wright")); let output = run_update( &dir, - &["update", "--check", "--version", RELEASE], + &["update", "--check", "self", "--version", RELEASE], &[("WRIGHT_INSTALL_BASE_URL", "http://127.0.0.1:1".to_string())], ); assert_eq!(output.status.code(), Some(0)); @@ -430,7 +523,7 @@ fn downgrade_is_refused() { let before = read(&dir.join("wright")); let output = run_update( &dir, - &["update", "--version", "0.0.1"], + &["update", "self", "--version", "0.0.1"], &[("WRIGHT_INSTALL_BASE_URL", "http://127.0.0.1:1".to_string())], ); assert_eq!( @@ -447,6 +540,206 @@ fn downgrade_is_refused() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn update_installs_a_named_provider_into_an_empty_store() { + if provider_target().is_none() { + return; + } + let dir = install_dir("provider-install"); + let mut files = HashMap::new(); + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let output = run_update(&dir, &["update", "provider", "opy"], &provider_env(&server)); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!("installed opy provider {PROVIDER_RELEASE}")), + "{stdout}" + ); + let store = provider_store(&dir); + assert_eq!( + std::fs::read_to_string(store.join("active")) + .unwrap() + .trim(), + PROVIDER_RELEASE + ); + let executable = store + .join(PROVIDER_RELEASE) + .join(provider_target().unwrap()) + .join(provider_binary()); + assert_eq!(read(&executable), provider_body(PROVIDER_RELEASE)); + // The self target is not part of `update provider `. + assert!(!stdout.contains("installing wright"), "{stdout}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn update_upgrades_the_installed_provider_alongside_self() { + if provider_target().is_none() { + return; + } + let dir = install_dir("provider-upgrade"); + seed_provider(&dir, PROVIDER_OLD); + let mut files = release(RELEASE).files; + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let output = run_update(&dir, &["update"], &provider_env(&server)); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!( + "updated opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" + )), + "{stdout}" + ); + assert_eq!( + std::fs::read_to_string(provider_store(&dir).join("active")) + .unwrap() + .trim(), + PROVIDER_RELEASE + ); + assert!( + String::from_utf8_lossy(&read(&dir.join("wright"))) + .contains(&format!("fake wright {RELEASE}")), + "the self target still updated" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn update_does_not_install_an_absent_provider() { + let dir = install_dir("provider-absent"); + let server = MockServer::new(release(RELEASE).files); + let output = run_update(&dir, &["update"], &server.env()); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("opy provider is not installed; skipping"), + "{stdout}" + ); + assert!( + !provider_store(&dir).join("active").exists(), + "a bare update must not create a provider installation" + ); + assert!( + String::from_utf8_lossy(&read(&dir.join("wright"))) + .contains(&format!("fake wright {RELEASE}")), + "the self target still updated" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn check_reports_provider_availability_without_modifying_anything() { + if provider_target().is_none() { + return; + } + let dir = install_dir("provider-check"); + seed_provider(&dir, PROVIDER_OLD); + let mut files = release(RELEASE).files; + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update", "--check"], &provider_env(&server)); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!( + "update available: opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" + )), + "{stdout}" + ); + assert_eq!( + std::fs::read_to_string(provider_store(&dir).join("active")) + .unwrap() + .trim(), + PROVIDER_OLD, + "--check must not touch the active provider" + ); + assert_eq!(read(&dir.join("wright")), before); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn update_provider_without_a_name_skips_an_empty_store() { + let dir = install_dir("provider-none"); + let output = run_update(&dir, &["update", "provider"], &[]); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("opy provider is not installed; skipping"), + "{stdout}" + ); + assert!(!provider_store(&dir).join("active").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_managed_wright_skips_self_update_but_updates_providers() { + if provider_target().is_none() { + return; + } + // The install directory name makes provenance detection classify the + // executable as Homebrew-managed. + let dir = install_dir("homebrew-managed"); + seed_provider(&dir, PROVIDER_OLD); + let mut files = HashMap::new(); + provider_release(PROVIDER_RELEASE, &mut files); + let server = MockServer::new(files); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update"], &provider_env(&server)); + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("skipped wright self-update") && stdout.contains("brew upgrade"), + "the skip line carries actionable package-manager guidance: {stdout}" + ); + assert!( + stdout.contains(&format!( + "updated opy provider {PROVIDER_OLD} -> {PROVIDER_RELEASE}" + )), + "{stdout}" + ); + assert_eq!(read(&dir.join("wright")), before, "self-update was skipped"); + assert_eq!( + std::fs::read_to_string(provider_store(&dir).join("active")) + .unwrap() + .trim(), + PROVIDER_RELEASE + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn explicit_self_update_still_refuses_package_managed_installations() { + let dir = install_dir("homebrew-self"); + let before = read(&dir.join("wright")); + let output = run_update(&dir, &["update", "self"], &[]); + assert_eq!(output.status.code(), Some(3)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("brew upgrade"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(read(&dir.join("wright")), before); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn unknown_update_flag_is_a_usage_error() { let dir = install_dir("update-usage"); diff --git a/crates/wright-driver/src/opy_provider.rs b/crates/wright-driver/src/opy_provider.rs index fc6d4bdb..ca8f3b5f 100644 --- a/crates/wright-driver/src/opy_provider.rs +++ b/crates/wright-driver/src/opy_provider.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::fmt; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -37,6 +38,16 @@ impl OpyProviderConfig { OpyProviderResolver::new(self.store_dir.clone().unwrap_or_else(default_store_dir)) .update(version) } + + pub fn installed(&self) -> Result, OpyProviderError> { + OpyProviderResolver::new(self.store_dir.clone().unwrap_or_else(default_store_dir)) + .installed() + } + + pub fn latest_version(&self) -> Result { + OpyProviderResolver::new(self.store_dir.clone().unwrap_or_else(default_store_dir)) + .latest_version() + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -58,8 +69,10 @@ impl OpyProviderResolver { Self { store_dir: store_dir.into(), target: None, - latest_version_url: DEFAULT_LATEST_VERSION_URL.to_string(), - base_url: DEFAULT_BASE_URL.to_string(), + latest_version_url: std::env::var("WRIGHT_OPY_PROVIDER_LATEST_URL") + .unwrap_or_else(|_| DEFAULT_LATEST_VERSION_URL.to_string()), + base_url: std::env::var("WRIGHT_OPY_PROVIDER_BASE_URL") + .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()), } } @@ -103,6 +116,25 @@ impl OpyProviderResolver { self.install_release(requested_version, &target) } + /// The provider activated in the store, when one is usable. Purely local: + /// it never touches the network and never installs anything. + pub fn installed(&self) -> Result, OpyProviderError> { + let target = self.target()?; + Ok(self + .active_provider(&target)? + .map(|(version, executable)| ResolvedOpyProvider { + executable, + version: Some(version), + })) + } + + /// The latest published provider version. Fetches the version pointer + /// only; nothing is downloaded or installed. + pub fn latest_version(&self) -> Result { + let client = provider_client()?; + self.fetch_latest_version(&client) + } + fn target(&self) -> Result { let target = match &self.target { Some(t) => Ok(t.clone()), @@ -157,10 +189,26 @@ impl OpyProviderResolver { ) -> Result { let requested_version = requested_version.map(normalize_version).transpose()?; let client = provider_client()?; - let version = match requested_version { - Some(v) => v, + let version = match &requested_version { + Some(v) => v.clone(), None => self.fetch_latest_version(&client)?, }; + if let Some((active, executable)) = self.active_provider(target)? { + // An explicit `--version` is the only downgrade contract; a bare + // update keeps a newer install instead of rolling it back. + let already_active = active == version; + let implicit_downgrade = requested_version.is_none() + && matches!( + (version_tuple(&active), version_tuple(&version)), + (Some(installed), Some(resolved)) if installed.cmp(&resolved) == Ordering::Greater + ); + if already_active || implicit_downgrade { + return Ok(ResolvedOpyProvider { + executable, + version: Some(active), + }); + } + } let archive_name = format!("opy-provider-{version}-{target}.{PROVIDER_ARCHIVE_EXTENSION}"); let archive_url = format!( "{}/{version}/{archive_name}", @@ -390,6 +438,16 @@ fn normalize_version(version: &str) -> Result { } } +fn version_tuple(version: &str) -> Option<(u64, u64, u64)> { + let mut parts = version.split('.'); + let tuple = ( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + ); + parts.next().is_none().then_some(tuple) +} + fn is_executable(path: &Path) -> bool { let Ok(metadata) = std::fs::metadata(path) else { return false; @@ -768,6 +826,85 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn installed_reports_the_active_provider_without_network() { + let root = test_root("installed"); + let target = "x86_64-unknown-linux-gnu"; + let resolver = OpyProviderResolver::new(&root) + .with_target(target) + .with_release_urls( + "http://127.0.0.1:1/opy-rs/latest/version", + "http://127.0.0.1:1/opy-rs/releases", + ); + assert_eq!(resolver.installed().unwrap(), None); + let bytes = archive("2.0.0", target, b"cached"); + resolver.install_archive("2.0.0", target, &bytes).unwrap(); + let installed = resolver.installed().unwrap().unwrap(); + assert_eq!(installed.version.as_deref(), Some("2.0.0")); + assert_eq!(std::fs::read(installed.executable).unwrap(), b"cached"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn update_to_the_active_version_never_downloads() { + let root = test_root("active-update"); + let target = "x86_64-unknown-linux-gnu"; + let resolver = OpyProviderResolver::new(&root) + .with_target(target) + .with_release_urls( + "http://127.0.0.1:1/opy-rs/latest/version", + "http://127.0.0.1:1/opy-rs/releases", + ); + let bytes = archive("1.2.3", target, b"first"); + resolver.install_archive("1.2.3", target, &bytes).unwrap(); + let resolved = resolver.update(Some("1.2.3")).unwrap(); + assert_eq!(resolved.version.as_deref(), Some("1.2.3")); + assert_eq!(std::fs::read(resolved.executable).unwrap(), b"first"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_bare_update_keeps_a_newer_installed_provider() { + let root = test_root("newer-installed"); + let target = "x86_64-unknown-linux-gnu"; + let bytes = archive("2.0.0", target, b"newer"); + let resolver = OpyProviderResolver::new(&root).with_target(target); + resolver.install_archive("2.0.0", target, &bytes).unwrap(); + // The release pointer resolves an older version; a bare update must + // not downgrade, so no archive or checksum request follows the + // version-pointer fetch. + let (base_url, requests, server) = + test_server_n(1, b"1.0.0".to_vec(), Vec::new(), Vec::new()); + let resolver = resolver.with_release_urls( + format!("{base_url}/opy-rs/latest/version"), + format!("{base_url}/opy-rs/releases"), + ); + let resolved = resolver.update(None).unwrap(); + server.join().unwrap(); + assert_eq!(requests.load(Ordering::Relaxed), 1); + assert_eq!(resolved.version.as_deref(), Some("2.0.0")); + assert_eq!(std::fs::read(resolved.executable).unwrap(), b"newer"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn latest_version_fetches_only_the_release_pointer() { + let root = test_root("latest"); + let (base_url, requests, server) = + test_server_n(1, b"4.5.6\n".to_vec(), Vec::new(), Vec::new()); + let resolver = OpyProviderResolver::new(&root) + .with_target("x86_64-unknown-linux-gnu") + .with_release_urls( + format!("{base_url}/opy-rs/latest/version"), + format!("{base_url}/opy-rs/releases"), + ); + assert_eq!(resolver.latest_version().unwrap(), "4.5.6"); + server.join().unwrap(); + assert_eq!(requests.load(Ordering::Relaxed), 1); + assert!(!root.join("active").is_file()); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn unsupported_target_is_structured() { let error = OpyProviderResolver::new(test_root("target")) @@ -833,13 +970,22 @@ mod tests { latest_version: Vec, archive: Vec, checksum: Vec, + ) -> (String, Arc, thread::JoinHandle<()>) { + test_server_n(3, latest_version, archive, checksum) + } + + fn test_server_n( + expected_requests: usize, + latest_version: Vec, + archive: Vec, + checksum: Vec, ) -> (String, Arc, thread::JoinHandle<()>) { let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); let address = listener.local_addr().unwrap(); let requests = Arc::new(AtomicUsize::new(0)); let seen = Arc::clone(&requests); let server = thread::spawn(move || { - for _ in 0..3 { + for _ in 0..expected_requests { let (mut stream, _) = listener.accept().unwrap(); respond(&mut stream, &latest_version, &archive, &checksum); seen.fetch_add(1, Ordering::Relaxed); diff --git a/docs/cli.md b/docs/cli.md index 7d05daf0..95c53ddf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -32,9 +32,9 @@ See [lint configuration and findings](cli/lint.md). See [machine-readable CLI contracts](cli/machine-contract.md). -## `wright update` (self-update) +## `wright update` (maintenance) -See [self-update](cli/update.md). +See [update](cli/update.md). ## stdout / stderr ownership diff --git a/docs/cli/commands.md b/docs/cli/commands.md index c3b95ac2..b33385ba 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -39,12 +39,13 @@ result. | `wright serve [INPUT]` | Serve `wright-agent/v1` over stdio or JSON-RPC 2.0 | one structured response per request | | `wright completion ` | Generate static completion script for bash, zsh, fish, or powershell | the generated completion script | | `wright completion install [SHELL]` | Install generated completion into standard user-local directory | installation progress and guidance | -| `wright update` | Self-update a standalone installation | update progress (text only) | +| `wright update [self\|provider [NAME]]` | Update Wright-managed components: a standalone installation and installed first-party providers | update progress (text only) | -`wright version` and `wright --version` print the implementation version -banner (`wright (wright-driver )`); the version is the -single authoritative workspace implementation version and is also reported -inside every `wright-result/v1` envelope. +`wright --version` prints the implementation version banner +(`wright (wright-driver )`); the version is the single +authoritative workspace implementation version and is also reported inside +every `wright-result/v1` envelope. `wright --help` is the canonical help +surface. All commands accept a file path, a project directory, or `-` for stdin. An omitted input uses the current directory. Input kind is detected from the diff --git a/docs/cli/machine-contract.md b/docs/cli/machine-contract.md index aa1cc1d4..d64decf0 100644 --- a/docs/cli/machine-contract.md +++ b/docs/cli/machine-contract.md @@ -9,7 +9,7 @@ | 0 | success | clean check, compiled artifact produced, reconstructed source produced | | 1 | source/user error | parse error, validation error, ambiguous input, unknown input kind, unreadable input, refused downgrade, non-Workshop `convert` input | | 2 | usage error | unknown command/flag, missing option value, missing/unknown `convert --target` | -| 3 | recognized but unsupported | `.opy` stdin via the explicit adapter fallback (default path is native), package-manager-managed installation, unsupported platform for `update`, a `convert` reconstruction rejection (a construct outside the declared OPY/OSTW reconstruction surface) | +| 3 | recognized but unsupported | `.opy` stdin via the explicit adapter fallback (default path is native), package-manager-managed installation for `update self`, unsupported platform for `update self`, a `convert` reconstruction rejection (a construct outside the declared OPY/OSTW reconstruction surface) | | 4 | internal/environment failure | catalog corruption, adapter bridge missing, I/O failure writing output, `update` network/checksum/extraction failure | Exit codes are deterministic for identical inputs and configuration and are diff --git a/docs/cli/update.md b/docs/cli/update.md index fd47893e..54cd1340 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -1,39 +1,55 @@ -# CLI self-update contract +# CLI update contract [← CLI contract index](../cli.md) -## `wright update` (self-update) - -`wright update` upgrades a **standalone** installation (one created by -`install.sh` or by unpacking a release archive manually) from the canonical -GitHub Release artifacts (the same archives and checksums the installer and -the package-manager manifests consume). It is not a compiler workflow, so it -is text-only and produces no `wright-result/v1` envelope. - -* `wright update`: resolve the latest stable release, download the platform - archive and its published SHA-256 checksum, verify the checksum before - touching anything, extract, and atomically replace `wright` and - `wright-lsp` in the running executable's directory, then smoke-check both - binaries report the new version. -* `wright update --check`: resolve and report whether an update is - available without modifying the installation. -* `wright update --version `: install an exact version instead of - the latest stable release. Refuses a downgrade (the installed version is - newer) with exit 1. - -Supported platforms mirror `install.sh`: Linux x86_64 and macOS +## `wright update` (maintenance) + +`wright update` is the single maintenance entry point for the components +Wright itself manages: a standalone installation and the first-party +providers recorded in Wright's provider store. It is not a compiler +workflow, so it is text-only and produces no `wright-result/v1` envelope. + +* `wright update`: update every Wright-managed target — the standalone + installation, plus already-installed first-party providers. It never + installs providers that are not already installed, and a skipped or failed + target does not block the remaining ones. +* `wright update --check`: resolve the same targets and report available + updates without modifying anything. +* `wright update self [--version ]`: update only the standalone + installation, from the canonical GitHub Release artifacts (the same + archives and checksums the installer and the package-manager manifests + consume). The checksum is verified before anything is replaced, then + `wright` and `wright-lsp` are swapped atomically as a matched pair and + smoke-checked against the new version. `--version` installs an exact + version instead of the latest stable release and refuses a downgrade + (exit 1). +* `wright update provider`: update installed first-party providers only. +* `wright update provider [--version ]`: install or update + the named first-party provider (currently `opy`). Provider updates come + from the provider release channel and are checksum-verified; a bare update + keeps an installation that is already at — or newer than — the resolved + release, and an explicit `--version` is the only downgrade path. + +Supported self-update platforms mirror `install.sh`: Linux x86_64 and macOS (x86_64/arm64), mapped to the release target matrix in `docs/release.md`. -On Windows, standalone self-update is refused with guidance to -`winget upgrade WrightKit.Wright` / `scoop update wright` (exit 3). +On Windows, standalone self-update is unsupported with guidance to +`winget upgrade WrightKit.Wright` / `scoop update wright`. Package-manager-managed installations are detected from the executable's -location (Homebrew/Cellar, Scoop, WinGet paths) and refused with guidance to -the channel's own upgrade command (exit 3); `wright update` never overwrites -a binary it does not own. A missing `wright-lsp` next to `wright`, or an -unwritable installation directory, fails with reinstall guidance (exit 4). - -Environment overrides (test/advanced hooks, matching `install.sh`): - -* `WRIGHT_INSTALL_BASE_URL`: base URL of release artifacts -* `WRIGHT_API_URL`: URL used to resolve the latest release -* `WRIGHT_INSTALL_OS` / `WRIGHT_INSTALL_ARCH`: override platform detection +location (Homebrew/Cellar, Scoop, WinGet paths). Through `wright update` the +self target is skipped with guidance to the channel's own upgrade command +while Wright-managed providers still update; the explicit +`wright update self` refuses with exit 3. Wright never overwrites a binary +it does not own. A missing `wright-lsp` next to `wright`, or an unwritable +installation directory, fails the self target with reinstall guidance +(exit 4). + +Environment overrides (test/advanced hooks): + +* `WRIGHT_INSTALL_BASE_URL`: base URL of Wright release artifacts +* `WRIGHT_API_URL`: URL used to resolve the latest Wright release +* `WRIGHT_INSTALL_OS` / `WRIGHT_INSTALL_ARCH`: override self-update platform + detection (matching `install.sh`) +* `WRIGHT_PROVIDER_DATA_DIR`: provider store root +* `WRIGHT_OPY_PROVIDER_LATEST_URL` / `WRIGHT_OPY_PROVIDER_BASE_URL`: provider + release routes diff --git a/docs/release.md b/docs/release.md index 4422564b..79b84951 100644 --- a/docs/release.md +++ b/docs/release.md @@ -47,8 +47,7 @@ The binaries report the workspace implementation version (one authoritative `version.workspace = true`). A stable release accepts only a stable `MAJOR.MINOR.PATCH` version; prerelease and build metadata are rejected. It synchronizes that version into `Cargo.lock`, `version.txt`, and the checked-in -`dist/` manifests before committing the release version on `main`. `wright version` / -`wright --version` prints the CLI banner, `wright-lsp --version` prints the LSP +`dist/` manifests before committing the release version on `main`. `wright --version` prints the CLI banner, `wright-lsp --version` prints the LSP banner, and the LSP `initialize` response carries `serverInfo.version`. Every `wright-result/v1` envelope carries `wright.version` + `wright.contract`. The release archive's `version.json` is the authoritative stamp for a shipped diff --git a/scripts/release.sh b/scripts/release.sh index c9db13d0..01050b7a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -35,7 +35,7 @@ test -x "$LSP_BIN" || { echo "release LSP binary missing"; exit 1; } test -x "$BENCH_BIN" || { echo "release benchmark binary missing"; exit 1; } echo "==> first-party provider integration (compat profile)" -"$BIN" provider update opy --version 0.1.38 +"$BIN" update provider opy --version 0.1.38 "$BIN" compile "$ROOT/tests/fixtures/opy/basic-rule.opy" --profile compat >/dev/null echo "==> benchmarks" diff --git a/scripts/smoke-native.py b/scripts/smoke-native.py index 5606d5b8..25356033 100644 --- a/scripts/smoke-native.py +++ b/scripts/smoke-native.py @@ -79,7 +79,7 @@ def main() -> None: if not (ROOT / path).is_file(): fail(f"{name} is missing: {ROOT / path}") - for name, binary in (("wright version", wright), ("wright-lsp version", lsp)): + for name, binary in (("wright --version", wright), ("wright-lsp --version", lsp)): output = run(name, [str(binary), "--version"]) if args.version not in output: fail(f"{name} did not report version {args.version}: {output.strip()}") @@ -112,7 +112,7 @@ def main() -> None: env["WRIGHT_PROVIDER_DATA_DIR"] = store run( "first-party OPY provider bootstrap from clean state", - [str(wright), "provider", "update", "opy"], + [str(wright), "update", "provider", "opy"], env, ) From d8ef665f0ec8c162ea2606cf12de764e45823ec1 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:03:19 +0800 Subject: [PATCH 2/2] fix(cli): name the pinned install command in update --check guidance A pinned 'wright update --check self --version V' suggested 'wright update', which installs the latest release rather than the checked version. The hint now names 'wright update self --version V' when a version was requested. --- crates/wright-cli/src/update.rs | 8 +++++--- crates/wright-cli/tests/update.rs | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/wright-cli/src/update.rs b/crates/wright-cli/src/update.rs index 678e9491..3b6d8a75 100644 --- a/crates/wright-cli/src/update.rs +++ b/crates/wright-cli/src/update.rs @@ -322,9 +322,11 @@ fn self_update(check_only: bool, requested: Option<&str>) -> Result {target_version} (run `wright update` to install)" - ); + let hint = match requested { + Some(_) => format!("wright update self --version {target_version}"), + None => "wright update".to_string(), + }; + println!("update available: {current} -> {target_version} (run `{hint}` to install)"); return Ok(exit::SUCCESS); } diff --git a/crates/wright-cli/tests/update.rs b/crates/wright-cli/tests/update.rs index ede6acb4..c4f8ea0f 100644 --- a/crates/wright-cli/tests/update.rs +++ b/crates/wright-cli/tests/update.rs @@ -342,7 +342,12 @@ fn check_with_pinned_version_reports_availability_without_network() { &[("WRIGHT_INSTALL_BASE_URL", "http://127.0.0.1:1".to_string())], ); assert_eq!(output.status.code(), Some(0)); - assert!(String::from_utf8_lossy(&output.stdout).contains("update available")); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("update available"), "{stdout}"); + assert!( + stdout.contains(&format!("wright update self --version {RELEASE}")), + "a pinned check names the pinned install command: {stdout}" + ); assert_eq!(read(&dir.join("wright")), before); let _ = std::fs::remove_dir_all(&dir); }