diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1c92d..8de11dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,18 @@ on: branches: [main] push: branches: [main] + # There was no way to ask this repository for a run, and on 2026-08-26 that + # mattered: GitHub dropped push events for about two hours, and six of the + # seven trees were left with no completed `ci` on the `main` they had just + # merged. The code was proven -- the same contexts were green on the pull + # request minutes earlier -- but nothing on `main` said so, and the only way + # to ask again was to push a commit that changed nothing. + # + # A repository whose proof can only be requested by changing it is a + # repository that invites empty commits. The monorepo has carried this + # trigger since its gate was built; the rendered trees did not, for no reason + # anyone chose. + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 18d83fc..919d0db 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -8,6 +8,9 @@ on: schedule: # Weekly, so an advisory published after the last push is still seen. - cron: '17 4 * * 1' + # Same reason as `ci`: an advisory worth re-checking now, or a scheduled run + # that never started, should not require a commit to ask about. + workflow_dispatch: permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0afde..7b711ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,46 @@ including claims a later release made false. ## [Unreleased] +## [0.0.6] - 2026-08-27 + +Two defects a consumer found, one this build could not see, and the +last product that was an exception. + +- **`provider_plan_digest` was null after every operation of every kind.** It + was read out of the plan *object*, which never carries it: the digest is + taken over the plan and travels beside it in the planner's envelope. Reported + as an empty-setup defect and never about emptiness at all. It stayed + invisible for four releases because `status` did not publish the field, and a + consumer skips what is absent -- publishing what is persisted is what made it + a value that could be compared and refused. +- **A backup refused a link while copying rather than before.** The slot was + created, files were written into it, and the walk then stopped -- a partial + operation and control artifacts for a shape that was knowable for free. Owned + paths are read before planning and again before any capture now, every + unsupported entry is named at once, and nothing is followed. +- **On Windows nothing was ever exposed, so no update was ever an update.** + Reading which version a prefix exposes resolved the command's path, and + Windows writes a hard link or a copy there rather than a link -- so the answer + was always "nothing is exposed" and every `software_update` refused as an + update of nothing. Shipped in 0.0.4 and 0.0.5. The version is recorded beside + the command and read back now; a dangling link still exposes nothing. +- **Pi installs like the other six.** It declared no software lifecycle on the + stated ground that npm resolves its closure at install time. The vendor ships + `npm-shrinkwrap.json`, so the closure is fixed -- and it does not matter, + because the published bundle imports only Node built-ins and runs with no + `node_modules` at all. All seven now declare the same four optional + operations. Its entry point is JavaScript, so Windows exposes `pi.cmd` + rather than a copy no platform would run. +- **A backup can be held.** The pool rolls at ten slots, so a long series of + captures evicted the baseline it meant to return to. A held slot is not + reclaimed and is not counted against the bound, the reason is recorded beside + it so a full pool says who would lose what, and the last reclaimable slot + cannot be held -- a target that can never be backed up again is worse than + the eviction. + +Vendor versions advanced where they moved: claude 2.1.246, cursor +2026.08.25-3e8eec8, antigravity 1.1.21. + ## [0.0.5] - 2026-08-26 The catalog now travels with the program, and a target's identity is diff --git a/Cargo.lock b/Cargo.lock index 488888b..523c0a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.5" +version = "0.0.6" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.5" +version = "0.0.6" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.5" +version = "0.0.6" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.5" +version = "0.0.6" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5dfc989..5f8612c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.5" +version = "0.0.6" edition = "2024" rust-version = "1.89" license = "AGPL-3.0-or-later" @@ -23,9 +23,9 @@ sha2 = "0.11" # `setup-core::archive`); an inflate loop is not, because its bugs are # memory-safety bugs and it is not improved by being hand-written here. miniz_oxide = "0.9" -setup-core = { path = "crates/setup-core", version = "0.0.5" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.5" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.5" } +setup-core = { path = "crates/setup-core", version = "0.0.6" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.6" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.6" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 539486e..b312270 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,18 @@ release's own `SHA256SUMS`, and places it at a predictable path: `~/.local/bin` on Linux and macOS, `%LOCALAPPDATA%\Programs` on Windows. Neither needs privilege and neither registers anything anywhere. +Somewhere else instead: + +```bash +OPENCODE_INSTALL_DIR=/opt/opencode-setup-system sh install.sh +``` + +The same variable on both scripts, and it is `OPENCODE_INSTALL_DIR` +rather than the longer prefix the setup-catalog variable uses -- the installer +is named after the product, not after the crate. It was always accepted and +never written down here, which is how someone reading only this page installs +into a home directory they did not mean to write to. + Releases carry six binaries — Linux, macOS and Windows, on x86_64 and arm64 — which is what `provider-info` declares, so the declaration and what you can download say the same thing. @@ -151,7 +163,7 @@ release is a convenience, not the authorised copy. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.5 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.6 \ status --target /config/ --json ``` diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs index 8f4a1e7..66a68f3 100644 --- a/crates/harness-runtime/src/human.rs +++ b/crates/harness-runtime/src/human.rs @@ -54,6 +54,22 @@ pub enum Command { /// The setup to apply. setup: String, }, + /// Keep one backup until it is released, so retention cannot reclaim it. + Hold { + /// The target whose pool holds it. + target: PathBuf, + /// The slot to keep. Named, never inferred. + backup: Option, + /// Why it is held, so whoever meets a full pool knows the cost. + reason: Option, + }, + /// Let retention have a held backup back. + Release { + /// The target whose pool holds it. + target: PathBuf, + /// The slot to let go. + backup: Option, + }, /// Report which versions of the product are installed, and which is exposed. Software { /// The program directory to read. @@ -123,6 +139,8 @@ pub fn is_human_command(name: &str) -> bool { | "diff" | "software" | "rollback" + | "hold" + | "release" ) } @@ -151,6 +169,7 @@ struct Arguments { backup: Option, prefix: Option, to: Option, + reason: Option, positional: Vec, } @@ -162,13 +181,14 @@ impl Arguments { backup: None, prefix: None, to: None, + reason: None, positional: Vec::new(), }; let mut index = 0; while index < rest.len() { let Some(token) = rest.get(index) else { break }; match token.as_str() { - "--target" | "--backup" | "--prefix" | "--to" => { + "--target" | "--backup" | "--prefix" | "--to" | "--reason" => { let Some(value) = rest.get(index + 1) else { return Err(local(format!("{token} has no value"))); }; @@ -194,6 +214,12 @@ impl Arguments { } parsed.to = Some(value.clone()); } + "--reason" => { + if parsed.reason.is_some() { + return Err(local("--reason was given twice")); + } + parsed.reason = Some(value.clone()); + } _ => { if parsed.backup.is_some() { return Err(local("--backup was given twice")); @@ -246,7 +272,7 @@ impl Arguments { } fn into_command(self, name: &str) -> Result { - if self.backup.is_some() && name != "restore" { + if self.backup.is_some() && !matches!(name, "restore" | "hold" | "release") { return Err(local(format!("--backup is not an argument of {name}"))); } if self.prefix.is_some() && !matches!(name, "software" | "rollback") { @@ -255,11 +281,29 @@ impl Arguments { if self.to.is_some() && name != "rollback" { return Err(local(format!("--to is not an argument of {name}"))); } + if self.reason.is_some() && name != "hold" { + return Err(local(format!("--reason is not an argument of {name}"))); + } match name { "list" => { self.no_setup(name)?; Ok(Command::List) } + "hold" => { + self.no_setup(name)?; + Ok(Command::Hold { + target: self.target(name)?, + backup: self.backup, + reason: self.reason, + }) + } + "release" => { + self.no_setup(name)?; + Ok(Command::Release { + target: self.target(name)?, + backup: self.backup, + }) + } "software" => { self.no_setup(name)?; Ok(Command::Software { @@ -351,11 +395,106 @@ pub fn run(harness: &Harness, command: Command) -> Result<()> { Command::Restore { target, backup } => restore(harness, &target, backup), Command::Adopt { target } => adopt_target(harness, &target), Command::Remove { target } => remove(harness, &target), + Command::Hold { + target, + backup, + reason, + } => hold(harness, &target, backup.as_deref(), reason.as_deref()), + Command::Release { target, backup } => release(harness, &target, backup.as_deref()), Command::Software { prefix } => software(harness, &prefix), Command::Rollback { prefix, to } => rollback(harness, &prefix, to.as_deref()), } } +/// Keep one backup until it is released. +/// +/// The pool rolls: ten slots, oldest evicted. A long series of captures makes +/// more than that, so a baseline someone means to return to at the end is gone +/// by the time they get there. A hold is the smallest thing that stops it — +/// one marker eviction has to read. +fn hold( + harness: &Harness, + target: &Path, + backup: Option<&str>, + reason: Option<&str>, +) -> Result<()> { + let (resolved, pool) = pool_of(harness, target)?; + let reference = named_slot(&pool, backup, "hold")?; + // A pool can be held by more than one run. Without a reason, whoever meets + // a full pool knows which slots to release and not what releasing one + // would cost. + let reason = reason.unwrap_or("no reason recorded"); + if pool.hold(&reference, reason)? { + println!( + "{} is held ({reason}). Retention will not reclaim it until it is released.", + reference.as_str() + ); + } else { + let why = pool + .held_reason(&reference)? + .unwrap_or_else(|| "no reason recorded".to_owned()); + println!("{} was already held ({why}).", reference.as_str()); + } + println!( + " release it with: release --backup {} --target {}", + reference.as_str(), + resolved.root().display() + ); + Ok(()) +} + +/// Let retention have a held backup back. +fn release(harness: &Harness, target: &Path, backup: Option<&str>) -> Result<()> { + let (_, pool) = pool_of(harness, target)?; + let reference = named_slot(&pool, backup, "release")?; + if pool.release(&reference)? { + println!( + "{} is released and will be reclaimed like any other slot.", + reference.as_str() + ); + } else { + // Not a refusal: a run cleaning up after itself should not have to tell + // "nothing to do" apart from "something is wrong". + println!("{} was not held; nothing to release.", reference.as_str()); + } + Ok(()) +} + +fn pool_of(harness: &Harness, target: &Path) -> Result<(Target, Pool)> { + let resolved = Target::resolve(target, harness.control_directory)?; + let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?; + Ok((resolved, pool)) +} + +/// The slot a caller named, or the list of the ones they could have named. +/// +/// Never inferred. `restore` may default to the newest because that is the one +/// thing a caller wanting "undo" can mean; keeping a slot is a decision about a +/// specific capture, and guessing which would be guessing what a run is for. +fn named_slot( + pool: &Pool, + backup: Option<&str>, + verb: &str, +) -> Result { + let available = pool.list()?; + let Some(text) = backup else { + return Err(local(if available.is_empty() { + format!("{verb} requires --backup , and this target has no backups") + } else { + format!( + "{verb} requires --backup ; this target holds {}", + available + .iter() + .map(|record| record.backup_ref.as_str()) + .collect::>() + .join(", ") + ) + })); + }; + setup_core::backup::BackupRef::parse(text) + .map_err(|error| local(format!("{text:?} is not a backup reference: {error}"))) +} + /// What the program directory holds, and which version answers to the command. /// /// Install and update are not here, and their absence is the design rather than @@ -562,12 +701,16 @@ fn backups(harness: &Harness, target: &Path) -> Result<()> { } println!("Backups of {}, newest first:", resolved.root().display()); println!(); + let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?; for (position, record) in records.iter().enumerate() { - let marker = if position == 0 { - " (restored by default)" - } else { - "" - }; + let mut marker = String::new(); + if position == 0 { + marker.push_str(" (restored by default)"); + } + if let Some(why) = pool.held_reason(&record.backup_ref)? { + use std::fmt::Write as _; + let _ = write!(marker, " (held: {why})"); + } println!(" {}{marker}", record.backup_ref.as_str()); println!( " before {}, setup {}", diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs index de51fb4..b098ee0 100644 --- a/crates/harness-runtime/src/lib.rs +++ b/crates/harness-runtime/src/lib.rs @@ -198,6 +198,8 @@ fn print_help(harness: &Harness) { println!(" diff --target "); println!(" backups --target "); println!(" restore [--backup ] --target "); + println!(" hold --backup [--reason ] --target "); + println!(" release --backup --target "); println!(" remove --target "); if !harness.predecessor_state_file.is_empty() { println!(" adopt --target "); @@ -231,6 +233,111 @@ fn print_help(harness: &Harness) { println!("previous, only what is on disk."); println!(); println!("A backup is captured before every change, so `restore` always has"); - println!("something to return to. Over the wire, install and replace arrive as"); - println!("a bundle and refuse -- this build reads setups from its own catalog."); + println!("something to return to. The pool rolls, so a long series of changes"); + println!("eventually evicts the oldest: `hold` keeps one until `release` lets"); + println!("it go, which is how a baseline survives more captures than the pool."); + println!("Over the wire, install and replace arrive as a bundle and refuse --"); + println!("this build reads setups from its own catalog."); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + /// The entry point uses the toolchain this tree pins, even where a + /// different one comes first on `PATH`. + /// + /// This was closed once by *running* it: with `~/.local/bin` first, + /// `cargo test --doc` failed with `E0514` and `scripts/gate.sh` reported + /// the pinned version anyway. A run is not a test. The next + /// `rust-toolchain.toml` bump is when a silently wrong entry point costs + /// something, and until now nothing would have been watching. + /// + /// The shim is the shadow the trap describes: a real executable named + /// `cargo`, earlier on `PATH`, reporting a different release. The test + /// proves it *would* have won, and then that the entry point selects past + /// it — otherwise a passing assertion could mean the shim was never + /// consulted at all. + #[test] + #[cfg(unix)] + fn the_entry_point_selects_the_pinned_toolchain_past_a_shadow() { + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + + // This crate is vendored into seven published trees, and the entry + // point it tests belongs to exactly one repository: the workspace where + // the code is written and the render is proved. A published tree ships + // no `scripts/` at all, because contributing there means something + // else -- so the test travelled somewhere its subject does not exist, + // and failed on ubuntu and macos in all seven at once. + // + // Absence is asserted rather than skipped. A rendered tree is + // identifiable: it carries no renderer either. If both are gone this is + // a published tree and there is nothing here to test; if the script is + // gone and the renderer is not, someone deleted the entry point in the + // workspace and that is a failure, not a skip. + if !root.join("scripts/gate.sh").is_file() { + assert!( + !root.join("tools/render_public_trees.py").is_file(), + "the toolchain entry point is missing from a workspace that still \ + renders the public trees; scripts/gate.sh was deleted rather than \ + never present" + ); + return; + } + + let pinned = std::fs::read_to_string(root.join("rust-toolchain.toml")) + .unwrap() + .lines() + .find_map(|line| { + line.strip_prefix("channel = \"") + .and_then(|rest| rest.strip_suffix('"')) + .map(str::to_owned) + }) + .expect("rust-toolchain.toml pins a channel"); + + let shadow = std::env::temp_dir().join(format!("gate-shadow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&shadow); + std::fs::create_dir_all(&shadow).unwrap(); + let shim = shadow.join("cargo"); + std::fs::write(&shim, "#!/bin/sh\necho 'cargo 1.0.0 (shadow)'\n").unwrap(); + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let shadowed = format!( + "{}:{}", + shadow.display(), + std::env::var("PATH").unwrap_or_default() + ); + + // The shim would have won. Without this the assertion below could pass + // because nothing ever put it in the way. + let shadowing = Command::new("sh") + .arg("-c") + .arg("cargo --version") + .env("PATH", &shadowed) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&shadowing.stdout).contains("1.0.0 (shadow)"), + "the shim did not shadow cargo, so this test proves nothing" + ); + + let asked = Command::new("bash") + .arg("scripts/gate.sh") + .arg("--toolchain") + .current_dir(&root) + .env("PATH", &shadowed) + .output() + .unwrap(); + let said = String::from_utf8_lossy(&asked.stdout).into_owned(); + let _ = std::fs::remove_dir_all(&shadow); + + assert!(asked.status.success(), "the entry point failed: {said}"); + assert!( + said.contains(&format!("cargo reports {pinned}")), + "the entry point used the shadow rather than the pinned {pinned}: {said}" + ); + } } diff --git a/crates/harness-runtime/src/software.rs b/crates/harness-runtime/src/software.rs index 74fa166..7fd2e66 100644 --- a/crates/harness-runtime/src/software.rs +++ b/crates/harness-runtime/src/software.rs @@ -116,14 +116,21 @@ pub(crate) fn plan( let root = program_directory(prefix, operation)?; version_for(&declared, software_version)?; - let entry_point = format!("bin/{}", declared.command); + // Not always the command: pi's entry point is JavaScript, and Windows runs + // a file by its extension rather than by a shebang, so what is exposed + // there is `pi.cmd`. The plan states what a caller will actually be able to + // run, which is the whole point of naming it. + let entry_point = format!( + "bin/{}", + setup_core::software::exposed_name(declared.command, declared.member_hint()) + ); let exposed = root.join(&entry_point); // Planning may read the local disk and may not reach the network, so what // is already under the prefix belongs in the plan. Without it an install // and an update produced byte-identical effects -- two names for one act, // and neither said what was about to be replaced. - let present = software::Present::under(&root, declared.command); + let present = software::Present::under_named(&root, declared.command, declared.member_hint()); if operation == Operation::SoftwareRemove { let mut effects = vec![ @@ -285,7 +292,7 @@ pub(crate) fn apply( // the prefix could have been emptied in between. The plan's digest binds // what was decided, not what the disk still holds. if operation == Operation::SoftwareUpdate - && software::Present::under(&root, declared.command) + && software::Present::under_named(&root, declared.command, declared.member_hint()) .versions .is_empty() { @@ -309,7 +316,10 @@ pub(crate) fn apply( "operation": operation.as_str(), "command": declared.command, "version": installed.version, - "entry_point": format!("bin/{}", declared.command), + "entry_point": format!( + "bin/{}", + setup_core::software::exposed_name(declared.command, declared.member_hint()) + ), "executable": installed.executable.to_string_lossy(), "files": installed.files, })) @@ -360,7 +370,13 @@ pub(crate) fn launch( let declared = declared(harness)?; let root = program_directory(prefix, Operation::Launch)?; - let executable = root.join("bin").join(declared.command); + // The same name the plan stated and the apply wrote. Three readings of one + // fact, and they have to be one expression or they will drift apart on the + // one platform where they differ. + let executable = root.join("bin").join(setup_core::software::exposed_name( + declared.command, + declared.member_hint(), + )); let found = executable.metadata().map_err(|error| { Error::refuse( diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index d21f0f2..ae6121f 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -2029,6 +2029,241 @@ mod tests { assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); } + /// A configuration operation is bound to the target it planned against; a + /// software operation is not, and that difference is load-bearing. + /// + /// `perform` re-checks `expected_target_digest` under the lock and refuses + /// `Stale` when the target moved. Software operations do not go through it + /// at all, so a configuration edit between plan and apply does **not** + /// strand a program install — which is correct: a program lives under + /// `--prefix` and has nothing to do with the configuration in the target, + /// and binding them would let someone editing their own instructions + /// invalidate a download of a hundred and sixty megabytes. + /// + /// It was correct, deliberate, undocumented and held by nothing. The + /// consumer has been told they may rely on it, so it is asserted from both + /// sides here: routing software through `perform` "for uniformity" is a + /// reasonable-looking cleanup that would break them silently, with every + /// existing test still green. + #[test] + fn a_configuration_edit_strands_a_configuration_plan_and_not_a_program_one() { + // The software side: plan, edit the target, apply, and it still lands. + let target = seeded("precondition-software"); + let file = downloaded(&target, TEST_PAYLOAD); + let planned = software_plan(&target, "software_install"); + let plan_path = target.join("..").join("precondition-plan.json"); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + + fs::write( + target.join("AGENTS.md"), + "# edited between plan and apply\n", + ) + .unwrap(); + + let prefix = target.join("..").join("precondition-prefix"); + let applied = run(args( + "apply-operation", + &target, + &[ + "--plan", + &plan_path.to_string_lossy(), + "--plan-digest", + planned["plan_digest"].as_str().unwrap(), + "--provider-release-digest", + RELEASE, + "--prefix", + &prefix.to_string_lossy(), + "--software-artifact", + &file.to_string_lossy(), + ], + )); + assert_eq!( + applied["state"], "verified", + "a configuration edit stranded a program install" + ); + + // The configuration side, same edit, and it must refuse: a plan that + // authorized one target state cannot be applied to another. + let other = seeded("precondition-configuration"); + let config_plan = run(args( + "plan-operation", + &other, + &[ + "--operation", + "backup", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01PRECOND", + "--expires-at", + far_future(), + ], + )); + let config_path = other.join("..").join("precondition-config-plan.json"); + fs::write( + &config_path, + setup_core::canonical::to_canonical_bytes(&config_plan["plan"]).unwrap(), + ) + .unwrap(); + + fs::write(other.join("AGENTS.md"), "# edited between plan and apply\n").unwrap(); + + let error = refuse(args( + "apply-operation", + &other, + &[ + "--plan", + &config_path.to_string_lossy(), + "--plan-digest", + config_plan["plan_digest"].as_str().unwrap(), + "--provider-release-digest", + RELEASE, + ], + )); + assert_eq!( + error.reason(), + Some(WireReason::Stale), + "a configuration plan survived the target changing under it: {}", + error.detail() + ); + } + + /// Holds a file unreadable for as long as it lives, and puts it back. + /// + /// Two mechanisms, one condition. On Windows a file another process holds + /// open with no sharing cannot be opened at all; on Unix the same effect + /// comes from permissions. A guard rather than a bare call so the condition + /// cannot outlive the test that wanted it. + struct Unreadable { + #[cfg(windows)] + _handle: fs::File, + #[cfg(unix)] + path: PathBuf, + } + + impl Unreadable { + /// `None` when this process can still read the file afterwards, which + /// is what running as root looks like. A test that cannot create its + /// condition must say so rather than pass. + fn of(path: &Path) -> Option { + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Deny every kind of sharing: what a running product does to a + // database or a log it owns. + let handle = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(path) + .ok()?; + fs::File::open(path) + .is_err() + .then_some(Self { _handle: handle }) + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o000)).ok()?; + if fs::File::open(path).is_ok() { + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o644)); + return None; + } + Some(Self { + path: path.to_path_buf(), + }) + } + } + } + + impl Drop for Unreadable { + fn drop(&mut self) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&self.path, fs::Permissions::from_mode(0o644)); + } + } + } + + /// A file inside an owned namespace that cannot be read stops the operation + /// before it starts, names the file, and leaves nothing behind. + /// + /// `G12` left this open and `run_once_it_is_not_busy` was never it: that is + /// a Linux `ETXTBSY` fork race the test harness creates by writing and + /// exec'ing in one multi-threaded process, and its own comment says so. + /// + /// The real condition is a product holding its own file open — a database, + /// a log — inside a namespace this provider owns. It is reachable on both + /// systems and it is the same condition: on Windows through a share mode + /// that denies everything, on Unix through permissions. + /// + /// What must be true is not that the operation succeeds. A slot that + /// silently skipped a file it could not read would not restore the target, + /// which is worse than refusing. What must be true is that the refusal + /// **names the file** and that nothing partial survives it: identity is + /// computed before any capture, so a target that cannot be read is a target + /// nothing has been done to. + #[test] + fn a_file_this_process_cannot_read_stops_the_operation_and_leaves_nothing() { + let target = seeded("unreadable"); + plan_then_apply(&target, "backup", &[]); + let control = target.join(TEST.control_directory); + let before = fs::read_dir(control.join("backups")).map_or(0, Iterator::count); + + let locked = target.join("skills").join("held-open.md"); + fs::write(&locked, "a product owns this").unwrap(); + let Some(held) = Unreadable::of(&locked) else { + // Cannot create the condition here — running as root, or the + // platform declined. Saying so beats a green that proves nothing. + panic!( + "this process can still read a file it made unreadable, so this test \ + would prove nothing; it needs to run as a user permissions apply to" + ); + }; + + let error = refuse(args( + "plan-operation", + &target, + &[ + "--operation", + "backup", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01LOCKED", + "--expires-at", + far_future(), + ], + )); + assert!( + error.detail().contains("held-open.md"), + "the refusal does not name the file it could not read: {}", + error.detail() + ); + + // Nothing started. No new slot, no journal, no transaction to recover. + assert_eq!( + fs::read_dir(control.join("backups")).map_or(0, Iterator::count), + before, + "a refused operation left a backup slot behind" + ); + assert!(!control.join("journal.json").exists()); + assert!(!control.join("transaction").exists()); + + // And it recovers by itself once the file is readable again: nothing + // was recorded that has to be undone. + drop(held); + assert_eq!( + plan_then_apply(&target, "backup", &[])["state"], + "verified", + "the target did not become usable again once the file could be read" + ); + } + #[test] fn two_status_calls_return_the_same_bytes() { // The consumer calls it twice and requires the answers to be identical, diff --git a/crates/setup-core/src/backup.rs b/crates/setup-core/src/backup.rs index a3ebe79..48c47d9 100644 --- a/crates/setup-core/src/backup.rs +++ b/crates/setup-core/src/backup.rs @@ -30,6 +30,20 @@ pub const SLOT_MARKER_NAME: &str = "slot.json"; /// The subdirectory holding the captured tree. pub const SLOT_PAYLOAD_NAME: &str = "payload"; +/// The marker naming a slot that retention may not reclaim. +/// +/// Beside the record rather than inside it, on purpose. The record states what +/// was *captured*; a hold states whether retention may take it back. Those are +/// two different facts about one slot, and writing the second into the first +/// would mean editing a completed capture every time someone changed their mind +/// about keeping it. +/// +/// The file holds the reason the hold was placed. A pool can be held by more +/// than one run at a time, and a refusal that says only *which* slots are held +/// leaves the next caller releasing one blind — possibly one another run is +/// still depending on. +pub const SLOT_HELD_NAME: &str = "HELD"; + /// The schema this kernel writes and is willing to read. pub const SLOT_SCHEMA: u32 = 1; @@ -329,6 +343,148 @@ impl Pool { Ok(record) } + /// Keep one slot until it is explicitly released. + /// + /// The pool rolls: ten slots, oldest evicted. A long evidence series makes + /// more captures than that, so the baseline it means to return to at the + /// end is gone by the time it gets there — reported after a run of fifty + /// captures could not restore the state it started from. + /// + /// A hold is the smallest thing that fixes it: one marker that eviction has + /// to read. Against an export/import format, which is a second format to + /// keep correct, and against a separate baseline store, which is a second + /// place state lives. With a hold, *retention never silently changes what a + /// `BackupRef` names* becomes a checkable statement rather than an + /// intention. + /// + /// # Errors + /// + /// Refuses a reference this pool does not hold, and refuses a hold that + /// would leave the pool no slot to rotate — ten held slots is a target that + /// can never be backed up again, which is a worse failure than the eviction + /// it was protecting against. The refusal names what is already held so a + /// caller knows what to release. + pub fn hold(&self, backup_ref: &BackupRef, reason: &str) -> Result { + let slot = self.root.join(backup_ref.as_str()); + if read_record(&slot)?.is_none() { + return Err(Error::new( + ReasonCode::InvalidTarget, + format!( + "{} is not a completed slot in this pool", + backup_ref.as_str() + ), + )); + } + let already = self.held()?; + if already.iter().any(|(held, _)| held == backup_ref) { + return Ok(false); + } + // One slot must stay reclaimable, or the next capture has nothing to + // evict and the pool grows past the bound it was opened with. + if already.len() + 1 >= self.capacity { + return Err(Error::new( + ReasonCode::InvalidTarget, + format!( + "holding {} would leave this pool of {} no slot to rotate; release one of \ + these first, and the reason each names is who would lose it: {}", + backup_ref.as_str(), + self.capacity, + already + .iter() + .map(|(held, why)| format!("{} ({why})", held.as_str())) + .collect::>() + .join(", ") + ), + )); + } + // The reason travels with the hold. Without it a caller reading a full + // pool knows what to release and not what releasing it would cost. + fs::write(slot.join(SLOT_HELD_NAME), reason.as_bytes()).map_err(|source| { + Error::new( + ReasonCode::StateUnavailable, + format!("cannot hold {}", slot.display()), + ) + .with_source(source) + })?; + Ok(true) + } + + /// Let retention have a slot back. + /// + /// Answers `false` for a slot that was not held rather than refusing: a run + /// cleaning up after itself should not have to tell "nothing to do" apart + /// from "something is wrong", which is the same reason `remove` answers + /// `removed: false` on a prefix it never wrote. + /// + /// # Errors + /// + /// Returns [`ReasonCode::StateUnavailable`] if the marker cannot be removed. + pub fn release(&self, backup_ref: &BackupRef) -> Result { + let marker = self.root.join(backup_ref.as_str()).join(SLOT_HELD_NAME); + match fs::remove_file(&marker) { + Ok(()) => Ok(true), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(Error::new( + ReasonCode::StateUnavailable, + format!("cannot release {}", marker.display()), + ) + .with_source(source)), + } + } + + /// Every slot retention may not reclaim, newest first, with why. + /// + /// # Errors + /// + /// Propagates the refusal from [`Pool::list`]. + pub fn held(&self) -> Result> { + let mut out = Vec::new(); + for record in self.list()? { + let marker = self + .root + .join(record.backup_ref.as_str()) + .join(SLOT_HELD_NAME); + if let Ok(reason) = fs::read_to_string(&marker) { + let reason = reason.trim().to_owned(); + let reason = if reason.is_empty() { + // A hold placed before reasons were carried, or one whose + // caller gave none. Saying so beats an empty parenthesis. + "no reason recorded".to_owned() + } else { + reason + }; + out.push((record.backup_ref, reason)); + } + } + Ok(out) + } + + /// Why one slot is held, when it is. + /// + /// # Errors + /// + /// Propagates the refusal from [`Pool::list`]. + pub fn held_reason(&self, backup_ref: &BackupRef) -> Result> { + Ok(self + .held()? + .into_iter() + .find(|(held, _)| held == backup_ref) + .map(|(_, reason)| reason)) + } + + /// Whether one slot is held. + /// + /// # Errors + /// + /// Never; the signature matches its neighbours so a caller reads them alike. + pub fn is_held(&self, backup_ref: &BackupRef) -> Result { + Ok(self + .root + .join(backup_ref.as_str()) + .join(SLOT_HELD_NAME) + .is_file()) + } + /// Drop the oldest completed slots beyond the pool capacity. /// /// Incomplete slots are never pruned: they are evidence recovery needs, and @@ -338,7 +494,15 @@ impl Pool { /// /// Returns [`ReasonCode::StateUnavailable`] if a slot cannot be removed. pub fn prune(&self) -> Result<()> { - let records = self.list()?; + // Held slots are not counted against the bound and are never reclaimed. + // Counting them would make a hold quietly shorten the rolling window + // instead of protecting one capture, and the caller asked for the + // second thing. + let records: Vec = self + .list()? + .into_iter() + .filter(|record| !self.is_held(&record.backup_ref).unwrap_or(false)) + .collect(); for record in records.into_iter().skip(self.capacity) { let slot = self.root.join(record.backup_ref.as_str()); fs::remove_dir_all(&slot).map_err(|source| { @@ -682,6 +846,111 @@ mod tests { assert_eq!(error.reason(), ReasonCode::IntegrityMismatch); } + /// The claim `#428` asks for: a held baseline survives a run longer than + /// the pool. + /// + /// The pool rolls at ten. A series that captures fifty times loses the + /// state it started from long before it gets back to it — reported after + /// exactly that, where the final restore had nothing to restore to. + #[test] + fn a_held_slot_outlives_far_more_captures_than_the_pool_holds() { + let base = scratch("held-baseline"); + let target = base.join("target"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("a.txt"), "the baseline").unwrap(); + + let pool = Pool::open(&base.join("control"), 10).unwrap(); + let baseline = pool.capture(&target, &["a.txt"], record_for).unwrap(); + assert!( + pool.hold(&baseline.backup_ref, "E00 baseline for the evidence series") + .unwrap() + ); + + // Fifty more, which is five times the pool. + for round in 0..50 { + fs::write(target.join("a.txt"), format!("round {round}")).unwrap(); + pool.capture(&target, &["a.txt"], record_for).unwrap(); + pool.prune().unwrap(); + } + + let held = pool.held().unwrap(); + assert_eq!(held.len(), 1); + assert_eq!(held[0].0, baseline.backup_ref); + assert_eq!(held[0].1, "E00 baseline for the evidence series"); + assert!( + pool.payload_of(&baseline.backup_ref).is_ok(), + "the held baseline was reclaimed by retention" + ); + assert_eq!( + fs::read_to_string(pool.payload_of(&baseline.backup_ref).unwrap().join("a.txt")) + .unwrap(), + "the baseline", + "the held slot survived but no longer names what it named" + ); + + // And the hold did not quietly shorten the rolling window: ten + // unheld slots are still there beside it. + let unheld: Vec<_> = pool + .list() + .unwrap() + .into_iter() + .filter(|r| !pool.is_held(&r.backup_ref).unwrap()) + .collect(); + assert_eq!(unheld.len(), 10); + + // Released, it becomes ordinary and the next prune reclaims it. + assert!(pool.release(&baseline.backup_ref).unwrap()); + assert!(!pool.release(&baseline.backup_ref).unwrap()); + pool.prune().unwrap(); + assert!(pool.payload_of(&baseline.backup_ref).is_err()); + } + + /// A pool that is entirely held is a target that can never be backed up + /// again, which is a worse failure than the eviction a hold prevents. + #[test] + fn a_hold_that_would_leave_nothing_to_rotate_is_refused_naming_what_to_release() { + let base = scratch("held-full"); + let target = base.join("target"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("a.txt"), "x").unwrap(); + + let pool = Pool::open(&base.join("control"), 3).unwrap(); + let first = pool.capture(&target, &["a.txt"], record_for).unwrap(); + let second = pool.capture(&target, &["a.txt"], record_for).unwrap(); + let third = pool.capture(&target, &["a.txt"], record_for).unwrap(); + + assert!(pool.hold(&first.backup_ref, "series A baseline").unwrap()); + assert!(pool.hold(&second.backup_ref, "series B baseline").unwrap()); + // Holding a third of three would leave nothing to evict. + let error = pool.hold(&third.backup_ref, "series C").unwrap_err(); + assert!(error.to_string().contains("no slot to rotate"), "{error}"); + assert!( + error.to_string().contains(first.backup_ref.as_str()), + "the refusal does not say what to release: {error}" + ); + // And what releasing it would cost, so nobody releases blind. + assert!( + error.to_string().contains("series A baseline"), + "the refusal does not say who holds it: {error}" + ); + + // Holding one that is already held is not an error and not a second + // hold: a run that re-runs its own setup should not have to check. + assert!(!pool.hold(&first.backup_ref, "series A again").unwrap()); + } + + /// A reference this pool never minted is refused rather than marked. + #[test] + fn holding_a_slot_that_is_not_here_is_refused() { + let base = scratch("held-absent"); + let pool = Pool::open(&base.join("control"), 3).unwrap(); + let absent = BackupRef::parse("slot-000000000009").unwrap(); + let error = pool.hold(&absent, "nothing").unwrap_err(); + assert!(error.to_string().contains("slot-000000000009"), "{error}"); + // And releasing one that is not here is still not an error. + assert!(!pool.release(&absent).unwrap()); + } + #[test] fn capture_records_the_slot_and_latest_names_it() { let base = scratch("capture"); diff --git a/crates/setup-core/src/software.rs b/crates/setup-core/src/software.rs index d2f81db..fb1256b 100644 --- a/crates/setup-core/src/software.rs +++ b/crates/setup-core/src/software.rs @@ -129,6 +129,16 @@ impl Present { /// plan for a prefix that does not exist yet is exactly the ordinary case. #[must_use] pub fn under(root: &Path, command: &str) -> Self { + Self::under_named(root, command, "") + } + + /// The same reading, told which member the exposed thing was made from. + /// + /// Windows exposes a JavaScript entry point as `.cmd`, so a + /// reading that only ever looked for `` would report nothing + /// exposed on exactly the harness that needed the launcher. + #[must_use] + pub fn under_named(root: &Path, command: &str, member: &str) -> Self { let mut versions: Vec = fs::read_dir(root) .into_iter() .flatten() @@ -170,7 +180,8 @@ impl Present { // remembers what `expose` last pointed at, and someone can break that // by hand afterwards. `metadata` follows links, so this is false for a // dangling one and true for both a real file and a live link. - let usable = fs::metadata(root.join("bin").join(command)).is_ok(); + let exposed_as = exposed_name(command, member); + let usable = fs::metadata(root.join("bin").join(&exposed_as)).is_ok(); let marker = Self::marker(root, command); let exposed = fs::read_to_string(&marker) .ok() @@ -183,7 +194,7 @@ impl Present { // Nothing recorded: a prefix written before this existed, or one // someone arranged themselves. Where a real link is what is // there, reading it is still the truth. - fs::canonicalize(root.join("bin").join(command)) + fs::canonicalize(root.join("bin").join(&exposed_as)) .ok() .zip(fs::canonicalize(root).ok()) .and_then(|(to, base)| { @@ -408,7 +419,9 @@ pub fn install( } }; - let exposed = root.join("bin").join(software.command); + let exposed = root + .join("bin") + .join(exposed_name(software.command, artifact.member)); expose(&executable, &exposed, software.version, software.command)?; Ok(Installed { @@ -439,7 +452,9 @@ pub fn remove(software: &Software, root: &Path) -> Result { ) .with_source(error) })?; - let exposed = root.join("bin").join(software.command); + let exposed = root + .join("bin") + .join(exposed_name(software.command, software.member_hint())); if exposed.symlink_metadata().is_ok() { fs::remove_file(&exposed).map_err(|error| { Error::new( @@ -479,7 +494,7 @@ pub fn remove(software: &Software, root: &Path) -> Result { /// Refuses a version that is not installed, naming the ones that are, and a /// version tree that holds no executable this build can find. pub fn rollback(software: &Software, root: &Path, to: &str) -> Result { - let present = Present::under(root, software.command); + let present = Present::under_named(root, software.command, software.member_hint()); if !present.versions.iter().any(|found| found == to) { return Err(Error::new( ReasonCode::InvalidTarget, @@ -524,7 +539,9 @@ pub fn rollback(software: &Software, root: &Path, to: &str) -> Result )); }; - let exposed = root.join("bin").join(software.command); + let exposed = root + .join("bin") + .join(exposed_name(software.command, software.member_hint())); expose(executable, &exposed, to, software.command)?; Ok(Installed { version: to.to_owned(), @@ -534,6 +551,49 @@ pub fn rollback(software: &Software, root: &Path, to: &str) -> Result }) } +/// The name the exposed command answers to, which is not always the command. +/// +/// Six of the seven products ship a native executable, and a link named after +/// the command is the whole story. Pi ships JavaScript — `dist/bundle/cli.js`, +/// with a `#!/usr/bin/env node` line and mode 755 — and the two systems part +/// company there. +/// +/// On Unix the shebang does the work and a symlink named `pi` runs. Windows has +/// no shebang: it decides how to run a file from its extension, and a copy +/// named `pi` with JavaScript inside it is not a program. So there the exposed +/// thing is `pi.cmd`, a launcher that names the interpreter. +/// +/// Said in one place because three callers need the same answer: the plan that +/// states `entry_point`, the apply that writes it, and `launch` that starts it. +/// They disagreed once already about where a program lives, and once was +/// enough. +#[must_use] +pub fn exposed_name(command: &str, member: &str) -> String { + exposed_name_on(command, member, cfg!(windows)) +} + +/// The same rule, with the platform as an argument rather than as a `cfg`. +/// +/// Written this way so it can be *asserted* from either system rather than +/// demonstrated on one. A `cfg!` here would make the Windows branch provable +/// only on Windows, and a mutation that deleted it would leave every Linux run +/// green — which is exactly what happened to the first version of this, and is +/// the reason it is a parameter now. +#[must_use] +pub fn exposed_name_on(command: &str, member: &str, windows: bool) -> String { + // Through the extension rather than the spelling: Windows treats `.JS` and + // `.js` as one extension, and a rule that missed the first would expose an + // unrunnable file on the only platform this branch exists for. + let javascript = Path::new(member) + .extension() + .is_some_and(|kind| kind.eq_ignore_ascii_case("js")); + if windows && javascript { + format!("{command}.cmd") + } else { + command.to_owned() + } +} + /// Point one stable path at the executable inside a versioned tree. /// /// The member is left where the archive put it. Codex's binary needs the `rg` @@ -562,12 +622,26 @@ fn expose(executable: &Path, exposed: &Path, version: &str, command: &str) -> Re } #[cfg(not(unix))] { - // Windows reserves symlink creation for privileged or developer-mode - // processes, so a hard link is what actually works; a copy is the last - // resort and costs a second copy of a large binary. - fs::hard_link(executable, exposed) - .or_else(|_| fs::copy(executable, exposed).map(|_| ())) + if exposed.extension().is_some_and(|kind| kind == "cmd") { + // A launcher rather than a link. Windows runs a file by its + // extension, so neither a hard link nor a copy of a `.js` is a + // program -- and the interpreter has to be named. `%*` forwards + // every argument, and the quotes survive a prefix with spaces, + // which `%LOCALAPPDATA%\Programs` is one bad default away from. + fs::write( + exposed, + format!("@node \"{}\" %*\r\n", executable.display()), + ) .map_err(fail)?; + } else { + // Windows reserves symlink creation for privileged or + // developer-mode processes, so a hard link is what actually works; + // a copy is the last resort and costs a second copy of a large + // binary. + fs::hard_link(executable, exposed) + .or_else(|_| fs::copy(executable, exposed).map(|_| ())) + .map_err(fail)?; + } } // Which version this now runs, recorded rather than left to be inferred @@ -654,6 +728,88 @@ mod tests { ); } + /// A JavaScript entry point is exposed as something the platform can run. + /// + /// Six of the seven products ship a native executable and a link named + /// after the command is the whole story. Pi ships `dist/bundle/cli.js`, and + /// the two systems part company: Unix has the shebang, Windows decides how + /// to run a file from its extension, so a copy named `pi` with JavaScript + /// inside it is not a program there. + /// + /// The rule is asserted for both systems from either, because it is the + /// answer three callers need to agree on -- the plan that states + /// `entry_point`, the apply that writes it, and `launch` that starts it. + #[test] + fn a_javascript_entry_point_is_exposed_as_something_the_platform_can_run() { + // A native member is the command, on every system. + assert_eq!(exposed_name("codex", CODEX_MEMBER), "codex"); + assert_eq!(exposed_name("grok", ""), "grok"); + + // A JavaScript member, asserted for both systems from whichever one is + // running: Windows cannot run a file named `pi` holding JavaScript, and + // on Unix the shebang does the work so a launcher would be noise. + let js = "package/dist/bundle/cli.js"; + assert_eq!(exposed_name_on("pi", js, true), "pi.cmd"); + assert_eq!(exposed_name_on("pi", js, false), "pi"); + + // A native member is unaffected by the platform, which is what makes + // this a rule about the artifact rather than about Windows. + assert_eq!(exposed_name_on("codex", CODEX_MEMBER, true), "codex"); + assert_eq!(exposed_name_on("codex", CODEX_MEMBER, false), "codex"); + + // And the shipped entry point agrees with the rule for this system. + assert_eq!( + exposed_name("pi", js), + exposed_name_on("pi", js, cfg!(windows)) + ); + } + + /// Pi installs from one tarball and the thing that lands runs. + /// + /// The declaration this replaces said pi could not be installed because + /// "its dependency closure is resolved at install time, so there is no + /// single artifact whose digest can be decided in advance". The published + /// package ships `npm-shrinkwrap.json`, so the closure is fixed -- and it + /// does not matter, because the bundle imports only Node built-ins and runs + /// with no `node_modules` at all. + #[test] + #[cfg(unix)] + fn a_javascript_program_installs_from_one_archive_and_runs() { + let member = "package/dist/bundle/cli.js"; + let (at, artifact) = staged( + "js-entry", + b"#!/usr/bin/env sh\necho 'js-stand-in 9.9.9'\n", + member, + ); + let software = Software { + version: "9.9.9", + command: "jsprog", + delivery: Delivery::Artifacts(&[]), + unsupported: &[], + }; + let root = at.join("prefix"); + let installed = install(&software, &artifact, &at.join("artifact.tgz"), &root).unwrap(); + + // Exposed under the name the rule gives, pointing into the archive's + // own layout rather than a copy lifted out of it. + assert_eq!( + installed.executable, + root.join("bin").join(exposed_name("jsprog", member)) + ); + assert_eq!( + fs::canonicalize(&installed.executable).unwrap(), + fs::canonicalize(root.join("9.9.9").join(member)).unwrap() + ); + + // And the reading of what is exposed agrees with what was written. + assert_eq!( + Present::under_named(&root, "jsprog", member) + .exposed + .as_deref(), + Some("9.9.9") + ); + } + /// The exposed version is recorded, so it is readable where no link exists. /// /// This is the Windows defect written as a test that fails on Linux too. diff --git a/install.ps1 b/install.ps1 index b9dd123..3462187 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,7 +7,7 @@ # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0 [CmdletBinding()] param( - [string]$Version = "0.0.5", + [string]$Version = "0.0.6", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index 66461a2..421bc9e 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -eu REPO="NDDev-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.5}" +VERSION="${1:-0.0.6}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in diff --git a/references/opencode-baseline.json b/references/opencode-baseline.json index f28db0a..75f4778 100644 --- a/references/opencode-baseline.json +++ b/references/opencode-baseline.json @@ -90,7 +90,7 @@ "OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_SHARE" ], - "verified_at": "2026-08-25T13:06:34+00:00", + "verified_at": "2026-08-26T23:01:06+00:00", "software_artifacts": { "command": "opencode", "shape": "gzip-tar", @@ -133,6 +133,6 @@ } }, "version": "1.18.23", - "verified_at": "2026-08-25T13:06:34+00:00" + "verified_at": "2026-08-26T23:01:06+00:00" } }