#!/usr/bin/env nu # reflection/modules/migrate.nu — ontoref protocol migration runner. # # Migrations live in $ONTOREF_ROOT/reflection/migrations/NNN-slug.ncl # Each has: id, slug, description, check, instructions # "Applied" = check passes. No state file — checks are the source of truth. # # Commands: # migrate list — all migrations with applied/pending status # migrate pending — only pending migrations # migrate show — instructions for a specific migration use env.nu * use store.nu [daemon-export-safe] use describe.nu [nickel-import-path] def project-root []: nothing -> string { $env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT } def migrations-dir []: nothing -> string { [$env.ONTOREF_ROOT, "reflection", "migrations"] | path join } # Run a check record against the given project root. Returns { passed, detail }. def run-migration-check [check: record, root: string]: nothing -> record { match $check.tag { "FileExists" => { let p = ([$root, $check.path] | path join) let exists = ($p | path exists) let want = ($check.present? | default true) { passed: ($exists == $want), detail: (if ($exists == $want) { "ok" } else if $want { $"missing: ($p)" } else { $"unexpected: ($p)" }) } } "Grep" => { let paths = ($check.paths | each { |p| [$root, $p] | path join } | where { |p| $p | path exists }) if ($paths | is-empty) { return { passed: false, detail: "no target paths exist" } } let r = do { ^rg --no-heading -l $check.pattern ...$paths } | complete let has_matches = ($r.exit_code == 0) let must_empty = ($check.must_be_empty? | default false) if $must_empty { let files = ($r.stdout | str trim) { passed: (not $has_matches), detail: (if $has_matches { $"pattern found in: ($files)" } else { "ok" }) } } else { { passed: $has_matches, detail: (if $has_matches { "ok" } else { "required pattern absent" }) } } } "NuCmd" => { let r = do { nu -c $check.cmd } | complete let expected = ($check.expect_exit? | default 0) { passed: ($r.exit_code == $expected), detail: (if ($r.exit_code == $expected) { "ok" } else { $"exit ($r.exit_code) ≠ ($expected): ($r.stderr | str trim | str substring 0..120)" }) } } _ => { passed: false, detail: $"unknown check tag: ($check.tag)" } } } def load-all-migrations []: nothing -> list { let dir = (migrations-dir) if not ($dir | path exists) { return [] } let ip = (nickel-import-path $env.ONTOREF_ROOT) glob $"($dir)/*.ncl" | sort | each { |f| try { let m = (daemon-export-safe $f --import-path $ip) if $m != null { [$m] } else { [] } } catch { [] } } | flatten } def migration-status [migrations: list, root: string]: nothing -> list { $migrations | each { |m| let r = (run-migration-check $m.check $root) { id: $m.id, slug: $m.slug, description: $m.description, applied: $r.passed, detail: $r.detail, } } } # List all protocol migrations with applied/pending status. export def "migrate list" [ --fmt (-f): string = "", --actor (-a): string = "", ]: nothing -> nothing { let root = (project-root) let actor = if ($actor | is-not-empty) { $actor } else { $env.ONTOREF_ACTOR? | default "developer" } let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" } let statuses = (migration-status (load-all-migrations) $root) if $fmt == "json" { print ($statuses | to json) return } let n_applied = ($statuses | where applied == true | length) let n_total = ($statuses | length) print $" migrations: ($n_applied)/($n_total) applied" print "" for s in $statuses { let mark = if $s.applied { "✓" } else { "○" } print $" ($mark) ($s.id) ($s.slug)" print $" ($s.description)" if not $s.applied and $s.detail != "ok" { print $" ($s.detail)" } } } # List only pending (not yet applied) migrations. export def "migrate pending" [ --fmt (-f): string = "", --actor (-a): string = "", ]: nothing -> nothing { let root = (project-root) let actor = if ($actor | is-not-empty) { $actor } else { $env.ONTOREF_ACTOR? | default "developer" } let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" } let pending = ( migration-status (load-all-migrations) $root | where applied == false ) if $fmt == "json" { print ($pending | to json) return } if ($pending | is-empty) { print " all migrations applied" return } print $" ($pending | length) pending:" for p in $pending { print $" ○ ($p.id) ($p.slug) — ($p.description)" } } # Show the instructions for a specific migration. Accepts id ("0001") or slug. export def "migrate show" [ id: string, --fmt (-f): string = "", --actor (-a): string = "", ]: nothing -> nothing { let root = (project-root) let actor = if ($actor | is-not-empty) { $actor } else { $env.ONTOREF_ACTOR? | default "developer" } let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" } let migrations = (load-all-migrations) let id_norm = ($id | str replace --regex '^0+' '') let id_norm = if $id_norm == "" { "0" } else { $id_norm } let matching = ($migrations | where { |m| $m.id == $id or $m.slug == $id or (($m.id | str replace --regex '^0+' '') == $id_norm) }) if ($matching | is-empty) { error make { msg: $"migration '($id)' not found" } } let m = ($matching | first) let r = (run-migration-check $m.check $root) # Interpolate runtime values into instruction text let project_name = ($root | path basename) let instructions = ($m.instructions | str replace --all "{project_root}" $root | str replace --all "{project_name}" $project_name | str replace --all "PROJECT_NAME" $project_name | str replace --all "$ONTOREF_PROJECT_ROOT" $root ) if $fmt == "json" { print ({ id: $m.id, slug: $m.slug, description: $m.description, applied: $r.passed, project_root: $root, project_name: $project_name, instructions: $instructions, } | to json) return } let status = if $r.passed { "✓ applied" } else { "○ pending" } print $" ($m.id) ($m.slug) — ($status)" print $" ($m.description)" if not $r.passed and $r.detail != "ok" { print $" check: ($r.detail)" } print "" print $instructions }