Skip to content

Commit f73a42b

Browse files
hyperpolymathclaude
andcommitted
refactor(lib): fold ownership_guard primitives into common.sh + wire env passthrough from Elixir TUI
Two polish items addressing the gaps surfaced 2026-04-26: (A) Fold ownership_guard into common.sh - Move owner_allowed / repo_owner_from_remote / repo_allowed / assert_owner_allowed into common.sh under `gs::`-prefixed names. - Adds GS_OWNERS_CONFIG env-var override (alongside the existing GIT_SCRIPTS_ALLOWED_OWNERS) for ergonomics. - Use the lib's colour palette + die helper for the rejection banner rather than open-coding it. - Convert the legacy scripts/lib/ownership_guard.sh into a thin backwards-compat shim that sources common.sh and re-exports the unprefixed names so any existing `source ownership_guard.sh` callers continue to work unchanged. - The Elixir mirror at lib/script_manager/ownership_guard.ex stays as-is (it's a parallel implementation called from pr_processor.ex; not part of the shell library merge). (B) Wire env passthrough from Elixir TUI - `ScriptManager.ScriptRunner.run_script/3` now accepts an `env` map and passes it through to `System.cmd`. Existing 2-arity callers continue to work (delegate to /3 with empty map). - Module docstring documents the common.sh env-var contract (GS_DRY_RUN, GS_YES, GS_LOG_LEVEL, GS_PARALLEL, GS_REPO_LIST, NO_COLOR) so TUI menu code can wire them through cleanly. - When env is non-empty the runner echoes a one-line "ℹ️ env: …" summary so the user can see what they triggered. This unblocks the TUI menus exposing dry-run / verbose / quiet toggles on every script invocation. Per-menu wiring is a separate cosmetic change; the runner-side plumbing it needs is now in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 24ce044 commit f73a42b

3 files changed

Lines changed: 165 additions & 126 deletions

File tree

lib/script_manager/script_runner.ex

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,60 @@
11
defmodule ScriptManager.ScriptRunner do
22
@moduledoc """
33
Runs shell scripts from `scripts/` and reports real duration + exit status.
4+
5+
Scripts that source `scripts/lib/common.sh` honour these env vars (set
6+
any of them via the third arg of `run_script/3`):
7+
8+
GS_DRY_RUN=1 no destructive writes
9+
GS_YES=1 skip confirmation prompts (CI mode)
10+
GS_LOG_LEVEL=debug|info|warn|error
11+
GS_PARALLEL=N bounded parallelism for repo iteration
12+
GS_REPO_LIST=… one-repo-per-line filter file
13+
NO_COLOR=1 disable ANSI in output
14+
15+
See `scripts/README.adoc` for the full common.sh contract.
416
"""
517

618
@scripts_dir "scripts"
719

20+
@doc """
21+
Run a script. Backwards-compatible 2-arity form.
22+
"""
823
@spec run_script(String.t(), list(String.t())) :: non_neg_integer()
9-
def run_script(script_name, args \\ []) do
24+
def run_script(script_name, args \\ []), do: run_script(script_name, args, %{})
25+
26+
@doc """
27+
Run a script with an explicit env map. Use this to surface common.sh
28+
flags (dry-run, log level, parallelism) from TUI menu choices.
29+
30+
Example: `run_script("audit_contractiles.sh", ["--report"], %{"GS_DRY_RUN" => "1"})`
31+
"""
32+
@spec run_script(String.t(), list(String.t()), map()) :: non_neg_integer()
33+
def run_script(script_name, args, env) when is_map(env) do
1034
script_path = Path.join(@scripts_dir, script_name)
1135

1236
if !File.exists?(script_path) do
1337
IO.puts("❌ Script not found: #{script_path}")
1438
127
1539
else
40+
env_list = Enum.map(env, fn {k, v} -> {to_string(k), to_string(v)} end)
41+
42+
if env_list != [] do
43+
env_summary =
44+
env_list
45+
|> Enum.map(fn {k, v} -> "#{k}=#{v}" end)
46+
|> Enum.join(" ")
47+
48+
IO.puts("ℹ️ env: #{env_summary}")
49+
end
50+
1651
started_at = System.monotonic_time(:millisecond)
1752

1853
{_out, status} =
1954
System.cmd("bash", [script_path | args],
2055
into: IO.stream(:stdio, :line),
21-
stderr_to_stdout: true
56+
stderr_to_stdout: true,
57+
env: env_list
2258
)
2359

