387 lines
13 KiB
Plaintext
387 lines
13 KiB
Plaintext
#!/usr/bin/env nu
|
|
# reflection/modules/config.nu — configuration profile management.
|
|
#
|
|
# Profiles live in reflection/configs/<profile>.ncl (mutable, git-versioned).
|
|
# Sealed history in reflection/configs/history/<profile>/cfg-NNN.ncl (append-only).
|
|
# Manifest at reflection/configs/manifest.ncl tracks active seal per profile.
|
|
#
|
|
# Seal = sha256(nickel export <profile.ncl>) 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
|
|
}
|