Professional package installer for SUSE family created

This commit is contained in:
John A. Hoeven 2026-08-06 20:32:08 +02:00
commit 99c4a41662
Signed by: giovannino
GPG key ID: 306E507219506D4E
10 changed files with 751 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Real, machine-specific package lists live here — never pushed to the
# public repo. See packages/*.example for the tracked format reference.
/local/

108
RATIONALE.md Normal file
View file

@ -0,0 +1,108 @@
# Rationale
Why a ~400-500 line orchestrator/subscript script set is worth more
than a one-line `sudo zypper install X Y Z`, and when it isn't.
## The core argument
The complexity here isn't overhead wrapped around a trivial task —
the logging and verification machinery *is* the actual value being
built. A one-liner installs packages. This project installs packages
**and produces a record of having done so correctly.**
## What the pre-flight/test machinery actually buys you
- **Audit trail.** Pre-flight state, exactly what was already
installed vs. newly installed, post-install verification per
package, and cache-cleanup outcome — all captured permanently in
`~/.local/logs/ceos_installer/`, rather than scrolling past in a
terminal and gone.
- **Transparency.** Nothing happens invisibly. Every check, skip,
install, and verification is logged as it happens.
- **No silent failure.** A bare `zypper install X Y Z` gives no
structured signal when one package among several fails or is
skipped — you find out later, indirectly, when something that
depended on it doesn't work. `test_package_available` and the
post-install `run_test` check surface that class of problem at the
point it happens, not downstream.
- **Correct handling of naming and dependency quirks.** Not
hypothetical — see `~/.local/opt/INSTALLER_NOTES.md`'s
`tinkerpad-stt` entry: `ffmpeg` doesn't exist as a bare package name
on Tumbleweed (only `ffmpeg-8` etc. do), and a naive
`zypper install ffmpeg` just fails with no clear diagnostic path.
The warn-not-fail design here exists specifically to surface that
kind of problem cleanly instead of aborting the whole run or
failing silently.
- **Reproducibility.** The package list itself is a declarative spec
of "what should be installed here." Rebuilding this machine, or
standing up a second one needing the same tooling, is "run this
list" instead of trying to recall what got installed by hand over
months.
- **Disaster recovery.** After a wipe or hardware failure, the list
plus its log history is the recovery runbook — not just what to
reinstall, but a record proving the last known-good state and
confirming each package actually verified present afterward, not
just that the install command didn't error.
## Who this is actually for
A single, private user is entirely free to use the one-liner to their
heart's content — nothing here claims that's wrong for personal use.
The case for this project's thoroughness is specifically:
1. **A regulated business context**, where a documented, auditable
change process is a real requirement, not a preference. This isn't
just opinion — it lines up with how established frameworks are
actually written:
- **NIST SP 800-53, CM-3 (Configuration Change Control)** requires
documenting configuration change decisions and retaining records
of configuration-controlled changes for a defined period.
- **SOC 2, CC8.1** expects a reconstructable change history and
evidence of testing — the `run_test` PASS/FAIL/WARN lines in this
project's logs are exactly that evidence.
- **ISO 27001, Annex A Control 8.32** requires changes to be
planned, assessed, authorised, tested, documented, and
communicated.
These frameworks scope themselves to organizations under audit or
compliance obligation — a private individual has no external party
requiring this evidence, which is exactly why the one-liner remains
a legitimate choice for that case.
2. **A machine you intend to keep documenting and maintaining long
past this session** — the specific motivating case for this repo is
tinkerpad13, John's first SUSE machine, being deliberately built as
an opinionated Dev/SysAdmin OS. Every install is scripted
specifically *for* the documentation this produces, not
incidentally. The payoff comes from repetition and time, not the
first run — it's overkill for a machine you're about to reinstall
next week anyway.
## Removal is not just install-in-reverse
Apps that get installed and later turn out not to be worth keeping
should have their removal documented with the same rigor as their
install — an incomplete history that only records what was kept, not
what was tried and rejected, isn't a complete build record.
This is **not yet built** (only `10_zypper-packages.sh`, the install
subscript, exists as of this writing). When it is, it must not simply
mirror the install subscript with `zypper remove` swapped in — removal
is inherently more dangerous than install, because it can break
dependencies that other, still-wanted packages rely on. The removal
subscript's design needs a `zypper remove --dry-run`-equivalent step
that surfaces what else would be pulled out as a dependent, shown for
confirmation *before* the real removal runs — not just a bare y/N on
the package name itself.
## Supporting sources
- [CM-3: Configuration Change Control (NIST SP 800-53 r5)](https://csf.tools/reference/nist-sp-800-53/r5/cm/cm-3/)
- [A Practical Guide to SOC 2 Change Management Controls](https://soc2auditors.org/insights/soc-2-change-management-controls/)
- [SOC 2 Change Management (CC8.1 Controls & Evidence)](https://episki.com/frameworks/soc2/change-management)
- [ISO 27001:2022 Annex A Control 8.32 Explained](https://www.isms.online/iso-27001/annex-a-2022/8-32-change-management-2022/)
- [ISO 27001 Change Management Policy: A Complete Guide](https://sprinto.com/blog/iso-27001-change-management-policy/)
- [Mastering `set -e` in Linux](https://linuxvox.com/blog/linux-set-e/)
- [Google SRE Book — Eliminating Toil](https://sre.google/workbook/eliminating-toil/)
- [Testing Idempotence for Infrastructure as Code](https://www.researchgate.net/publication/255978303_Testing_Idempotence_for_Infrastructure_as_Code)
- [Google Cloud Anthos — Running preflight checks](https://docs.cloud.google.com/anthos/clusters/docs/on-prem/1.11/how-to/preflight-checks)

View file

@ -1,2 +1,20 @@
# suse-professional-package-installer # suse-professional-package-installer
A small, reusable zypper package installer for SUSE-family systems.
Instead of a one-off install script per project, packages to install
are kept in an external, hand-edited list; the installer reads it,
skips what's already installed, warns (without aborting) on anything
zypper doesn't recognize, and installs the rest — prompting for
privilege escalation only when it's actually about to run zypper.
Follows the CE OS orchestrator/subscript script standard: an
unprivileged orchestrator (`main.sh`) dispatches to a dedicated
subscript per package manager, each of which escalates privileges only
for its own run. zypper and pip/pipx are always kept as fully separate
subscripts, never combined into one installer.
Real, machine-specific package lists live in `local/` (gitignored —
never pushed). `packages/*.example` holds the tracked format
reference only.
See the wiki for usage instructions.

36
lib/00_preflight.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Created by John A. Hoeven with the ethical assistance of Claude AI
# ---------------------------------------------------------------------------
# 00_preflight.sh
# /home/john/projects/suse-professional-package-installer/lib/00_preflight.sh
# Version: v0.1.0 | Status: DEVELOPMENT
# ---------------------------------------------------------------------------
# Purpose: Environment checks only — no system changes. Runs unconditionally
# before any privileged subscript, unprivileged itself.
# Target: SUSE family (zypper).
# Entry: ./00_preflight.sh
# Depends: ce-common.sh (same directory)
# ---------------------------------------------------------------------------
# shellcheck disable=SC2016
# ^ run_test()'s cmd args are intentionally single-quoted — deferred-eval
# expressions, not meant to expand at the call site.
_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
# shellcheck source=./ce-common.sh
source "${_SCRIPT_DIR}/ce-common.sh"
log_info "=== PRE-FLIGHT ==="
run_test "not running as root" '[[ "${EUID}" -ne 0 ]]' fail
run_test "zypper available" 'command -v zypper >/dev/null 2>&1' fail
run_test "sudo or doas available" \
'command -v sudo >/dev/null 2>&1 || command -v doas >/dev/null 2>&1' fail
run_test "internet reachable" \
'curl --silent --max-time 5 --output /dev/null https://download.opensuse.org' \
warn
run_test "disk space >= 500MB" \
'[[ $(df / --output=avail | tail -1) -ge 512000 ]]' \
warn
log_info "Pre-flight complete"

39
lib/10_zypper-packages.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Created by John A. Hoeven with the ethical assistance of Claude AI
# ---------------------------------------------------------------------------
# 10_zypper-packages.sh
# /home/john/projects/suse-professional-package-installer/lib/10_zypper-packages.sh
# Version: v0.1.0 | Status: DEVELOPMENT
# ---------------------------------------------------------------------------
# Purpose: Install every package listed in a zypper package-list file.
# Target: SUSE family (zypper).
# Entry: ./10_zypper-packages.sh [package-list-file]
# Depends: ce-common.sh (same directory)
# ---------------------------------------------------------------------------
_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
# shellcheck source=./ce-common.sh
source "${_SCRIPT_DIR}/ce-common.sh"
_LIST_FILE="${1:-${_SCRIPT_DIR}/../local/zypper.list}"
# ── Read list, confirm, detect environment ──────────────────────────────
declare -a PACKAGES
read_package_list "${_LIST_FILE}" PACKAGES
log_info "Package list: ${_LIST_FILE} (${#PACKAGES[@]} entries)"
detect_package_manager
detect_priv_cmd
confirm_proceed "Install ${#PACKAGES[@]} package(s) via zypper: ${PACKAGES[*]}?"
# ── Install ───────────────────────────────────────────────────────────────
if install_packages PACKAGES; then
log_info "zypper package install complete — no failures"
exit 0
else
log_error "zypper package install finished with failures — see log above"
exit 1
fi

219
lib/ce-common.sh Executable file
View file

@ -0,0 +1,219 @@
#!/usr/bin/env bash
# Created by John A. Hoeven with the ethical assistance of Claude AI
# ---------------------------------------------------------------------------
# ce-common.sh
# /home/john/projects/suse-professional-package-installer/lib/ce-common.sh
# Version: v0.1.0 | Status: DEVELOPMENT
# ---------------------------------------------------------------------------
# Purpose: Shared logging, privilege-command detection, and zypper wrapper
# functions for this installer's subscripts.
# Target: SUSE family (zypper). Not a multi-distro abstraction — that's a
# deliberate scope choice, see spec/main_spec.md.
# Entry: source ce-common.sh — not executed directly.
# Depends: N/A
# ---------------------------------------------------------------------------
# Guard against double-sourcing (main.sh sources this directly, then each
# subscript it invokes also sources it for standalone-run capability).
[[ -n "${_CE_COMMON_SOURCED:-}" ]] && return 0
readonly _CE_COMMON_SOURCED=1
# ── Logging ──────────────────────────────────────────────────────────────
readonly LOG_DIR="${HOME}/.local/logs/ceos_installer"
mkdir -p "${LOG_DIR}"
_ce_log_date="$(date '+%Y-%m-%d')"
readonly LOG_FILE="${LOG_DIR}/${CE_INSTALLER_NAME:-suse-pkg-install}-${_ce_log_date}.log"
_log() {
local level="${1}"
local msg="${2}"
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
printf '[%s] [%s] %s\n' "${ts}" "${level}" "${msg}" | tee -a "${LOG_FILE}"
}
log_info() { _log "INFO " "${1}"; }
log_warn() { _log "WARN " "${1}" >&2; }
log_error() { _log "ERROR" "${1}" >&2; }
log_debug() { [[ "${CE_DEBUG:-0}" == "1" ]] && _log "DEBUG" "${1}"; }
# ── Test helper ──────────────────────────────────────────────────────────
# shellcheck disable=SC2016
# ^ run_test()'s cmd args are single-quoted at call sites throughout this
# project — deferred-eval expressions passed to `eval`, not meant to
# expand at the call site.
run_test() {
local desc="${1}"
local cmd="${2}"
local mode="${3:-fail}" # fail | warn
if eval "${cmd}" >/dev/null 2>&1; then
log_info " PASS: ${desc}"
return 0
fi
if [[ "${mode}" == "fail" ]]; then
log_error " FAIL: ${desc}"
exit 1
fi
log_warn " WARN: ${desc}"
return 1
}
# ── Privilege command detection ─────────────────────────────────────────
# Never assume doas or sudo — detect per source-discipline doc.
detect_priv_cmd() {
if command -v doas >/dev/null 2>&1; then
PRIV_CMD="doas"
elif command -v sudo >/dev/null 2>&1; then
PRIV_CMD="sudo"
else
log_error "Neither doas nor sudo found — cannot perform privileged operations"
exit 1
fi
log_info "Privilege command: ${PRIV_CMD}"
}
# ── zypper wrapper ───────────────────────────────────────────────────────
detect_package_manager() {
if ! command -v zypper >/dev/null 2>&1; then
log_error "zypper not found — this installer targets the SUSE family only"
exit 1
fi
log_info "Package manager: zypper"
}
test_package_available() {
local pkg="${1:?test_package_available: pkg required}"
rpm -q "${pkg}" >/dev/null 2>&1 && return 0
zypper --non-interactive search -x "${pkg}" 2>/dev/null | grep -q " ${pkg} " && return 0
log_warn "Package '${pkg}' not available via zypper"
return 1
}
package_installed() {
local pkg="${1:?package_installed: pkg required}"
rpm -q "${pkg}" >/dev/null 2>&1
}
install_packages() {
local -n pkg_list="${1:?install_packages: pkg_list required}"
local failed=()
local unavailable=()
local skipped=()
for pkg in "${pkg_list[@]}"; do
if package_installed "${pkg}"; then
log_info "Already installed, skipping: ${pkg}"
skipped+=( "${pkg}" )
continue
fi
if ! test_package_available "${pkg}"; then
unavailable+=( "${pkg}" )
continue
fi
if ! ${PRIV_CMD} zypper --non-interactive install "${pkg}"; then
log_error "Failed to install: ${pkg}"
failed+=( "${pkg}" )
else
log_info "Installed: ${pkg}"
CE_PKG_MODIFIED=1
run_test "${pkg} present after install" \
"package_installed '${pkg}'" \
warn
fi
done
if [[ ${#unavailable[@]} -gt 0 ]]; then
log_warn "Unavailable: ${unavailable[*]}"
fi
if [[ ${#failed[@]} -gt 0 ]]; then
log_error "Installation failures: ${failed[*]}"
return 1
fi
return 0
}
# ── Package-list file parsing ────────────────────────────────────────────
# One package name per line; '#'-prefixed comments and blank lines ignored.
read_package_list() {
local list_file="${1:?read_package_list: list_file required}"
local -n out_array="${2:?read_package_list: out_array required}"
if [[ ! -f "${list_file}" ]]; then
log_error "Package list not found: ${list_file}"
exit 1
fi
out_array=()
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "${line}" ]] && continue
out_array+=( "${line}" )
done < "${list_file}"
if [[ ${#out_array[@]} -eq 0 ]]; then
log_error "Package list is empty: ${list_file}"
exit 1
fi
}
# ── Cleanup trap (shared) ────────────────────────────────────────────────
# Registered unconditionally, before any work begins (style guide §2.9).
# Temp-dir removal always runs (harmless no-op if CE_TEMP_DIRS is empty).
# The privileged cache-clean step only runs if CE_PKG_MODIFIED was
# actually set — i.e. this run really touched zypper — not inferred from
# an unrelated side effect.
CE_TEMP_DIRS=()
CE_PKG_MODIFIED=0
_CE_CLEANUP_TRAP_SET=0
register_temp_dir() {
CE_TEMP_DIRS+=( "${1:?register_temp_dir: path required}" )
}
_ce_cleanup() {
local exit_code="${?}"
for d in "${CE_TEMP_DIRS[@]}"; do
if [[ -n "${d}" && -d "${d}" ]]; then
rm -rf "${d}"
log_info "Removed temp dir: ${d}"
fi
done
if [[ "${CE_PKG_MODIFIED}" -eq 1 && -n "${PRIV_CMD:-}" ]]; then
if ${PRIV_CMD} zypper clean --all 2>/dev/null; then
log_info "Package cache cleared"
else
log_warn "Package cache clear skipped or failed (non-fatal)"
fi
fi
log_info "Cleanup complete (exit code: ${exit_code})"
}
ensure_cleanup_trap() {
[[ "${_CE_CLEANUP_TRAP_SET}" -eq 1 ]] && return 0
trap _ce_cleanup EXIT INT TERM HUP
_CE_CLEANUP_TRAP_SET=1
}
ensure_cleanup_trap
# ── User confirmation ────────────────────────────────────────────────────
confirm_proceed() {
local prompt="${1:-Proceed with installation?}"
printf '\n%s [y/N] ' "${prompt}"
read -r response
case "${response}" in
[yY]|[yY][eE][sS]) return 0 ;;
*) log_info "Aborted by Organic Humanoid I/O Device unit ID ${USER}"; exit 0 ;;
esac
}

60
main.sh Executable file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Created by John A. Hoeven with the ethical assistance of Claude AI
# ---------------------------------------------------------------------------
# main.sh
# /home/john/projects/suse-professional-package-installer/main.sh
# Version: v0.1.0 | Status: DEVELOPMENT
# ---------------------------------------------------------------------------
# Purpose: Orchestrator — installs zypper packages from an external,
# hand-edited package list instead of a one-off script per
# project. Add new package-list-driven subscripts under lib/ as
# new package managers are needed (kept fully separate per
# subscript — never combined, see spec/main_spec.md).
# Target: SUSE family (zypper). Common orchestrator, distro-specific
# subscripts.
# Entry: ./main.sh [package-list-file]
# Depends: lib/ce-common.sh, lib/00_preflight.sh, lib/10_zypper-packages.sh
# ---------------------------------------------------------------------------
# Orchestrator: exempt from the 100-line subscript boundary (argument
# parsing and subscript dispatch legitimately require it) — see
# ceos_script_architecture.md.
# ---------------------------------------------------------------------------
set -euo pipefail
_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
usage() {
cat <<EOF
Usage: $(basename "${0}") [package-list-file]
Installs zypper packages listed in package-list-file (default:
local/zypper.list — gitignored, machine-specific; copy
packages/zypper.list.example there to start one) — one package name
per line, '#' comments and blank lines ignored.
Each package manager gets its own dedicated subscript under lib/,
invoked separately; this orchestrator never mixes zypper and pip/pipx
installs in the same run.
EOF
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
_LIST_FILE="${1:-${_SCRIPT_DIR}/local/zypper.list}"
# shellcheck source=lib/ce-common.sh
source "${_SCRIPT_DIR}/lib/ce-common.sh"
log_info "=== suse-professional-package-installer starting ==="
log_info "Orchestrator holds no elevation; ${_SCRIPT_DIR}/lib/10_zypper-packages.sh escalates on its own"
"${_SCRIPT_DIR}/lib/00_preflight.sh"
"${_SCRIPT_DIR}/lib/10_zypper-packages.sh" "${_LIST_FILE}"
_STATUS=$?
log_info "=== suse-professional-package-installer finished (exit ${_STATUS}) ==="
exit "${_STATUS}"

View file

@ -0,0 +1,10 @@
# zypper package list — one package name per line, '#' comments ignored.
#
# This is a tracked FORMAT EXAMPLE, not a real list. Real, machine-specific
# package lists belong in local/ (gitignored — see .gitignore) so a
# public clone of this repo never carries anyone's actual install choices.
#
# Usage: copy this file to local/<name>.list, edit it, then run:
# ./main.sh local/<name>.list
# example-package-name

185
spec/main_spec.md Normal file
View file

@ -0,0 +1,185 @@
# suse-professional-package-installer — spec
Spec-first entries per CE OS Script Development standards, one per
script, written before generation.
---
## `main.sh` (orchestrator)
- **Name:** `main.sh` — top-level entry point; parses arguments, sources
`lib/ce-common.sh`, invokes the requested subscript(s) in order.
- **Inputs:** CLI args — an optional package-list file path, default
`local/zypper.list` (gitignored, machine-specific real list — see
`.gitignore` and `packages/*.example` below).
- **Outputs:** stdout/log lines via `ce-common.sh` logging; exit 0 on
full success, non-zero if any subscript reports failure.
- **Error behaviour:** halt on missing/unreadable list file; otherwise
log-and-continue between independent subscripts (a zypper failure
doesn't block an unrelated later subscript), non-zero exit if any
subscript failed.
- **Privilege:** standard user — orchestrator never elevates itself or
carries elevation between subscripts (per CE OS privilege model).
- **Doc references:** `ceos_script_architecture.md` (orchestrator/
subscript model, privilege model).
- **Distro target:** common (dispatches to distro-specific subscripts).
- Exempt from the 100-line subscript boundary per the orchestrator
exemption.
---
## `lib/ce-common.sh` (shared library)
- **Name:** `ce-common.sh` — logging, privilege-command detection,
zypper wrapper functions, cleanup trap, user confirmation. Promoted/
generalized from the private `tinkerpad-stt` deployment's copy —
project-specific bits (`VOSK_HOME`, hardcoded installer name)
stripped for public reuse.
- **Inputs:** sourced only, not executed directly. Reads
`CE_INSTALLER_NAME` env var (optional, defaults to `suse-pkg-install`).
- **Outputs:** exported functions/vars for consuming scripts
(`log_info`, `run_test`, `detect_priv_cmd`, `test_package_available`,
`package_installed`, `install_packages`, `confirm_proceed`, etc.).
`run_test(desc, cmd, mode=fail|warn)` is the shared PASS/FAIL/WARN
check helper (matches the established pattern in
`~/.local/opt/package-installer/`) — used both by `00_preflight.sh`
and by `install_packages()` itself for a post-install presence check
per package (warn-mode: a verification miss is logged, not fatal).
- **Error behaviour:** `log_error` + `exit 1` on unrecoverable
conditions (no priv command found, no zypper found).
- **Privilege:** none itself — only detects what's available;
escalation happens per-subscript at the point of use.
- **Doc references:** `ceos_script_architecture.md`,
`ceos_script_source_discipline.md` (never assume sudo vs doas).
- **Distro target:** SUSE family (zypper only, scoped to this repo's
name/purpose — unlike the private multi-manager `ce-common.sh`).
- **Line count:** 188 non-blank lines — over the 100-line soft signal
but within the 200-line hard ceiling for non-orchestrator scripts
(RIS doc-set `65ppjj`, section `lin`, chunks `sft`/`hrd`/`exm`
supersedes `0nxwoh`, the original text which omitted the 200-line
figure entirely; corrected 2026-08-06 per John's direct clarification
during this project's review). Also arguably orchestrator-like
itself, per John. Line limits are being deliberately not enforced
strictly at this early-dev stage, with the intent to split this file
further once real use/testing surfaces where the actual seams are —
splitting now, before that, would be guessing at boundaries rather
than finding them.
---
## `lib/00_preflight.sh` (subscript)
- **Name:** `00_preflight.sh` — environment checks only, no system
changes. This is the "dry run" step: it always runs, unconditionally,
before any privileged subscript, and never itself prompts for
privilege escalation.
- **Inputs:** none.
- **Outputs:** exit 0 if all `fail`-mode checks pass (warn-mode checks —
internet reachability, disk space — log but don't block); exit 1 on
the first fail-mode check that fails.
- **Error behaviour:** halt immediately via `run_test`'s fail mode
(not running as root, zypper present, sudo-or-doas present); warn
and continue for environmental checks that aren't hard requirements.
- **Privilege:** standard user only — this subscript makes no
privileged calls itself, by design.
- **Doc references:** matches the pre-flight phase pattern in
`~/.local/opt/package-installer/20260726-201557-package-installer.sh`
(the established local convention for this project's "dry-run and
tests" behaviour, per John, 2026-08-06).
- **Distro target:** SUSE family (zypper check), otherwise common.
---
## `tests/test_list_parsing.sh` (test script — kept separate, never merged into an install script)
- **Name:** `test_list_parsing.sh` — exercises `read_package_list()`
(comment stripping, whitespace trimming, blank-line skipping,
comment-only-file rejection, missing-file rejection) with no zypper
calls and no privilege escalation.
- **Inputs:** none (writes its own fixture files to a `mktemp -d`
registered via `register_temp_dir` for automatic cleanup).
- **Outputs:** exit 0 if every `run_test` check passes; exit 1 (via
`run_test`'s fail mode) on the first failing assertion.
- **Error behaviour:** fail-fast — every assertion in this file is
fail-mode, since a parsing regression should never be silently
downgraded to a warning.
- **Privilege:** standard user only.
- **Doc references:** `ceos_script_architecture.md` ("Test scripts
always remain separate — they are never merged into installation or
configuration scripts").
- **Distro target:** common (pure bash, no package-manager calls).
---
## `lib/10_zypper-packages.sh` (subscript)
- **Name:** `10_zypper-packages.sh` — installs every package listed in
a given zypper package-list file.
- **Inputs:** `$1` = path to a package-list file (one package name per
line, `#`-prefixed comments and blank lines ignored). Defaults to
`local/zypper.list` (gitignored) if not given.
- **Outputs:** exit 0 if every listed package ends up installed or was
already present; exit 1 if any install failed. Unavailable packages
are warned about, not treated as fatal (matches existing
`install_packages()` behaviour — a typo in one line shouldn't abort
the whole run).
- **Error behaviour:** halt immediately if the list file doesn't
exist or is empty; per-package log-and-continue during install.
- **Privilege:** requires escalation (installing system packages) —
prompts for it at the point `install_packages()` first needs it, via
`detect_priv_cmd`. Does not hold elevation before that point.
- **Doc references:** `ceos_script_architecture.md` (privilege model —
escalation isolated to the subscript that needs it).
- **Distro target:** SUSE family (zypper).
---
## Deferred (not built this pass)
- **`lib/20_pip-packages.sh`** — pip/pipx package-list subscript.
Not needed for the immediate target (`poppler-tools`, a zypper
package), and per the standing rule zypper/pip must stay fully
separate subscripts, never combined. Spec entry to be written when
an actual pip/pipx list is needed.
- **Multi-distro (`apk`/`apt`/`nix`) support** — deliberately out of
scope. This repo's name and purpose are SUSE-specific; a general
multi-distro engine already exists privately as `ce-common.sh` in
`~/.local/opt/tinkerpad-stt/lib/` and is not what this public repo
is for.
- **Textual list-authoring/validation TUI** — explicitly deferred
until the plain-text list format has been used for real and any
rough edges are known.
---
## Known gaps (Ambrosiana Gap Protocol — documented per `ceos_script_source_discipline.md`)
- **No Ambrosiana-sourced zypper/Tumbleweed documentation.** Per the
"no training-derived assumptions" rule, every zypper-specific
behavior used here (`zypper --non-interactive search -x`, cache
cleanup via `zypper clean --all`, `rpm -q` as the query mechanism)
was verified live against the real machine, not sourced from
Ambrosiana — it holds no zypper/Tumbleweed doc-set yet. Same gap
already logged privately for `tinkerpad-stt` in
`~/.local/opt/INSTALLER_NOTES.md`. Per protocol step 3 (no quality
alternative exists yet): proceeding with elevated live-verification
discipline instead of blocking on it; this note is that documentation
requirement being met. Should travel into the README's "Known bugs"
section at public-release time.
- **Full CE OS deployment workflow not implemented.** The methodology's
full sequence (full-stack discovery, conflict analysis, FHS Relocator,
offline USB preparation, formal two-stage QC with a specialised local
agent ahead of Claude Code) targets full service/system deployments.
This project is a lightweight package-list installer, not a service
deployer — no package here lands in a non-FHS default location the
Relocator would need to catch, and there's no service being deployed
to discover conflicts against. Treating those pieces as out of
proportion to scope rather than a compliance gap, but flagging
explicitly rather than silently omitting them — John's call to
confirm.
- **QC is single-stage in practice.** No specialised local agent is
currently trusted to run a Stage 1 pass ahead of Claude Code — local
fleet models aren't yet evaluated as reliable enough for this role.
Claude Code performs both drafting and QC (shellcheck + manual
review) in one pass, consistent with how this gap is already handled
elsewhere in this project ecosystem.

73
tests/test_list_parsing.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Created by John A. Hoeven with the ethical assistance of Claude AI
# ---------------------------------------------------------------------------
# test_list_parsing.sh
# /home/john/projects/suse-professional-package-installer/tests/test_list_parsing.sh
# Version: v0.1.0 | Status: DEVELOPMENT
# ---------------------------------------------------------------------------
# Purpose: Exercises read_package_list() (comments/blank-line handling,
# empty-file rejection) without touching zypper or requiring
# privilege escalation. Kept separate from installation scripts
# per CE OS standard — never merged into main.sh or a subscript.
# Target: common (pure bash, no package-manager calls).
# Entry: ./test_list_parsing.sh
# Depends: lib/ce-common.sh
# ---------------------------------------------------------------------------
# shellcheck disable=SC2016
# ^ run_test()'s cmd args are intentionally single-quoted — deferred-eval
# expressions, not meant to expand at the call site.
_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
# shellcheck source=../lib/ce-common.sh
source "${_SCRIPT_DIR}/../lib/ce-common.sh"
_TMP="$(mktemp -d)"
register_temp_dir "${_TMP}"
log_info "=== test_list_parsing ==="
# ── Comments, blank lines, whitespace ────────────────────────────────────
cat > "${_TMP}/mixed.list" <<'EOF'
# a full-line comment
foo-pkg
bar-pkg # trailing comment
baz-pkg
EOF
# shellcheck disable=SC2034 # populated via nameref inside read_package_list, read via eval in run_test
declare -a parsed
read_package_list "${_TMP}/mixed.list" parsed
run_test "parses exactly 3 packages" '[[ ${#parsed[@]} -eq 3 ]]' fail
run_test "first entry is foo-pkg" '[[ "${parsed[0]}" == "foo-pkg" ]]' fail
run_test "second entry is bar-pkg (trailing comment stripped)" \
'[[ "${parsed[1]}" == "bar-pkg" ]]' fail
run_test "third entry is baz-pkg (leading whitespace stripped)" \
'[[ "${parsed[2]}" == "baz-pkg" ]]' fail
# ── Empty / comment-only file is rejected ────────────────────────────────
cat > "${_TMP}/empty.list" <<'EOF'
# nothing but comments
EOF
# Functions are inherited into subshells, so a bare subshell (no re-source
# needed) is enough to isolate read_package_list()'s own `exit 1` from this
# test script's own process.
run_test "comment-only list exits non-zero" \
"! (declare -a x; read_package_list '${_TMP}/empty.list' x) 2>/dev/null" \
fail
# ── Missing file is rejected ─────────────────────────────────────────────
run_test "missing list file exits non-zero" \
"! (declare -a x; read_package_list '${_TMP}/does-not-exist.list' x) 2>/dev/null" \
fail
log_info "=== test_list_parsing: all checks passed ==="