chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
+339
@@ -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
|
||||
Reference in New Issue
Block a user