diff --git a/CHANGELOG.md b/CHANGELOG.md index 77c068d..4ac1997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,25 @@ sibling project the same week this note was added. ## [Unreleased] +## [0.0.55] - 2026-09-02 + +A plan whose scope contradicts the target's record is refused by +name. A target managed under `project` used to accept a plan naming the +global profile, measure the global inventory at a workspace where those +namespaces are simply absent, and — had it been applied — rewrite the record +with the wrong ownership. The consumer met the first half as a bare +`expected_target_digest` mismatch when their remove plan carried no scope; +the refusal now says which scope to send (`unsupported_operation`). One +direction only: a home managed globally may still be asked about a scope. + +`status --target-scope ` is honoured: asked, `status` measures the +inventory the plan will, instead of the global namespaces at a workspace +root that a repository may spell for its own reasons. Absent the flag, +`status` is exactly what it was. The `provider-info` member that lets a +consumer send it, `status_request_fields`, is present in the build and +empty until the kit names it and a released consumer accepts it; this +release publishes the same thirteen names 0.0.54 did. + ## [0.0.54] - 2026-09-02 `remove` reads a bundle, and the plan says per path what stays. diff --git a/Cargo.lock b/Cargo.lock index 25b5341..bf2172a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.54" +version = "0.0.55" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.54" +version = "0.0.55" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.54" +version = "0.0.55" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.54" +version = "0.0.55" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index ce96094..2003889 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.54" +version = "0.0.55" 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.54" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.54" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.54" } +setup-core = { path = "crates/setup-core", version = "0.0.55" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.55" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.55" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index def7a18..cadac6d 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,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.54 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.55 \ status --target /config/ --json ``` diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index d5eded8..76628cb 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -49,7 +49,10 @@ pub fn dispatch(harness: &Harness, invocation: Invocation) -> Result status(harness, &target), + Invocation::Status { + target, + target_scope, + } => status(harness, &target, target_scope), Invocation::ValidateBundle { bundle, .. } => Ok(validate_bundle(harness, &bundle)), Invocation::PlanOperation { target, request } => plan(harness, &target, &request), Invocation::ApplyOperation { @@ -262,15 +265,59 @@ fn shadowed_here(harness: &Harness, root: &Path) -> Vec { /// `managed`, and `target_digest` — and it calls this twice, requiring the two /// answers to be *identical*. So nothing here may vary between calls: no clock, /// no counter, no ordering that depends on a directory walk. -fn status(harness: &Harness, target: &Path) -> Result { +fn status( + harness: &Harness, + target: &Path, + asked: Option, +) -> Result { let (resolved, control, pool) = observe(harness, target)?; - // `status` is handed a target and nothing else, so the scope has to come - // from the target. It is already written down: see `scope_recorded_at`. - let scope = scope_recorded_at(harness, &resolved); + let scope = scope_to_measure(harness, &resolved, asked)?; let owned = owned_here(harness, &resolved, scope)?; let identity = resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?; let journal = Journal::read(&control).ok().flatten(); + status_of(harness, &resolved, &pool, &identity, journal) +} +/// Which scope `status` measures a target under. +fn scope_to_measure( + harness: &Harness, + resolved: &Target, + asked: Option, +) -> Result> { + // A scope this provider never published cannot be asked about: the + // answer would be an inventory this build has made no statement on. + if let Some(named) = asked + && harness.scoped_for(Some(named)).is_none() + { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "--target-scope {named} names a target this provider publishes no \ + projection profile for" + ), + )); + } + // The scope the caller asked about wins; without one, it comes from the + // target's own record (`scope_recorded_at`). The record covers every + // managed target. What it cannot cover is a workspace nobody has installed + // into: no record, so the *global* namespaces are measured at its root -- + // and a repository is free to carry a top-level `skills/` or `rules/` of + // its own that happens to spell one of them. The plan the consumer binds + // to this answer is made under the scope it is about to install, where an + // unrecorded target is exactly nothing of ours. Two inventories, one + // comparison; asked, `status` measures the one the plan will. Agreed with + // the consumer on 2026-09-02, out of their project-scope branch. + Ok(asked.or_else(|| scope_recorded_at(harness, resolved))) +} + +/// The status answer, once the inventory has been measured. +fn status_of( + harness: &Harness, + resolved: &Target, + pool: &Pool, + identity: &str, + journal: Option, +) -> Result { let reading = ProviderState::read(resolved.root(), harness.state_file)?; // `managed` carries our state; `unmanaged` holds content that is not ours; // `missing` means there is nothing here at all. @@ -711,6 +758,7 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result Result { - let mut lines = vec!["capture the current target before removing".to_owned()]; - lines.extend(taken_before_writing(harness, request.target_scope)); - // A bundle on a remove names what stays: the consumer rebuilt a - // host file without the key this setup put there, and the file - // outlives the setup at exactly those bytes. Read and verified - // here, as install's is, so the plan is never issued for bytes - // the apply would refuse -- same reader, same limits, same - // `validate-bundle` semantics, by the consumer's request. - if let Some(named) = request.bundle.as_ref() { - let verified = verified_bundle(harness, named, Surface::At(request.target_scope))?; - end_state = end_states_of(harness, &resolved, request.target_scope, &verified)?; - lines.push(format!( - "leave {} declared files behind at the bytes the bundle carries", - verified.files.len() - )); - lines.extend( - verified - .files - .keys() - .take(16) - .map(|path| format!("leave {path}")), - ); - } + let (lines, states) = removal_effects(harness, &resolved, request)?; + end_state = states; (lines, None, None) } Operation::Install | Operation::Replace => (bundle_effects(harness, request)?, None, None), @@ -827,6 +854,77 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result, +) -> Result<()> { + let Some(recorded) = scope_recorded_at(harness, target) else { + return Ok(()); + }; + if asked == Some(recorded) { + return Ok(()); + } + Err(Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{} is managed under target_scope {}, and this plan names {}; a plan \ + under another scope would measure another inventory, so name the \ + scope the target is managed under", + target.root().display(), + recorded.as_str(), + asked.map_or("the global profile", provider_v3::TargetScope::as_str) + ), + )) +} + +/// What a removal will do, and what each path becomes when a bundle rides. +/// +/// A bundle on a remove names what stays: the consumer rebuilt a host file +/// without the key this setup put there, and the file outlives the setup at +/// exactly those bytes. Read and verified here, as install's is, so the plan +/// is never issued for bytes the apply would refuse -- same reader, same +/// limits, same `validate-bundle` semantics, by the consumer's request. +fn removal_effects( + harness: &Harness, + resolved: &Target, + request: &PlanRequest, +) -> Result<(Vec, Vec)> { + let mut lines = vec!["capture the current target before removing".to_owned()]; + lines.extend(taken_before_writing(harness, request.target_scope)); + let Some(named) = request.bundle.as_ref() else { + return Ok((lines, Vec::new())); + }; + let verified = verified_bundle(harness, named, Surface::At(request.target_scope))?; + let states = end_states_of(harness, resolved, request.target_scope, &verified)?; + lines.push(format!( + "leave {} declared files behind at the bytes the bundle carries", + verified.files.len() + )); + lines.extend( + verified + .files + .keys() + .take(16) + .map(|path| format!("leave {path}")), + ); + Ok((lines, states)) +} + /// What each path a removal touches looks like afterwards, when a bundle of /// surviving bytes rides along. /// @@ -5155,6 +5253,275 @@ mod tests { ); } + /// The consumer binds a plan to the `target_digest` it observed through + /// `status` a moment before. Reported from their project-scope branch on + /// 2026-09-02: at a workspace, install passed on an empty target and the + /// remove that followed was refused for `expected_target_digest`. Both + /// numbers must come from the same owned set, whatever `status` was told. + #[test] + fn status_and_a_scoped_plan_agree_on_the_identity_after_a_scoped_install() { + let target = seeded("scoped-status-agrees"); + install_scoped(&target, "agree", "ours", "ours\n"); + let observed = run(args("status", &target, &[])); + let planned = scoped_plan(&target, "remove", "operation_01AGREE"); + assert_eq!( + planned["plan"]["expected_target_digest"], observed["target_digest"], + "status {observed}\nplan {planned}" + ); + } + + /// The same question at a *workspace*: a project-scoped harness shaped like + /// cursor's, a target holding the person's own source tree, and a scoped + /// install of one skill under `.cursor/`. This is the exact shape the + /// consumer's project-scope branch measured on 2026-09-02 and found the + /// remove plan refused for `expected_target_digest`. + #[test] + fn status_and_a_project_plan_agree_on_the_identity_at_a_workspace() { + let harness = project_shaped(); + let workspace = scratch("project-status-agrees").join("workspace"); + fs::create_dir_all(workspace.join("src")).unwrap(); + fs::write(workspace.join("src").join("main.rs"), "fn main() {}\n").unwrap(); + fs::write(workspace.join("README.md"), "# theirs\n").unwrap(); + + let before = run_for(&harness, args("status", &workspace, &[])); + let (bytes, bundle_digest, artifact) = + bundle_bytes(&[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)]); + let artifact_path = workspace.join("..").join("project.zip"); + fs::write(&artifact_path, &bytes).unwrap(); + let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); + let mut plan_args = vec![ + "--operation".to_owned(), + "install".to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + "--operation-id".to_owned(), + "operation_01PROJECTIN".to_owned(), + "--expires-at".to_owned(), + far_future().to_owned(), + "--target-scope".to_owned(), + "project".to_owned(), + ]; + plan_args.extend(flags.clone()); + let borrowed: Vec<&str> = plan_args.iter().map(String::as_str).collect(); + let planned = run_for(&harness, args("plan-operation", &workspace, &borrowed)); + assert_eq!(planned["state"], "planned", "{planned}"); + assert_eq!( + planned["plan"]["expected_target_digest"], before["target_digest"], + "install: status {before}\nplan {planned}" + ); + let plan_path = workspace.join("..").join("project-plan.json"); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + let mut apply_args = vec![ + "--plan".to_owned(), + plan_path.to_string_lossy().into_owned(), + "--plan-digest".to_owned(), + planned["plan_digest"].as_str().unwrap().to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + ]; + apply_args.extend(flags); + let borrowed: Vec<&str> = apply_args.iter().map(String::as_str).collect(); + let applied = run_for(&harness, args("apply-operation", &workspace, &borrowed)); + assert_eq!(applied["state"], "verified", "{applied}"); + assert!(workspace.join(".cursor/skills/probe/SKILL.md").exists()); + + let after = run_for(&harness, args("status", &workspace, &[])); + let removal = run_for( + &harness, + args( + "plan-operation", + &workspace, + &[ + "--operation", + "remove", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01PROJECTRM", + "--expires-at", + far_future(), + "--target-scope", + "project", + ], + ), + ); + assert_eq!(removal["state"], "planned", "{removal}"); + assert_eq!( + removal["plan"]["expected_target_digest"], after["target_digest"], + "remove: status {after}\nplan {removal}" + ); + } + + /// A workspace nobody has installed into, whose own tree happens to carry + /// a top-level directory spelled like one of the global namespaces -- + /// here `skills/`, which is a repository's own business. No record to + /// read a scope from, so an unasked `status` measures the global set and + /// hashes those files, while the plan the consumer binds to it is made + /// under `project`, where an unrecorded target is nothing of ours. Asked, + /// the two agree. + #[test] + fn a_status_asked_about_a_scope_measures_that_scopes_inventory_before_any_record() { + let harness = project_shaped(); + let workspace = scratch("project-status-asked").join("workspace"); + fs::create_dir_all(workspace.join("skills")).unwrap(); + fs::write( + workspace.join("skills/theirs.md"), + "# the repository's own\n", + ) + .unwrap(); + fs::write(workspace.join("README.md"), "# theirs\n").unwrap(); + + let unasked = run_for(&harness, args("status", &workspace, &[])); + let asked = run_for( + &harness, + args("status", &workspace, &["--target-scope", "project"]), + ); + assert_ne!( + unasked["target_digest"], asked["target_digest"], + "the global set hashes the repository's skills/; the project set has no record and nothing of ours" + ); + let (bytes, bundle_digest, artifact) = + bundle_bytes(&[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)]); + let artifact_path = workspace.join("..").join("asked.zip"); + fs::write(&artifact_path, &bytes).unwrap(); + let mut plan_args = vec![ + "--operation".to_owned(), + "install".to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + "--operation-id".to_owned(), + "operation_01ASKED".to_owned(), + "--expires-at".to_owned(), + far_future().to_owned(), + "--target-scope".to_owned(), + "project".to_owned(), + ]; + plan_args.extend(bundle_flags( + &artifact_path, + &bundle_digest, + &artifact, + bytes.len(), + )); + let borrowed: Vec<&str> = plan_args.iter().map(String::as_str).collect(); + let planned = run_for(&harness, args("plan-operation", &workspace, &borrowed)); + assert_eq!(planned["state"], "planned", "{planned}"); + assert_eq!( + planned["plan"]["expected_target_digest"], + asked["target_digest"] + ); + assert_ne!( + planned["plan"]["expected_target_digest"], + unasked["target_digest"] + ); + + let error = refuse_for( + &harness, + args("status", &workspace, &["--target-scope", "user_root"]), + ); + assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation)); + } + + /// A target managed under `project` refuses a plan that names no scope, + /// and says which one to name -- the consumer's remove plan carried none + /// and met a digest mismatch instead of this sentence. + #[test] + fn a_plan_whose_scope_contradicts_the_record_is_refused_by_name() { + let harness = project_shaped(); + let workspace = scratch("project-scope-contradiction").join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + let (bytes, bundle_digest, artifact) = + bundle_bytes(&[(".cursor/skills/probe/SKILL.md", "probe\n", 0o644)]); + let artifact_path = workspace.join("..").join("contra.zip"); + fs::write(&artifact_path, &bytes).unwrap(); + let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); + let mut plan_args = vec![ + "--operation".to_owned(), + "install".to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + "--operation-id".to_owned(), + "operation_01CONTRAIN".to_owned(), + "--expires-at".to_owned(), + far_future().to_owned(), + "--target-scope".to_owned(), + "project".to_owned(), + ]; + plan_args.extend(flags.clone()); + let borrowed: Vec<&str> = plan_args.iter().map(String::as_str).collect(); + let planned = run_for(&harness, args("plan-operation", &workspace, &borrowed)); + let plan_path = workspace.join("..").join("contra-plan.json"); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + let mut apply_args = vec![ + "--plan".to_owned(), + plan_path.to_string_lossy().into_owned(), + "--plan-digest".to_owned(), + planned["plan_digest"].as_str().unwrap().to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + ]; + apply_args.extend(flags); + let borrowed: Vec<&str> = apply_args.iter().map(String::as_str).collect(); + assert_eq!( + run_for(&harness, args("apply-operation", &workspace, &borrowed))["state"], + "verified" + ); + + let error = refuse_for( + &harness, + args( + "plan-operation", + &workspace, + &[ + "--operation", + "remove", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01CONTRARM", + "--expires-at", + far_future(), + ], + ), + ); + assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation)); + assert!( + error + .detail() + .contains("managed under target_scope project") + && error.detail().contains("names the global profile"), + "{}", + error.detail() + ); + assert!( + workspace.join(".cursor/skills/probe/SKILL.md").exists(), + "a refusal made an effect" + ); + } + + /// A harness shaped like cursor's project scope, for the workspace cases. + fn project_shaped() -> Harness { + let mut harness = TEST; + harness.scoped_projections = &[crate::facts::Scoped { + target_scope: provider_v3::TargetScope::Project, + profile_id: "test/native-files/project/1", + component_kinds: &[ + provider_v3::ComponentKind::Skill, + provider_v3::ComponentKind::Instruction, + ], + projection_kinds: &[provider_v3::ProjectionKind::NativeFiles], + native_namespaces: &[".cursor/skills", ".cursor/rules"], + }]; + harness + } + /// Under a shared root the record is the inventory. A file this build /// leaves behind at the person's bytes must leave the record too, or the /// next removal would take it -- which is the file the whole extension diff --git a/crates/provider-v3/src/argv.rs b/crates/provider-v3/src/argv.rs index b23ee6b..5457be0 100644 --- a/crates/provider-v3/src/argv.rs +++ b/crates/provider-v3/src/argv.rs @@ -132,7 +132,15 @@ pub const fn usage(command: Command) -> Usage { Command::Status => Usage { command, required: &["--target", "--json"], - optional: &[], + // Accepted since 0.0.55, declared through `status_request_fields` + // once the kit names the member: a workspace that nobody has + // installed into yet has no record to read a scope from, and the + // consumer binds a plan to the identity `status` reports -- so it + // must be able to ask about the scope it is about to plan under. + optional: &[( + "--target-scope", + "which scope to measure the target under; absent, the target's own record decides", + )], note: "Report the target's current state. Never changes it.", }, Command::RecoverOperation => Usage { @@ -267,6 +275,9 @@ pub enum Invocation { Status { /// The target the caller named. target: PathBuf, + /// The scope the caller is asking about, when it said. Absent, the + /// scope is read from the target's own record, as it always was. + target_scope: Option, }, /// Start the product. Optional command. Launch { @@ -386,7 +397,7 @@ impl Invocation { | Self::PlanOperation { target, .. } | Self::ApplyOperation { target, .. } | Self::RecoverOperation { target } - | Self::Status { target } + | Self::Status { target, .. } | Self::Launch { target, .. } => Some(target), } } @@ -466,7 +477,10 @@ where let invocation = match command { Command::ProviderInfo => return Err(local("provider-info never reaches this branch")), - Command::Status => Invocation::Status { target }, + Command::Status => Invocation::Status { + target, + target_scope: take_target_scope(&mut flags)?, + }, Command::RecoverOperation => Invocation::RecoverOperation { target }, Command::Launch => Invocation::Launch { target, @@ -884,9 +898,18 @@ mod tests { assert_eq!( parse(with_target("status", &[])).unwrap(), Invocation::Status { - target: PathBuf::from("/tmp/target") + target: PathBuf::from("/tmp/target"), + target_scope: None, } ); + assert!( + parse(with_target( + "recover-operation", + &["--target-scope", "project"] + )) + .is_err(), + "recovery reads its scope from the journal, never from argv" + ); assert_eq!( parse(with_target("recover-operation", &[])).unwrap(), Invocation::RecoverOperation { @@ -895,6 +918,26 @@ mod tests { ); } + /// The consumer binds a plan to the identity `status` reports a moment + /// before, and a workspace nobody has installed into has no record to + /// read a scope from -- so `status` must be able to be asked. + #[test] + fn status_may_be_asked_about_a_scope() { + assert_eq!( + parse(with_target("status", &["--target-scope", "project"])).unwrap(), + Invocation::Status { + target: PathBuf::from("/tmp/target"), + target_scope: Some(TargetScope::Project), + } + ); + let error = parse(with_target("status", &["--target-scope", "galaxy"])).unwrap_err(); + assert!( + error.detail().contains("not a target scope"), + "{}", + error.detail() + ); + } + #[test] fn a_full_plan_request_parses_every_field() { let mut tokens = with_target( diff --git a/crates/provider-v3/src/info.rs b/crates/provider-v3/src/info.rs index 7ccd1b1..7b145fa 100644 --- a/crates/provider-v3/src/info.rs +++ b/crates/provider-v3/src/info.rs @@ -229,6 +229,20 @@ pub struct ProviderInfo { /// exactly what it published before this field existed. #[serde(skip_serializing_if = "Vec::is_empty")] pub scoped_projection_profiles: Vec, + /// The arguments `status` accepts beside its target, once the kit names + /// the member. + /// + /// Empty and therefore absent until kit `0.2.9` publishes the member and + /// a released consumer accepts it -- the same two gates every + /// `provider-info` field has to pass, because the field set is compared + /// for exact equality and an unknown member refuses the whole document. + /// The runtime already honours `status --target-scope` (0.0.55); this is + /// the sentence that lets a consumer send it. Agreed with the consumer on + /// 2026-09-02 after their project-scope branch found that a workspace + /// nobody has installed into has no record `status` could read a scope + /// from, while the plan it is bound to is made under one. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub status_request_fields: Vec, /// The request-side arguments this release accepts. /// /// **A provider says what it will tolerate, so a consumer can send it.** A @@ -394,6 +408,9 @@ impl ProviderInfo { TargetScope::REQUEST_FIELD.to_owned(), EndState::REQUEST_FIELD.to_owned(), ], + // Held until the kit names it; see the field's own note. The test + // beside `plan_request_fields`' flips this the day it does. + status_request_fields: Vec::new(), }) } @@ -786,6 +803,31 @@ mod tests { declared, expected, "declared {declared:?}, kit {published:?}" ); + + // The same rule for `status_request_fields`: while the kit's schema + // has no such property, declaring anything would refuse the whole + // document; the day it appears, this asserts the declaration follows. + let status_declared = info(Command::ALL, Operation::CORE) + .unwrap() + .status_request_fields; + match schema["properties"].get("status_request_fields") { + None => assert!( + status_declared.is_empty(), + "the kit does not name status_request_fields and this build declares {status_declared:?}" + ), + Some(property) => { + let mut named: Vec<&str> = property["items"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + named.sort_unstable(); + let mut declared = status_declared; + declared.sort_unstable(); + assert_eq!(declared, named, "the kit names {named:?} for status"); + } + } } /// One target, one owner. diff --git a/install.ps1 b/install.ps1 index ecf9f1d..43b7696 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.54", + [string]$Version = "0.0.55", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index 201ef66..428ef92 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.54}" +VERSION="${1:-0.0.55}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in