diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c23b1b2b..3aadc1a1 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -38,8 +38,10 @@ jobs: - name: Setup Erlang/Elixir uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: - otp-version: '27.0' - elixir-version: '1.17' + # Versions derive from opsm.toml [runtime] via .tool-versions + # (generated by `just toolchain-sync` — see docs/TOOLCHAIN.adoc) + version-file: '.tool-versions' + version-type: 'strict' - name: Restore Mix cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4 with: @@ -65,8 +67,10 @@ jobs: - name: Setup Erlang/Elixir uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: - otp-version: '27.0' - elixir-version: '1.17' + # Versions derive from opsm.toml [runtime] via .tool-versions + # (generated by `just toolchain-sync` — see docs/TOOLCHAIN.adoc) + version-file: '.tool-versions' + version-type: 'strict' - name: Restore Mix cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4 with: diff --git a/.github/workflows/echidna-validation.yml b/.github/workflows/echidna-validation.yml index 4ec80e3e..44b7080c 100644 --- a/.github/workflows/echidna-validation.yml +++ b/.github/workflows/echidna-validation.yml @@ -49,6 +49,8 @@ jobs: if: steps.detect.outputs.relevant == 'true' uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable with: + # Pinned to the canonical rust version in opsm.toml [runtime] + toolchain: '1.97.0' components: clippy - name: Cache Rust dependencies if: steps.detect.outputs.relevant == 'true' diff --git a/.github/workflows/nif-build.yml b/.github/workflows/nif-build.yml index 1908f359..f7077abe 100644 --- a/.github/workflows/nif-build.yml +++ b/.github/workflows/nif-build.yml @@ -26,6 +26,9 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable + with: + # Pinned to the canonical rust version in opsm.toml [runtime] + toolchain: '1.97.0' - name: Cache Rust build uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 with: @@ -33,8 +36,10 @@ jobs: - name: Setup Erlang/Elixir uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: - otp-version: "27.0" - elixir-version: "1.17" + # Versions derive from opsm.toml [runtime] via .tool-versions + # (generated by `just toolchain-sync` — see docs/TOOLCHAIN.adoc) + version-file: '.tool-versions' + version-type: 'strict' - name: Restore Mix cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4 with: diff --git a/.github/workflows/trust-pipeline-e2e.yml b/.github/workflows/trust-pipeline-e2e.yml index b89b264a..c40ef0ed 100644 --- a/.github/workflows/trust-pipeline-e2e.yml +++ b/.github/workflows/trust-pipeline-e2e.yml @@ -42,8 +42,10 @@ jobs: - name: Install Elixir and OTP uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: - otp-version: '26.2' - elixir-version: '1.16.0' + # Versions derive from opsm.toml [runtime] via .tool-versions + # (generated by `just toolchain-sync` — see docs/TOOLCHAIN.adoc) + version-file: '.tool-versions' + version-type: 'strict' - name: Cache Mix dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4 with: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8dafd33c..96c73737 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,11 @@ # SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell +# Copyright (c) Jonathan D.A. Jewell # Primary CI/CD - GitLab is the source of truth +# +# Toolchain images (elixir/rust) are pinned to the canonical versions in +# opsm.toml [runtime] — drift is gated by `just toolchain-check`. +# Scanner images (trivy/semgrep/trufflehog) deliberately float: their +# detection databases need to stay fresh, so pinning them is worse. stages: - security @@ -32,7 +37,7 @@ semgrep: allow_failure: true cargo-audit: stage: security - image: rust:latest + image: rust:1.97 script: - cargo install cargo-audit - cargo audit @@ -41,7 +46,7 @@ cargo-audit: - Cargo.toml cargo-deny: stage: security - image: rust:latest + image: rust:1.97 script: - cargo install cargo-deny - cargo deny check @@ -51,7 +56,7 @@ cargo-deny: allow_failure: true mix-audit: stage: security - image: elixir:latest + image: elixir:1.19.5-otp-28 script: - mix local.hex --force - mix archive.install hex mix_audit --force @@ -66,7 +71,7 @@ mix-audit: # ================== rustfmt: stage: lint - image: rust:latest + image: rust:1.97 script: - rustup component add rustfmt - cargo fmt -- --check @@ -75,7 +80,7 @@ rustfmt: - Cargo.toml clippy: stage: lint - image: rust:latest + image: rust:1.97 script: - rustup component add clippy - cargo clippy -- -D warnings @@ -85,7 +90,7 @@ clippy: allow_failure: true mix-format: stage: lint - image: elixir:latest + image: elixir:1.19.5-otp-28 script: - mix format --check-formatted rules: @@ -93,7 +98,7 @@ mix-format: - mix.exs credo: stage: lint - image: elixir:latest + image: elixir:1.19.5-otp-28 script: - mix local.hex --force - mix deps.get @@ -107,7 +112,7 @@ credo: # ================== cargo-test: stage: test - image: rust:latest + image: rust:1.97 script: - cargo test --all-features rules: @@ -115,7 +120,7 @@ cargo-test: - Cargo.toml mix-test: stage: test - image: elixir:latest + image: elixir:1.19.5-otp-28 script: - mix local.hex --force - mix deps.get @@ -128,7 +133,7 @@ mix-test: # ================== cargo-build: stage: build - image: rust:latest + image: rust:1.97 script: - cargo build --release artifacts: @@ -140,7 +145,7 @@ cargo-build: - Cargo.toml mix-build: stage: build - image: elixir:latest + image: elixir:1.19.5-otp-28 script: - mix local.hex --force - mix deps.get diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index d296e752..f6530deb 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -1,11 +1,11 @@ # SPDX-License-Identifier: MPL-2.0 # STATE.a2ml — Project state checkpoint -# Updated 2026-04-25f: CRG-B achieved — 6 external targets documented +# Updated 2026-07-09: toolchain single source of truth (opsm.toml [runtime]) [metadata] project = "odds-and-sods-package-manager" version = "2.0.0" -last-updated = "2026-04-25" +last-updated = "2026-07-09" status = "first-class system active" [project-context] @@ -169,5 +169,19 @@ evidence-file = "READINESS.md" - Health poll loop (180s deadline, 3s interval) before running tests - Full diagnostics (service logs) on failure; docker compose down always +[completed-2026-07-09] +- Toolchain single source of truth established: opsm.toml [runtime] canonical ✅ + - Stale pins corrected (erlang 26.2.0→28.3.1, elixir 1.16.0→1.19.5-otp-28) + - Pin set completed: rust 1.97.0, nickel 1.16.0 (was unpinned), idris2 0.8.0 + - Vestigial nodejs pin dropped — Deno is the sole JS runtime + - .tool-versions now GENERATED (scripts/toolchain.sh; just toolchain-sync) + - Drift gate: just toolchain-check, wired into Mustfile + - GitLab CI images pinned off :latest; GitHub workflows read .tool-versions + via setup-beam version-file (previously 3 disagreeing hardcoded sets) + - [runtime] parser hardened: comment lines + inline comments (manager.ex) + - Full story: docs/TOOLCHAIN.adoc (incl. bootstrap layering + Idris2/pack route) + [next-actions] - Grade A: requires external user confirmation outside hyperpolymath +- guix.scm is a stub (source #f) — grow into real manifest (erlang, elixir, + zig, just, nickel, idris2, chez-scheme, gmp) for reproducible bootstrap diff --git a/.tool-versions b/.tool-versions index 1cdeafbe..a4e1e803 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,8 +1,17 @@ # SPDX-License-Identifier: MPL-2.0 -# Pinned runtime versions for OPSM -# Managed via asdf (https://asdf-vm.com) -elixir 1.19.5-otp-28 +# GENERATED FILE — do not edit by hand. +# Source of truth: opsm.toml [runtime]. Regenerate with: just toolchain-sync +# Drift is CI-gated by: just toolchain-check +# +# Plugin sources: erlang/elixir via the standard asdf plugins; +# zig, deno, nickel via https://github.com/hyperpolymath/asdf-tool-plugins +# +# Canonical pins deliberately NOT listed here (see docs/TOOLCHAIN.adoc): +# idris2 0.8.0 — no asdf plugin; provision via pack + Chez Scheme + GMP +# rust 1.97.0 — asdf-global-managed to avoid cargo-audit conflicts; CI pins it erlang 28.3.1 -# Note: Rust managed via asdf global (nightly). Not pinned here to avoid -# conflicting with cargo-audit and other cargo subcommands. -nodejs 25.6.1 +elixir 1.19.5-otp-28 +zig 0.14.0 +just 1.46.0 +deno 2.6.10 +nickel 1.16.0 diff --git a/Justfile b/Justfile index 64822826..4a827418 100644 --- a/Justfile +++ b/Justfile @@ -107,6 +107,18 @@ fmt-check: # Format all code fmt: fmt-ex fmt-deno +# ═══════════════════════════════════════════════════════════════════════════════ +# TOOLCHAIN (single source of truth: opsm.toml [runtime] — docs/TOOLCHAIN.adoc) +# ═══════════════════════════════════════════════════════════════════════════════ + +# Regenerate .tool-versions from opsm.toml [runtime] +toolchain-sync: + sh scripts/toolchain.sh sync + +# Verify .tool-versions and CI image pins match opsm.toml [runtime] +toolchain-check: + sh scripts/toolchain.sh check + # ═══════════════════════════════════════════════════════════════════════════════ # RUN # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/Mustfile b/Mustfile index ee751099..22e20554 100644 --- a/Mustfile +++ b/Mustfile @@ -11,4 +11,6 @@ checks: run: just test - name: format run: just fmt + - name: toolchain + run: just toolchain-check diff --git a/docs/TOOLCHAIN.adoc b/docs/TOOLCHAIN.adoc new file mode 100644 index 00000000..f9e6f83e --- /dev/null +++ b/docs/TOOLCHAIN.adoc @@ -0,0 +1,171 @@ += OPSM Toolchain — Single Source of Truth +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell +:toc: +:toc-placement!: + +toc::[] + +== The rule + +*`opsm.toml [runtime]` is the canonical source of OPSM's own toolchain +versions.* Every other copy is derived from it or checked against it. If you +need to change a version, change it there and run `just toolchain-sync`. + +This is dogfooding, not just convention: `opsm runtime install` reads the +`[runtime]` block of an `opsm.toml` and provisions the pinned tools +(`opsm_ex/lib/opsm/runtime/manager.ex`). OPSM's own manifest declaring OPSM's +own toolchain is the self-managed path the READINESS assessment (Target 1) +already claims. + +== Derivation chain + +---- +opsm.toml [runtime] <- edit HERE, nowhere else + | + | just toolchain-sync (scripts/toolchain.sh sync) + v +.tool-versions <- GENERATED; do not edit + | consumed by: + |-- asdf / mise (local development bootstrap) + |-- GitHub CI: erlef/setup-beam via `version-file: .tool-versions` + | + | just toolchain-check (scripts/toolchain.sh check; Mustfile-gated) + v +.gitlab-ci.yml image pins <- verified to match, fails loudly on drift +---- + +`just toolchain-check` is a mandatory check in the `Mustfile`. It regenerates +`.tool-versions` to a temp file and diffs it against the committed copy, and +verifies the GitLab CI `elixir:`/`rust:` image tags match the canonical +versions. Any drift is a hard failure. + +== The toolchain + +[cols="1,1,3,2"] +|=== +|Tool |Version |Why OPSM needs it |Provisioner + +|erlang +|28.3.1 +|BEAM under the Elixir orchestration layer (`opsm_ex/`) +|asdf core plugin / setup-beam in CI + +|elixir +|1.19.5-otp-28 +|OPSM's primary implementation language — the escript CLI +|asdf core plugin / setup-beam in CI + +|rust +|1.97.0 +|PQ-crypto NIF (`opsm_ex/native/opsm_pq_nif`), QUIC transport + (`opsm_ex/native/quic_transport`), ratatui TUI (`opsm-ui/tui`), the six + `services/*` microservices +|rustup / dtolnay/rust-toolchain in CI. *Deliberately excluded from + `.tool-versions`* — rust is asdf-global-managed locally so cargo-audit and + other cargo subcommands don't fight the pin; CI pins the same canonical + version explicitly. + +|zig +|0.14.0 +|`build.zig` — unified CLI binary + the Idris2 FFI bridge (`src/abi/ffi.zig`) +|estate plugin: https://github.com/hyperpolymath/asdf-tool-plugins[`asdf-zig-plugin`] + +|just +|1.46.0 +|`Justfile` build recipes (estate standard, replaces Make) +|community asdf plugin / mise core + +|deno +|2.6.10 +|The *sole* JS runtime: drives the ReScript CLI build (`deno.json` tasks), + tests, lint, fmt +|estate plugin: https://github.com/hyperpolymath/asdf-tool-plugins[`asdf-deno-plugin`] + +|nickel +|1.16.0 +|`runtime/` version-management contracts and URL handlers (`**/*.ncl`) — + previously *unpinned anywhere* +|estate plugin: https://github.com/hyperpolymath/asdf-tool-plugins[`asdf-nickel-plugin`] + +|idris2 +|0.8.0 +|ABI/FFI formal modelling (`src/abi/{Types,Layout,Foreign}.idr`) — a real + build dependency, not optional +|pack + Chez Scheme + GMP — see below. *Excluded from `.tool-versions`*: no + asdf plugin exists. +|=== + +Tools that need *no* separate pin: + +* *ReScript* — locked by `deno.lock` (`npm:rescript ^12.0.0` in `deno.json`). + Deno resolves it; there is no separate toolchain install. +* *Node.js* — *dropped, 2026-07-09.* The old `.tool-versions` pinned + `nodejs 25.6.1`, but nothing uses Node: there is no `package.json`, the + ReScript build runs through Deno's `npm:` specifiers, and no script or + workflow invokes `node`. Deno is the single JS runtime, per estate policy. + +== The bootstrap story (read this before calling OPSM an "asdf replacement") + +OPSM is an Elixir escript (`mix escript.build`). It therefore *cannot +provision Erlang or Elixir for itself* — something must put a BEAM on the +machine before `opsm` can run at all. That base layer is, explicitly: + +1. *asdf or mise*, reading the generated `.tool-versions` (this is why the + file is kept even though `opsm.toml` is canonical), or +2. *Guix*, once `guix.scm` grows real inputs (see below), or +3. any system package manager that installs Erlang 28.3.1 + Elixir + 1.19.5-otp-28. + +Once the BEAM exists, `opsm runtime install` can provision everything else +from `opsm.toml [runtime]`. So the honest claim is: *OPSM replaces asdf for +the projects it manages; for its own bootstrap it sits on asdf/mise/Guix like +everything else.* The "complete asdf replacement" wording in `opsm.toml`'s +package description should be read with that scope. + +== Idris2: the hard one + +Idris2 0.8.0 is a genuine build dependency (the Zig FFI bridge in `build.zig` +exists so Idris2 can call into the system). There is *no clean asdf/mise +plugin* — Idris2 bootstraps through *Chez Scheme* (or Racket) plus *GMP*. + +Recommended route: https://github.com/stefan-hoeck/idris2-pack[pack]: + +[source,bash] +---- +# Debian/Ubuntu prerequisites +apt-get install chezscheme libgmp-dev + +# install pack, which installs and manages Idris2 toolchains +bash -c "$(curl -fsSL https://raw.githubusercontent.com/stefan-hoeck/idris2-pack/main/install.bash)" +pack switch latest # or a collection pinning Idris2 0.8.0 +---- + +*Status of the Guix route:* `guix.scm` at the repo root is currently a stub +(`source #f`, no inputs) — it does *not* yet provision Idris2, Chez, or +anything else. Growing it into a real manifest (erlang, elixir, zig, just, +nickel, idris2, chez-scheme, gmp) is the intended endgame for fully +reproducible bootstrap, but it is TODO, not reality. Until then, pack is the +documented path. + +== CI pinning decisions + +* *GitHub workflows* (`e2e.yml`, `nif-build.yml`, `trust-pipeline-e2e.yml`) + no longer hardcode BEAM versions — `erlef/setup-beam` reads + `.tool-versions` (`version-file` + `version-type: strict`), so they follow + the canonical pins automatically. Rust steps pin `toolchain: 1.97.0`. +* *GitLab CI* (`.gitlab-ci.yml`) pins `elixir:1.19.5-otp-28` and `rust:1.97` + images (tags verified to exist on Docker Hub). `just toolchain-check` fails + if these drift from `opsm.toml`. +* *Scanner images* (trivy, semgrep, trufflehog) deliberately float: their + value is fresh detection databases; pinning them trades real security for + cosmetic reproducibility. + +== History + +Before 2026-07-09 there were *five* disagreeing version sets across +`.tool-versions` (28.3.1/1.19.5), `opsm.toml` (26.2.0/1.16.0), +`.gitlab-ci.yml` (`latest`), `e2e.yml`+`nif-build.yml` (27.0/1.17), and +`trust-pipeline-e2e.yml` (26.2/1.16.0) — plus Nickel used but pinned nowhere, +Idris2 unpinned, and a vestigial Node pin. This document and +`scripts/toolchain.sh` are the fix. diff --git a/opsm.toml b/opsm.toml index b3c12609..6785ff10 100644 --- a/opsm.toml +++ b/opsm.toml @@ -46,9 +46,17 @@ registry = "hf" self_hosted = true [runtime] -# Pinned tool versions — managed by OPSM runtime extension (replaces .tool-versions) -elixir = "1.16.0" -erlang = "26.2.0" +# CANONICAL toolchain pins — the single source of truth for OPSM's own toolchain. +# `.tool-versions` is GENERATED from this block: run `just toolchain-sync` after +# editing, and `just toolchain-check` verifies nothing drifted (CI-gated). +# Full provisioning story (incl. idris2 via pack/Chez, rust rationale, the +# bootstrap layer underneath OPSM): docs/TOOLCHAIN.adoc. +# NOTE: keep pin lines free of inline comments; the [runtime] parser is line-based. +erlang = "28.3.1" +elixir = "1.19.5-otp-28" +rust = "1.97.0" +zig = "0.14.0" just = "1.46.0" deno = "2.6.10" -zig = "0.14.0" +nickel = "1.16.0" +idris2 = "0.8.0" diff --git a/opsm_ex/lib/opsm/cli.ex b/opsm_ex/lib/opsm/cli.ex index ca9eb8c6..911c8778 100644 --- a/opsm_ex/lib/opsm/cli.ex +++ b/opsm_ex/lib/opsm/cli.ex @@ -530,12 +530,10 @@ defmodule Opsm.CLI do results = Enum.map(members, fn member -> IO.puts("\n── #{member} ──") - case Wiring.run_audit(config, member) do - {:ok, _} -> {member, :ok} - {:error, reason} -> - IO.puts(" [ERROR] #{inspect(reason)}") - {member, {:error, reason}} - end + # run_audit/2 reports check failures on stdout and always returns + # {:ok, _}; an audit that cannot run at all raises instead. + {:ok, _} = Wiring.run_audit(config, member) + {member, :ok} end) failures = Enum.filter(results, fn {_, r} -> r != :ok end) @@ -1702,17 +1700,6 @@ defmodule Opsm.CLI do System.halt(exit_code) end - defp resolve_installed_version(package, lockfile: lockfile_mod) do - case lockfile_mod.read() do - {:ok, lf} -> - case lockfile_mod.packages_for_name(lf, package) do - [entry | _] -> entry.version - [] -> nil - end - _ -> nil - end - end - defp run({:tui, _opts}) do case find_tui_binary() do nil -> @@ -1735,6 +1722,258 @@ defmodule Opsm.CLI do end end + defp run({:error, message}) do + Errors.print_error({:error, message}) + IO.puts(:stderr, "Run 'opsm help' for usage information") + System.halt(1) + end + + # --------------------------------------------------------------------------- + # Runtime management handlers (asdf replacement) + # --------------------------------------------------------------------------- + + defp run({:runtime_list, opts}) do + alias Opsm.Runtime.Manager + + json? = Keyword.get(opts, :json, false) + installed = Manager.list_installed() + + if json? do + IO.puts(Jason.encode!(installed, pretty: true)) + else + if installed == [] do + IO.puts("No runtime tools installed via OPSM.") + IO.puts("Use 'opsm runtime install @' to install.") + else + IO.puts("Installed runtime tools") + IO.puts("=======================") + max_name = installed |> Enum.map(fn t -> String.length(t.name) end) |> Enum.max(fn -> 4 end) + + for tool <- installed do + name_pad = String.pad_trailing(tool.name, max_name) + active_marker = if Map.get(tool, :active, false), do: " *", else: " " + IO.puts("#{active_marker} #{name_pad} #{tool.version}") + end + + IO.puts("") + IO.puts("(* = active version)") + end + end + + System.halt(0) + end + + defp run({:runtime_update, [], opts}) do + # Update all installed tools + alias Opsm.Runtime.Manager + + dry_run? = Keyword.get(opts, :dry_run, false) + IO.puts("Checking for runtime tool updates...") + + case Manager.check_updates() do + {:ok, []} -> + IO.puts("All runtime tools are up to date.") + + {:ok, updates} -> + IO.puts("#{length(updates)} update(s) available:\n") + max_name = updates |> Enum.map(fn u -> String.length(u.name) end) |> Enum.max(fn -> 4 end) + max_cur = updates |> Enum.map(fn u -> String.length(u.current) end) |> Enum.max(fn -> 7 end) + + for u <- updates do + name_pad = String.pad_trailing(u.name, max_name) + cur_pad = String.pad_trailing(u.current, max_cur) + IO.puts(" #{name_pad} #{cur_pad} → #{u.latest}") + end + + unless dry_run? do + IO.puts("") + IO.puts("Updating...") + for u <- updates do + case Manager.install(u.name, u.latest) do + :ok -> + IO.puts(" ✓ #{u.name} #{u.current} → #{u.latest}") + {:error, reason} -> + IO.puts(:stderr, " ✗ #{u.name}: #{inspect(reason)}") + end + end + end + end + + System.halt(0) + end + + defp run({:runtime_update, tools, opts}) do + # Update specific tools + alias Opsm.Runtime.Manager + + dry_run? = Keyword.get(opts, :dry_run, false) + + for tool_spec <- tools do + {tool, _version} = parse_tool_spec(tool_spec) + IO.puts("Checking updates for #{tool}...") + + case Manager.latest_version(tool) do + {:ok, latest} -> + current = Manager.current_version(tool) + if current == latest do + IO.puts(" #{tool} is already at #{latest}") + else + IO.puts(" #{tool}: #{current} → #{latest}") + unless dry_run? do + case Manager.install(tool, latest) do + :ok -> + IO.puts(" ✓ Updated #{tool} to #{latest}") + {:error, reason} -> + IO.puts(:stderr, " ✗ #{tool}: #{inspect(reason)}") + end + end + end + + {:error, :not_installed} -> + IO.puts(" #{tool} is not installed. Use: opsm runtime install #{tool}") + + {:error, reason} -> + IO.puts(:stderr, " Error: #{inspect(reason)}") + end + end + + System.halt(0) + end + + defp run({:runtime_install, [], opts}) do + # Install from [runtime] section of opsm.toml + alias Opsm.Runtime.Manager + + from = Keyword.get(opts, :from, "opsm.toml") + dry_run? = Keyword.get(opts, :dry_run, false) + IO.puts("Reading runtime pins from #{from}...") + + case Manager.install_from_manifest(from) do + {:ok, tools} when tools == [] -> + IO.puts("No [runtime] section found in #{from}.") + + {:ok, tools} -> + IO.puts("Installing #{length(tools)} pinned runtime tool(s)...") + for {tool, version} <- tools do + IO.puts(" #{tool}@#{version}") + unless dry_run? do + case Manager.install(tool, version) do + :ok -> IO.puts(" ✓ #{tool}@#{version}") + {:error, reason} -> IO.puts(:stderr, " ✗ #{tool}@#{version}: #{inspect(reason)}") + end + end + end + + {:error, reason} -> + IO.puts(:stderr, "Error reading #{from}: #{inspect(reason)}") + System.halt(1) + end + + System.halt(0) + end + + defp run({:runtime_install, tool_specs, opts}) do + alias Opsm.Runtime.Manager + + dry_run? = Keyword.get(opts, :dry_run, false) + + for spec <- tool_specs do + {tool, version} = parse_tool_spec(spec) + resolved_version = if version == "latest" do + case Manager.latest_version(tool) do + {:ok, v} -> v + {:error, _} -> "latest" + end + else + version + end + + IO.puts("Installing #{tool}@#{resolved_version}...") + + unless dry_run? do + case Manager.install(tool, resolved_version) do + :ok -> + IO.puts("✓ Installed #{tool}@#{resolved_version}") + IO.puts(" Run: opsm runtime which #{tool}") + {:error, {:missing_system_dependencies, deps}} -> + IO.puts(:stderr, "✗ #{tool}: missing system dependencies: #{Enum.join(deps, ", ")}") + System.halt(1) + {:error, reason} -> + IO.puts(:stderr, "✗ #{tool}: #{inspect(reason)}") + System.halt(1) + end + end + end + + System.halt(0) + end + + defp run({:runtime_remove, tool, _opts}) do + alias Opsm.Runtime.Manager + + IO.puts("Removing runtime tool: #{tool}") + + case Manager.remove(tool) do + :ok -> + IO.puts("✓ Removed #{tool}") + {:error, :not_installed} -> + IO.puts("#{tool} is not installed") + {:error, reason} -> + IO.puts(:stderr, "Error removing #{tool}: #{inspect(reason)}") + System.halt(1) + end + + System.halt(0) + end + + defp run({:runtime_which, tool, _opts}) do + alias Opsm.Runtime.Manager + + case Manager.which(tool) do + {:ok, path} -> + IO.puts(path) + {:error, :not_installed} -> + IO.puts(:stderr, "#{tool} is not managed by OPSM runtime") + System.halt(1) + end + + System.halt(0) + end + + defp run({:runtime_current, opts}) do + alias Opsm.Runtime.Manager + + json? = Keyword.get(opts, :json, false) + current = Manager.list_active() + + if json? do + IO.puts(Jason.encode!(current, pretty: true)) + else + if current == [] do + IO.puts("No active runtime tools.") + else + IO.puts("Active runtime tools") + IO.puts("====================") + for {tool, version} <- current do + IO.puts(" #{tool} #{version}") + end + end + end + + System.halt(0) + end + + defp resolve_installed_version(package, lockfile: lockfile_mod) do + case lockfile_mod.read() do + {:ok, lf} -> + case lockfile_mod.packages_for_name(lf, package) do + [entry | _] -> entry.version + [] -> nil + end + _ -> nil + end + end + defp find_tui_binary do env_override = System.get_env("OPSM_TUI_BIN") cond do @@ -1744,12 +1983,6 @@ defmodule Opsm.CLI do end end - defp run({:error, message}) do - Errors.print_error({:error, message}) - IO.puts(:stderr, "Run 'opsm help' for usage information") - System.halt(1) - end - # Helper functions defp show_dependencies_from_lockfile(lockfile, package, recursive?, json?) do @@ -2013,241 +2246,6 @@ defmodule Opsm.CLI do end end - # --------------------------------------------------------------------------- - # Runtime management handlers (asdf replacement) - # --------------------------------------------------------------------------- - - defp run({:runtime_list, opts}) do - alias Opsm.Runtime.Manager - - json? = Keyword.get(opts, :json, false) - installed = Manager.list_installed() - - if json? do - IO.puts(Jason.encode!(installed, pretty: true)) - else - if installed == [] do - IO.puts("No runtime tools installed via OPSM.") - IO.puts("Use 'opsm runtime install @' to install.") - else - IO.puts("Installed runtime tools") - IO.puts("=======================") - max_name = installed |> Enum.map(fn t -> String.length(t.name) end) |> Enum.max(fn -> 4 end) - - for tool <- installed do - name_pad = String.pad_trailing(tool.name, max_name) - active_marker = if Map.get(tool, :active, false), do: " *", else: " " - IO.puts("#{active_marker} #{name_pad} #{tool.version}") - end - - IO.puts("") - IO.puts("(* = active version)") - end - end - - System.halt(0) - end - - defp run({:runtime_update, [], opts}) do - # Update all installed tools - alias Opsm.Runtime.Manager - - dry_run? = Keyword.get(opts, :dry_run, false) - IO.puts("Checking for runtime tool updates...") - - case Manager.check_updates() do - {:ok, []} -> - IO.puts("All runtime tools are up to date.") - - {:ok, updates} -> - IO.puts("#{length(updates)} update(s) available:\n") - max_name = updates |> Enum.map(fn u -> String.length(u.name) end) |> Enum.max(fn -> 4 end) - max_cur = updates |> Enum.map(fn u -> String.length(u.current) end) |> Enum.max(fn -> 7 end) - - for u <- updates do - name_pad = String.pad_trailing(u.name, max_name) - cur_pad = String.pad_trailing(u.current, max_cur) - IO.puts(" #{name_pad} #{cur_pad} → #{u.latest}") - end - - unless dry_run? do - IO.puts("") - IO.puts("Updating...") - for u <- updates do - case Manager.install(u.name, u.latest) do - :ok -> - IO.puts(" ✓ #{u.name} #{u.current} → #{u.latest}") - {:error, reason} -> - IO.puts(:stderr, " ✗ #{u.name}: #{inspect(reason)}") - end - end - end - end - - System.halt(0) - end - - defp run({:runtime_update, tools, opts}) do - # Update specific tools - alias Opsm.Runtime.Manager - - dry_run? = Keyword.get(opts, :dry_run, false) - - for tool_spec <- tools do - {tool, _version} = parse_tool_spec(tool_spec) - IO.puts("Checking updates for #{tool}...") - - case Manager.latest_version(tool) do - {:ok, latest} -> - current = Manager.current_version(tool) - if current == latest do - IO.puts(" #{tool} is already at #{latest}") - else - IO.puts(" #{tool}: #{current} → #{latest}") - unless dry_run? do - case Manager.install(tool, latest) do - :ok -> - IO.puts(" ✓ Updated #{tool} to #{latest}") - {:error, reason} -> - IO.puts(:stderr, " ✗ #{tool}: #{inspect(reason)}") - end - end - end - - {:error, :not_installed} -> - IO.puts(" #{tool} is not installed. Use: opsm runtime install #{tool}") - - {:error, reason} -> - IO.puts(:stderr, " Error: #{inspect(reason)}") - end - end - - System.halt(0) - end - - defp run({:runtime_install, [], opts}) do - # Install from [runtime] section of opsm.toml - alias Opsm.Runtime.Manager - - from = Keyword.get(opts, :from, "opsm.toml") - dry_run? = Keyword.get(opts, :dry_run, false) - IO.puts("Reading runtime pins from #{from}...") - - case Manager.install_from_manifest(from) do - {:ok, tools} when tools == [] -> - IO.puts("No [runtime] section found in #{from}.") - - {:ok, tools} -> - IO.puts("Installing #{length(tools)} pinned runtime tool(s)...") - for {tool, version} <- tools do - IO.puts(" #{tool}@#{version}") - unless dry_run? do - case Manager.install(tool, version) do - :ok -> IO.puts(" ✓ #{tool}@#{version}") - {:error, reason} -> IO.puts(:stderr, " ✗ #{tool}@#{version}: #{inspect(reason)}") - end - end - end - - {:error, reason} -> - IO.puts(:stderr, "Error reading #{from}: #{inspect(reason)}") - System.halt(1) - end - - System.halt(0) - end - - defp run({:runtime_install, tool_specs, opts}) do - alias Opsm.Runtime.Manager - - dry_run? = Keyword.get(opts, :dry_run, false) - - for spec <- tool_specs do - {tool, version} = parse_tool_spec(spec) - resolved_version = if version == "latest" do - case Manager.latest_version(tool) do - {:ok, v} -> v - {:error, _} -> "latest" - end - else - version - end - - IO.puts("Installing #{tool}@#{resolved_version}...") - - unless dry_run? do - case Manager.install(tool, resolved_version) do - :ok -> - IO.puts("✓ Installed #{tool}@#{resolved_version}") - IO.puts(" Run: opsm runtime which #{tool}") - {:error, {:missing_system_dependencies, deps}} -> - IO.puts(:stderr, "✗ #{tool}: missing system dependencies: #{Enum.join(deps, ", ")}") - System.halt(1) - {:error, reason} -> - IO.puts(:stderr, "✗ #{tool}: #{inspect(reason)}") - System.halt(1) - end - end - end - - System.halt(0) - end - - defp run({:runtime_remove, tool, _opts}) do - alias Opsm.Runtime.Manager - - IO.puts("Removing runtime tool: #{tool}") - - case Manager.remove(tool) do - :ok -> - IO.puts("✓ Removed #{tool}") - {:error, :not_installed} -> - IO.puts("#{tool} is not installed") - {:error, reason} -> - IO.puts(:stderr, "Error removing #{tool}: #{inspect(reason)}") - System.halt(1) - end - - System.halt(0) - end - - defp run({:runtime_which, tool, _opts}) do - alias Opsm.Runtime.Manager - - case Manager.which(tool) do - {:ok, path} -> - IO.puts(path) - {:error, :not_installed} -> - IO.puts(:stderr, "#{tool} is not managed by OPSM runtime") - System.halt(1) - end - - System.halt(0) - end - - defp run({:runtime_current, opts}) do - alias Opsm.Runtime.Manager - - json? = Keyword.get(opts, :json, false) - current = Manager.list_active() - - if json? do - IO.puts(Jason.encode!(current, pretty: true)) - else - if current == [] do - IO.puts("No active runtime tools.") - else - IO.puts("Active runtime tools") - IO.puts("====================") - for {tool, version} <- current do - IO.puts(" #{tool} #{version}") - end - end - end - - System.halt(0) - end - # Parse the [workspace] members list from an opsm.toml content string. defp parse_workspace_members(content) do case Toml.decode(content) do diff --git a/opsm_ex/lib/opsm/package/cleanup.ex b/opsm_ex/lib/opsm/package/cleanup.ex index 798e9c6b..ba9d50ff 100644 --- a/opsm_ex/lib/opsm/package/cleanup.ex +++ b/opsm_ex/lib/opsm/package/cleanup.ex @@ -29,20 +29,20 @@ defmodule Opsm.Package.Cleanup do include_data = Keyword.get(opts, :include_data, false) secure = Keyword.get(opts, :secure, false) - actions = [] - - actions = actions ++ remove_desktop_shortcuts(package_name) - actions = actions ++ remove_autostart(package_name) - actions = actions ++ remove_systemd_units(package_name) - actions = actions ++ remove_file_associations(package_name) + actions = + remove_desktop_shortcuts(package_name) ++ + remove_autostart(package_name) ++ + remove_systemd_units(package_name) ++ + remove_file_associations(package_name) if include_data do - actions = actions ++ remove_config_dirs(package_name, secure) - actions = actions ++ remove_data_dirs(package_name, secure) - actions = actions ++ remove_cache_dirs(package_name) + actions ++ + remove_config_dirs(package_name, secure) ++ + remove_data_dirs(package_name, secure) ++ + remove_cache_dirs(package_name) + else + actions end - - actions end @doc """ @@ -101,12 +101,10 @@ defmodule Opsm.Package.Cleanup do xdg_config = System.get_env("XDG_CONFIG_HOME", Path.join(System.user_home!(), ".config")) systemd_dir = Path.join([xdg_config, "systemd", "user"]) - actions = [] - - for ext <- [".service", ".timer", ".socket"] do - unit_file = Path.join(systemd_dir, "#{package_name}#{ext}") - - if File.exists?(unit_file) do + actions = + for ext <- [".service", ".timer", ".socket"], + unit_file = Path.join(systemd_dir, "#{package_name}#{ext}"), + File.exists?(unit_file) do # Stop and disable before removing System.cmd("systemctl", ["--user", "stop", "#{package_name}#{ext}"], stderr_to_stdout: true @@ -118,16 +116,13 @@ defmodule Opsm.Package.Cleanup do case File.rm(unit_file) do :ok -> Logger.info("Removed systemd unit: #{unit_file}") - actions ++ [{:removed, :systemd_unit, unit_file}] + {:removed, :systemd_unit, unit_file} {:error, reason} -> Logger.warning("Failed to remove systemd unit #{unit_file}: #{reason}") - actions ++ [{:failed, :systemd_unit, unit_file, reason}] + {:failed, :systemd_unit, unit_file, reason} end - else - actions end - end # Reload systemd daemon if we removed anything if actions != [] do diff --git a/opsm_ex/lib/opsm/package/installer.ex b/opsm_ex/lib/opsm/package/installer.ex index 321db5e0..09a30986 100644 --- a/opsm_ex/lib/opsm/package/installer.ex +++ b/opsm_ex/lib/opsm/package/installer.ex @@ -190,14 +190,10 @@ defmodule Opsm.Package.Installer do # Trust is advisory during install; only an explicit :failed blocks. IO.puts(" Running trust checks...") {trust_result, enriched_package} = try do - case Pipeline.verify(package) do - {:ok, trust_result} -> - {trust_result, package} - - {:error, reason} -> - IO.puts(" ⚠ Trust pipeline returned error: #{reason} — continuing") - {%{overall: :warning, warnings: ["Trust pipeline error: #{reason}"], checks: %{}, recommendations: []}, package} - end + # Pipeline.verify/2 cannot return {:error, _} — failed checks come back + # inside the {:ok, results} map; service failures raise into the rescue. + {:ok, trust_result} = Pipeline.verify(package) + {trust_result, package} rescue e -> IO.puts(" ⚠ Trust pipeline unavailable: #{Exception.message(e)} — continuing") diff --git a/opsm_ex/lib/opsm/runtime/manager.ex b/opsm_ex/lib/opsm/runtime/manager.ex index 5e5f392f..0b5a627b 100644 --- a/opsm_ex/lib/opsm/runtime/manager.ex +++ b/opsm_ex/lib/opsm/runtime/manager.ex @@ -471,12 +471,19 @@ defmodule Opsm.Runtime.Manager do cond do stripped == "[runtime]" -> {true, acc} + String.starts_with?(stripped, "#") -> + {in_section, acc} String.starts_with?(stripped, "[") and in_section -> {false, acc} in_section and String.contains?(stripped, "=") -> [tool, version] = String.split(stripped, "=", parts: 2) - pair = {String.trim(tool), String.trim(version) |> String.trim("\"")} - {true, [pair | acc]} + version = + version + |> String.split("#", parts: 2) + |> hd() + |> String.trim() + |> String.trim("\"") + {true, [{String.trim(tool), version} | acc]} true -> {in_section, acc} end diff --git a/opsm_ex/lib/opsm/runtime/source_builder.ex b/opsm_ex/lib/opsm/runtime/source_builder.ex index 12ee88be..16ae37e1 100644 --- a/opsm_ex/lib/opsm/runtime/source_builder.ex +++ b/opsm_ex/lib/opsm/runtime/source_builder.ex @@ -57,11 +57,9 @@ defmodule Opsm.Runtime.SourceBuilder do source_install(plugin, version, install_dir) :both -> - # Try prebuilt first, fall back to source - case prebuilt_available?(tool_name, version) do - true -> {:ok, :prebuilt} - false -> source_install(plugin, version, install_dir) - end + # Prebuilt binaries are not yet supported (the availability probe + # was a stub always returning false) — build from source for now. + source_install(plugin, version, install_dir) :prebuilt_binary -> # Source builder is not the right path for prebuilt — caller error @@ -206,8 +204,6 @@ defmodule Opsm.Runtime.SourceBuilder do Path.join([@runtimes_dir, tool_name, version]) end - defp prebuilt_available?(_tool_name, _version), do: false - defp download_file(url, dest_path) do case VerifiedHttp.get(url, receive_timeout: 120_000) do {:ok, %{body: body, status: status}} when status in 200..299 -> diff --git a/opsm_ex/lib/opsm/trust/pipeline.ex b/opsm_ex/lib/opsm/trust/pipeline.ex index 4ce96dcd..0bf83bcb 100644 --- a/opsm_ex/lib/opsm/trust/pipeline.ex +++ b/opsm_ex/lib/opsm/trust/pipeline.ex @@ -198,16 +198,14 @@ defmodule Opsm.Trust.Pipeline do cond do is_nil(slsa_attestation) -> - case Opsm.Slsa.Provenance.generate(package) do - {:ok, provenance} -> - if provenance.slsa_level >= 1 do - {:info, "SLSA Level #{provenance.slsa_level} (self-attested, unverified)"} - else - {:warning, "No SLSA provenance available (Level 0)"} - end - - {:error, _reason} -> - {:skipped, "SLSA provenance generation failed"} + # Provenance.generate/2 cannot fail (raises are caught by the + # rescue below), so only the :ok shape exists to match on. + {:ok, provenance} = Opsm.Slsa.Provenance.generate(package) + + if provenance.slsa_level >= 1 do + {:info, "SLSA Level #{provenance.slsa_level} (self-attested, unverified)"} + else + {:warning, "No SLSA provenance available (Level 0)"} end true -> @@ -277,9 +275,8 @@ defmodule Opsm.Trust.Pipeline do # Clamp negative scores (defensive — heuristic should not produce them) {:ok, %{score: 0, level: :critical, source: :oikos_clamped}} - {:error, reason} -> - Logger.warning("Oikos analysis failed for #{package.package}: #{inspect(reason)}") - {:skipped, "Oikos analysis failed: #{inspect(reason)}"} + # No {:error, _} clause: analyze_package/4 is deliberately infallible — + # it falls back to heuristic scoring; exceptions hit the rescue below. end rescue e -> diff --git a/opsm_ex/lib/opsm/wiring.ex b/opsm_ex/lib/opsm/wiring.ex index 765faf69..085da37b 100644 --- a/opsm_ex/lib/opsm/wiring.ex +++ b/opsm_ex/lib/opsm/wiring.ex @@ -180,11 +180,8 @@ defmodule Opsm.Wiring do IO.puts(" ⚠ Low sustainability score: #{score}/100 — consider improving before publish") :ok - {:error, reason} -> - severity = Errors.classify_severity({:network_error, reason}) - IO.puts(" ⚠ #{Errors.severity_description(severity)}") - IO.puts(" Sustainability analysis unavailable: #{inspect(reason)}") - :ok + # No {:error, _} clause: analyze_package/4 is deliberately infallible — + # it falls back to heuristic scoring when the oikos service is unreachable. end end diff --git a/opsm_ex/test/opsm/runtime/manager_test.exs b/opsm_ex/test/opsm/runtime/manager_test.exs index c0ab3d1c..82a75642 100644 --- a/opsm_ex/test/opsm/runtime/manager_test.exs +++ b/opsm_ex/test/opsm/runtime/manager_test.exs @@ -81,6 +81,28 @@ defmodule Opsm.Runtime.ManagerTest do test "returns error for missing file" do assert {:error, :enoent} = Manager.install_from_manifest("/no/such/file.toml") end + + test "ignores comment lines, including comments containing =" do + manifest = Path.join(@runtimes_base, "opsm_comments.toml") + File.write!(manifest, """ + [runtime] + # canonical pins, synced via just toolchain-sync + # example override: zig = "9.9.9" + zig = "0.14.0" + """) + + assert {:ok, [{"zig", "0.14.0"}]} = Manager.install_from_manifest(manifest) + end + + test "strips inline comments from pin values" do + manifest = Path.join(@runtimes_base, "opsm_inline.toml") + File.write!(manifest, """ + [runtime] + nickel = "1.16.0" # config language + """) + + assert {:ok, [{"nickel", "1.16.0"}]} = Manager.install_from_manifest(manifest) + end end # --------------------------------------------------------------------------- diff --git a/scripts/toolchain.sh b/scripts/toolchain.sh new file mode 100755 index 00000000..598f1ac0 --- /dev/null +++ b/scripts/toolchain.sh @@ -0,0 +1,123 @@ +#!/bin/sh +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# toolchain.sh — derive .tool-versions from the canonical [runtime] block in +# opsm.toml, and verify that no downstream copy has drifted. +# +# scripts/toolchain.sh sync regenerate .tool-versions from opsm.toml +# scripts/toolchain.sh check exit 1 if .tool-versions or .gitlab-ci.yml +# image pins disagree with opsm.toml [runtime] +# +# opsm.toml [runtime] is the single source of truth (see docs/TOOLCHAIN.adoc). +# Tools excluded from the generated .tool-versions: +# idris2 — no asdf plugin exists; provisioned via pack/Chez (docs/TOOLCHAIN.adoc) +# rust — managed via asdf global to avoid conflicting with cargo-audit and +# other cargo subcommands; CI pins it to the same canonical version + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +MANIFEST="$ROOT/opsm.toml" +TOOL_VERSIONS="$ROOT/.tool-versions" +GITLAB_CI="$ROOT/.gitlab-ci.yml" +SKIP_TOOLS="idris2 rust" + +usage() { + echo "usage: $0 {sync|check}" >&2 + exit 2 +} + +[ $# -eq 1 ] || usage + +# Emit "tool version" pairs from the [runtime] section, in file order. +runtime_pins() { + awk -F'=' ' + /^\[runtime\]/ { insec = 1; next } + /^\[/ { insec = 0 } + insec && $0 !~ /^[[:space:]]*#/ && NF >= 2 { + key = $1; val = $2 + gsub(/[[:space:]]/, "", key) + sub(/#.*/, "", val) + gsub(/[[:space:]"]/, "", val) + if (key != "" && val != "") print key, val + } + ' "$MANIFEST" +} + +pin_version() { + runtime_pins | awk -v t="$1" '$1 == t { print $2 }' +} + +skipped() { + for s in $SKIP_TOOLS; do + [ "$1" = "$s" ] && return 0 + done + return 1 +} + +generate() { + idris2_v=$(pin_version idris2) + rust_v=$(pin_version rust) + cat <&2 + return 1 + fi + if grep -qE "image:[[:space:]]*${image}:" "$GITLAB_CI" && + ! grep -qE "image:[[:space:]]*${image}:${expected}" "$GITLAB_CI"; then + echo "DRIFT: $GITLAB_CI pins ${image} to something other than ${expected} (opsm.toml [runtime])" >&2 + return 1 + fi + return 0 +} + +case "$1" in + sync) + generate > "$TOOL_VERSIONS" + echo "wrote $TOOL_VERSIONS from opsm.toml [runtime]" + ;; + check) + status=0 + + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + generate > "$tmp" + if ! diff -u "$TOOL_VERSIONS" "$tmp" >&2; then + echo "DRIFT: .tool-versions does not match opsm.toml [runtime] — run: just toolchain-sync" >&2 + status=1 + fi + + elixir_v=$(pin_version elixir) + rust_mm=$(pin_version rust | cut -d. -f1,2) + check_gitlab_image elixir "$elixir_v" || status=1 + check_gitlab_image rust "$rust_mm" || status=1 + + if [ "$status" -eq 0 ]; then + echo "toolchain-check OK: .tool-versions and CI image pins match opsm.toml [runtime]" + fi + exit "$status" + ;; + *) + usage + ;; +esac