chore: consolidate new foundation and archive v1 (#1495)

This commit is contained in:
2026-09-07 12:32:57 -05:00
3511 changed files with 727899 additions and 10 deletions
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env bash
#
# credentials.sh — Shared credential loader for Mosaic tool suites
#
# Usage: source ~/.config/mosaic/tools/_lib/credentials.sh
# load_credentials <service-name>
#
# credentials.json is the single source of truth.
# For Woodpecker, credentials are also synced to ~/.woodpecker/<instance>.env.
#
# Supported services:
# portainer, coolify, authentik, glpi, github,
# gitea-mosaicstack, gitea-usc, woodpecker, cloudflare,
# turbo-cache, openbrain
#
# After loading, service-specific env vars are exported.
# Run `load_credentials --help` for details.
#
# Resolution order (first match wins):
# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed)
# 2. $HOME/.config/mosaic/credentials.json
# 3. /etc/mosaic/credentials.json (host-level fallback)
# The /etc fallback exists for HOME-redirected profile environments, where
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
done
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
fi
export MOSAIC_CREDENTIALS_FILE
_mosaic_require_jq() {
if ! command -v jq &>/dev/null; then
echo "Error: jq is required but not installed" >&2
return 1
fi
}
_mosaic_read_cred() {
local jq_path="$1"
if [[ ! -f "$MOSAIC_CREDENTIALS_FILE" ]]; then
echo "Error: Credentials file not found: $MOSAIC_CREDENTIALS_FILE" >&2
return 1
fi
jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE"
}
# Decide curl TLS flag for a target URL: validate public hosts (MITM matters on
# WAN); allow self-signed only for private-network IP literals (trusted LAN) or an
# explicit $MOSAIC_INSECURE_TLS opt-in. Echoes "-k" or "" (empty).
_mosaic_tls_opt() {
local url="$1" host
[[ -n "${MOSAIC_INSECURE_TLS:-}" ]] && { echo "-k"; return; }
host=$(printf '%s' "$url" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')
if [[ "$host" =~ ^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
echo "-k"; return
fi
echo ""
}
# Sync Woodpecker credentials to ~/.woodpecker/<instance>.env
# Only writes when values differ to avoid unnecessary disk writes.
_mosaic_sync_woodpecker_env() {
local instance="$1" url="$2" token="$3"
local env_file="$HOME/.woodpecker/${instance}.env"
[[ -d "$HOME/.woodpecker" ]] || return 0
local expected
expected=$(printf '# %s Woodpecker CI\nexport WOODPECKER_SERVER="%s"\nexport WOODPECKER_TOKEN="%s"\n' \
"$instance" "$url" "$token")
if [[ -f "$env_file" ]]; then
local current_url current_token
current_url=$(grep -oP '(?<=WOODPECKER_SERVER=").*(?=")' "$env_file" 2>/dev/null || true)
current_token=$(grep -oP '(?<=WOODPECKER_TOKEN=").*(?=")' "$env_file" 2>/dev/null || true)
[[ "$current_url" == "$url" && "$current_token" == "$token" ]] && return 0
fi
printf '%s\n' "$expected" > "$env_file"
}
# Load legacy flat Woodpecker credentials (.woodpecker.url / .woodpecker.token).
# Some environments export WOODPECKER_INSTANCE=mosaic, but the current
# credentials.json may still use the legacy flat schema. Treat "mosaic" as the
# default flat instance when a nested .woodpecker.mosaic object is absent.
_mosaic_load_woodpecker_legacy() {
export WOODPECKER_URL="$(_mosaic_read_cred '.woodpecker.url')"
export WOODPECKER_TOKEN="$(_mosaic_read_cred '.woodpecker.token')"
export WOODPECKER_INSTANCE="${WOODPECKER_INSTANCE:-mosaic}"
WOODPECKER_URL="${WOODPECKER_URL%/}"
[[ -n "$WOODPECKER_URL" ]] || { echo "Error: woodpecker.url not found" >&2; return 1; }
[[ -n "$WOODPECKER_TOKEN" ]] || { echo "Error: woodpecker.token not found" >&2; return 1; }
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
}
load_credentials() {
local service="$1"
if [[ -z "$service" || "$service" == "--help" ]]; then
cat <<'EOF'
Usage: load_credentials <service>
Services and exported variables:
portainer → PORTAINER_URL, PORTAINER_API_KEY
coolify → COOLIFY_URL, COOLIFY_TOKEN
authentik → AUTHENTIK_URL, AUTHENTIK_TOKEN, AUTHENTIK_TEST_USER, AUTHENTIK_TEST_PASSWORD (uses default instance)
authentik-<name> → AUTHENTIK_URL, AUTHENTIK_TOKEN, AUTHENTIK_TEST_USER, AUTHENTIK_TEST_PASSWORD (specific instance, e.g. authentik-usc)
glpi → GLPI_URL, GLPI_APP_TOKEN, GLPI_USER_TOKEN
github → GITHUB_TOKEN
gitea-mosaicstack → GITEA_URL, GITEA_TOKEN
gitea-usc → GITEA_URL, GITEA_TOKEN
woodpecker → WOODPECKER_URL, WOODPECKER_TOKEN (uses default instance)
woodpecker-<name> → WOODPECKER_URL, WOODPECKER_TOKEN (specific instance, e.g. woodpecker-usc)
cloudflare → CLOUDFLARE_API_TOKEN (uses default instance)
cloudflare-<name> → CLOUDFLARE_API_TOKEN (specific instance, e.g. cloudflare-personal)
turbo-cache → TURBO_API, TURBO_TOKEN, TURBO_TEAM
openbrain → OPENBRAIN_URL, OPENBRAIN_TOKEN
EOF
return 0
fi
_mosaic_require_jq || return 1
case "$service" in
portainer)
export PORTAINER_URL="${PORTAINER_URL:-$(_mosaic_read_cred '.portainer.url')}"
export PORTAINER_API_KEY="${PORTAINER_API_KEY:-$(_mosaic_read_cred '.portainer.api_key')}"
PORTAINER_URL="${PORTAINER_URL%/}"
[[ -n "$PORTAINER_URL" ]] || { echo "Error: portainer.url not found" >&2; return 1; }
[[ -n "$PORTAINER_API_KEY" ]] || { echo "Error: portainer.api_key not found" >&2; return 1; }
;;
coolify)
export COOLIFY_URL="${COOLIFY_URL:-$(_mosaic_read_cred '.coolify.url')}"
export COOLIFY_TOKEN="${COOLIFY_TOKEN:-$(_mosaic_read_cred '.coolify.app_token')}"
COOLIFY_URL="${COOLIFY_URL%/}"
[[ -n "$COOLIFY_URL" ]] || { echo "Error: coolify.url not found" >&2; return 1; }
[[ -n "$COOLIFY_TOKEN" ]] || { echo "Error: coolify.app_token not found" >&2; return 1; }
;;
authentik-*)
local ak_instance="${service#authentik-}"
export AUTHENTIK_URL="$(_mosaic_read_cred ".authentik.${ak_instance}.url")"
export AUTHENTIK_TOKEN="$(_mosaic_read_cred ".authentik.${ak_instance}.token")"
export AUTHENTIK_TEST_USER="$(_mosaic_read_cred ".authentik.${ak_instance}.test_user.username")"
export AUTHENTIK_TEST_PASSWORD="$(_mosaic_read_cred ".authentik.${ak_instance}.test_user.password")"
export AUTHENTIK_INSTANCE="$ak_instance"
AUTHENTIK_URL="${AUTHENTIK_URL%/}"
[[ -n "$AUTHENTIK_URL" ]] || { echo "Error: authentik.${ak_instance}.url not found" >&2; return 1; }
;;
authentik)
local ak_default
ak_default="${AUTHENTIK_INSTANCE:-$(_mosaic_read_cred '.authentik.default')}"
if [[ -z "$ak_default" ]]; then
# Fallback: try legacy flat structure (.authentik.url)
local legacy_url
legacy_url="$(_mosaic_read_cred '.authentik.url')"
if [[ -n "$legacy_url" ]]; then
export AUTHENTIK_URL="${AUTHENTIK_URL:-$legacy_url}"
export AUTHENTIK_TOKEN="${AUTHENTIK_TOKEN:-$(_mosaic_read_cred '.authentik.token')}"
export AUTHENTIK_TEST_USER="${AUTHENTIK_TEST_USER:-$(_mosaic_read_cred '.authentik.test_user.username')}"
export AUTHENTIK_TEST_PASSWORD="${AUTHENTIK_TEST_PASSWORD:-$(_mosaic_read_cred '.authentik.test_user.password')}"
AUTHENTIK_URL="${AUTHENTIK_URL%/}"
[[ -n "$AUTHENTIK_URL" ]] || { echo "Error: authentik.url not found" >&2; return 1; }
else
echo "Error: authentik.default not set and no AUTHENTIK_INSTANCE env var" >&2
echo "Available instances: $(jq -r '.authentik | keys | join(", ")' "$MOSAIC_CREDENTIALS_FILE" 2>/dev/null)" >&2
return 1
fi
else
load_credentials "authentik-${ak_default}"
fi
;;
glpi)
export GLPI_URL="${GLPI_URL:-$(_mosaic_read_cred '.glpi.url')}"
export GLPI_APP_TOKEN="${GLPI_APP_TOKEN:-$(_mosaic_read_cred '.glpi.app_token')}"
export GLPI_USER_TOKEN="${GLPI_USER_TOKEN:-$(_mosaic_read_cred '.glpi.user_token')}"
GLPI_URL="${GLPI_URL%/}"
[[ -n "$GLPI_URL" ]] || { echo "Error: glpi.url not found" >&2; return 1; }
;;
github)
export GITHUB_TOKEN="${GITHUB_TOKEN:-$(_mosaic_read_cred '.github.token')}"
[[ -n "$GITHUB_TOKEN" ]] || { echo "Error: github.token not found" >&2; return 1; }
;;
gitea-mosaicstack)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
;;
gitea-usc)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;;
woodpecker-*)
local wp_instance="${service#woodpecker-}"
# credentials.json is authoritative — always read from it, ignore env.
# Backward compatibility: the default Mosaic Woodpecker instance may be
# stored in the legacy flat schema (.woodpecker.url/.token) instead of
# .woodpecker.mosaic.url/.token.
if [[ "$wp_instance" == "mosaic" ]] && [[ -z "$(_mosaic_read_cred '.woodpecker.mosaic.url')" ]] && [[ -n "$(_mosaic_read_cred '.woodpecker.url')" ]]; then
WOODPECKER_INSTANCE="mosaic" _mosaic_load_woodpecker_legacy
return $?
fi
export WOODPECKER_URL="$(_mosaic_read_cred ".woodpecker.${wp_instance}.url")"
export WOODPECKER_TOKEN="$(_mosaic_read_cred ".woodpecker.${wp_instance}.token")"
export WOODPECKER_INSTANCE="$wp_instance"
WOODPECKER_URL="${WOODPECKER_URL%/}"
[[ -n "$WOODPECKER_URL" ]] || { echo "Error: woodpecker.${wp_instance}.url not found" >&2; return 1; }
[[ -n "$WOODPECKER_TOKEN" ]] || { echo "Error: woodpecker.${wp_instance}.token not found" >&2; return 1; }
# Sync to ~/.woodpecker/<instance>.env so the wp CLI wrapper stays current
_mosaic_sync_woodpecker_env "$wp_instance" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
;;
woodpecker)
# Resolve default instance, then load it. If WOODPECKER_INSTANCE is set to
# "mosaic" by a shell/profile but credentials.json still uses the legacy
# flat .woodpecker.url/.token schema, load the flat credentials instead of
# failing with "woodpecker.mosaic.url not found".
local wp_default
wp_default="${WOODPECKER_INSTANCE:-$(_mosaic_read_cred '.woodpecker.default')}"
if [[ -z "$wp_default" ]]; then
# Fallback: try legacy flat structure (.woodpecker.url / .woodpecker.token)
local legacy_url
legacy_url="$(_mosaic_read_cred '.woodpecker.url')"
if [[ -n "$legacy_url" ]]; then
_mosaic_load_woodpecker_legacy
else
echo "Error: woodpecker.default not set and no WOODPECKER_INSTANCE env var" >&2
echo "Available instances: $(jq -r '.woodpecker | keys | join(", ")' "$MOSAIC_CREDENTIALS_FILE" 2>/dev/null)" >&2
return 1
fi
else
if [[ "$wp_default" == "mosaic" ]] && [[ -z "$(_mosaic_read_cred '.woodpecker.mosaic.url')" ]] && [[ -n "$(_mosaic_read_cred '.woodpecker.url')" ]]; then
WOODPECKER_INSTANCE="mosaic" _mosaic_load_woodpecker_legacy
else
load_credentials "woodpecker-${wp_default}"
fi
fi
;;
cloudflare-*)
local cf_instance="${service#cloudflare-}"
export CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:-$(_mosaic_read_cred ".cloudflare.${cf_instance}.api_token")}"
export CLOUDFLARE_INSTANCE="$cf_instance"
[[ -n "$CLOUDFLARE_API_TOKEN" ]] || { echo "Error: cloudflare.${cf_instance}.api_token not found" >&2; return 1; }
;;
cloudflare)
# Resolve default instance, then load it
local cf_default
cf_default="${CLOUDFLARE_INSTANCE:-$(_mosaic_read_cred '.cloudflare.default')}"
if [[ -z "$cf_default" ]]; then
echo "Error: cloudflare.default not set and no CLOUDFLARE_INSTANCE env var" >&2
return 1
fi
load_credentials "cloudflare-${cf_default}"
;;
turbo-cache)
export TURBO_API="${TURBO_API:-$(_mosaic_read_cred '.turbo_cache.api_url')}"
export TURBO_TOKEN="${TURBO_TOKEN:-$(_mosaic_read_cred '.turbo_cache.token')}"
export TURBO_TEAM="${TURBO_TEAM:-$(_mosaic_read_cred '.turbo_cache.team')}"
[[ -n "$TURBO_API" ]] || { echo "Error: turbo_cache.api_url not found" >&2; return 1; }
[[ -n "$TURBO_TOKEN" ]] || { echo "Error: turbo_cache.token not found" >&2; return 1; }
[[ -n "$TURBO_TEAM" ]] || { echo "Error: turbo_cache.team not found" >&2; return 1; }
;;
openbrain)
export OPENBRAIN_URL="${OPENBRAIN_URL:-$(_mosaic_read_cred '.openbrain.url')}"
export OPENBRAIN_TOKEN="${OPENBRAIN_TOKEN:-$(_mosaic_read_cred '.openbrain.api_key')}"
OPENBRAIN_URL="${OPENBRAIN_URL%/}"
[[ -n "$OPENBRAIN_URL" ]] || { echo "Error: openbrain.url not found" >&2; return 1; }
[[ -n "$OPENBRAIN_TOKEN" ]] || { echo "Error: openbrain.api_key not found" >&2; return 1; }
;;
*)
echo "Error: Unknown service '$service'" >&2
echo "Supported: portainer, coolify, authentik[-<name>], glpi, github, gitea-mosaicstack, gitea-usc, woodpecker[-<name>], cloudflare[-<name>], turbo-cache, openbrain" >&2
return 1
;;
esac
}
# Common HTTP helper — makes a curl request and separates body from status code
# Usage: mosaic_http GET "/api/v1/endpoint" "Authorization: Bearer $TOKEN" [base_url]
# Returns: body on stdout, sets MOSAIC_HTTP_CODE
mosaic_http() {
local method="$1"
local endpoint="$2"
local auth_header="$3"
local base_url="${4:-}"
local response
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
-H "$auth_header" \
-H "Content-Type: application/json" \
"${base_url}${endpoint}")
MOSAIC_HTTP_CODE=$(echo "$response" | tail -n1)
echo "$response" | sed '$d'
}
# POST variant with body
# Usage: mosaic_http_post "/api/v1/endpoint" "Authorization: Bearer $TOKEN" '{"key":"val"}' [base_url]
mosaic_http_post() {
local endpoint="$1"
local auth_header="$2"
local data="$3"
local base_url="${4:-}"
local response
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
-H "$auth_header" \
-H "Content-Type: application/json" \
-d "$data" \
"${base_url}${endpoint}")
MOSAIC_HTTP_CODE=$(echo "$response" | tail -n1)
echo "$response" | sed '$d'
}
# PATCH variant with body
mosaic_http_patch() {
local endpoint="$1"
local auth_header="$2"
local data="$3"
local base_url="${4:-}"
local response
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
-H "$auth_header" \
-H "Content-Type: application/json" \
-d "$data" \
"${base_url}${endpoint}")
MOSAIC_HTTP_CODE=$(echo "$response" | tail -n1)
echo "$response" | sed '$d'
}
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# Shared bash reader for framework-manifest.txt (#791).
#
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
# framework-manifest.txt and MUST resolve identical ownership for any path — the
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
# compares it against the TS resolver, so the two can never drift (the #631
# two-copies failure class this closes).
#
# Ownership resolution (deny-wins / fail-safe):
# 1. operator glob matches -> operator
# 2. else framework glob -> framework
# 3. else -> operator (UNKNOWN defaults to operator, #791)
#
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
# calls it once per file across the whole tree.
#
# Usage as a library (source it, then):
# manifest_load [manifest-file] # populates + compiles the manifest
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
# manifest_resolve <rel-path> # echoes: framework | operator
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
#
# Usage as a CLI (parity harness):
# bash manifest.sh resolve <rel-path>
# bash manifest.sh subtree-roots
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
_MF_ROOTS=()
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
# trailing / (mirrors normalizeRel in manifest.ts).
_manifest_norm() {
local p="$1"
p="${p//\\//}"
p="${p#./}"
while [[ "$p" == /* ]]; do p="${p#/}"; done
while [[ "$p" == */ ]]; do p="${p%/}"; done
printf '%s' "$p"
}
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
_manifest_glob_to_ere() {
local pattern; pattern="$(_manifest_norm "$1")"
local out="" c n i len=${#pattern} trailing
for (( i = 0; i < len; i++ )); do
c="${pattern:i:1}"
if [[ "$c" == "*" ]]; then
n="${pattern:i+1:1}"
if [[ "$n" == "*" ]]; then
i=$((i + 1))
trailing=0
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
if [[ "$out" == */ ]]; then
out="${out%/}(/.*)?"
elif [[ "$trailing" -eq 1 ]]; then
out="$out(.*/)?"
else
out="$out.*"
fi
else
out="${out}[^/]*"
fi
else
case "$c" in
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
*) out="$out$c" ;;
esac
fi
done
printf '%s' "$out"
}
# Compile one raw glob into (kind, exact, re) appended to the given section.
# $1 = raw glob, $2 = section letter (F|O).
_manifest_compile_one() {
local norm; norm="$(_manifest_norm "$1")"
[[ -n "$norm" ]] || return 0
if [[ "$norm" == *"*"* ]]; then
local re
re="^$(_manifest_glob_to_ere "$norm")\$"
if [[ "$2" == F ]]; then
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
else
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
fi
else
if [[ "$2" == F ]]; then
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
else
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
fi
fi
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
return 0
}
_manifest_compile() {
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
local g
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
# short-circuit to rc 1, which would otherwise become this function's (and
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
# the last loop's exit status here.
return 0
}
# Load + compile the manifest. Rejects a malformed file the same way
# parseManifest() does (entry before a section header / unknown header).
manifest_load() {
local file="${1:-}"
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
# aborts on a raw redirection error with no explanation; downstream that reads
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
if [[ ! -r "$file" ]]; then
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
return 1
fi
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
local section="" line
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line%"${line##*[![:space:]]}"}" # rtrim
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
case "$line" in
"[framework]") section=framework; continue ;;
"[operator]") section=operator; continue ;;
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
esac
if [[ -z "$section" ]]; then
echo "manifest: entry before any [section] header: $line" >&2
return 1
fi
if [[ "$section" == framework ]]; then
MANIFEST_FRAMEWORK+=("$line")
else
MANIFEST_OPERATOR+=("$line")
fi
done < "$file"
# An empty or comment-only manifest defines NO framework-owned paths. Treating
# that as valid would make every path resolve operator and an upgrade prune
# nothing / write nothing — a silent no-op indistinguishable from success.
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
return 1
fi
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
# matches no path — so a manifest whose only [framework] entries are degenerate
# passes the count guard above but leaves the framework matcher empty: every
# path resolves operator, the exact silent no-op we fail closed against. Require
# at least one entry with a real (non-slash, non-dot) character. Mirrors
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
local _g _usable=0
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
done
if [[ "$_usable" -eq 0 ]]; then
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
return 1
fi
_manifest_compile
return 0
}
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
_mo_matches() {
local path="$1" i n=${#_MO_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MO_KIND[i]}" == exact ]]; then
pat="${_MO_EXACT[i]}"
# Operator exact entries are file carve-outs, not implicit directory
# prefixes. Subtree ownership must be declared explicitly as `dir/**`;
# otherwise one bare directory entry can hide all drift beneath it.
[[ "$path" == "$pat" ]] && return 0
else
re="${_MO_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# Fork-free: does $1 match a framework glob?
_mf_matches() {
local path="$1" i n=${#_MF_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MF_KIND[i]}" == exact ]]; then
pat="${_MF_EXACT[i]}"
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
else
re="${_MF_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
manifest_is_framework() {
_mo_matches "$1" && return 1
_mf_matches "$1" && return 0
return 1
}
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
# safe for CLI / test callers passing unnormalized input.
manifest_resolve() {
local path; path="$(_manifest_norm "$1")"
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
}
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
manifest_subtree_roots() {
local r
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
}
# CLI dispatch — only when executed directly, never when sourced.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set -o pipefail
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
# exit instead of continuing to resolve against empty compiled arrays — that is
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
manifest_load "${MANIFEST_FILE:-}" || exit 1
cmd="${1:-}"
case "$cmd" in
resolve) manifest_resolve "${2:?path required}" ;;
subtree-roots) manifest_subtree_roots ;;
classify)
while IFS= read -r p; do
[[ -z "$p" ]] && continue
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
done
;;
*)
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
exit 2
;;
esac
fi