ontoref-code/ontoref

371 lines
12 KiB
Bash
Executable file

#!/bin/bash
# ontoref — canonical entry point. Shell alias: ontoref
# Release: 0.1.0
# Responsibilities:
# 1. Verify Nushell >= 0.110.0 is installed
# 2. Detect actor (developer | admin | agent | ci)
# 3. Load env vars from .ontoref/config.ncl (NICKEL_IMPORT_PATH etc.)
# 4. Acquire advisory lock for write operations (mkdir-based, cross-platform)
# 5. Delegate to reflection/bin/ontoref.nu
#
# Actor detection priority (highest to lowest):
# --actor <value> explicit flag
# ONTOREF_ACTOR env var pre-set by caller
# CI env vars CI/CD environment
# non-interactive TTY agent / piped context
# default developer
#
# Flags:
# --env-only export env vars and return (for `source onref --env-only`)
# --actor <value> override actor detection
#
# Locking:
# Operations that write shared files acquire a per-resource advisory lock.
# Lock = directory under .ontoref/locks/<resource>.lock (mkdir is POSIX-atomic).
# Stale locks (owner process gone) are cleared automatically.
# Resources: manifest (config apply/rollback), changelog (register), backlog (backlog done/cancel)
set -euo pipefail
_release() { grep "^# Release:" "$0" | sed "s/# Release: //g"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly DISPATCHER="${SCRIPT_DIR}/reflection/bin/ontoref.nu"
readonly CONFIG_NCL="${SCRIPT_DIR}/.ontoref/config.ncl"
readonly LOCKS_DIR="${SCRIPT_DIR}/.ontoref/locks"
# ── Nushell check ─────────────────────────────────────────────────────────────
if ! command -v nu &>/dev/null; then
echo ""
echo " onref requires Nushell (>= 0.110.0)"
echo ""
echo " Install:"
echo " cargo install nu # via Rust"
echo " brew install nushell # macOS"
echo " winget install nushell # Windows"
echo ""
echo " Then re-run: ./ontoref $*"
echo ""
exit 1
fi
# ── Version gate ──────────────────────────────────────────────────────────────
NU_VERSION="$(nu --version 2>/dev/null | tr -d '[:space:]')"
NU_MAJOR="${NU_VERSION%%.*}"
NU_MAJOR="${NU_MAJOR%%[^0-9]*}"
NU_MINOR="${NU_VERSION#*.}"; NU_MINOR="${NU_MINOR%%.*}"
NU_MINOR="${NU_MINOR%%[^0-9]*}"
if [[ "${NU_MAJOR}" -lt 0 ]] || { [[ "${NU_MAJOR}" -eq 0 ]] && [[ "${NU_MINOR}" -lt 110 ]]; }; then
echo ""
echo " Nushell ${NU_VERSION} found — 0.110.0+ required"
echo " Update: cargo install nu --force"
echo ""
exit 1
fi
# ── Version flag (early exit before any env loading) ─────────────────────────
for _arg in "$@"; do
case "${_arg}" in
-V|-v|--version|version)
echo "ontoref $(_release)"
exit 0
;;
esac
done
# ── Actor detection ────────────────────────────────────────────────────────────
# Extract --actor from args without mapfile (macOS Bash 3.2 compatible).
# Sets ACTOR_FROM_ARGS and rebuilds REMAINING_ARGS without the --actor flag.
ACTOR_FROM_ARGS=""
ENV_ONLY=0
REMAINING_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--actor)
shift
ACTOR_FROM_ARGS="${1:-}"
shift
;;
--actor=*)
ACTOR_FROM_ARGS="${1#--actor=}"
shift
;;
--env-only)
ENV_ONLY=1
shift
;;
*)
REMAINING_ARGS+=("$1")
shift
;;
esac
done
# Priority resolution
if [[ -n "${ACTOR_FROM_ARGS}" ]]; then
export ONTOREF_ACTOR="${ACTOR_FROM_ARGS}"
elif [[ -n "${ONTOREF_ACTOR:-}" ]]; then
: # already set by caller
elif [[ -n "${CI:-}" ]] || [[ -n "${GITHUB_ACTIONS:-}" ]] || [[ -n "${WOODPECKER_BUILD_NUMBER:-}" ]]; then
export ONTOREF_ACTOR="ci"
elif [[ ! -t 0 ]]; then
export ONTOREF_ACTOR="agent"
else
export ONTOREF_ACTOR="developer"
fi
# ── Load project env vars ──────────────────────────────────────────────────────
load_ontoref_env() {
local fallback_paths="${SCRIPT_DIR}:${SCRIPT_DIR}/.ontology:${SCRIPT_DIR}/adrs:${SCRIPT_DIR}/reflection/requirements"
if [[ ! -f "${CONFIG_NCL}" ]]; then
export NICKEL_IMPORT_PATH="${fallback_paths}"
return 0
fi
local raw_paths=""
if command -v nickel &>/dev/null; then
# shellcheck disable=SC2016 # $env.ONTOREF_ROOT and $p are Nushell variables, not bash
raw_paths="$(nickel export "${CONFIG_NCL}" 2>/dev/null | ONTOREF_ROOT="${SCRIPT_DIR}" nu -c 'from json | get nickel_import_paths | each { |p| $env.ONTOREF_ROOT + "/" + $p } | str join ":"' 2>/dev/null)" || true
fi
if [[ -n "${raw_paths}" ]]; then
export NICKEL_IMPORT_PATH="${raw_paths}"
else
export NICKEL_IMPORT_PATH="${fallback_paths}"
fi
}
if [[ -z "${NICKEL_IMPORT_PATH:-}" ]]; then
load_ontoref_env
fi
# ── Advisory locking (mkdir-based, POSIX-atomic, cross-platform) ──────────────
determine_lock() {
local cmd="${REMAINING_ARGS[0]:-}"
local sub="${REMAINING_ARGS[1]:-}"
case "${cmd}" in
config)
case "${sub}" in
apply|rollback) echo "manifest" ;;
*) echo "" ;;
esac
;;
register) echo "changelog" ;;
backlog)
case "${sub}" in
done|cancel) echo "backlog" ;;
*) echo "" ;;
esac
;;
*) echo "" ;;
esac
}
is_stale_lock() {
local lockdir="$1"
local owner_file="${lockdir}/owner"
if [[ ! -f "${owner_file}" ]]; then
return 0
fi
local owner_pid
owner_pid="$(cut -d: -f1 "${owner_file}" 2>/dev/null)" || return 0
if [[ -z "${owner_pid}" ]]; then return 0; fi
if ! kill -0 "${owner_pid}" 2>/dev/null; then
return 0
fi
return 1
}
ACQUIRED_LOCK=""
acquire_lock() {
local resource="$1"
local timeout="${2:-30}"
local lockdir="${LOCKS_DIR}/${resource}.lock"
local elapsed=0
mkdir -p "${LOCKS_DIR}"
local stale_retries=0
while ! mkdir "${lockdir}" 2>/dev/null; do
local stale=0
# shellcheck disable=SC2310
is_stale_lock "${lockdir}" && stale=1 || true
if [[ "${stale}" -eq 1 ]]; then
stale_retries=$((stale_retries + 1))
if [[ "${stale_retries}" -gt 5 ]]; then
echo " onref: stale lock on '${resource}' could not be cleared after 5 attempts" >&2
return 1
fi
rm -rf "${lockdir}"
sleep 0.1
continue
fi
if [[ "${elapsed}" -ge "${timeout}" ]]; then
local owner="unknown"
[[ -f "${lockdir}/owner" ]] && owner="$(cat "${lockdir}/owner")"
echo " onref: lock timeout on '${resource}' after ${timeout}s (held by: ${owner})" >&2
return 1
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
local now
now="$(date -u +%Y%m%dT%H%M%SZ)"
echo "$$:${ONTOREF_ACTOR}:${now}" > "${lockdir}/owner"
ACQUIRED_LOCK="${lockdir}"
}
release_lock() {
if [[ -n "${ACQUIRED_LOCK}" ]]; then
rm -rf "${ACQUIRED_LOCK}" 2>/dev/null || true
ACQUIRED_LOCK=""
fi
}
# ── Delegate to Nushell ───────────────────────────────────────────────────────
export ONTOREF_ROOT="${SCRIPT_DIR}"
# Allow callers (consumer project wrappers) to pre-set ONTOREF_PROJECT_ROOT.
# Only default to SCRIPT_DIR when running ontoref against its own repo.
export ONTOREF_PROJECT_ROOT="${ONTOREF_PROJECT_ROOT:-${SCRIPT_DIR}}"
_caller_name="$(basename "$0")"
export ONTOREF_CALLER="./${_caller_name}"
if [[ "${ENV_ONLY}" -eq 1 ]]; then
# shellcheck disable=SC2317
return 0 2>/dev/null || exit 0
fi
# ── No-args usage ─────────────────────────────────────────────────────────────
if [[ "${#REMAINING_ARGS[@]}" -eq 0 ]]; then
_caller="$(basename "$0")"
echo ""
echo " ${_caller}"
echo " Usage: ./${_caller} <command> [options]"
echo ""
echo " Use './${_caller} help' for available commands"
echo ""
exit 0
fi
# ── Rewrite help flags ────────────────────────────────────────────────────────
# Transform --help, -help, -h anywhere in args into: help <preceding-args>
_has_help=0
_non_help_args=()
for _a in "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}"; do
case "${_a}" in
--help|-help|-h) _has_help=1 ;;
*) _non_help_args+=("${_a}") ;;
esac
done
if [[ "${_has_help}" -eq 1 ]]; then
if [[ "${#_non_help_args[@]}" -gt 0 ]]; then
REMAINING_ARGS=("help" "${_non_help_args[@]}")
else
REMAINING_ARGS=("help")
fi
fi
# ── Normalize --fmt/-f: extract from any position and append after subcommand ─
# Allows: `ontoref -f json d s` → `ontoref d s --fmt json`
_fmt_val=""
_no_fmt_args=()
_fi=0
while [[ $_fi -lt ${#REMAINING_ARGS[@]} ]]; do
_a="${REMAINING_ARGS[$_fi]}"
case "${_a}" in
--fmt|-f|--format|-fmt)
_fi=$(( _fi + 1 ))
_fmt_val="${REMAINING_ARGS[$_fi]:-}"
;;
--fmt=*|--format=*)
_fmt_val="${_a#*=}"
;;
*) _no_fmt_args+=("${_a}") ;;
esac
_fi=$(( _fi + 1 ))
done
if [[ -n "${_fmt_val}" ]]; then
REMAINING_ARGS=("${_no_fmt_args[@]+"${_no_fmt_args[@]}"}" "--fmt" "${_fmt_val}")
fi
# ── Fix trailing flags that require a value ────────────────────────────────
if [[ "${#REMAINING_ARGS[@]}" -gt 0 ]]; then
_last="${REMAINING_ARGS[${#REMAINING_ARGS[@]}-1]}"
# shellcheck disable=SC2249
case "${_last}" in
--fmt|--format|-fmt|--actor|--context|--severity|--backend|--kind|--priority|--status)
REMAINING_ARGS+=("select")
;;
esac
fi
# ── Universal --clip: capture stdout, strip ANSI, copy to clipboard ───────────
_has_clip=0
_no_clip_args=()
for _a in "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}"; do
case "${_a}" in
--clip|-c) _has_clip=1 ;;
*) _no_clip_args+=("${_a}") ;;
esac
done
_strip_ansi() { sed $'s/\033\\[[0-9;]*[mGKHFJABCDEFM]//g'; }
_copy_to_clipboard() {
if command -v pbcopy &>/dev/null; then
printf '%s' "${1}" | pbcopy
elif command -v xclip &>/dev/null; then
printf '%s' "${1}" | xclip -selection clipboard
elif command -v wl-copy &>/dev/null; then
printf '%s' "${1}" | wl-copy
else
echo " No clipboard tool found (install pbcopy, xclip, or wl-copy)" >&2
return 1
fi
echo " ✓ Copied to clipboard" >&2
}
LOCK_RESOURCE="$(determine_lock)"
# --clip strategy:
# Structured --fmt (json/yaml/toml/md): non-interactive subprocess capture via stdin redirect.
# Text (no --fmt or --fmt text): pass --clip to Nushell — it handles clipboard after selection.
_fmt_is_structured=0
case "${_fmt_val}" in
json|yaml|toml|md|j|y|t|m) _fmt_is_structured=1 ;;
esac
if [[ "${_has_clip}" -eq 1 ]] && [[ "${_fmt_is_structured}" -eq 1 ]]; then
if [[ -n "${LOCK_RESOURCE}" ]]; then
acquire_lock "${LOCK_RESOURCE}" 30
trap 'release_lock' EXIT INT TERM
fi
_captured="$(nu "${DISPATCHER}" "${_no_clip_args[@]+"${_no_clip_args[@]}"}" 2>&1 < /dev/null | _strip_ansi)"
printf '%s\n' "${_captured}"
_copy_to_clipboard "${_captured}"
elif [[ "${_has_clip}" -eq 1 ]]; then
if [[ -n "${LOCK_RESOURCE}" ]]; then
acquire_lock "${LOCK_RESOURCE}" 30
trap 'release_lock' EXIT INT TERM
fi
# Text mode: pass --clip through; Nushell copies after interactive selection.
nu "${DISPATCHER}" "${_no_clip_args[@]+"${_no_clip_args[@]}"}" "--clip"
else
if [[ -n "${LOCK_RESOURCE}" ]]; then
acquire_lock "${LOCK_RESOURCE}" 30
trap 'release_lock' EXIT INT TERM
fi
nu "${DISPATCHER}" "${REMAINING_ARGS[@]+"${REMAINING_ARGS[@]}"}"
fi