diff --git a/spec/functional/FR-016-update-subcommand.md b/spec/functional/FR-016-update-subcommand.md new file mode 100644 index 0000000..2ae0709 --- /dev/null +++ b/spec/functional/FR-016-update-subcommand.md @@ -0,0 +1,89 @@ +--- +id: FR-016 +title: "quire update subcommand (install-source-aware self-update)" +type: FR +object_type: cli_command +relationships: + - target: "ix://agent-ix/quire-cli/spec/stakeholder/StR-001" + type: "implements" + cardinality: "1:1" +--- + +## Description + +The CLI SHALL expose an `update` subcommand that upgrades the installed `quire` +binary to the latest published version. Because the single binary +([StR-001](../stakeholder/StR-001-static-binary-hot-path.md)) is distributed +through more than one channel — the npm prebuilt-binary wrapper +(`@agent-ix/quire-cli`) and `cargo install --git` — `update` SHALL first detect +**how the running binary was installed** and then drive the matching package +manager. + +`update` is the one subcommand exempt from the quire-rs thin-boundary +([StR-004](../stakeholder/StR-004-thin-boundary-over-quire-rs.md)): it manages +binary lifecycle, not artifact behavior, so it carries no parser/renderer/ +validator logic and wraps no `quire-rs` API. Its logic lives in a deliberately +**package-agnostic** `self_update` engine (config-struct driven, no quire +coupling), so the engine can later move verbatim into a shared Rust CLI kit; the +`update` command itself is a thin wrapper that supplies quire's distribution +coordinates. + +The npm wrapper version and the Cargo crate version are independently numbered, +so `update` SHALL NOT compare the running binary's version against a registry — +a cross-scheme diff is meaningless. Idempotency is delegated to npm/cargo, which +already no-op (or rebuild) when current. + +## Behavior + +``` +quire update [--check] [--registry ] +``` + +1. Resolve the running executable path (`current_exe`) and classify the install + source: a path under a `node_modules` tree ⇒ **npm**; a path under `.cargo` + ⇒ **cargo**; anything else ⇒ **unknown**. +2. **npm channel**: + - `--check`: run `npm view @agent-ix/quire-cli version` and report the latest + published version; install nothing. + - otherwise: run `npm install -g @agent-ix/quire-cli@latest` with inherited + stdio. + - When `--registry ` is given, the override is applied as the + **scope-specific** `--@agent-ix:registry=` form, because a plain + `--registry` is silently ignored for a scoped package when the user's npmrc + pins a `@scope:registry`. With no `--registry`, the ambient npm config + resolves the package (mirroring how it was installed). +3. **cargo channel**: + - `--check`: report that cargo installs track the git default branch (no + single published version to compare); install nothing. + - otherwise: run `cargo install --git https://github.com/agent-ix/quire-cli + --force` with inherited stdio. +4. **unknown source**: never guess and clobber a binary the tool did not place. + Print manual upgrade instructions (the npm and cargo recipes plus the + releases URL) and exit **0** — performing no install, touching no network. +5. The summary lines describing the detected source and action are the + subcommand's primary output on **stdout**; any npm/cargo progress is the + child process's own inherited output. +6. Exit codes: **0** on success (including `--check` and the unknown-source + manual path); **1** when an invoked `npm`/`cargo` command fails or the + registry cannot be reached. + +## Acceptance Criteria + +| ID | Criteria | Verification | +|----|----------|--------------| +| FR-016-AC-1 | A binary path under `node_modules/...` classifies as the npm channel; a path under `.cargo/...` classifies as the cargo channel; any other path classifies as unknown | Test | +| FR-016-AC-2 | On an unknown source, `quire update` (with or without `--check`) prints manual upgrade instructions including the npm recipe, the cargo recipe, and the releases URL, exits **0**, and performs no install | Test | +| FR-016-AC-3 | On the npm channel, `--check` runs `npm view @agent-ix/quire-cli version` and reports the latest version without installing | Test | +| FR-016-AC-4 | A `--registry ` override for the scoped package is passed as `--@agent-ix:registry=` (scope form), not a bare `--registry`; with no override no registry flag is added | Test | +| FR-016-AC-5 | `update` performs no version comparison against the running binary and shells out to npm/cargo for idempotency (no cross-scheme version diff in `src/`) | Inspection | +| FR-016-AC-6 | The `self_update` engine is package-agnostic: it takes quire's coordinates via a config struct and imports nothing from quire's `io`/command context, so it is extractable into a shared crate; `commands/update.rs` is the only quire-specific glue | Inspection | +| FR-016-AC-7 | A failing `npm`/`cargo` invocation, or an unreachable registry, exits **1** | Test | + +## Dependencies + +- **Upstream**: none in `quire-rs` (binary-lifecycle feature, not an engine + behavior). The shared `self_update` engine is in-crate today and is the + intended extraction unit for a future Rust CLI kit. +- **Downstream**: agent/CI setup docs that pin a single binary version + ([StR-001](../stakeholder/StR-001-static-binary-hot-path.md)) — `update` + keeps that pinned binary current. diff --git a/spec/functional/index.md b/spec/functional/index.md index 5fba4ac..b4ab782 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -22,3 +22,4 @@ description: "Index of artifacts in this directory." * [FR-013: quire lint subcommand](./FR-013-lint-subcommand.md) * [FR-014: quire validate --okf bundle posture](./FR-014-validate-okf-bundle.md) * [FR-015: quire fix subcommand (unlinked-reference autofix)](./FR-015-fix-subcommand.md) +* [FR-016: quire update subcommand (install-source-aware self-update)](./FR-016-update-subcommand.md) diff --git a/spec/log.md b/spec/log.md index ff833ed..847d07e 100644 --- a/spec/log.md +++ b/spec/log.md @@ -10,4 +10,5 @@ description: "Chronological log of structural changes to this bundle." * **2026-06-15** — Adopted OKF-compatible bundle structure with directory indexes. * **2026-06-16** — Added [FR-014](./functional/FR-014-validate-okf-bundle.md) (`quire validate --okf` permissive OKF bundle posture: `type` required, unknown-type/broken-link/index-incompleteness warn). Added [FR-003-AC-5](./functional/FR-003-extract-subcommand.md) (extract emits shared `[frontmatter]` untyped-document diagnostic). Backsynced the `artifact_type` → `type` discriminator rename across [FR-003](./functional/FR-003-extract-subcommand.md)/004/007/013 and spec.md via CR notes. Mapped IT-069..072 (`tests/cli_okf.rs`) + IT-026 reuse in tests.md. * **2026-06-17** — Added [FR-015](./functional/FR-015-fix-subcommand.md) (`quire fix` subcommand, ADR 0007): surfaces quire-rs unlinked-reference suggestions (FR-039) and, with `--write`, applies the auto-fixable ones via byte-exact writeback. Dry-run lists `would-fix`/`warning` and exits 1 when auto-fixes remain (CI gate); `--write` is idempotent; warn-only (unresolved/ambiguous) tokens are never written. Mapped IT-076..080 + AUDIT-002 (thin boundary) in tests.md. +* **2026-06-20** — Added [FR-016](./functional/FR-016-update-subcommand.md) (`quire update` subcommand): install-source-aware self-update that detects the install channel from `current_exe` (`node_modules` ⇒ npm, `.cargo` ⇒ cargo, else unknown→manual instructions) and drives the matching package manager. No cross-scheme version diff (npm-wrapper and Cargo versions are decoupled); idempotency delegated to npm/cargo. A `--registry` override is applied as the scope-specific `--@agent-ix:registry=` form. Logic lives in a package-agnostic `self_update` engine (kit-extraction unit); the command is a thin wrapper. Deliberate exception to the quire-rs thin boundary ([StR-004](./stakeholder/StR-004-thin-boundary-over-quire-rs.md)) since it manages binary lifecycle, not artifact behavior. * **2026-06-19** — [FR-004](./functional/FR-004-validate-subcommand.md) scoped discovery now also searches the default install root `~/.ix/filament/modules` and `IX_FILAMENT_MODULES_PATH` (preferred over the legacy `IX_SCHEMA_PATH`); on zero discovered modules it lazy-installs the default set via `quoin plugin ensure-defaults` and reloads once (FR-004-AC-13/AC-14). Added [ADR-0001](./assets/adr/0001-validate-lazy-init-module-bootstrap.md) and amended [NFR-004](./non-functional/NFR-004-no-network.md) via CR note: the no-network guarantee is scoped to quire's own process; the lazy-init's `quoin` child is the sole documented network exception. Mapped IT-081 (scoped discovery network-free) + IT-082 (quoin-absent actionable error) in tests.md. diff --git a/spec/tests.md b/spec/tests.md index 0f3b153..e16b3de 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -36,7 +36,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | StR | Trace to US/FR | Verifying IT/BENCH/AUDIT | Status | |-----|---------------|--------------------------|--------| -| StR-001 Static binary hot path (revised — surviving subcommands) | US-002, US-003, US-004, US-005, FR-002..012 | IT-002, IT-004, IT-047, IT-033, AUDIT-001 (ldd), AUDIT-003 (no-network) | ✅ | +| StR-001 Static binary hot path (revised — surviving subcommands) | US-002, US-003, US-004, US-005, FR-002..012, FR-016 (binary-lifecycle: keeps the pinned binary current) | IT-002, IT-004, IT-047, IT-033, IT-083, AUDIT-001 (ldd), AUDIT-003 (no-network) | ✅ | | StR-002 Sub-50 ms render budget | ⊘ RETIRED (§2bis) | — (render bench removed) | ⊘ | | StR-003 Sandbox inheritance (revised — path-safety) | FR-005 | IT-005 (..), IT-006 (symlink escape), IT-055 (doc path safety) | ✅ | | StR-004 Thin boundary | FR-002..004, FR-009, FR-011, FR-014, NFR-005 | AUDIT-002 (src grep for parse logic), IT-033..038 | ✅ | @@ -70,6 +70,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | FR-013 lint subcommand | AC-1..5 | IT-064 (clean exit 0 silent), IT-065 (warning exit 0 + stderr), IT-066 (error exit 1), IT-067 (--archetype scoping), IT-068 (missing manifest fails fast — also covers the FR-004 CR-note eager-loader behavior for validate/extract/schema) | ✅ | | FR-014 validate --okf bundle posture (`type` discriminator) | AC-1..9 | IT-069 (untyped → exit 1, `[frontmatter]`), IT-070 (unknown type + broken link → warn, exit 0), IT-071 (index incompleteness → warn, exit 0), IT-072 (defaults to --scope dir), IT-026 (bare `validate` no `--okf` → exit 2, `required_unless_present`), AUDIT-002 (thin boundary) | ✅ | | FR-015 fix subcommand (unlinked-reference autofix, ADR 0007) | AC-1..6 | IT-076 (dry-run `would-fix` → exit 1, no write), IT-077 (`--write` applies + idempotent re-run exit 0), IT-078 (warn-only never written, no nonzero exit), IT-079 (clean bundle exit 0 empty stdout), IT-080 (`--scope` root + path-safety reject), AUDIT-002 (thin boundary) | 🚧 | +| FR-016 update subcommand (install-source-aware self-update) | AC-1..7 | IT-083 (Unknown source: `update --check` prints npm+cargo+releases recipes, exit 0, no install/network), IT-084 (Unknown source: bare `update` also no-install, exit 0), UT-SU-1 (`detect_source` npm/cargo/unknown classification — `self_update::tests`), UT-SU-2 (`registry_args` scope-form for scoped pkg / plain for unscoped / empty when no override), UT-SU-3 (`cargo` `--check` reports branch-tracking, `latest: None`), AUDIT-002 (thin boundary — `update` carries no parser/validator), AUDIT-005 (`self_update` engine imports nothing from quire's `io`/command ctx — package-agnostic) ⚠️ npm-channel `--check`/install (AC-3), registry-unreachable/`npm`-fail exit 1 (AC-7), and cargo install (part of AC-1 dispatch) have **no automated trace** (network + global-install side effects) | ⚠️ | ## Non-Functional Requirement Coverage @@ -170,11 +171,17 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | IT-078 | A warn-only (unresolved/ambiguous) token is surfaced as `warning: … ()`, never written even under `--write`, and does not alone cause a nonzero exit | Integration | P0 | FR-015-AC-3 | | IT-079 | A clean bundle (no auto-fix findings) exits 0 with empty stdout in both dry-run and `--write` | Integration | P1 | FR-015-AC-4 | | IT-080 | `quire fix --scope --module $M` with no positional uses `--scope` as root; a `..`/symlink-escape on root or `--module` is rejected by path-safety before any load | Integration | P0 | FR-015-AC-5, FR-005 | +| IT-083 | `update --check` on an Unknown install source (test binary under `target/`) prints manual instructions (npm recipe + cargo recipe + releases URL), exits 0, performs no install/network (`cli_update::update_check_on_unknown_source_prints_manual_instructions_and_exits_zero`) | Integration | P0 | FR-016-AC-1, FR-016-AC-2 | +| IT-084 | bare `update` (no `--check`) on an Unknown source also performs no install and exits 0 (`cli_update::update_without_check_on_unknown_source_is_also_safe`) | Integration | P1 | FR-016-AC-2 | +| UT-SU-1 | `detect_source` classifies `node_modules` path → Npm, `.cargo` path → Cargo, bare path → Unknown (`self_update::tests::detect_*`) | Unit | P0 | FR-016-AC-1 | +| UT-SU-2 | `registry_args` yields the `--@scope:registry=` form for a scoped package, a plain `--registry ` for an unscoped package, and an empty vec when no override is supplied (`self_update::tests::registry_args_*`) | Unit | P0 | FR-016-AC-4 | +| UT-SU-3 | `run_for_source(Cargo, --check)` reports git-branch tracking with `latest: None` (no cross-scheme version); `run_for_source(Unknown)` emits manual report without installing (`self_update::tests`) | Unit | P1 | FR-016-AC-1, FR-016-AC-5 | | BENCH-001 | ⊘ RETIRED (§2bis) — hyperfine render p95 ≤ 50 ms on FR archetype | Benchmark | P0 | NFR-001-AC-1..2 (retired), StR-002 (retired) | | AUDIT-001 | `ldd` shows only libc + loader (no project .so) | Static | P0 | NFR-002-AC-1 | | AUDIT-002 | `src/` grep finds no markdown parsing, no structural-validation logic, and **no render/template code** (validation delegated to quire-rs `validate_document` / `validate_bundle_at`; render removed per §2bis) | Static | P1 | StR-004-AC-2, FR-004-AC-9, FR-014-AC-9, FR-015-AC-6 | | AUDIT-003 | `cargo deny check bans` rejects HTTP client crates | Static | P0 | NFR-004-AC-1 | | AUDIT-004 | `scripts/check_unsafe_comments.sh` zero unsafe in src/ + tests/ | Static | P0 | NFR-003-AC-1 | +| AUDIT-005 | `src/self_update/` imports nothing from `quire`'s `io`/command context (engine is package-agnostic, config-struct driven); `commands/update.rs` is the only quire-specific glue and carries no parser/renderer/validator logic | Static | P1 | FR-016-AC-5, FR-016-AC-6, StR-004-AC-2 | --- diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5484a1c..095a0aa 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ -//! Subcommand implementations. Each module owns one of the seven -//! `quire ` subcommands and stays a thin wrapper over `quire-rs`. +//! Subcommand implementations. Each module owns one `quire ` subcommand. +//! The quire-rs-backed verbs stay thin wrappers over the engine; `update` is a +//! thin wrapper over the package-agnostic `self_update` engine instead. pub mod edit; pub mod extract; @@ -8,6 +9,7 @@ pub mod lint; pub mod lookup; pub mod parse; pub mod schema; +pub mod update; pub mod validate; use std::path::Path; diff --git a/src/commands/update.rs b/src/commands/update.rs new file mode 100644 index 0000000..d0cce89 --- /dev/null +++ b/src/commands/update.rs @@ -0,0 +1,51 @@ +//! `quire update [--check] [--registry ]` +//! +//! Thin, quire-specific wrapper over the package-agnostic +//! [`quire_cli::self_update`] engine. This file is the ONLY place quire's +//! install coordinates live; the engine itself is reusable and is the unit +//! intended to move into a shared Rust CLI kit later. + +use clap::Parser; + +use quire_cli::io; +use quire_cli::self_update::{self, SelfUpdateConfig, SelfUpdateOpts}; + +use super::Ctx; + +/// quire's distribution coordinates. Quire ships on public npm +/// (`@agent-ix/quire-cli`, prebuilt-binary wrapper) and via `cargo install` +/// from its GitHub repo. +const CONFIG: SelfUpdateConfig = SelfUpdateConfig { + npm_package: "@agent-ix/quire-cli", + cargo_git: "https://github.com/agent-ix/quire-cli", + releases_url: "https://github.com/agent-ix/quire-cli/releases", +}; + +#[derive(Parser, Debug)] +pub struct Args { + /// Report whether an update is available without installing. + #[arg(long)] + pub check: bool, + + /// Force an npm registry to query/install from (npm channel only). + /// Defaults to the ambient npm config — i.e. however quire was installed. + #[arg(long, value_name = "URL")] + pub registry: Option, +} + +pub fn run(_ctx: &Ctx, args: Args) -> anyhow::Result<()> { + let report = self_update::run_self_update( + &CONFIG, + &SelfUpdateOpts { + check: args.check, + registry: args.registry, + }, + )?; + + // The engine already let npm/cargo draw their own progress to the inherited + // streams; here we print the summary lines as the command's primary output. + for line in &report.messages { + io::write_primary_stdout(format!("{line}\n").as_bytes())?; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 9c56ac7..b358f2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,4 @@ pub mod io; pub mod safety; +pub mod self_update; diff --git a/src/main.rs b/src/main.rs index e92076f..a66f336 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ //! `quire` binary entry point. //! -//! Dispatches to one of six subcommands: `parse`, `extract`, `lookup`, -//! `edit`, `validate`, `schema`. Every command is a thin wrapper over `quire-rs` — -//! no markdown parsing or structural-validation logic lives in this crate -//! (StR-004). +//! Dispatches to the `quire ` subcommands: `parse`, `extract`, `lookup`, +//! `edit`, `validate`, `schema`, `lint`, `fix`. Each is a thin wrapper over +//! `quire-rs` — no markdown parsing or structural-validation logic lives in +//! this crate (StR-004). `update` is the one exception: it wraps the +//! package-agnostic `self_update` engine instead of `quire-rs`. use clap::{Parser, Subcommand}; @@ -53,6 +54,8 @@ enum Command { Lint(commands::lint::Args), /// Surface (and with --write, apply) internal relative-path link fixes. Fix(commands::fix::Args), + /// Check for and install the latest quire (auto-detects npm vs cargo). + Update(commands::update::Args), } fn main() { @@ -70,6 +73,7 @@ fn main() { Command::Schema(a) => commands::schema::run(&ctx, a), Command::Lint(a) => commands::lint::run(&ctx, a), Command::Fix(a) => commands::fix::run(&ctx, a), + Command::Update(a) => commands::update::run(&ctx, a), }; match result { Ok(()) => std::process::exit(exit::OK), diff --git a/src/self_update/mod.rs b/src/self_update/mod.rs new file mode 100644 index 0000000..f93bac3 --- /dev/null +++ b/src/self_update/mod.rs @@ -0,0 +1,349 @@ +//! Self-update for a CLI distributed as a static binary. +//! +//! **This module is deliberately package-agnostic.** It has no dependency on +//! `quire`'s I/O layer, command context, or package identity — everything a +//! concrete CLI needs to supply arrives through [`SelfUpdateConfig`]. The +//! intent is that when the IX CLIs are ported to Rust behind a shared CLI kit +//! crate, this whole file moves there verbatim and only the thin command +//! wrapper (`commands/update.rs`) is rewritten per binary. +//! +//! The binary can arrive through more than one install channel, so a generic +//! self-update must first work out *how it was installed* and then drive the +//! matching package manager. We never diff the running binary's version +//! against the registry: a CLI's npm wrapper and its Cargo crate version are +//! independently numbered, so a naive compare is meaningless. Instead we hand +//! idempotency to npm/cargo, which already no-op when up to date. + +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Context, Result}; + +/// How the running binary was placed on disk. Determines which package manager +/// (if any) can upgrade it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallSource { + /// Installed via npm (prebuilt-binary wrapper); the executable lives under + /// a `node_modules` tree. + Npm, + /// Installed via `cargo install`; the executable lives under `~/.cargo`. + Cargo, + /// Some other placement (prebuilt tarball dropped on `$PATH`, a copied + /// binary, a dev build). We cannot safely guess an upgrade command. + Unknown, +} + +/// The only CLI-specific data the generic engine needs. A consuming binary +/// supplies these as constants. +pub struct SelfUpdateConfig { + /// npm package name, e.g. `@agent-ix/quire-cli`. + pub npm_package: &'static str, + /// Git repository for `cargo install --git`, e.g. + /// `https://github.com/agent-ix/quire-cli`. + pub cargo_git: &'static str, + /// Releases page surfaced in the manual/Unknown path. + pub releases_url: &'static str, +} + +/// Runtime options parsed from the command line. +pub struct SelfUpdateOpts { + /// Report availability without installing. + pub check: bool, + /// Override the npm registry (npm channel only). When `None`, npm resolves + /// the package via the ambient config — i.e. however it was installed. + pub registry: Option, +} + +/// npm flags that force `registry` for a scoped/unscoped package. A plain +/// `--registry` is silently ignored for a scoped package when the user's npmrc +/// pins a `@scope:registry`; the scope-specific override is the one npm +/// honors. Returns an empty vec when no override is requested (ambient config +/// resolves the package). +fn registry_args(npm_package: &str, registry: Option<&str>) -> Vec { + let Some(registry) = registry else { + return Vec::new(); + }; + if let Some((scope, _)) = npm_package + .split_once('/') + .filter(|_| npm_package.starts_with('@')) + { + vec![format!("--{scope}:registry={registry}")] + } else { + vec!["--registry".to_string(), registry.to_string()] + } +} + +/// What [`run_self_update`] decided/did. Reporting is the caller's job — this +/// keeps the engine free of any I/O-format policy (kit-ready). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// `--check` only: no changes made. `latest` carries the npm-reported + /// version when the source is npm; `None` for the cargo channel (which + /// tracks a git branch and has no single "latest" to report). + Checked { latest: Option }, + /// An upgrade command ran to completion. + Installed, + /// The install source was unknown; the report carries manual instructions. + Manual, +} + +/// The outcome of a self-update attempt: the detected source, the action +/// taken, and human-readable summary lines for the caller to render. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelfUpdateReport { + pub source: InstallSource, + pub action: Action, + pub messages: Vec, +} + +/// Classify an install from the binary's path. Pure and I/O-free so it is unit +/// testable without touching the real executable. Symlinked global bins (npm, +/// pnpm) resolve through `current_exe()` into the store, so a `node_modules` +/// component is still present after resolution. Matching whole path +/// *components* (not substrings) avoids false positives like `.cargo-backup` +/// or a project literally named `node_modules-tools`. npm wins if both appear. +pub fn detect_source(exe_path: &Path) -> InstallSource { + let mut npm = false; + let mut cargo = false; + for component in exe_path.components() { + if let std::path::Component::Normal(c) = component { + if c == "node_modules" { + npm = true; + } else if c == ".cargo" { + cargo = true; + } + } + } + if npm { + InstallSource::Npm + } else if cargo { + InstallSource::Cargo + } else { + InstallSource::Unknown + } +} + +/// Detect the install source from the running executable and dispatch the +/// matching upgrade path. Shelling out to npm/cargo (with inherited stdio so +/// their progress is visible) is the one side effect, and it is gated behind +/// `opts.check`. +pub fn run_self_update(cfg: &SelfUpdateConfig, opts: &SelfUpdateOpts) -> Result { + let exe = std::env::current_exe().context("resolving the running executable path")?; + run_for_source(cfg, opts, detect_source(&exe)) +} + +/// Source-injected core, split out so tests can exercise every channel without +/// manipulating the real `current_exe()`. +pub fn run_for_source( + cfg: &SelfUpdateConfig, + opts: &SelfUpdateOpts, + source: InstallSource, +) -> Result { + match source { + InstallSource::Npm => npm_update(cfg, opts), + InstallSource::Cargo => cargo_update(cfg, opts), + InstallSource::Unknown => Ok(manual_report(cfg)), + } +} + +fn npm_update(cfg: &SelfUpdateConfig, opts: &SelfUpdateOpts) -> Result { + let reg_args = registry_args(cfg.npm_package, opts.registry.as_deref()); + + if opts.check { + let mut args = vec!["view", cfg.npm_package, "version"]; + args.extend(reg_args.iter().map(String::as_str)); + let latest = run_capture("npm", &args) + .context("querying the npm registry for the latest version")?; + let messages = vec![ + format!("installed via npm ({})", cfg.npm_package), + format!("latest published version: {latest}"), + "run `quire update` to upgrade".to_string(), + ]; + return Ok(SelfUpdateReport { + source: InstallSource::Npm, + action: Action::Checked { + latest: Some(latest), + }, + messages, + }); + } + + let spec = format!("{}@latest", cfg.npm_package); + let mut args = vec!["install", "-g", spec.as_str()]; + args.extend(reg_args.iter().map(String::as_str)); + run_inherited("npm", &args).context("running npm install -g to upgrade")?; + Ok(SelfUpdateReport { + source: InstallSource::Npm, + action: Action::Installed, + messages: vec![format!("upgraded {} via npm", cfg.npm_package)], + }) +} + +fn cargo_update(cfg: &SelfUpdateConfig, opts: &SelfUpdateOpts) -> Result { + if opts.check { + let messages = vec![ + "installed via cargo".to_string(), + format!( + "cargo installs track the git default branch ({}); there is no single \ + published version to compare against", + cfg.cargo_git + ), + "run `quire update` to rebuild from the latest source".to_string(), + ]; + return Ok(SelfUpdateReport { + source: InstallSource::Cargo, + action: Action::Checked { latest: None }, + messages, + }); + } + + run_inherited("cargo", &["install", "--git", cfg.cargo_git, "--force"]) + .context("running cargo install --git to upgrade")?; + Ok(SelfUpdateReport { + source: InstallSource::Cargo, + action: Action::Installed, + messages: vec![format!("upgraded from {} via cargo", cfg.cargo_git)], + }) +} + +/// Unknown install source: never guess and clobber a binary we did not place. +/// Print how to upgrade and exit successfully. +fn manual_report(cfg: &SelfUpdateConfig) -> SelfUpdateReport { + let messages = vec![ + "could not determine how this binary was installed".to_string(), + format!( + "if installed via npm: npm install -g {}@latest", + cfg.npm_package + ), + format!( + "if installed via cargo: cargo install --git {} --force", + cfg.cargo_git + ), + format!("or download the latest release: {}", cfg.releases_url), + ]; + SelfUpdateReport { + source: InstallSource::Unknown, + action: Action::Manual, + messages, + } +} + +/// Run a command with inherited stdio; non-zero exit is an error. +fn run_inherited(cmd: &str, args: &[&str]) -> Result<()> { + let status = Command::new(cmd) + .args(args) + .status() + .with_context(|| format!("spawning `{cmd}` (is it installed and on PATH?)"))?; + if !status.success() { + bail!("`{cmd}` exited with {status}"); + } + Ok(()) +} + +/// Run a command and capture trimmed stdout; non-zero exit is an error. +fn run_capture(cmd: &str, args: &[&str]) -> Result { + let output = Command::new(cmd) + .args(args) + .output() + .with_context(|| format!("spawning `{cmd}` (is it installed and on PATH?)"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("`{cmd}` failed: {}", stderr.trim()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CFG: SelfUpdateConfig = SelfUpdateConfig { + npm_package: "@agent-ix/quire-cli", + cargo_git: "https://github.com/agent-ix/quire-cli", + releases_url: "https://github.com/agent-ix/quire-cli/releases", + }; + + #[test] + fn registry_args_omitted_when_no_override() { + assert!(registry_args("@agent-ix/quire-cli", None).is_empty()); + } + + #[test] + fn registry_args_uses_scope_form_for_scoped_package() { + // A plain --registry is ignored for scoped packages when an npmrc pins + // a scope registry; the @scope:registry form is what npm honors. + assert_eq!( + registry_args("@agent-ix/quire-cli", Some("http://npm.ix/")), + vec!["--@agent-ix:registry=http://npm.ix/".to_string()] + ); + } + + #[test] + fn registry_args_uses_plain_registry_for_unscoped_package() { + assert_eq!( + registry_args("some-cli", Some("https://registry.npmjs.org/")), + vec![ + "--registry".to_string(), + "https://registry.npmjs.org/".to_string() + ] + ); + } + + #[test] + fn detect_npm_from_node_modules_path() { + let p = + Path::new("/home/u/.npm-global/lib/node_modules/@agent-ix/quire-cli-linux-x64/quire"); + assert_eq!(detect_source(p), InstallSource::Npm); + } + + #[test] + fn detect_cargo_from_cargo_bin_path() { + let p = Path::new("/home/u/.cargo/bin/quire"); + assert_eq!(detect_source(p), InstallSource::Cargo); + } + + #[test] + fn detect_unknown_from_bare_path() { + let p = Path::new("/usr/local/bin/quire"); + assert_eq!(detect_source(p), InstallSource::Unknown); + } + + #[test] + fn detect_ignores_lookalike_path_components() { + // Substring matching would misclassify these; component matching does not. + assert_eq!( + detect_source(Path::new("/home/u/.cargo-backup/bin/quire")), + InstallSource::Unknown + ); + assert_eq!( + detect_source(Path::new("/opt/node_modules-tools/quire")), + InstallSource::Unknown + ); + } + + #[test] + fn unknown_source_emits_manual_instructions_without_installing() { + let opts = SelfUpdateOpts { + check: false, + registry: None, + }; + let report = run_for_source(&CFG, &opts, InstallSource::Unknown).unwrap(); + assert_eq!(report.action, Action::Manual); + assert_eq!(report.source, InstallSource::Unknown); + // Carries both upgrade recipes and the releases URL. + let joined = report.messages.join("\n"); + assert!(joined.contains("npm install -g @agent-ix/quire-cli@latest")); + assert!(joined.contains("cargo install --git")); + assert!(joined.contains("releases")); + } + + #[test] + fn cargo_check_reports_branch_tracking_without_a_version() { + let opts = SelfUpdateOpts { + check: true, + registry: None, + }; + let report = run_for_source(&CFG, &opts, InstallSource::Cargo).unwrap(); + assert_eq!(report.action, Action::Checked { latest: None }); + } +} diff --git a/tests/cli_edit.rs b/tests/cli_edit.rs index 080f1ee..62ae845 100644 --- a/tests/cli_edit.rs +++ b/tests/cli_edit.rs @@ -179,7 +179,11 @@ fn edit_rejects_doc_and_content_both_stdin() { .stderr(std::process::Stdio::piped()) .spawn() .unwrap(); - child.stdin.take().unwrap().write_all(b"x").unwrap(); + // The child rejects the both-stdin-args combination before it reads stdin, + // so this write races the child's exit: if the child has already closed its + // stdin we get a (harmless, expected) BrokenPipe. The real assertions are + // the exit code and the stderr message, so don't let the race panic. + let _ = child.stdin.take().unwrap().write_all(b"x"); let out = child.wait_with_output().unwrap(); assert_eq!(out.status.code(), Some(1)); assert!(String::from_utf8_lossy(&out.stderr).contains("both and --content from stdin")); diff --git a/tests/cli_update.rs b/tests/cli_update.rs new file mode 100644 index 0000000..72f4e9c --- /dev/null +++ b/tests/cli_update.rs @@ -0,0 +1,45 @@ +//! `quire update` end-to-end behavior at the process boundary. +//! +//! The test binary lives under `target//` — neither a `node_modules` +//! tree nor `~/.cargo` — so the install-source detector resolves to `Unknown`. +//! On the Unknown path `update` must print manual upgrade instructions and exit +//! 0 WITHOUT shelling out to npm/cargo (so the test never touches the network). + +mod common; + +use common::quire; + +#[test] +fn update_check_on_unknown_source_prints_manual_instructions_and_exits_zero() { + let out = quire() + .args(["update", "--check"]) + .output() + .expect("update runs"); + assert!(out.status.success(), "update --check should exit 0"); + let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8"); + assert!( + stdout.contains("could not determine how this binary was installed"), + "expected Unknown-source guidance, got:\n{stdout}" + ); + assert!( + stdout.contains("npm install -g @agent-ix/quire-cli@latest"), + "expected npm upgrade recipe, got:\n{stdout}" + ); + assert!( + stdout.contains("cargo install --git"), + "expected cargo upgrade recipe, got:\n{stdout}" + ); +} + +#[test] +fn update_without_check_on_unknown_source_is_also_safe() { + // Even without --check, an Unknown source performs no install — it only + // emits instructions — so this stays network-free and exits 0. + let out = quire().arg("update").output().expect("update runs"); + assert!( + out.status.success(), + "update should exit 0 on Unknown source" + ); + let stdout = String::from_utf8(out.stdout).expect("stdout is UTF-8"); + assert!(stdout.contains("could not determine how this binary was installed")); +} diff --git a/tests/snapshots/help.txt b/tests/snapshots/help.txt index f840d1d..2bdfd24 100644 --- a/tests/snapshots/help.txt +++ b/tests/snapshots/help.txt @@ -11,6 +11,7 @@ Commands: schema Emit an archetype's input contract (frontmatter schema + asserts) as JSON lint Evaluate the module's advisory lint rules against a document fix Surface (and with --write, apply) internal relative-path link fixes + update Check for and install the latest quire (auto-detects npm vs cargo) help Print this message or the help of the given subcommand(s) Options: