#!/usr/bin/env nu # reflection/modules/config.nu — configuration profile management. # # Profiles live in reflection/configs/.ncl (mutable, git-versioned). # Sealed history in reflection/configs/history//cfg-NNN.ncl (append-only). # Manifest at reflection/configs/manifest.ncl tracks active seal per profile. # # Seal = sha256(nickel export ) written at apply time. # Rollback = restore values from a history entry + write new seal. # Verify = sha256 of current file == seal.hash of active history entry. use env.nu * use store.nu [daemon-export, daemon-export-safe] export def "config show" [ profile: string, --fmt: string = "", ] { let f = if ($fmt | is-not-empty) { $fmt } else { "table" } let file = (config-file $profile) let data = (daemon-export $file) let active = (config-active-seal $profile) print "" print $"Profile: ($profile | str upcase)" print $"Active seal: ($active | default '(none)')" print "" match $f { "json" => { print ($data | to json) }, _ => { print ($data | table) }, } print "" } export def "config history" [ profile: string, --fmt: string = "", ] { let entries = (config-history-entries $profile) if ($entries | is-empty) { print $"No history for ($profile)" return } let rows = $entries | each { |e| { id: $e.id, applied_at: $e.seal.applied_at, applied_by: $e.seal.applied_by, hash: ($e.seal.hash | str substring 0..8), adr: ($e.seal.related_adr? | default ""), pr: ($e.seal.related_pr? | default ""), bug: ($e.seal.related_bug? | default ""), note: ($e.seal.note? | default ""), } } match ($fmt | default "table") { "json" => { print ($rows | to json) }, "md" => { print ($rows | to md) }, _ => { print ($rows | table) }, } } export def "config diff" [ profile: string, from_id: string, to_id: string, ] { let root = (config-root) let hist = $"($root)/reflection/configs/history/($profile | str downcase)" let from_f = $"($hist)/($from_id).ncl" let to_f = $"($hist)/($to_id).ncl" for f in [$from_f, $to_f] { if not ($f | path exists) { error make { msg: $"Config state ($f) not found" } } } let from_data = (daemon-export $from_f) let to_data = (daemon-export $to_f) # values_snapshot is stored as a JSON string inside the Nickel file let from_vals = $from_data | get values_snapshot | from json let to_vals = $to_data | get values_snapshot | from json let from_json = $from_vals | to json --indent 2 let to_json = $to_vals | to json --indent 2 let tmp_from = (mktemp --suffix ".json") let tmp_to = (mktemp --suffix ".json") $from_json | save --force $tmp_from $to_json | save --force $tmp_to print $"diff ($from_id) → ($to_id)" do { ^diff $tmp_from $tmp_to } | complete | get stdout | print rm $tmp_from $tmp_to } export def "config verify" [profile: string] { let file = (config-file $profile) let active = (config-active-seal $profile) if ($active | is-empty) { print $" ($profile): no active seal — run 'ontoref config apply ($profile)' first" return } let root = (config-root) let hist_f = $"($root)/reflection/configs/history/($profile | str downcase)/($active).ncl" if not ($hist_f | path exists) { print $" ($profile): seal ($active) not found in history" return } let hist_data = (daemon-export-safe $hist_f) let stored_hash = if $hist_data != null { $hist_data | get seal.hash } else { "" } let current_hash = (config-hash $file) if $stored_hash == $current_hash { print $" ($profile): ✓ verified ($current_hash | str substring 0..16)..." } else { print $" ($profile): ✗ DRIFT DETECTED" print $" stored: ($stored_hash | str substring 0..16)..." print $" current: ($current_hash | str substring 0..16)..." print $" File was modified after last seal. Run 'ontoref config apply ($profile)' to reseal." } } export def "config audit" [] { let root = (config-root) let manifest = (config-manifest) print "" print "CONFIG AUDIT" print "────────────────────────────────────────" for profile in ($manifest.profiles? | default []) { config verify ($profile | into string | str downcase) } print "" } export def "config apply" [ profile: string, --adr: string = "", --pr: string = "", --bug: string = "", --note: string = "", ] { let root = (config-root) let file = (config-file $profile) let actor = ($env.ONTOREF_ACTOR? | default "developer") | str replace --all " " "_" let today = (date now | format date "%Y-%m-%dT%H:%M:%S") let ts = (date now | format date "%Y%m%dT%H%M%S") # Canonical enum label: "ci" → "CI", "development" → "Development" let profile_key = (manifest-canonical-key $root $profile) # Export + hash let export_data = (daemon-export $file) let hash = (config-hash $file) # Collision-free ID: timestamp + actor — no sequential counter, no TOCTOU race let hist_dir = $"($root)/reflection/configs/history/($profile | str downcase)" ^mkdir -p $hist_dir let cfg_id = $"cfg-($ts)-($actor)" let hist_f = $"($hist_dir)/($cfg_id).ncl" let schema_rel = "../../../../reflection/schemas/config.ncl" let prev_seal = (config-active-seal $profile | default "") # values_snapshot stored as Nickel String (inline JSON is not valid Nickel — # `:` is a contract separator, not a key-value delimiter). let vals_json_raw = ($export_data | get values | to json) let snap_hash = (config-string-hash $vals_json_raw) # Escape for embedding inside a Nickel string literal. # Single-quoted Nu strings are raw: '\"' = two chars (\, "), not one. # Order matters: escape backslashes first so the new `\` from `\"` isn't re-escaped. let vals_json_esc = ( $vals_json_raw | str replace --all '\' '\\' # literal \ → \\ (single backslash → escaped backslash) | str replace --all '"' '\"' # literal " → \" (quote → escaped quote) ) let content = $"let s = import \"($schema_rel)\" in { id = \"($cfg_id)\", profile = '($profile_key), seal = { hash = \"($hash)\", snapshot_hash = \"($snap_hash)\", applied_at = \"($today)\", applied_by = \"($actor)\", note = \"($note)\", related_adr = \"($adr)\", related_pr = \"($pr)\", related_bug = \"($bug)\", }, values_snapshot = \"($vals_json_esc)\", supersedes = \"($prev_seal)\", } | s.ConfigState " $content | save --force $hist_f config-set-active $profile $cfg_id print $" applied: ($profile) → ($cfg_id) hash=($hash | str substring 0..16)..." if ($adr | str length) > 0 { print $" adr: ($adr)" } if ($pr | str length) > 0 { print $" pr: ($pr)" } if ($bug | str length) > 0 { print $" bug: ($bug)" } } export def "config rollback" [ profile: string, to_id: string, --adr: string = "", --note: string = "", ] { let root = (config-root) let hist_dir = $"($root)/reflection/configs/history/($profile | str downcase)" let src_f = $"($hist_dir)/($to_id).ncl" if not ($src_f | path exists) { error make { msg: $"Config state ($to_id) not found" } } let entry = (daemon-export $src_f) let snapshot_json = ($entry | get values_snapshot) # raw JSON string let stored_snap_h = ($entry | get seal.snapshot_hash) # sha256 of that string at apply time let actual_snap_h = (config-string-hash $snapshot_json) if $stored_snap_h != $actual_snap_h { error make { msg: $"Integrity check failed for ($to_id): snapshot_hash mismatch. Rollback aborted." } } let snapshot = ($snapshot_json | from json) let file = (config-file $profile) let profile_key = (manifest-canonical-key $root $profile) print $" rollback: ($profile) → ($to_id) [snapshot integrity verified]" profile-file-write $file $profile_key $snapshot print $" restored: ($file)" config apply $profile --adr $adr --note $"rollback to ($to_id): ($note)" } # ── Internal ──────────────────────────────────────────────────────────────────── def config-root []: nothing -> string { $env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT } def config-file [profile: string]: nothing -> string { $"(config-root)/reflection/configs/($profile | str downcase).ncl" } def config-manifest []: nothing -> record { let f = $"(config-root)/reflection/configs/manifest.ncl" if not ($f | path exists) { return {} } daemon-export-safe $f | default {} } def config-active-seal [profile: string]: nothing -> string { let manifest = (config-manifest) let key = (manifest-canonical-key (config-root) $profile) $manifest.active? | default {} | get --optional $key | default "" } def config-set-active [profile: string, cfg_id: string] { let root = (config-root) let manifest_f = $"($root)/reflection/configs/manifest.ncl" # Look up the canonical key from the manifest export — avoids str-capitalize mismatches # (e.g. "ci" → "CI" not "Ci", "development" → "Development"). let key = (manifest-canonical-key $root $profile) if ($key | is-empty) { print $" warning: profile '($profile)' not found in manifest.ncl active map — add it manually" return } let script = $"s/($key)[[:space:]]*=[[:space:]]*\"[^\"]*\"/($key) = \"($cfg_id)\"/" let r1 = do { ^sed -i '' $script $manifest_f } | complete if $r1.exit_code != 0 { let r2 = do { ^sed -i $script $manifest_f } | complete if $r2.exit_code != 0 { print $" warning: could not patch manifest.ncl active.($key) — update manually to ($cfg_id)" } } } # Resolve the canonical key name as it appears in manifest.ncl active map. # Matches case-insensitively so "ci" → "CI", "development" → "Development". def manifest-canonical-key [root: string, profile: string]: nothing -> string { let manifest_f = $"($root)/reflection/configs/manifest.ncl" let data = (daemon-export-safe $manifest_f) if $data == null { return ($profile | str capitalize) } let keys = ($data | get active | columns) $keys | where { |k| ($k | str downcase) == ($profile | str downcase) } | first | default ($profile | str capitalize) } def config-hash [file: string]: nothing -> string { let r = do { ^nickel export $file } | complete if $r.exit_code != 0 { return "" } let tmp = (mktemp --suffix ".json") $r.stdout | save --force $tmp let hash = do { let h = (do { ^sha256sum $tmp } | complete) if $h.exit_code == 0 { $h.stdout | split words | first } else { # macOS shasum fallback let h2 = do { ^shasum -a 256 $tmp } | complete if $h2.exit_code == 0 { $h2.stdout | split words | first } else { "" } } } rm $tmp $hash } # Hash an arbitrary string (not a file). Used for snapshot_hash in seals. def config-string-hash [s: string]: nothing -> string { let tmp = (mktemp --suffix ".json") $s | save --force $tmp let hash = do { let h = (do { ^sha256sum $tmp } | complete) if $h.exit_code == 0 { $h.stdout | split words | first } else { let h2 = do { ^shasum -a 256 $tmp } | complete if $h2.exit_code == 0 { $h2.stdout | split words | first } else { "" } } } rm $tmp $hash } # Recursively convert a Nu value to a Nickel literal string. # depth controls indentation (2-space units). def nickel-indent [n: int]: nothing -> string { 0..<$n | reduce --fold "" { |_, acc| $acc + " " } } def value-to-nickel [v: any, depth: int = 0]: nothing -> string { let pad = (nickel-indent $depth) let pad2 = (nickel-indent ($depth + 1)) let type = ($v | describe | str replace --regex '<.*>' '') if $type == "string" { let s = ($v | into string) let esc = ($s | str replace --all '\\' '\\\\' | str replace --all '"' '\\"') '"' + $esc + '"' } else if $type == "int" { $v | into string } else if $type == "float" { $v | into string } else if $type == "bool" { $v | into string } else if $type == "list" { if ($v | is-empty) { "[]" } else { let items = ($v | each { |item| $"($pad2)(value-to-nickel $item ($depth + 1))" } | str join ",\n") $"[\n($items),\n($pad)]" } } else if $type == "record" { let fields = ($v | transpose k val | each { |f| $"($pad2)($f.k) = (value-to-nickel $f.val ($depth + 1))," } | str join "\n") $"{\n($fields)\n($pad)}" } else { $v | to json } } # Reconstruct a profile .ncl file from the canonical profile key and a values record. # Preserves the file's import line; replaces the values block wholesale. def profile-file-write [file: string, profile_key: string, values: record] { let vals_nickel = (value-to-nickel $values 2) let content = $"let s = import \"../schemas/config.ncl\" in\n\n\{\n profile = '($profile_key),\n\n values = ($vals_nickel),\n}\n" $content | save --force $file } def config-history-entries [profile: string] { let root = (config-root) let hist_dir = $"($root)/reflection/configs/history/($profile | str downcase)" if not ($hist_dir | path exists) { return [] } ls $hist_dir | where name =~ 'cfg-.*\.ncl$' | sort-by name | each { |f| let d = (daemon-export-safe $f.name) if $d != null { { id: $d.id, seal: $d.seal, supersedes: ($d.supersedes? | default "") } } else { null } } | compact }