203 lines
6.5 KiB
Plaintext
Raw Permalink Normal View History

2026-03-29 00:19:56 +00:00
#!/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 <id> — 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<record> {
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<record>, root: string]: nothing -> list<record> {
$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
}