2460
elapsed_ms = System.monotonic_time(:millisecond) - started_at

scripts/lib/common.sh

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,115 @@ gs::have() {
393393
command -v "$1" >/dev/null 2>&1
394394
}
395395

396+
# -----------------------------------------------------------------------------
397+
# Ownership guard. Refuse to operate on repos owned by anyone outside the
398+
# allowlist. Folded in from the legacy scripts/lib/ownership_guard.sh
399+
# (which now sources this lib for backwards compat).
400+
#
401+
# Configuration (first match wins):
402+
# $GIT_SCRIPTS_ALLOWED_OWNERS space-separated list, env-var override
403+
# ${GS_OWNERS_CONFIG} or scripts/../config/owners.config (sources ALLOWED_OWNERS=())
404+
# /var/mnt/eclipse/repos/git-scripts/config/owners.config (legacy fallback)
405+
# hard-coded ["hyperpolymath"] (final fallback)
406+
# -----------------------------------------------------------------------------
407+
408+
if [[ -z "${GS_OWNERSHIP_GUARD_LOADED:-}" ]]; then
409+
GS_OWNERSHIP_GUARD_LOADED=1
410+
411+
# Load allowlist.
412+
if [[ -n "${GIT_SCRIPTS_ALLOWED_OWNERS:-}" ]]; then
413+
# shellcheck disable=SC2206
414+
ALLOWED_OWNERS=(${GIT_SCRIPTS_ALLOWED_OWNERS})
415+
GS_OWNERS_SOURCE="env:GIT_SCRIPTS_ALLOWED_OWNERS"
416+
else
417+
__gs_owners_candidates=(
418+
"${GS_OWNERS_CONFIG:-}"
419+
"$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")/../../config/owners.config"
420+
"/var/mnt/eclipse/repos/git-scripts/config/owners.config"
421+
)
422+
GS_OWNERS_SOURCE=""
423+
for __gs_c in "${__gs_owners_candidates[@]}"; do
424+
[[ -z "${__gs_c}" || ! -f "${__gs_c}" ]] && continue
425+
# shellcheck disable=SC1090
426+
source "${__gs_c}"
427+
GS_OWNERS_SOURCE="${__gs_c}"
428+
break
429+
done
430+
[[ -z "${GS_OWNERS_SOURCE}" ]] && ALLOWED_OWNERS=("hyperpolymath")
431+
fi
432+
fi
433+
434+
__gs_lc() { printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'; }
435+
436+
# Return 0 if $1 is in ALLOWED_OWNERS (case-insensitive).
437+
gs::owner_allowed() {
438+
local needle; needle="$(__gs_lc "${1:-}")"
439+
[[ -z "${needle}" ]] && return 1
440+
local allowed
441+
for allowed in "${ALLOWED_OWNERS[@]}"; do
442+
[[ "${needle}" = "$(__gs_lc "${allowed}")" ]] && return 0
443+
done
444+
return 1
445+
}
446+
447+
# Print the owner segment of a local repo's `origin` URL.
448+
# Host-agnostic: GitHub, GitLab, Bitbucket, Gitea, codeberg, SSH, HTTPS.
449+
gs::repo_owner_from_remote() {
450+
local repo_path="${1:-.}"
451+
local url
452+
url="$(git -C "${repo_path}" config --get remote.origin.url 2>/dev/null)" || return 1
453+
[[ -z "${url}" ]] && return 1
454+
url="${url%.git}"
455+
456+
local path_part=""
457+
if [[ "${url}" =~ ^[^[:space:]/@]+@[^:]+:(.+)$ ]]; then
458+
path_part="${BASH_REMATCH[1]}"
459+
elif [[ "${url}" =~ ^[a-zA-Z]+://[^/]+(/.+)$ ]]; then
460+
path_part="${BASH_REMATCH[1]}"
461+
else
462+
return 1
463+
fi
464+
path_part="${path_part#/}"; path_part="${path_part%/}"
465+
[[ -z "${path_part}" ]] && return 1
466+
467+
local owner_dir owner
468+
owner_dir="$(dirname "${path_part}")"
469+
[[ "${owner_dir}" = "." || "${owner_dir}" = "/" ]] && return 1
470+
owner="$(basename "${owner_dir}")"
471+
[[ -z "${owner}" ]] && return 1
472+
printf '%s\n' "${owner}"
473+
}
474+
475+
# Soft check: 0 if local repo's owner is allowed.
476+
gs::repo_allowed() {
477+
local owner
478+
owner="$(gs::repo_owner_from_remote "${1:-.}")" || return 1
479+
gs::owner_allowed "${owner}"
480+
}
481+
482+
# Hard guard: print explanation and exit 78 (EX_CONFIG) if owner not allowed.
483+
gs::assert_owner_allowed() {
484+
local owner="${1:-}"
485+
if gs::owner_allowed "${owner}"; then
486+
return 0
487+
fi
488+
{
489+
printf '\n%s%s%s REFUSING to operate on owner %s%s%s\n' \
490+
"${GS_C_RED}" "" "${GS_C_RST}" \
491+
"${GS_C_BOLD}" "'${owner}'" "${GS_C_RST}"
492+
printf ' This owner is not in the allowlist for git-scripts.\n'
493+
printf ' Allowed owners: %s\n' "${ALLOWED_OWNERS[*]}"
494+
printf ' Configure via:\n'
495+
if [[ -n "${GS_OWNERS_SOURCE}" ]]; then
496+
printf ' %s\n' "${GS_OWNERS_SOURCE}"
497+
else
498+
printf ' scripts/../config/owners.config\n'
499+
fi
500+
printf ' …or set GIT_SCRIPTS_ALLOWED_OWNERS="owner1 owner2" in env.\n\n'
501+
} >&2
502+
exit 78
503+
}
504+
396505
# -----------------------------------------------------------------------------
397506
# Tiny flag parser. Consumes the standard flags and leaves the rest in
398507
# $GS_ARGS (an array). Recognised:

scripts/lib/ownership_guard.sh

Lines changed: 18 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,29 @@
11
#!/usr/bin/env bash
22
# SPDX-License-Identifier: PMPL-1.0-or-later
3+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
34
#
4-
# ownership_guard.sh — refuse to operate on repositories owned by anyone
5-
# outside the configured allowlist. Source this from any script that
6-
# touches GitHub or pushes to remotes.
5+
# ownership_guard.sh — backwards-compat shim.
76
#
8-
# Public functions:
7+
# The canonical implementation lives in `lib/common.sh` under `gs::`-prefixed
8+
# names (gs::owner_allowed, gs::repo_owner_from_remote, gs::repo_allowed,
9+
# gs::assert_owner_allowed). This file re-exports them under their legacy
10+
# unprefixed names so existing `source` callers keep working.
11+
#
12+
# Public functions (legacy names — prefer the gs:: forms in new code):
913
# owner_allowed <owner> — return 0 if allowed, 1 otherwise
10-
# assert_owner_allowed <owner> — exit 78 if owner is not allowed
1114
# repo_owner_from_remote <path> — print the GitHub owner of a local repo
1215
# repo_allowed <path> — return 0 if a local repo's owner is allowed
13-
#
14-
# Configuration is loaded from the first existing file:
15-
# $(dirname this)/../../config/owners.config
16-
# /var/mnt/eclipse/repos/git-scripts/config/owners.config
17-
# falling back to a hard-coded ["hyperpolymath"].
16+
# assert_owner_allowed <owner> — exit 78 if owner is not allowed
1817

19-
# Idempotent: only load once per shell.
20-
if [[ "${_OWNERSHIP_GUARD_LOADED:-0}" == "1" ]]; then
21-
return 0 2>/dev/null || true
22-
fi
18+
# Idempotent guard kept for source-twice safety.
19+
[[ -n "${_OWNERSHIP_GUARD_LOADED:-}" ]] && return 0
2320
_OWNERSHIP_GUARD_LOADED=1
2421

25-
_GUARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26-
_OWNERS_CONFIG_CANDIDATES=(
27-
"${_GUARD_DIR}/../../config/owners.config"
28-
"/var/mnt/eclipse/repos/git-scripts/config/owners.config"
29-
)
30-
31-
_loaded_owners_config=""
32-
for _candidate in "${_OWNERS_CONFIG_CANDIDATES[@]}"; do
33-
if [[ -f "${_candidate}" ]]; then
34-
# shellcheck disable=SC1090
35-
source "${_candidate}"
36-
_loaded_owners_config="${_candidate}"
37-
break
38-
fi
39-
done
40-
41-
if [[ -z "${_loaded_owners_config}" ]]; then
42-
ALLOWED_OWNERS=("hyperpolymath")
43-
fi
44-
45-
# Lowercase a string (portable; no `${var,,}` to keep bash 3 compat).
46-
_lc() {
47-
printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
48-
}
49-
50-
# Return 0 if $1 is in ALLOWED_OWNERS (case-insensitive).
51-
owner_allowed() {
52-
local needle
53-
needle="$(_lc "${1:-}")"
54-
[[ -z "${needle}" ]] && return 1
55-
local allowed
56-
for allowed in "${ALLOWED_OWNERS[@]}"; do
57-
if [[ "${needle}" == "$(_lc "${allowed}")" ]]; then
58-
return 0
59-
fi
60-
done
61-
return 1
62-
}
63-
64-
# Print the owner of a local git repo, derived from `origin` URL.
65-
# Host-agnostic: works for GitHub, GitLab, Bitbucket, Gitea, codeberg,
66-
# self-hosted servers, and SSH-style URLs. The owner is taken as the
67-
# second-to-last path segment (after stripping a trailing .git).
68-
# Returns 1 (and prints nothing) if no owner can be parsed.
69-
repo_owner_from_remote() {
70-
local repo_path="${1:-.}"
71-
local url
72-
url=$(git -C "${repo_path}" config --get remote.origin.url 2>/dev/null) || return 1
73-
[[ -z "${url}" ]] && return 1
74-
75-
# Strip a trailing .git for clean splitting.
76-
url="${url%.git}"
77-
78-
local path_part=""
79-
80-
if [[ "${url}" =~ ^[^[:space:]/@]+@[^:]+:(.+)$ ]]; then
81-
# SSH-style: [user@]host:path
82-
path_part="${BASH_REMATCH[1]}"
83-
elif [[ "${url}" =~ ^[a-zA-Z]+://[^/]+(/.+)$ ]]; then
84-
# URL-style: proto://[creds@]host[:port]/path
85-
path_part="${BASH_REMATCH[1]}"
86-
else
87-
return 1
88-
fi
89-
90-
# Trim leading/trailing slashes, then take the segment before the last.
91-
path_part="${path_part#/}"
92-
path_part="${path_part%/}"
93-
[[ -z "${path_part}" ]] && return 1
94-
95-
local owner_dir owner
96-
owner_dir="$(dirname "${path_part}")"
97-
[[ "${owner_dir}" == "." || "${owner_dir}" == "/" ]] && return 1
98-
99-
owner="$(basename "${owner_dir}")"
100-
[[ -z "${owner}" ]] && return 1
101-
102-
printf '%s\n' "${owner}"
103-
}
104-
105-
# Soft check: returns 0 if the local repo's owner is allowed.
106-
repo_allowed() {
107-
local owner
108-
owner="$(repo_owner_from_remote "${1:-.}")" || return 1
109-
owner_allowed "${owner}"
110-
}
22+
__OG_DIR="$(cd -- "$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
23+
# shellcheck disable=SC1091
24+
. "${__OG_DIR}/common.sh"
11125

112-
# Hard guard: print an explanation and exit if the owner is not allowed.
113-
# Use at the top of any script that targets a single org/user.
114-
assert_owner_allowed() {
115-
local owner="${1:-}"
116-
if owner_allowed "${owner}"; then
117-
return 0
118-
fi
119-
{
120-
echo ""
121-
echo "❌ REFUSING to operate on owner '${owner}'."
122-
echo " This owner is not in the allowlist for git-scripts."
123-
echo " Allowed owners: ${ALLOWED_OWNERS[*]}"
124-
echo ""
125-
echo " To allow it, edit:"
126-
if [[ -n "${_loaded_owners_config}" ]]; then
127-
echo " ${_loaded_owners_config}"
128-
else
129-
echo " config/owners.config"
130-
fi
131-
echo " …or set GIT_SCRIPTS_ALLOWED_OWNERS=\"owner1 owner2\" in the environment."
132-
echo ""
133-
} >&2
134-
exit 78 # EX_CONFIG
135-
}
26+
owner_allowed() { gs::owner_allowed "$@"; }
27+
repo_owner_from_remote() { gs::repo_owner_from_remote "$@"; }
28+
repo_allowed() { gs::repo_allowed "$@"; }
29+
assert_owner_allowed() { gs::assert_owner_allowed "$@"; }

0 commit comments

Comments
 (0)