diff --git a/DESIGN.md b/DESIGN.md index 1f06813e..04d2e57d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -427,7 +427,7 @@ An entry with only `name` and no override fields has no effect on the build. `sy ```toml [group.cosmic] -packages = ["cosmic-session-git", "cosmic-comp-git", "cosmic-settings-git"] +packages = ["cosmic-session-git", "cosmic-comp-git", "cosmic-settings-git", ...] # Optional defaults inherited by every member: # enable_build_from_source = true ``` @@ -971,7 +971,7 @@ The name always reflects what was *applied*: a Propeller → AutoFDO fallback in **Headers/docs subpackages (`build_headers` / `build_docs`):** the kernel image is always built; the optional `-headers` and `-docs` subpackages are toggled independently. `build_headers` defaults **on** (DKMS and any out-of-tree module build need the matching kernel headers to compile); `build_docs` defaults **off** (the docs subpackage is slow to package and rarely needed) — note this drops the `-docs` subpackage that a stock kernel PKGBUILD would otherwise build. Resolution per toggle: CLI flag (`--headers`/`--no-headers`, `--docs`/`--no-docs`, `argparse.BooleanOptionalAction`, default unset → `None`) > `kernel.toml` (`build_headers`/`build_docs`) > hard default. The single home for the resolve is `resolve_subpackages`. Disabling a subpackage works by **dropping its entry from the PKGBUILD's `pkgname` array** — `pkgbuild_patcher.patch_kernel_subpackages` (called from `makepkg_wrapper` for every kernel build, after `patch_kernel_btf_guard`) removes any array token whose dequoted value ends with `-headers`/`-docs`. It rewrites **both** the initial `pkgname=(...)` literal **and** every `pkgname+=(...)` append — modern Arch kernel PKGBUILDs add the optional subpackages via `pkgname+=("$pkgbase-docs")`, which a single-array walk would miss (leaving `-docs` to build and install; B7); an append that existed solely to add now-dropped subpackage(s) has its whole statement line removed rather than leaving an empty `pkgname+=()`. Standard Arch kernels synthesize each `package_$_p()` via `for _p in "${pkgname[@]}"; do eval …`, so an entry absent from the array is never packaged; the `_package-headers()`/`_package-docs()` helper bodies are left untouched. **Disabling docs additionally neutralizes the doc *build*:** stock Arch `linux` builds the documentation in `build()` (`make htmldocs SPHINXOPTS=-QT`), not only in `_package-docs()`, so dropping the `-docs` pkgname token stops packaging but not the (often multi-minute Sphinx) compile — which would make the summary's `docs=off` a lie. When `build_docs` is off the same patcher neutralizes any doc-target make line (`_neutralize_kernel_doc_build`, two passes over the goals — htmldocs/pdfdocs/infodocs/mandocs/…): a **mixed** line such as `make all htmldocs` loses *only* its `*docs` goals (→ `make all`) so the real build survives, while a line whose goals are **exclusively** `*docs` (with optional `VAR=val`/flag tokens) is commented out whole with a `# sysforge(docs off):` prefix. Both passes treat shell control operators and redirections (`&`, `;`, `|`, `&&`, `>…`) as punctuation the shell owns, never as make goals — stock Arch `linux` *backgrounds* its doc build (`make htmldocs SPHINXOPTS=-QT &`), and counting the trailing `&` as a surviving real goal made an exclusive-doc line look mixed: only `htmldocs` was stripped, leaving a goalless `make &` that resolves to make's **default** goal (`all`) and runs a second kernel build concurrently with the real one, killing `build()` with `E_USER_FUNCTION_FAILED` (B15). A backgrounded exclusive-doc line is additionally rewritten to `true &` rather than merely commented: the following line is invariably `local pid_docs=$!` and the function later `wait`s on that pid, so removing the only background job would leave the capture reading an unset or stale `$!` — and a `wait` on a non-child exits non-zero, fatal under makepkg's errexit. A trivial background job keeps `$!` valid and `wait` at 0, with the original command preserved in the trailing `# sysforge(docs off):` comment. The patcher needs no sentinel (every edit is self-idempotent — a commented line no longer starts with `make`, a stripped/removed token no longer matches), is a no-op when both subpackages are kept or the targeted subpackage is absent, and preserves both the single-line and one-token-per-line array layouts. When `build_headers` is disabled, Gate 1 escalates the standing DKMS reminder into a hard warning: no `-headers` will be installed, so out-of-tree and DKMS modules (nvidia-open-dkms → black screen) cannot rebuild and won't load on reboot; any present DKMS modules are named. -**Resolution summary.** After resolving compiler (+ its origin), variant, bootloader (+ whether the chosen one is detected installed), source, interactive mode, kconfig counts, the headers/docs subpackage toggles (`subpkgs:` line), and the boot-safety gate settings, the stage emits a single labelled "Kernel build plan:" block (`log_resolution_summary`). It prints on every run (useful before a multi-hour build) and is the readable core of `--dry-run`, replacing decisions previously scattered across the log. The standalone interactive default also emits a one-line nudge pointing at `--no-interactive` for unattended runs. +**Resolution summary.** After resolving compiler (+ its origin), variant, bootloader (+ whether the chosen one is detected installed), source, config review, kconfig counts, the headers/docs subpackage toggles (`subpkgs:` line), and the boot-safety gate settings, the stage emits a single labelled "Kernel build plan:" block (`log_resolution_summary`). It prints on every run (useful before a multi-hour build) and is the readable core of `--dry-run`, replacing decisions previously scattered across the log. A run that opens the config review also emits a one-line nudge pointing at `--no-review-config`. **Variant-inheritance nudge.** When `compiler` is unset (neither CLI nor `kernel.toml`) and the toolchain-stage variant is `pgo_llvm`, the stage emits a WARN naming the inherited variant and recommending that the operator persist `compiler = "llvm"` in `kernel.toml` so the choice survives a future toolchain-stage disable (which clears `[stages.toolchain.result]`). `stock_llvm` gets the same nudge at INFO level. `gcc` and `system` variants are silent — gcc is the safe default and `system` means there's no opinion to project. @@ -983,7 +983,7 @@ The name always reflects what was *applied*: a Propeller → AutoFDO fallback in **Pkgname/pkgbase consistency check.** After the source sync, the stage static-parses the PKGBUILD via `parse_pkgbuild` and confirms the parsed `pkgbase` (or `pkgname` for non-split packages) matches the *pre-rename* name of the tree — `upstream_pkgname` when tracking an upstream, else `pkgname` (the local rename is a patch applied later in `makepkg_wrapper`, so the on-disk PKGBUILD always carries the upstream name). A typo or a cloned PKGBUILD whose `pkgbase` has drifted from the directory name raises a clear `RuntimeError` at stage entry instead of failing late at `makepkg --install` after a multi-hour build. -**Pkgname repo-collision check.** Immediately after the consistency check, the stage tests `kernel.toml pkgname` against the pacman sync DBs via `aur.is_repo_package` (one `pacman -Si`). A custom kernel should carry a unique name; if the name matches an official package (e.g. `linux`, `linux-lts`), building and installing it would overwrite the stock package on `pacman -U`. Interactive runs prompt for confirmation (`prompt_choice`, default no); unattended runs (`--no-interactive` or no TTY) abort; `--dry-run` warns without prompting. +**Pkgname repo-collision check.** Immediately after the consistency check, the stage tests `kernel.toml pkgname` against the pacman sync DBs via `aur.is_repo_package` (one `pacman -Si`). A custom kernel should carry a unique name; if the name matches an official package (e.g. `linux`, `linux-lts`), building and installing it would overwrite the stock package on `pacman -U`. A run that may ask (`prompt.is_interactive()`) prompts for confirmation (`prompt_choice`, default no); one that may not (`--unattended` or no TTY) aborts, naming the reason; `--dry-run` warns without prompting. **kconfig fragment:** @@ -1027,7 +1027,7 @@ The resolved sequence is threaded as `BuildOptions.kconfig_targets` (kernel.py **Interactive kconfig (kernel-stage default):** -`sysforge run kernel` is interactive by default — the kernel stage passes `interactive=True` into `BuildOptions`. On the interactive path the caller contributes `kconfig_plan.review_step()` to the `REVIEW` slot when no configured review target already fills it, rendering a TTY-guarded pause followed by `make nconfig` into the PKGBUILD's `prepare()` (in the `POST` region, after the seed/merge/generate/hotplug slots) so the user reviews and edits the resolved config before the build proceeds — sysforge supplies the interactive target itself rather than depending on the PKGBUILD having one (a stock PKGBUILD that only runs `make olddefconfig` would otherwise show no menu). `KconfigPlan.install`'s drop rules suppress the injected `REVIEW` when the PKGBUILD already has an interactive target of its own — no second `nconfig`. The makepkg subprocess inherits the parent TTY in interactive mode (`makepkg_invoke`), so the ncurses UI renders on the controlling terminal. The pause is part of `review_step`'s rendered lines, immediately before `make nconfig` — the only point that is genuinely *after all merges, before the editor* (B6). A stage-level pause before `makepkg` necessarily fired before those in-`prepare()` merges ran, so the operator confirmed before the config was even assembled; the injected pause (`if [ -t 0 ]; then read -rp … || true; fi`) instead lets them read the merged result just before `nconfig` opens. It is TTY-guarded (inert on `--no-interactive`/no-TTY/captured-stdin/`--dry-run` paths) and errexit-safe under makepkg's `set -e` (`read` returning non-zero on EOF is swallowed by `|| true`; the `if` yields 0), and `Ctrl-C` still aborts. It is rendered by **both** review-slot builders: the injected `review_step()` and — as of F22 — a *configured* `kconfig_targets` UI tail (`ui_target_step`), whose prompt names the configured target rather than a hardcoded `nconfig`. The pause originally shipped only with the injected `nconfig`, reasoning that a target the operator named in `kernel.toml` needed no confirmation; that was wrong, because the pause is not a confirmation of the *target* but the operator's checkpoint on the assembled `.config`, equally wanted however the review target got there — a `kconfig_targets = ["olddefconfig", "nconfig"]` config otherwise dropped straight into the menu with no chance to inspect the merge result. A PKGBUILD supplying its own interactive target still keeps it with no pause: no plan step renders that line, so there is nothing to attach a pause to. The default can be flipped via `kernel.toml interactive = false` or the `--no-interactive` CLI flag; `install(noninteractive=True)` then drops the injected `REVIEW` slot outright (or rewrites a *configured* UI tail to `olddefconfig` via its `noninteractive_rewrite`, per `ui_target_step`), and separately rewrites any surviving PKGBUILD-owned interactive target (`oldconfig`, `nconfig`, `menuconfig`, `xconfig`, `gconfig`) to `make olddefconfig` for unattended runs. `olddefconfig` applies defaults for all new symbols without terminal interaction; VAR=val arguments before the target (e.g. `ARCH=x86_64`) and trailing comments are preserved. `--noconfirm` only controls makepkg's own prompts and has no effect on interactive make targets inside the PKGBUILD. +**Two questions, one answer each (`3.5.0-F22`).** The stage asks two separate things, and before F22 it derived both from one overloaded word in four places with different formulas: *may sysforge ask its own questions?* — the repo-collision and diverged-source confirmations and the stale-sentinel recovery — is `prompt.is_interactive()` alone (a terminal, and not `--unattended`); *does this run open the PKGBUILD's config UI?* is `kernel/config.py::resolve_config_review`, the one home for `kernel.toml review_config` (was `interactive`; `KernelConfig.from_toml` still reads the old key with a warning, and `config check`/`clean` migrate it via `RETIRED_KEYS`) ∧ ¬`--no-review-config` ∧ `prompt.is_interactive()`. Its answer, `config_review`, feeds the kconfig rewrite, `kconfig_targets` validation, the makepkg terminal attachment, the AlreadyBuilt arbitration and the kernel install's `noconfirm`; nothing re-derives it. So `--no-review-config` only skips the UI — the safety questions are still asked — while `--unattended` turns off both. `sysforge run kernel` opens the config review by default — the kernel stage passes `interactive=config_review` into `BuildOptions`. On the interactive path the caller contributes `kconfig_plan.review_step()` to the `REVIEW` slot when no configured review target already fills it, rendering a TTY-guarded pause followed by `make nconfig` into the PKGBUILD's `prepare()` (in the `POST` region, after the seed/merge/generate/hotplug slots) so the user reviews and edits the resolved config before the build proceeds — sysforge supplies the interactive target itself rather than depending on the PKGBUILD having one (a stock PKGBUILD that only runs `make olddefconfig` would otherwise show no menu). `KconfigPlan.install`'s drop rules suppress the injected `REVIEW` when the PKGBUILD already has an interactive target of its own — no second `nconfig`. The makepkg subprocess inherits the parent TTY in interactive mode (`makepkg_invoke`), so the ncurses UI renders on the controlling terminal. The pause is part of `review_step`'s rendered lines, immediately before `make nconfig` — the only point that is genuinely *after all merges, before the editor* (B6). A stage-level pause before `makepkg` necessarily fired before those in-`prepare()` merges ran, so the operator confirmed before the config was even assembled; the injected pause (`if [ -t 0 ]; then read -rp … || true; fi`) instead lets them read the merged result just before `nconfig` opens. It is TTY-guarded (inert on `--no-review-config`/`--unattended`/no-TTY/captured-stdin/`--dry-run` paths) and errexit-safe under makepkg's `set -e` (`read` returning non-zero on EOF is swallowed by `|| true`; the `if` yields 0), and `Ctrl-C` still aborts. It is rendered by **both** review-slot builders: the injected `review_step()` and — as of F22 — a *configured* `kconfig_targets` UI tail (`ui_target_step`), whose prompt names the configured target rather than a hardcoded `nconfig`. The pause originally shipped only with the injected `nconfig`, reasoning that a target the operator named in `kernel.toml` needed no confirmation; that was wrong, because the pause is not a confirmation of the *target* but the operator's checkpoint on the assembled `.config`, equally wanted however the review target got there — a `kconfig_targets = ["olddefconfig", "nconfig"]` config otherwise dropped straight into the menu with no chance to inspect the merge result. A PKGBUILD supplying its own interactive target still keeps it with no pause: no plan step renders that line, so there is nothing to attach a pause to. The default can be flipped via `kernel.toml review_config = false` or the `--no-review-config` CLI flag (and is off on any run that may not ask); `install(noninteractive=True)` then drops the injected `REVIEW` slot outright (or rewrites a *configured* UI tail to `olddefconfig` via its `noninteractive_rewrite`, per `ui_target_step`), and separately rewrites any surviving PKGBUILD-owned interactive target (`oldconfig`, `nconfig`, `menuconfig`, `xconfig`, `gconfig`) to `make olddefconfig` for unattended runs. `olddefconfig` applies defaults for all new symbols without terminal interaction; VAR=val arguments before the target (e.g. `ARCH=x86_64`) and trailing comments are preserved. `--noconfirm` only controls makepkg's own prompts and has no effect on interactive make targets inside the PKGBUILD. Note: when other verbs (`sysforge build`, `sysforge update`) build a kernel PKGBUILD with `build_mode = "kernel"` on the resolved profile, those paths still default to *non-interactive* — interactive-by-default is a kernel-stage-only contract because the stage is the user-driven kernel build entry point. @@ -1037,7 +1037,7 @@ Note: when other verbs (`sysforge build`, `sysforge update`) build a kernel PKGB The kernel stage routes its source refresh through `source_sync.get_scheduler().request(SyncRequest(..., source=))` ahead of the build, the same path as the toolchain stage — and (F40) *before* `pkgbuild_path` requires the PKGBUILD, so a missing tree is bootstrapped by the scheduler's clone-if-missing path. With `source = "local"` (explicit or auto-resolved), the scheduler short-circuits (no RPC, no clone, no fetch) — only `--cleansrc` / `--cleansrc-force` would attempt a purge, but a hand-maintained tree has no remote to re-clone from, so users on the `local` path leave cleansrc unset. For `source = "repo"` / `"aur"`, the normal sync runs: `--cleansrc` purges and re-clones (refusing on dirty/ahead/no-upstream clones); `--cleansrc-force` overrides that guard; cleansrc forces a sync even when `--no-update` is also set. `STATUS_FAILED` / `STATUS_RATE_LIMITED` / `STATUS_PURGE_REFUSED` raise. -`STATUS_DIVERGED` (upstream advanced but the local tree can't fast-forward — local commits or a dirty tree) gets stronger handling in the *kernel* stage than the plain warning the other verbs use, because building a kernel off stale or hand-edited source is exactly the easy-to-miss footgun. `warn_and_confirm_diverged` enriches the WARN with ahead/behind counts (`classify_head_vs_upstream`) so the "upstream has new commits but the local repo is dirty" case is spelled out, then **gates the build**: an interactive run must confirm (`prompt_choice`, default no), and an unattended run (`--no-interactive` or no TTY) aborts. Either decline raises, leaving nothing built (the sync runs before the sentinel). `--cleansrc` to discard local edits is the suggested escape hatch. +`STATUS_DIVERGED` (upstream advanced but the local tree can't fast-forward — local commits or a dirty tree) gets stronger handling in the *kernel* stage than the plain warning the other verbs use, because building a kernel off stale or hand-edited source is exactly the easy-to-miss footgun. `warn_and_confirm_diverged` enriches the WARN with ahead/behind counts (`classify_head_vs_upstream`) so the "upstream has new commits but the local repo is dirty" case is spelled out, then **gates the build**: a run that may ask must confirm (`prompt_choice`, default no), and one that may not (`--unattended` or no TTY) aborts; `--no-review-config` does not change this. Either decline raises, leaving nothing built (the sync runs before the sentinel). `--cleansrc` to discard local edits is the suggested escape hatch. The source sync (including the `--cleansrc` purge, which `purge_src` does as a non-atomic `shutil.rmtree`) runs **outside** the boot sentinel by design: it mutates only the src tree, nothing boot-critical, so wrapping it in the sentinel — whose `recovery_cmd` is `sudo mkinitcpio -P` — would be semantically wrong. The atomicity contract is "purge, then clone"; an interrupted purge leaves a missing/partial PKGBUILD that fails **loudly** at `pkgbuild_path` on the next run (with a hint to re-run `--cleansrc` to re-clone), not a silent brick. No sentinel is needed because the running kernel was never touched. @@ -1090,7 +1090,7 @@ To make a *pre-install* hard-fail possible, the build is **split from the instal **CLI surface (`sysforge run kernel`):** -`--dry-run`, `--no-update`, `--cleansrc`, `--cleansrc-force`, `--no-interactive`, `--toolchain {gcc,llvm}`, `--bootloader {systemd-boot,grub,none}`, `--base-config {pkgbuild,running,}`, `--allow-no-fallback`, `--allow-boot-audit-failure`, `--headers`/`--no-headers`, `--docs`/`--no-docs`, `--no-pkg-log`, `--persist-log`, `--log-dir`, `--cache-report`, `--abi-check`, `--state-dir`, `--profile-conf`. +`--dry-run`, `--no-update`, `--cleansrc`, `--cleansrc-force`, `--no-review-config`, `--toolchain {gcc,llvm}`, `--bootloader {systemd-boot,grub,none}`, `--base-config {pkgbuild,running,}`, `--allow-no-fallback`, `--allow-boot-audit-failure`, `--headers`/`--no-headers`, `--docs`/`--no-docs`, `--no-pkg-log`, `--persist-log`, `--log-dir`, `--cache-report`, `--abi-check`, `--state-dir`, `--profile-conf`. **Post-install steps** (run after the artifact is installed): 1. `sudo mkinitcpio -P` @@ -1362,7 +1362,7 @@ spelling per meaning, across every verb: `state profiles --delete STORE`), with `--yes` to skip its prompt. `artifact remove --purge` keeps the package-manager sense of also deleting the managed copy. -- **A negative switch is `--no-`** (`--no-update`, `--no-interactive`), +- **A negative switch is `--no-`** (`--no-update`, `--no-review-config`), or a `--/--no-` pair (`BooleanOptionalAction`) when config can default it either way (`--headers/--no-headers`). Never `--non-…`/`--disable-…`. - **No two flags differ only by a plural `s`** (`--no-pkg-log`, not @@ -1960,7 +1960,7 @@ Integration sites: `sysforge/verbs/runner.py::run_verb` (universal `: star ### `prompt.py` -Single shared interactive-prompt helper. Every stage that needs user input goes through `prompt.prompt_text()` (free-form), `prompt.prompt_choice()` (fixed-set), or `prompt.prompt_key()` (single keypress), so the behaviour on empty input, unrecognized input, and EOF is consistent across the codebase. `is_interactive()` wraps `sys.stdin.isatty()` for stages that need to gate prompts on a TTY. +Single shared interactive-prompt helper. Every stage that needs user input goes through `prompt.prompt_text()` (free-form), `prompt.prompt_choice()` (fixed-set), or `prompt.prompt_key()` (single keypress), so the behaviour on empty input, unrecognized input, and EOF is consistent across the codebase. `is_interactive()` wraps `sys.stdin.isatty()` for stages that need to gate prompts on a TTY, and is the only stdin TTY check in the package (`tests/test_unattended.py` fails on another). **Unattended runs** (`3.5.0-F21`): `set_unattended()` is called once by `cli._main` from `cli._resolve_unattended` (`--unattended` > `SYSFORGE_UNATTENDED`, which also turns a configured on off for one run > `[ui] unattended` > off). While set, `is_interactive()` is false even at a terminal and the three helpers take their EOF path without reading (`prompt_text`/`prompt_choice` return `eof_default`, `prompt_key` raises `EOFError`), so the run behaves exactly as one with stdin closed, including at a call site that never checked `is_interactive()`. It changes *whether sysforge may ask*, nothing else: it is not a global `--yes` (a removal still refuses without one; the review gate still refuses an unreviewed change rather than accepting it), and it does not touch the run-level `--interactive` flags of `build`/`update`, which decide whether makepkg and pacman get the terminal. sudo reads the terminal itself, so the EOF path cannot reach its password prompt: `privilege.ensure_credentials()` returns `False` without running the prompting `sudo -v` (cached credentials or `NOPASSWD` still pass the `sudo -n true` probe), and `privilege.privileged_argv()` adds `-n` to every escalation, so one that skipped `authenticate()` fails fast instead of waiting for a password. Refuse/skip messages take their reason from `no_prompt_reason()` (`"unattended run"` or `"no terminal"`). `unattended()` distinguishes a chosen unattended run from a missing TTY, so the kernel stage narrates the skipped kconfig review instead of warning about it. Key contract: @@ -2285,7 +2285,7 @@ Probes are sub-second: `rust:native` is `rustc --version`, `rust:cross:[ The clang probe additionally checks the `LLVM_LOCKSTEP_SUITE` for a pkgver skew, and reports it **structurally** on `ToolchainCheck.versions` — a tuple of `(label, installed_ver, target_ver)` rows where every version group is stated against the newest installed pkgver (resolved with `vercmp`, falling back to a lexical max if the pacman binary is unavailable, since a display helper must never abort a batch). `render_preflight` turns those rows into an indented `clang/lld: 22.1.5 → 22.1.6` table under the check's detail line, collapsing the already-current group to `llvm/llvm-libs: 22.1.6 (=)`. The probe reports facts; the renderer owns formatting. Checks with nothing version-shaped to say carry an empty `versions` and render exactly as before — a healthy suite is one detail line, not seven rows. -Auto-remediation: failures with `auto_remediable=True` (currently `rustup target add` only) get an interactive y/n prompt via `primitives.prompt.prompt_choice`; on accept the command is executed and the failed probe is re-run. Non-interactive runs (`--no-interactive`, `--noconfirm`, or no TTY) print the fix block and exit 1 instead. Per-package profile-resolution failures are caught and warned — preflight is best-effort and never blocks a build the real wrapper could still succeed at. +Auto-remediation: failures with `auto_remediable=True` (currently `rustup target add` only) get an interactive y/n prompt via `primitives.prompt.prompt_choice`; on accept the command is executed and the failed probe is re-run. A run that may not ask (`--unattended` or no TTY, via `prompt.is_interactive()`) prints the fix block and exits 1 instead. Per-package profile-resolution failures are caught and warned — preflight is best-effort and never blocks a build the real wrapper could still succeed at. Public API: `collect_required_toolchains(per_pkg, lib32_pkgs, rust_toolchain_pins=None, compilers=None)`, `run_preflight(required)`, `render_preflight(report)`, `auto_remediate(report, *, non_interactive=False)`. Wiring: `update` only, behind `--no-toolchain-preflight`. The companion `primitives.build_diag.diagnose` runs in `makepkg_wrapper.invoke_makepkg` on non-zero exit and matches known failure signatures (E0463 missing std crate, gstreamer PTP-no-rust, meson "Unknown options" → stale build dir, `cuda:host-gcc-too-new` → nvcc rejecting a system gcc newer than the CUDA toolkit supports, `toolchain:lib32-reduced-target` → a link-time `ld.lld: undefined symbol: LLVMInitialize…` against a lib32 libLLVM (matched with an `-m32` / `lib32/libLLVM` signal) — lib32-clang's offload tools referencing all-target init symbols the reduced lib32 libLLVM lacks, pointing at building lib32 with the full target set rather than a spurious version skew; `toolchain:lib32-clang-libgcc` → a lib32 (`-m32`) build where `ld.lld` can't find the 32-bit `libgcc_s` clang implicitly links (`unable to find library`/`cannot find -lgcc_s`, gated on an `-m32`/`lib32` signal so a 64-bit miss doesn't match), which CMake/meson surfaces as the compiler being "broken" even though clang is fine — points at a per-package `--cc gcc --cxx g++` override (gcc `-m32` links its own multilib libgcc); `toolchain:llvm-broken` → a clang/libLLVM mismatch where clang can't run, matched on `undefined symbol: LLVMInitialize…` / `symbol lookup error: …clang` / meson's `Unknown compiler(s): [['clang']]` (the lib32 reduced-target case is matched first and suppresses this generic one); `pkgconfig:version-gate` → meson's canonical `Dependency lookup for with method 'pkg-config' failed: Invalid version, need '' ['>= X'] found 'Y'` line, which is accurate but leaves the operator to work out by hand who owns the `.pc` and whether the repos can satisfy the floor at all — the matcher parses module/floor/found, resolves the owner of `.pc` via `pacman.owners_of` over `/usr/lib`, `/usr/share` and `/usr/lib32` pkgconfig dirs, and reports the repo version (`pacman.get_pacman_sync_version` + `version.vercmp` against the floor) plus whether an AUR `-git` exists (`aur.aur_info`). Every probe is best-effort — a failure degrades the message, never the diagnosis. It emits **no** `fix_cmd` by design: waiting for the repo bump and adopting the `-git` dep have materially different costs (the latter forfeits the distro's soname-rebuild guarantee, for which sysforge has no general equivalent), and a copy-pasteable command would pre-pick the riskier one; `deps:undeclared` → a dependency the PKGBUILD never declared, which the host carries but a clean (sandboxed) build environment lacks: pkgconf's `Package 'X' not found` (owner of `X.pc` resolved via the same `_pc_owner` probe, or a `pacman -F X.pc` lookup when the host lacks it too) and makepkg's `Cannot find the X package needed to handle Y sources`, naming the package to add to `makedepends` and flagging it as a PKGBUILD defect to report upstream; `cxx:module-identifier` → clang's `module declaration must not come from an #include directive` (P1857: a line-initial `module` identifier, e.g. a `module _module;` member, read as a C++20 module declaration, which GCC still accepts), pointing at the per-package gcc swap) in the captured output and any `meson-logs/meson-log.txt` **or `CMakeFiles/CMakeError.log`** under the build directory; deduped on signature, never masks the real error. The CUDA matcher reads the toolkit's `crt/host_config.h` `#if __GNUC__ > N` gate and the highest installed `/usr/bin/g++-≤N` to emit a concrete `NVCC_APPEND_FLAGS='-ccbin …'` fix. Each `FixSuggestion`'s `signature`/`fix_cmd` is also carried up the exception (`.diagnosis`) and persisted to `build_state.toml`'s `[failures]` table by `sysforge update` (see §`build_state.py`). **Interactive builds** inherit the TTY so makepkg's stdout is never captured; on failure `invoke_makepkg` instead runs `diagnose([], _effective_build_dir(...))` over the side-car logs (resolving `$BUILDDIR/` when `BUILDDIR` redirects the build out-of-tree) and threads the result through the user-abort RuntimeError, so `state failed` records a real signature rather than "Aborted by user". Log tags: `[PREFLIGHT]`, `[DIAG]`. @@ -2410,7 +2410,7 @@ High-level flow: 4. Import GPG keys via `aur.import_pgp_keys` (bundled `keys/pgp/*.asc` first, keyserver fallback) 5. Run pre-build soname dep analysis 6. If `source_built` or `kernel` mode: extract PKGBUILD flags, write extracted profile, apply patch -7. If `kernel` mode and not `interactive`: patch interactive kconfig targets in `PKGBUILD.sysforge` to `olddefconfig` +7. If `kernel` mode and the config review is off (`interactive=False`, from `resolve_config_review`): patch interactive kconfig targets in `PKGBUILD.sysforge` to `olddefconfig` 8. If `kernel` mode: detect effective CC; if clang, inject `LLVM=1 LLVM_IAS=1` into build env 9. Emit complete temp `makepkg.conf` (merged system conf + profile overrides; kernel mode omits `CFLAGS`/`CXXFLAGS`/`LDFLAGS`/`CPPFLAGS`/`DEBUG_*` profile overrides — system conf values preserved verbatim) 10. Resolve env vars for subprocess injection @@ -2978,7 +2978,6 @@ extends = "bare" CFLAGS = "-march=native -O2 -pipe" CXXFLAGS = "$CFLAGS" LDFLAGS = "-Wl,-O1,--sort-common,--as-needed,-z,relro,-z,now" -makepkg_flags = ["--noconfirm"] [profiles.optimized] extends = "standard" @@ -2997,7 +2996,7 @@ build_mode = "source_built" # the old token "patched_pkgbuild" raises since 4. extends = "bare" build_mode = "kernel" batch = true -makepkg_flags = ["--noconfirm", "-f", "-c"] +makepkg_flags = ["-f", "-c"] ``` ### `build_mode` vocabulary @@ -3449,7 +3448,7 @@ makedep_probe_failed = "warn_and_fallback" ### Interactive mode -`--interactive` on `sysforge build` does two things: it strips `--noconfirm` from the profile's `makepkg_flags`, and it runs makepkg under `pty_runner.run_with_pty(..., interactive=True)`, a two-way proxy in which the pty is makepkg's stdin, stdout, stderr and controlling terminal and the user's keystrokes are copied into it, inside `yield_terminal("child")`, instead of piping output through the line-classification loop. Before `3.2.0-F19` it inherited the parent's stdio outright; the proxy keeps everything that made that worthwhile. Passing every byte straight through is what keeps unbuffered prompts visible — pacman's conflict prompt (`Remove sysforge? [y/N]`) and similar `\r`/no-newline output reaches the terminal immediately rather than sitting in a pipe buffer until the user blindly presses Enter. The tradeoff is that line-based output classification (`failed_stage`, `missing_deps`, `toolchain_mismatch` auto-retry, stdout-match fallback for `AlreadyBuilt`, `captured_output` for `auto_repair`) is still bypassed in this branch, but every line reaches `invoke_makepkg`: the stream is teed (ANSI-stripped) into the per-package log through `log.write_pkg_log`, and on a failure the anchored tail (`select_failure_tail`) is repeated under `[BUILD]` at DEBUG, which lands in the unified run log and the per-package log (`_log_interactive_failure_tail`); exit-code-based detection (`returncode == 13` → `AlreadyBuilt`, `returncode == 8` → install failure) still fires. Useful during development to review makepkg prompts without editing the profile; not appropriate for `update` batch flows, which depend on the classification path and therefore default `interactive=False`. +Whether makepkg may prompt is the run's decision, not the profile's (`3.5.0-F20`): `makepkg_invoke._base_flags` puts `--noconfirm` in front of the profile's `makepkg_flags` for every batch build and leaves it off an interactive one, removing a copy a profile written before that still carries (so it is never doubled and never survives into an interactive run). The build sandbox needs no special case, since `makechrootpkg` passes `--noconfirm` itself. `--interactive` on `sysforge build` does two things: it runs makepkg without `--noconfirm`, and it runs makepkg under `pty_runner.run_with_pty(..., interactive=True)`, a two-way proxy in which the pty is makepkg's stdin, stdout, stderr and controlling terminal and the user's keystrokes are copied into it, inside `yield_terminal("child")`, instead of piping output through the line-classification loop. Before `3.2.0-F19` it inherited the parent's stdio outright; the proxy keeps everything that made that worthwhile. Passing every byte straight through is what keeps unbuffered prompts visible — pacman's conflict prompt (`Remove sysforge? [y/N]`) and similar `\r`/no-newline output reaches the terminal immediately rather than sitting in a pipe buffer until the user blindly presses Enter. The tradeoff is that line-based output classification (`failed_stage`, `missing_deps`, `toolchain_mismatch` auto-retry, stdout-match fallback for `AlreadyBuilt`, `captured_output` for `auto_repair`) is still bypassed in this branch, but every line reaches `invoke_makepkg`: the stream is teed (ANSI-stripped) into the per-package log through `log.write_pkg_log`, and on a failure the anchored tail (`select_failure_tail`) is repeated under `[BUILD]` at DEBUG, which lands in the unified run log and the per-package log (`_log_interactive_failure_tail`); exit-code-based detection (`returncode == 13` → `AlreadyBuilt`, `returncode == 8` → install failure) still fires. Useful during development to review makepkg prompts without editing the profile; not appropriate for `update` batch flows, which depend on the classification path and therefore default `interactive=False`. ### Toolchain-mismatch auto-retry @@ -3476,8 +3475,9 @@ exit 13 and fails hard instead (3.0.0-B9); `"review-gated"` (kernel stage) preserves the B5 semantics — the skipped build also skipped the promised in-prepare() kconfig review, so interactive runs get an install-as-built / rebuild-with-`-f` / abort prompt while unattended runs -proceed. The unattended arbitration (caller interactivity ∧ `--no-interactive` -∧ TTY) lives only in the seam. `makepkg_wrapper`'s own `except AlreadyBuilt` +proceed. The kernel caller passes its one resolved `config_review` +(`resolve_config_review`, which already folds in `review_config`, +`--no-review-config`, `--unattended` and TTY), so the seam re-derives nothing. `makepkg_wrapper`'s own `except AlreadyBuilt` (manifest capture for renamed builds) is a side-effect that fires regardless of policy, then re-raises. @@ -3630,7 +3630,7 @@ the full bundle swap (`_reemit_for_swap` → `profile.swap_toolchain`). [profiles.batch] extends = "standard" batch = true -makepkg_flags = ["--noconfirm", "--noprogressbar", "--log", "--cleanbuild"] +makepkg_flags = ["--noprogressbar", "--log", "--cleanbuild"] clean_builddir = true ``` diff --git a/completions/_sysforge b/completions/_sysforge index d433018b..b5c57647 100644 --- a/completions/_sysforge +++ b/completions/_sysforge @@ -15,6 +15,7 @@ _sysforge() { '--py-profile-out[write raw cProfile stats to FILE]:file:_files' \ '--timings[print phase wall-clock timing report after build/update]' \ '--color[colorize output]:when:(auto always never)' \ + '--unattended[never prompt, even at a terminal]' \ '--no-throttle[ignore the configured build throttle for this run]' \ '--turbo[raise build priority (stronger than --no-throttle)]' \ '--frozen[refuse all new source downloads for this run]' \ @@ -159,7 +160,7 @@ _sysforge_build() { _arguments \ $_sysforge_help_spec \ '(-m --makepkg)'{-m,--makepkg}'[extra makepkg flags, appended after profile flags]:flags' \ - '--interactive[strip --noconfirm from makepkg_flags]' \ + '--interactive[run makepkg without --noconfirm, attached to the terminal]' \ '--profile-conf[alternate profiles.toml]:file:_files' \ '--toolchain[build with this toolchain as a whole]:toolchain:(gcc llvm)' \ '(--reset-toolchain)--save-toolchain[also save --toolchain for these packages]' \ @@ -710,7 +711,7 @@ _sysforge_run_kernel() { '--no-update[skip the source sync, build as checked out]' \ '--cleansrc[purge the kernel src dir and re-clone]' \ '--cleansrc-force[--cleansrc, bypassing the dirty guard]' \ - '--no-interactive[use olddefconfig, no interactive kconfig]' \ + '--no-review-config[skip the kernel config review (olddefconfig)]' \ '--toolchain[build the kernel with this toolchain]:toolchain:(gcc llvm)' \ '--bootloader[override kernel.toml bootloader]:bootloader:(systemd-boot grub none)' \ '--base-config[override kernel.toml base_config]:source:_sysforge_base_config_source' \ diff --git a/completions/sysforge.bash b/completions/sysforge.bash index ef997e2b..5847b864 100644 --- a/completions/sysforge.bash +++ b/completions/sysforge.bash @@ -58,7 +58,7 @@ _sysforge() { if [[ -z $verb ]]; then if [[ $cur == -* ]]; then - COMPREPLY=( $(compgen -W "-h --help -v --verbose --quiet --py-profile --py-profile-out --timings --color --no-throttle --turbo --frozen --no-frozen --thaw" -- "$cur") ) + COMPREPLY=( $(compgen -W "-h --help -v --verbose --quiet --py-profile --py-profile-out --timings --color --unattended --no-throttle --turbo --frozen --no-frozen --thaw" -- "$cur") ) else COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) fi @@ -465,7 +465,7 @@ _sysforge_run() { kernel) [[ $cur == -* ]] && COMPREPLY=( $(compgen -W "\ --dry-run --no-update --cleansrc --cleansrc-force \ - --no-interactive --toolchain --bootloader --base-config \ + --no-review-config --toolchain --bootloader --base-config \ --base-config-merge \ --allow-no-fallback --allow-boot-audit-failure \ --headers --no-headers --docs --no-docs \ diff --git a/docs/design/05-package-manifest.md b/docs/design/05-package-manifest.md index bf87fc7c..fdab0f4e 100644 --- a/docs/design/05-package-manifest.md +++ b/docs/design/05-package-manifest.md @@ -82,7 +82,7 @@ An entry with only `name` and no override fields has no effect on the build. `sy ```toml [group.cosmic] -packages = ["cosmic-session-git", "cosmic-comp-git", "cosmic-settings-git"] +packages = ["cosmic-session-git", "cosmic-comp-git", "cosmic-settings-git", ...] # Optional defaults inherited by every member: # enable_build_from_source = true ``` diff --git a/docs/design/07-pipeline-layer.md b/docs/design/07-pipeline-layer.md index 9ed5f16a..ecbdfac3 100644 --- a/docs/design/07-pipeline-layer.md +++ b/docs/design/07-pipeline-layer.md @@ -347,7 +347,7 @@ The name always reflects what was *applied*: a Propeller → AutoFDO fallback in **Headers/docs subpackages (`build_headers` / `build_docs`):** the kernel image is always built; the optional `-headers` and `-docs` subpackages are toggled independently. `build_headers` defaults **on** (DKMS and any out-of-tree module build need the matching kernel headers to compile); `build_docs` defaults **off** (the docs subpackage is slow to package and rarely needed) — note this drops the `-docs` subpackage that a stock kernel PKGBUILD would otherwise build. Resolution per toggle: CLI flag (`--headers`/`--no-headers`, `--docs`/`--no-docs`, `argparse.BooleanOptionalAction`, default unset → `None`) > `kernel.toml` (`build_headers`/`build_docs`) > hard default. The single home for the resolve is `resolve_subpackages`. Disabling a subpackage works by **dropping its entry from the PKGBUILD's `pkgname` array** — `pkgbuild_patcher.patch_kernel_subpackages` (called from `makepkg_wrapper` for every kernel build, after `patch_kernel_btf_guard`) removes any array token whose dequoted value ends with `-headers`/`-docs`. It rewrites **both** the initial `pkgname=(...)` literal **and** every `pkgname+=(...)` append — modern Arch kernel PKGBUILDs add the optional subpackages via `pkgname+=("$pkgbase-docs")`, which a single-array walk would miss (leaving `-docs` to build and install; B7); an append that existed solely to add now-dropped subpackage(s) has its whole statement line removed rather than leaving an empty `pkgname+=()`. Standard Arch kernels synthesize each `package_$_p()` via `for _p in "${pkgname[@]}"; do eval …`, so an entry absent from the array is never packaged; the `_package-headers()`/`_package-docs()` helper bodies are left untouched. **Disabling docs additionally neutralizes the doc *build*:** stock Arch `linux` builds the documentation in `build()` (`make htmldocs SPHINXOPTS=-QT`), not only in `_package-docs()`, so dropping the `-docs` pkgname token stops packaging but not the (often multi-minute Sphinx) compile — which would make the summary's `docs=off` a lie. When `build_docs` is off the same patcher neutralizes any doc-target make line (`_neutralize_kernel_doc_build`, two passes over the goals — htmldocs/pdfdocs/infodocs/mandocs/…): a **mixed** line such as `make all htmldocs` loses *only* its `*docs` goals (→ `make all`) so the real build survives, while a line whose goals are **exclusively** `*docs` (with optional `VAR=val`/flag tokens) is commented out whole with a `# sysforge(docs off):` prefix. Both passes treat shell control operators and redirections (`&`, `;`, `|`, `&&`, `>…`) as punctuation the shell owns, never as make goals — stock Arch `linux` *backgrounds* its doc build (`make htmldocs SPHINXOPTS=-QT &`), and counting the trailing `&` as a surviving real goal made an exclusive-doc line look mixed: only `htmldocs` was stripped, leaving a goalless `make &` that resolves to make's **default** goal (`all`) and runs a second kernel build concurrently with the real one, killing `build()` with `E_USER_FUNCTION_FAILED` (B15). A backgrounded exclusive-doc line is additionally rewritten to `true &` rather than merely commented: the following line is invariably `local pid_docs=$!` and the function later `wait`s on that pid, so removing the only background job would leave the capture reading an unset or stale `$!` — and a `wait` on a non-child exits non-zero, fatal under makepkg's errexit. A trivial background job keeps `$!` valid and `wait` at 0, with the original command preserved in the trailing `# sysforge(docs off):` comment. The patcher needs no sentinel (every edit is self-idempotent — a commented line no longer starts with `make`, a stripped/removed token no longer matches), is a no-op when both subpackages are kept or the targeted subpackage is absent, and preserves both the single-line and one-token-per-line array layouts. When `build_headers` is disabled, Gate 1 escalates the standing DKMS reminder into a hard warning: no `-headers` will be installed, so out-of-tree and DKMS modules (nvidia-open-dkms → black screen) cannot rebuild and won't load on reboot; any present DKMS modules are named. -**Resolution summary.** After resolving compiler (+ its origin), variant, bootloader (+ whether the chosen one is detected installed), source, interactive mode, kconfig counts, the headers/docs subpackage toggles (`subpkgs:` line), and the boot-safety gate settings, the stage emits a single labelled "Kernel build plan:" block (`log_resolution_summary`). It prints on every run (useful before a multi-hour build) and is the readable core of `--dry-run`, replacing decisions previously scattered across the log. The standalone interactive default also emits a one-line nudge pointing at `--no-interactive` for unattended runs. +**Resolution summary.** After resolving compiler (+ its origin), variant, bootloader (+ whether the chosen one is detected installed), source, config review, kconfig counts, the headers/docs subpackage toggles (`subpkgs:` line), and the boot-safety gate settings, the stage emits a single labelled "Kernel build plan:" block (`log_resolution_summary`). It prints on every run (useful before a multi-hour build) and is the readable core of `--dry-run`, replacing decisions previously scattered across the log. A run that opens the config review also emits a one-line nudge pointing at `--no-review-config`. **Variant-inheritance nudge.** When `compiler` is unset (neither CLI nor `kernel.toml`) and the toolchain-stage variant is `pgo_llvm`, the stage emits a WARN naming the inherited variant and recommending that the operator persist `compiler = "llvm"` in `kernel.toml` so the choice survives a future toolchain-stage disable (which clears `[stages.toolchain.result]`). `stock_llvm` gets the same nudge at INFO level. `gcc` and `system` variants are silent — gcc is the safe default and `system` means there's no opinion to project. @@ -359,7 +359,7 @@ The name always reflects what was *applied*: a Propeller → AutoFDO fallback in **Pkgname/pkgbase consistency check.** After the source sync, the stage static-parses the PKGBUILD via `parse_pkgbuild` and confirms the parsed `pkgbase` (or `pkgname` for non-split packages) matches the *pre-rename* name of the tree — `upstream_pkgname` when tracking an upstream, else `pkgname` (the local rename is a patch applied later in `makepkg_wrapper`, so the on-disk PKGBUILD always carries the upstream name). A typo or a cloned PKGBUILD whose `pkgbase` has drifted from the directory name raises a clear `RuntimeError` at stage entry instead of failing late at `makepkg --install` after a multi-hour build. -**Pkgname repo-collision check.** Immediately after the consistency check, the stage tests `kernel.toml pkgname` against the pacman sync DBs via `aur.is_repo_package` (one `pacman -Si`). A custom kernel should carry a unique name; if the name matches an official package (e.g. `linux`, `linux-lts`), building and installing it would overwrite the stock package on `pacman -U`. Interactive runs prompt for confirmation (`prompt_choice`, default no); unattended runs (`--no-interactive` or no TTY) abort; `--dry-run` warns without prompting. +**Pkgname repo-collision check.** Immediately after the consistency check, the stage tests `kernel.toml pkgname` against the pacman sync DBs via `aur.is_repo_package` (one `pacman -Si`). A custom kernel should carry a unique name; if the name matches an official package (e.g. `linux`, `linux-lts`), building and installing it would overwrite the stock package on `pacman -U`. A run that may ask (`prompt.is_interactive()`) prompts for confirmation (`prompt_choice`, default no); one that may not (`--unattended` or no TTY) aborts, naming the reason; `--dry-run` warns without prompting. **kconfig fragment:** @@ -403,7 +403,7 @@ The resolved sequence is threaded as `BuildOptions.kconfig_targets` (kernel.py **Interactive kconfig (kernel-stage default):** -`sysforge run kernel` is interactive by default — the kernel stage passes `interactive=True` into `BuildOptions`. On the interactive path the caller contributes `kconfig_plan.review_step()` to the `REVIEW` slot when no configured review target already fills it, rendering a TTY-guarded pause followed by `make nconfig` into the PKGBUILD's `prepare()` (in the `POST` region, after the seed/merge/generate/hotplug slots) so the user reviews and edits the resolved config before the build proceeds — sysforge supplies the interactive target itself rather than depending on the PKGBUILD having one (a stock PKGBUILD that only runs `make olddefconfig` would otherwise show no menu). `KconfigPlan.install`'s drop rules suppress the injected `REVIEW` when the PKGBUILD already has an interactive target of its own — no second `nconfig`. The makepkg subprocess inherits the parent TTY in interactive mode (`makepkg_invoke`), so the ncurses UI renders on the controlling terminal. The pause is part of `review_step`'s rendered lines, immediately before `make nconfig` — the only point that is genuinely *after all merges, before the editor* (B6). A stage-level pause before `makepkg` necessarily fired before those in-`prepare()` merges ran, so the operator confirmed before the config was even assembled; the injected pause (`if [ -t 0 ]; then read -rp … || true; fi`) instead lets them read the merged result just before `nconfig` opens. It is TTY-guarded (inert on `--no-interactive`/no-TTY/captured-stdin/`--dry-run` paths) and errexit-safe under makepkg's `set -e` (`read` returning non-zero on EOF is swallowed by `|| true`; the `if` yields 0), and `Ctrl-C` still aborts. It is rendered by **both** review-slot builders: the injected `review_step()` and — as of F22 — a *configured* `kconfig_targets` UI tail (`ui_target_step`), whose prompt names the configured target rather than a hardcoded `nconfig`. The pause originally shipped only with the injected `nconfig`, reasoning that a target the operator named in `kernel.toml` needed no confirmation; that was wrong, because the pause is not a confirmation of the *target* but the operator's checkpoint on the assembled `.config`, equally wanted however the review target got there — a `kconfig_targets = ["olddefconfig", "nconfig"]` config otherwise dropped straight into the menu with no chance to inspect the merge result. A PKGBUILD supplying its own interactive target still keeps it with no pause: no plan step renders that line, so there is nothing to attach a pause to. The default can be flipped via `kernel.toml interactive = false` or the `--no-interactive` CLI flag; `install(noninteractive=True)` then drops the injected `REVIEW` slot outright (or rewrites a *configured* UI tail to `olddefconfig` via its `noninteractive_rewrite`, per `ui_target_step`), and separately rewrites any surviving PKGBUILD-owned interactive target (`oldconfig`, `nconfig`, `menuconfig`, `xconfig`, `gconfig`) to `make olddefconfig` for unattended runs. `olddefconfig` applies defaults for all new symbols without terminal interaction; VAR=val arguments before the target (e.g. `ARCH=x86_64`) and trailing comments are preserved. `--noconfirm` only controls makepkg's own prompts and has no effect on interactive make targets inside the PKGBUILD. +**Two questions, one answer each (`3.5.0-F22`).** The stage asks two separate things, and before F22 it derived both from one overloaded word in four places with different formulas: *may sysforge ask its own questions?* — the repo-collision and diverged-source confirmations and the stale-sentinel recovery — is `prompt.is_interactive()` alone (a terminal, and not `--unattended`); *does this run open the PKGBUILD's config UI?* is `kernel/config.py::resolve_config_review`, the one home for `kernel.toml review_config` (was `interactive`; `KernelConfig.from_toml` still reads the old key with a warning, and `config check`/`clean` migrate it via `RETIRED_KEYS`) ∧ ¬`--no-review-config` ∧ `prompt.is_interactive()`. Its answer, `config_review`, feeds the kconfig rewrite, `kconfig_targets` validation, the makepkg terminal attachment, the AlreadyBuilt arbitration and the kernel install's `noconfirm`; nothing re-derives it. So `--no-review-config` only skips the UI — the safety questions are still asked — while `--unattended` turns off both. `sysforge run kernel` opens the config review by default — the kernel stage passes `interactive=config_review` into `BuildOptions`. On the interactive path the caller contributes `kconfig_plan.review_step()` to the `REVIEW` slot when no configured review target already fills it, rendering a TTY-guarded pause followed by `make nconfig` into the PKGBUILD's `prepare()` (in the `POST` region, after the seed/merge/generate/hotplug slots) so the user reviews and edits the resolved config before the build proceeds — sysforge supplies the interactive target itself rather than depending on the PKGBUILD having one (a stock PKGBUILD that only runs `make olddefconfig` would otherwise show no menu). `KconfigPlan.install`'s drop rules suppress the injected `REVIEW` when the PKGBUILD already has an interactive target of its own — no second `nconfig`. The makepkg subprocess inherits the parent TTY in interactive mode (`makepkg_invoke`), so the ncurses UI renders on the controlling terminal. The pause is part of `review_step`'s rendered lines, immediately before `make nconfig` — the only point that is genuinely *after all merges, before the editor* (B6). A stage-level pause before `makepkg` necessarily fired before those in-`prepare()` merges ran, so the operator confirmed before the config was even assembled; the injected pause (`if [ -t 0 ]; then read -rp … || true; fi`) instead lets them read the merged result just before `nconfig` opens. It is TTY-guarded (inert on `--no-review-config`/`--unattended`/no-TTY/captured-stdin/`--dry-run` paths) and errexit-safe under makepkg's `set -e` (`read` returning non-zero on EOF is swallowed by `|| true`; the `if` yields 0), and `Ctrl-C` still aborts. It is rendered by **both** review-slot builders: the injected `review_step()` and — as of F22 — a *configured* `kconfig_targets` UI tail (`ui_target_step`), whose prompt names the configured target rather than a hardcoded `nconfig`. The pause originally shipped only with the injected `nconfig`, reasoning that a target the operator named in `kernel.toml` needed no confirmation; that was wrong, because the pause is not a confirmation of the *target* but the operator's checkpoint on the assembled `.config`, equally wanted however the review target got there — a `kconfig_targets = ["olddefconfig", "nconfig"]` config otherwise dropped straight into the menu with no chance to inspect the merge result. A PKGBUILD supplying its own interactive target still keeps it with no pause: no plan step renders that line, so there is nothing to attach a pause to. The default can be flipped via `kernel.toml review_config = false` or the `--no-review-config` CLI flag (and is off on any run that may not ask); `install(noninteractive=True)` then drops the injected `REVIEW` slot outright (or rewrites a *configured* UI tail to `olddefconfig` via its `noninteractive_rewrite`, per `ui_target_step`), and separately rewrites any surviving PKGBUILD-owned interactive target (`oldconfig`, `nconfig`, `menuconfig`, `xconfig`, `gconfig`) to `make olddefconfig` for unattended runs. `olddefconfig` applies defaults for all new symbols without terminal interaction; VAR=val arguments before the target (e.g. `ARCH=x86_64`) and trailing comments are preserved. `--noconfirm` only controls makepkg's own prompts and has no effect on interactive make targets inside the PKGBUILD. Note: when other verbs (`sysforge build`, `sysforge update`) build a kernel PKGBUILD with `build_mode = "kernel"` on the resolved profile, those paths still default to *non-interactive* — interactive-by-default is a kernel-stage-only contract because the stage is the user-driven kernel build entry point. @@ -413,7 +413,7 @@ Note: when other verbs (`sysforge build`, `sysforge update`) build a kernel PKGB The kernel stage routes its source refresh through `source_sync.get_scheduler().request(SyncRequest(..., source=))` ahead of the build, the same path as the toolchain stage — and (F40) *before* `pkgbuild_path` requires the PKGBUILD, so a missing tree is bootstrapped by the scheduler's clone-if-missing path. With `source = "local"` (explicit or auto-resolved), the scheduler short-circuits (no RPC, no clone, no fetch) — only `--cleansrc` / `--cleansrc-force` would attempt a purge, but a hand-maintained tree has no remote to re-clone from, so users on the `local` path leave cleansrc unset. For `source = "repo"` / `"aur"`, the normal sync runs: `--cleansrc` purges and re-clones (refusing on dirty/ahead/no-upstream clones); `--cleansrc-force` overrides that guard; cleansrc forces a sync even when `--no-update` is also set. `STATUS_FAILED` / `STATUS_RATE_LIMITED` / `STATUS_PURGE_REFUSED` raise. -`STATUS_DIVERGED` (upstream advanced but the local tree can't fast-forward — local commits or a dirty tree) gets stronger handling in the *kernel* stage than the plain warning the other verbs use, because building a kernel off stale or hand-edited source is exactly the easy-to-miss footgun. `warn_and_confirm_diverged` enriches the WARN with ahead/behind counts (`classify_head_vs_upstream`) so the "upstream has new commits but the local repo is dirty" case is spelled out, then **gates the build**: an interactive run must confirm (`prompt_choice`, default no), and an unattended run (`--no-interactive` or no TTY) aborts. Either decline raises, leaving nothing built (the sync runs before the sentinel). `--cleansrc` to discard local edits is the suggested escape hatch. +`STATUS_DIVERGED` (upstream advanced but the local tree can't fast-forward — local commits or a dirty tree) gets stronger handling in the *kernel* stage than the plain warning the other verbs use, because building a kernel off stale or hand-edited source is exactly the easy-to-miss footgun. `warn_and_confirm_diverged` enriches the WARN with ahead/behind counts (`classify_head_vs_upstream`) so the "upstream has new commits but the local repo is dirty" case is spelled out, then **gates the build**: a run that may ask must confirm (`prompt_choice`, default no), and one that may not (`--unattended` or no TTY) aborts; `--no-review-config` does not change this. Either decline raises, leaving nothing built (the sync runs before the sentinel). `--cleansrc` to discard local edits is the suggested escape hatch. The source sync (including the `--cleansrc` purge, which `purge_src` does as a non-atomic `shutil.rmtree`) runs **outside** the boot sentinel by design: it mutates only the src tree, nothing boot-critical, so wrapping it in the sentinel — whose `recovery_cmd` is `sudo mkinitcpio -P` — would be semantically wrong. The atomicity contract is "purge, then clone"; an interrupted purge leaves a missing/partial PKGBUILD that fails **loudly** at `pkgbuild_path` on the next run (with a hint to re-run `--cleansrc` to re-clone), not a silent brick. No sentinel is needed because the running kernel was never touched. @@ -466,7 +466,7 @@ To make a *pre-install* hard-fail possible, the build is **split from the instal **CLI surface (`sysforge run kernel`):** -`--dry-run`, `--no-update`, `--cleansrc`, `--cleansrc-force`, `--no-interactive`, `--toolchain {gcc,llvm}`, `--bootloader {systemd-boot,grub,none}`, `--base-config {pkgbuild,running,}`, `--allow-no-fallback`, `--allow-boot-audit-failure`, `--headers`/`--no-headers`, `--docs`/`--no-docs`, `--no-pkg-log`, `--persist-log`, `--log-dir`, `--cache-report`, `--abi-check`, `--state-dir`, `--profile-conf`. +`--dry-run`, `--no-update`, `--cleansrc`, `--cleansrc-force`, `--no-review-config`, `--toolchain {gcc,llvm}`, `--bootloader {systemd-boot,grub,none}`, `--base-config {pkgbuild,running,}`, `--allow-no-fallback`, `--allow-boot-audit-failure`, `--headers`/`--no-headers`, `--docs`/`--no-docs`, `--no-pkg-log`, `--persist-log`, `--log-dir`, `--cache-report`, `--abi-check`, `--state-dir`, `--profile-conf`. **Post-install steps** (run after the artifact is installed): 1. `sudo mkinitcpio -P` diff --git a/docs/design/08-cli-verb-framework.md b/docs/design/08-cli-verb-framework.md index 2518a3f6..23bf962d 100644 --- a/docs/design/08-cli-verb-framework.md +++ b/docs/design/08-cli-verb-framework.md @@ -49,7 +49,7 @@ spelling per meaning, across every verb: `state profiles --delete STORE`), with `--yes` to skip its prompt. `artifact remove --purge` keeps the package-manager sense of also deleting the managed copy. -- **A negative switch is `--no-`** (`--no-update`, `--no-interactive`), +- **A negative switch is `--no-`** (`--no-update`, `--no-review-config`), or a `--/--no-` pair (`BooleanOptionalAction`) when config can default it either way (`--headers/--no-headers`). Never `--non-…`/`--disable-…`. - **No two flags differ only by a plural `s`** (`--no-pkg-log`, not diff --git a/docs/design/09-primitives-layer.md b/docs/design/09-primitives-layer.md index 3e5c00c2..5deada88 100644 --- a/docs/design/09-primitives-layer.md +++ b/docs/design/09-primitives-layer.md @@ -65,7 +65,7 @@ Integration sites: `sysforge/verbs/runner.py::run_verb` (universal `: star ### `prompt.py` -Single shared interactive-prompt helper. Every stage that needs user input goes through `prompt.prompt_text()` (free-form), `prompt.prompt_choice()` (fixed-set), or `prompt.prompt_key()` (single keypress), so the behaviour on empty input, unrecognized input, and EOF is consistent across the codebase. `is_interactive()` wraps `sys.stdin.isatty()` for stages that need to gate prompts on a TTY. +Single shared interactive-prompt helper. Every stage that needs user input goes through `prompt.prompt_text()` (free-form), `prompt.prompt_choice()` (fixed-set), or `prompt.prompt_key()` (single keypress), so the behaviour on empty input, unrecognized input, and EOF is consistent across the codebase. `is_interactive()` wraps `sys.stdin.isatty()` for stages that need to gate prompts on a TTY, and is the only stdin TTY check in the package (`tests/test_unattended.py` fails on another). **Unattended runs** (`3.5.0-F21`): `set_unattended()` is called once by `cli._main` from `cli._resolve_unattended` (`--unattended` > `SYSFORGE_UNATTENDED`, which also turns a configured on off for one run > `[ui] unattended` > off). While set, `is_interactive()` is false even at a terminal and the three helpers take their EOF path without reading (`prompt_text`/`prompt_choice` return `eof_default`, `prompt_key` raises `EOFError`), so the run behaves exactly as one with stdin closed, including at a call site that never checked `is_interactive()`. It changes *whether sysforge may ask*, nothing else: it is not a global `--yes` (a removal still refuses without one; the review gate still refuses an unreviewed change rather than accepting it), and it does not touch the run-level `--interactive` flags of `build`/`update`, which decide whether makepkg and pacman get the terminal. sudo reads the terminal itself, so the EOF path cannot reach its password prompt: `privilege.ensure_credentials()` returns `False` without running the prompting `sudo -v` (cached credentials or `NOPASSWD` still pass the `sudo -n true` probe), and `privilege.privileged_argv()` adds `-n` to every escalation, so one that skipped `authenticate()` fails fast instead of waiting for a password. Refuse/skip messages take their reason from `no_prompt_reason()` (`"unattended run"` or `"no terminal"`). `unattended()` distinguishes a chosen unattended run from a missing TTY, so the kernel stage narrates the skipped kconfig review instead of warning about it. Key contract: @@ -390,7 +390,7 @@ Probes are sub-second: `rust:native` is `rustc --version`, `rust:cross:[ The clang probe additionally checks the `LLVM_LOCKSTEP_SUITE` for a pkgver skew, and reports it **structurally** on `ToolchainCheck.versions` — a tuple of `(label, installed_ver, target_ver)` rows where every version group is stated against the newest installed pkgver (resolved with `vercmp`, falling back to a lexical max if the pacman binary is unavailable, since a display helper must never abort a batch). `render_preflight` turns those rows into an indented `clang/lld: 22.1.5 → 22.1.6` table under the check's detail line, collapsing the already-current group to `llvm/llvm-libs: 22.1.6 (=)`. The probe reports facts; the renderer owns formatting. Checks with nothing version-shaped to say carry an empty `versions` and render exactly as before — a healthy suite is one detail line, not seven rows. -Auto-remediation: failures with `auto_remediable=True` (currently `rustup target add` only) get an interactive y/n prompt via `primitives.prompt.prompt_choice`; on accept the command is executed and the failed probe is re-run. Non-interactive runs (`--no-interactive`, `--noconfirm`, or no TTY) print the fix block and exit 1 instead. Per-package profile-resolution failures are caught and warned — preflight is best-effort and never blocks a build the real wrapper could still succeed at. +Auto-remediation: failures with `auto_remediable=True` (currently `rustup target add` only) get an interactive y/n prompt via `primitives.prompt.prompt_choice`; on accept the command is executed and the failed probe is re-run. A run that may not ask (`--unattended` or no TTY, via `prompt.is_interactive()`) prints the fix block and exits 1 instead. Per-package profile-resolution failures are caught and warned — preflight is best-effort and never blocks a build the real wrapper could still succeed at. Public API: `collect_required_toolchains(per_pkg, lib32_pkgs, rust_toolchain_pins=None, compilers=None)`, `run_preflight(required)`, `render_preflight(report)`, `auto_remediate(report, *, non_interactive=False)`. Wiring: `update` only, behind `--no-toolchain-preflight`. The companion `primitives.build_diag.diagnose` runs in `makepkg_wrapper.invoke_makepkg` on non-zero exit and matches known failure signatures (E0463 missing std crate, gstreamer PTP-no-rust, meson "Unknown options" → stale build dir, `cuda:host-gcc-too-new` → nvcc rejecting a system gcc newer than the CUDA toolkit supports, `toolchain:lib32-reduced-target` → a link-time `ld.lld: undefined symbol: LLVMInitialize…` against a lib32 libLLVM (matched with an `-m32` / `lib32/libLLVM` signal) — lib32-clang's offload tools referencing all-target init symbols the reduced lib32 libLLVM lacks, pointing at building lib32 with the full target set rather than a spurious version skew; `toolchain:lib32-clang-libgcc` → a lib32 (`-m32`) build where `ld.lld` can't find the 32-bit `libgcc_s` clang implicitly links (`unable to find library`/`cannot find -lgcc_s`, gated on an `-m32`/`lib32` signal so a 64-bit miss doesn't match), which CMake/meson surfaces as the compiler being "broken" even though clang is fine — points at a per-package `--cc gcc --cxx g++` override (gcc `-m32` links its own multilib libgcc); `toolchain:llvm-broken` → a clang/libLLVM mismatch where clang can't run, matched on `undefined symbol: LLVMInitialize…` / `symbol lookup error: …clang` / meson's `Unknown compiler(s): [['clang']]` (the lib32 reduced-target case is matched first and suppresses this generic one); `pkgconfig:version-gate` → meson's canonical `Dependency lookup for with method 'pkg-config' failed: Invalid version, need '' ['>= X'] found 'Y'` line, which is accurate but leaves the operator to work out by hand who owns the `.pc` and whether the repos can satisfy the floor at all — the matcher parses module/floor/found, resolves the owner of `.pc` via `pacman.owners_of` over `/usr/lib`, `/usr/share` and `/usr/lib32` pkgconfig dirs, and reports the repo version (`pacman.get_pacman_sync_version` + `version.vercmp` against the floor) plus whether an AUR `-git` exists (`aur.aur_info`). Every probe is best-effort — a failure degrades the message, never the diagnosis. It emits **no** `fix_cmd` by design: waiting for the repo bump and adopting the `-git` dep have materially different costs (the latter forfeits the distro's soname-rebuild guarantee, for which sysforge has no general equivalent), and a copy-pasteable command would pre-pick the riskier one; `deps:undeclared` → a dependency the PKGBUILD never declared, which the host carries but a clean (sandboxed) build environment lacks: pkgconf's `Package 'X' not found` (owner of `X.pc` resolved via the same `_pc_owner` probe, or a `pacman -F X.pc` lookup when the host lacks it too) and makepkg's `Cannot find the X package needed to handle Y sources`, naming the package to add to `makedepends` and flagging it as a PKGBUILD defect to report upstream; `cxx:module-identifier` → clang's `module declaration must not come from an #include directive` (P1857: a line-initial `module` identifier, e.g. a `module _module;` member, read as a C++20 module declaration, which GCC still accepts), pointing at the per-package gcc swap) in the captured output and any `meson-logs/meson-log.txt` **or `CMakeFiles/CMakeError.log`** under the build directory; deduped on signature, never masks the real error. The CUDA matcher reads the toolkit's `crt/host_config.h` `#if __GNUC__ > N` gate and the highest installed `/usr/bin/g++-≤N` to emit a concrete `NVCC_APPEND_FLAGS='-ccbin …'` fix. Each `FixSuggestion`'s `signature`/`fix_cmd` is also carried up the exception (`.diagnosis`) and persisted to `build_state.toml`'s `[failures]` table by `sysforge update` (see §`build_state.py`). **Interactive builds** inherit the TTY so makepkg's stdout is never captured; on failure `invoke_makepkg` instead runs `diagnose([], _effective_build_dir(...))` over the side-car logs (resolving `$BUILDDIR/` when `BUILDDIR` redirects the build out-of-tree) and threads the result through the user-abort RuntimeError, so `state failed` records a real signature rather than "Aborted by user". Log tags: `[PREFLIGHT]`, `[DIAG]`. @@ -515,7 +515,7 @@ High-level flow: 4. Import GPG keys via `aur.import_pgp_keys` (bundled `keys/pgp/*.asc` first, keyserver fallback) 5. Run pre-build soname dep analysis 6. If `source_built` or `kernel` mode: extract PKGBUILD flags, write extracted profile, apply patch -7. If `kernel` mode and not `interactive`: patch interactive kconfig targets in `PKGBUILD.sysforge` to `olddefconfig` +7. If `kernel` mode and the config review is off (`interactive=False`, from `resolve_config_review`): patch interactive kconfig targets in `PKGBUILD.sysforge` to `olddefconfig` 8. If `kernel` mode: detect effective CC; if clang, inject `LLVM=1 LLVM_IAS=1` into build env 9. Emit complete temp `makepkg.conf` (merged system conf + profile overrides; kernel mode omits `CFLAGS`/`CXXFLAGS`/`LDFLAGS`/`CPPFLAGS`/`DEBUG_*` profile overrides — system conf values preserved verbatim) 10. Resolve env vars for subprocess injection diff --git a/docs/design/10-flag-profile-system.md b/docs/design/10-flag-profile-system.md index aa34a921..916b5c14 100644 --- a/docs/design/10-flag-profile-system.md +++ b/docs/design/10-flag-profile-system.md @@ -18,7 +18,6 @@ extends = "bare" CFLAGS = "-march=native -O2 -pipe" CXXFLAGS = "$CFLAGS" LDFLAGS = "-Wl,-O1,--sort-common,--as-needed,-z,relro,-z,now" -makepkg_flags = ["--noconfirm"] [profiles.optimized] extends = "standard" @@ -37,7 +36,7 @@ build_mode = "source_built" # the old token "patched_pkgbuild" raises since 4. extends = "bare" build_mode = "kernel" batch = true -makepkg_flags = ["--noconfirm", "-f", "-c"] +makepkg_flags = ["-f", "-c"] ``` ### `build_mode` vocabulary diff --git a/docs/design/11-makepkg-wrapper.md b/docs/design/11-makepkg-wrapper.md index 4879570a..2464f405 100644 --- a/docs/design/11-makepkg-wrapper.md +++ b/docs/design/11-makepkg-wrapper.md @@ -68,7 +68,7 @@ makedep_probe_failed = "warn_and_fallback" ### Interactive mode -`--interactive` on `sysforge build` does two things: it strips `--noconfirm` from the profile's `makepkg_flags`, and it runs makepkg under `pty_runner.run_with_pty(..., interactive=True)`, a two-way proxy in which the pty is makepkg's stdin, stdout, stderr and controlling terminal and the user's keystrokes are copied into it, inside `yield_terminal("child")`, instead of piping output through the line-classification loop. Before `3.2.0-F19` it inherited the parent's stdio outright; the proxy keeps everything that made that worthwhile. Passing every byte straight through is what keeps unbuffered prompts visible — pacman's conflict prompt (`Remove sysforge? [y/N]`) and similar `\r`/no-newline output reaches the terminal immediately rather than sitting in a pipe buffer until the user blindly presses Enter. The tradeoff is that line-based output classification (`failed_stage`, `missing_deps`, `toolchain_mismatch` auto-retry, stdout-match fallback for `AlreadyBuilt`, `captured_output` for `auto_repair`) is still bypassed in this branch, but every line reaches `invoke_makepkg`: the stream is teed (ANSI-stripped) into the per-package log through `log.write_pkg_log`, and on a failure the anchored tail (`select_failure_tail`) is repeated under `[BUILD]` at DEBUG, which lands in the unified run log and the per-package log (`_log_interactive_failure_tail`); exit-code-based detection (`returncode == 13` → `AlreadyBuilt`, `returncode == 8` → install failure) still fires. Useful during development to review makepkg prompts without editing the profile; not appropriate for `update` batch flows, which depend on the classification path and therefore default `interactive=False`. +Whether makepkg may prompt is the run's decision, not the profile's (`3.5.0-F20`): `makepkg_invoke._base_flags` puts `--noconfirm` in front of the profile's `makepkg_flags` for every batch build and leaves it off an interactive one, removing a copy a profile written before that still carries (so it is never doubled and never survives into an interactive run). The build sandbox needs no special case, since `makechrootpkg` passes `--noconfirm` itself. `--interactive` on `sysforge build` does two things: it runs makepkg without `--noconfirm`, and it runs makepkg under `pty_runner.run_with_pty(..., interactive=True)`, a two-way proxy in which the pty is makepkg's stdin, stdout, stderr and controlling terminal and the user's keystrokes are copied into it, inside `yield_terminal("child")`, instead of piping output through the line-classification loop. Before `3.2.0-F19` it inherited the parent's stdio outright; the proxy keeps everything that made that worthwhile. Passing every byte straight through is what keeps unbuffered prompts visible — pacman's conflict prompt (`Remove sysforge? [y/N]`) and similar `\r`/no-newline output reaches the terminal immediately rather than sitting in a pipe buffer until the user blindly presses Enter. The tradeoff is that line-based output classification (`failed_stage`, `missing_deps`, `toolchain_mismatch` auto-retry, stdout-match fallback for `AlreadyBuilt`, `captured_output` for `auto_repair`) is still bypassed in this branch, but every line reaches `invoke_makepkg`: the stream is teed (ANSI-stripped) into the per-package log through `log.write_pkg_log`, and on a failure the anchored tail (`select_failure_tail`) is repeated under `[BUILD]` at DEBUG, which lands in the unified run log and the per-package log (`_log_interactive_failure_tail`); exit-code-based detection (`returncode == 13` → `AlreadyBuilt`, `returncode == 8` → install failure) still fires. Useful during development to review makepkg prompts without editing the profile; not appropriate for `update` batch flows, which depend on the classification path and therefore default `interactive=False`. ### Toolchain-mismatch auto-retry @@ -95,8 +95,9 @@ exit 13 and fails hard instead (3.0.0-B9); `"review-gated"` (kernel stage) preserves the B5 semantics — the skipped build also skipped the promised in-prepare() kconfig review, so interactive runs get an install-as-built / rebuild-with-`-f` / abort prompt while unattended runs -proceed. The unattended arbitration (caller interactivity ∧ `--no-interactive` -∧ TTY) lives only in the seam. `makepkg_wrapper`'s own `except AlreadyBuilt` +proceed. The kernel caller passes its one resolved `config_review` +(`resolve_config_review`, which already folds in `review_config`, +`--no-review-config`, `--unattended` and TTY), so the seam re-derives nothing. `makepkg_wrapper`'s own `except AlreadyBuilt` (manifest capture for renamed builds) is a side-effect that fires regardless of policy, then re-raises. @@ -249,7 +250,7 @@ the full bundle swap (`_reemit_for_swap` → `profile.swap_toolchain`). [profiles.batch] extends = "standard" batch = true -makepkg_flags = ["--noconfirm", "--noprogressbar", "--log", "--cleanbuild"] +makepkg_flags = ["--noprogressbar", "--log", "--cleanbuild"] clean_builddir = true ``` diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index f0e82470..95655463 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -102,6 +102,16 @@ upgrade, and the protections in force) and asks `Proceed? [Y/n]`. `--yes`, or `confirm_plan = false` in `packages.toml`, skips the question. `sysforge setup` asks about these choices (confirmation, review, AUR, build sandbox) and saves the ones you change. +To stop sysforge asking anything at all, even at a terminal (an overnight `update` left +running, an `ssh -t` script), pass `--unattended`, or set `unattended = true` under `[ui]` in +`sysforge.toml` (`SYSFORGE_UNATTENDED=0` gets the questions back for one run). Every question +then gets the answer it would get with no terminal: an unreviewed change is refused, no PGP key +is imported, `update` goes ahead without asking `Proceed?`, and a removal refuses unless you +pass `--yes`. Apart from the plan question, which only confirms what you asked for, it never +answers yes for you. It does not ask for your sudo password either, so a run that installs +anything needs sudo credentials that are already cached or a `NOPASSWD` rule; without them it +stops before changing anything. + If you have one clear goal, `sysforge setup` first offers a preset that sets several of these at once, showing each change before it writes anything: `performance` (the LLVM toolchain and the `optimized` profile), `low-friction` (no plan confirmation and no source review) or `secure` diff --git a/docs/guides/kernel.md b/docs/guides/kernel.md index 7c6d1293..f21e2d99 100644 --- a/docs/guides/kernel.md +++ b/docs/guides/kernel.md @@ -47,7 +47,7 @@ when a new version is available and leaves the build to the kernel stage. ## Reviewing the config -By default a kernel run is interactive. After sysforge has merged its config changes into the +By default a kernel run opens a config review. After sysforge has merged its config changes into the kernel's `.config`, the build pauses with a prompt saying the merged config is assembled and asking you to press Enter to review it. @@ -58,8 +58,8 @@ continues. Ctrl-C at the pause aborts the build with nothing installed. If the PKGBUILD runs its own menu (`make menuconfig`, for example), sysforge uses that one and adds no second menu or pause. -The review needs a real terminal. A run with its output piped or captured falls back to an -unattended run and warns. +The review needs a real terminal. A run with its output piped or captured skips it and warns +(an `--unattended` run skips it without the warning, since you asked for that). An interactive build's output is still logged. The whole of it goes to the kernel's per-package log, and if the build fails, its last lines also go to the run log @@ -68,27 +68,40 @@ scrolled past it. --- -## Running unattended +## Skipping the config review, or running unattended -For a timer or a script, turn the review off for one run: +The stage asks two separate things, and each has its own switch. + +**The config review** is the `make nconfig` (or `menuconfig`) step where you look over the +merged config before the build. To skip it for one run: ```bash -sysforge run kernel --no-interactive +sysforge run kernel --no-review-config ``` or for good, in `kernel.toml`: ```toml -interactive = false +review_config = false ``` The review step is dropped, and any interactive step the PKGBUILD has of its own (`make nconfig`, `menuconfig`, `oldconfig` and the like) is rewritten to `make olddefconfig`, which -takes the kernel's default for every option the config doesn't set. +takes the kernel's default for every option the config doesn't set. The stage still asks its +safety questions below if something needs your answer. + +**Unattended** means sysforge asks nothing at all: pass the global `--unattended` (or set +`[ui] unattended = true` in `sysforge.toml`), or run with no terminal, as a timer does. That +skips the config review too, and every safety question gets its safe answer, which for the +kernel stage is to stop rather than guess. + +In `kernel.toml` this setting was called `interactive` before 3.5.0. An old `interactive = +false` still works and warns; `sysforge config clean` renames it. -Two other things stop an unattended run rather than guess: a kernel tree with local changes -(see the next section), and a prompting step named in `kconfig_targets` (see [Choosing the -config steps](#choosing-the-config-steps)). +Two other things stop a run rather than guess: a kernel tree with local changes, when +sysforge can't ask you about it (see the next section), and a prompting step named in +`kconfig_targets` on any run that skips the review (see [Choosing the config +steps](#choosing-the-config-steps)). --- @@ -132,7 +145,7 @@ Your kernel's `.config` is built up in layers, each applied over the one before: ``` 3. **The config steps**: the PKGBUILD's own, or your `kconfig_targets` (next section). -4. **Your review** in `nconfig`, on an interactive run. +4. **Your review** in `nconfig`, unless the review is skipped. After the build, sysforge warns about any option it merged that did not survive into the final config. Usually you turned it off in the menu, or a step such as `olddefconfig` dropped it @@ -195,15 +208,15 @@ The targets fall into four groups: | Group | Targets | Rule | | --- | --- | --- | | Silent | `olddefconfig`, `defconfig`, `allmodconfig`, `alldefconfig`, `savedefconfig`, `listnewconfig` | always allowed, run in your order | -| Prompting | `oldconfig`, `localmodconfig`, `localyesconfig`, `mod2yesconfig` | interactive runs only | +| Prompting | `oldconfig`, `localmodconfig`, `localyesconfig`, `mod2yesconfig` | only when the review runs | | Menu | `config`, `nconfig`, `menuconfig`, `xconfig`, `gconfig` | at most one, always run last | | Refused | `randconfig` | never: a random config is a boot hazard | -An unattended run refuses a prompting target instead of hanging on its prompt. For `oldconfig` -the error names `olddefconfig`, its silent equivalent. The others have none, so run those -interactively. A menu target listed earlier is moved to the end, so it always shows the -finished config, with the same pause before it as the default review. On an unattended run it -becomes `olddefconfig`. +A run that skips the review refuses a prompting target instead of hanging on its prompt. For +`oldconfig` the error names `olddefconfig`, its silent equivalent. The others have none, so +they need the review on. A menu target listed earlier is moved to the end, so it always shows the +finished config, with the same pause before it as the default review. When the review is +skipped it becomes `olddefconfig`. ### Trimming to this machine's hardware @@ -265,9 +278,10 @@ mkinitcpio -P`), the step whose absence would leave the kernel unbootable. | I want to... | Run or set | | --- | --- | -| build and review interactively | `sysforge run kernel` | +| build and review the config | `sysforge run kernel` | | see the plan without building | `sysforge run kernel --dry-run` | -| build from a timer or script | `sysforge run kernel --no-interactive` | +| skip the config review | `sysforge run kernel --no-review-config` | +| build from a timer or script | `sysforge --unattended run kernel` (or no terminal) | | start from my running kernel's config | `base_config = "running"` or `--base-config running` | | force a config option | a `[[kconfig]]` entry in `kernel.toml` | | add the compile-time hardening set | `hardening = true` | diff --git a/docs/release-notes/unreleased.md b/docs/release-notes/unreleased.md index 97871729..db42598e 100644 --- a/docs/release-notes/unreleased.md +++ b/docs/release-notes/unreleased.md @@ -110,6 +110,10 @@ https://keepachangelog.com/en/1.1.0/ - **`3.5.0-F19` — `sysforge self-uninstall` removes SysForge in one command.** Removing SysForge used to be a list of steps in the "What SysForge touches" guide: revert each package by hand, remove the package, delete four directories, edit `/etc/pacman.conf`, delete the group. `sysforge self-uninstall` does all of it. It first puts back the repo build of every package SysForge built that a repo carries, the same way `revert-to-stock` does, and leaves anything no repo carries (an AUR package, your own kernel) installed and working. Then it takes `sf-build` out of `IgnoreGroup` and the build sandbox's local repo out of the chroot, removes the package, and deletes SysForge's config, state, caches and optimization profiles, your own `sysforge` directories and the `sysforge` group. `--dry-run` shows the plan first; `--yes` skips the question, and without a terminal it is required; `--keep-data` removes only the package, keeping your edited config as `.pacsave` along with the state and profiles; `--state-dir` reads build state from another directory. If a revert fails it stops with SysForge still installed. The guide also said `pacman -Rns` keeps edited config as `.pacsave`; `-n` is what discards it, and the guide now describes the command instead. +--- + +- **`3.5.0-F21` — `--unattended`: never prompt, even at a terminal.** A new global flag, a `[ui] unattended` key in `sysforge.toml` and a `SYSFORGE_UNATTENDED` environment variable make every question get the answer it gets when there is no terminal, the way a timer or cron run already behaves. That is useful for an overnight `update` left running in a terminal, or a script run over `ssh -t`. Safe defaults apply where there is one: an unreviewed PKGBUILD change is refused, no PGP key is imported, the kernel config review is skipped, and `update` proceeds without asking `Proceed?`. Anything that removes packages or data still refuses unless you pass `--yes`, so this is not a way to say yes to everything. sysforge does not ask for your sudo password either: an unattended run needs credentials sudo has already cached, or a `NOPASSWD` rule, and otherwise stops before changing anything, as it does with no terminal. The flag wins over the variable, and the variable over the key; `SYSFORGE_UNATTENDED=0` gets the questions back for one run when the key is on. `build --interactive` and `update --interactive` are unchanged: they still give makepkg and pacman the terminal. A message that refuses or skips something because it could not ask now gives the reason in brackets, `(unattended run)` or `(no terminal)`. Before, it always said there was no terminal, which was wrong for an unattended run at a terminal; `--yes` help now says it is needed in both cases. + ## Changed - **`3.0.0-F3` — `update` no longer builds a changed source nobody looked at.** The PKGBUILD review gate, which diffs a package's whole source tree since the last build you accepted, used to accept every change during `update` with a logged line, and accepted silently whenever there was no terminal, so a timer-driven `update` built and installed whatever had arrived. Now a changed source is shown to you at a terminal (view, accept, skip or abort, as `--review` did), and with no terminal (a timer, cron, a pipe) that package is refused: the rest of the run still builds, the summary lists it under "Refused at review" and the run exits 1. Changes from Arch's own packaging repos are accepted with a logged line, since the same people publish the binary packages you already install; `[build] review_trusted_sources` in `packages.toml` changes that list, and `--review` reviews everything. `build` and the toolchain stage's rebuild of libLLVM consumers follow the same rules, and `build` gains `--review`. To keep the old unattended behaviour, set `[build] review = "accept"` (every accepted change is still listed in the summary) or pass `--no-review`. The config comment explains the risk: a build sandbox limits what a build can read, but it does not inspect what gets installed. @@ -288,12 +292,24 @@ https://keepachangelog.com/en/1.1.0/ --- +- **`3.5.0-DOC16` — the `packages.toml` COSMIC group example lists the whole core desktop.** The commented `[group.cosmic]` example named only three packages (`cosmic-session-git`, `cosmic-comp-git`, `cosmic-settings-git`), so uncommenting it gave you a compositor and settings app but no panel, launcher, greeter, file manager or portal. It now lists the `-git` build of every member of the repo `cosmic` group, 28 packages, so uncommenting it tracks a complete desktop. + +--- + - **`3.5.0-F2` — `run toolchain` builds the LLVM toolchain from upstream's single `llvm` package.** The repos now build clang, lld, polly, compiler-rt and openmp, along with lldb, libclc and others, as part of the `llvm` package. With PGO on, the whole set now goes through the profiling build together, which also means clang's own code (`libclang-cpp`) is optimized with the profile for the first time; before, only libLLVM was. Building them separately would have meant building the entire llvm source tree two extra times. When it installs, the toolchain stage now also replaces the other packages that come from the same build and that you already have installed (for example lldb and libclc), so they match the new libLLVM, and the rollback covers them too. Packages from that build you never installed, such as the WebAssembly runtimes, are built but not installed. Your `[packages]` lists in `toolchain.toml` keep working unchanged. --- - **`3.5.0-F3` — `[bolt] enabled` can now run, and optimizes clang's library inside the toolchain build.** The BOLT option in `toolchain.toml` could never work: it tried to build the BOLT tools in a way the LLVM packages cannot support, then would have rewritten the 117 KB `clang` launcher instead of the library that holds clang's code, and it relied on a CPU feature (branch sampling) that Zen 3 processors lack. It now runs during `sysforge run toolchain`, right after the optimized LLVM is built and before anything is installed: it profiles `libclang-cpp` (and `libLLVM`, with `libllvm = true`) while compiling a sample of LLVM's own source, rewrites them, checks the result still compiles, and packages it. It uses the BOLT tools from the same build, so it needs the repos' single `llvm` package, and it no longer needs `perf`. Any failure warns and the toolchain installs without BOLT. Still experimental and off by default: it has not yet been through a full toolchain build. +--- + +- **`3.5.0-F20` — sysforge decides whether makepkg may ask questions; profiles no longer carry `--noconfirm`.** Every build sysforge runs on its own now gets `--noconfirm` whether or not the profile lists it, and `sysforge build --interactive` or an interactive kernel configuration runs without it, as before. The shipped profiles no longer list it, so `makepkg_flags` describes only how a package is built. Profiles you wrote yourself keep working unchanged: a `--noconfirm` in them is recognised rather than passed twice. Removing it from a profile no longer makes builds ask questions, since only `--interactive` does that. The `--interactive` help, man page and completion now say what it does without referring to profile flags. + +--- + +- **`3.5.0-F22` — the kernel config review has its own name, separate from whether sysforge may ask.** **Breaking:** `run kernel --non-interactive` is now `--no-review-config` (`--no-interactive`, its spelling earlier in this cycle, is gone too), and `kernel.toml`'s `interactive` is now `review_config`. An existing `interactive = false` keeps working and warns until `sysforge config clean` renames it, so nobody gets the `nconfig` menu back unexpectedly. The stage used to treat two questions as one: whether to open the PKGBUILD's config menu, and whether it may ask you anything. Now `--no-review-config` and `review_config = false` only skip the menu: if the kernel name matches an official package, or the kernel tree has local changes, you are still asked at a terminal instead of the run stopping. To have the stage ask nothing, use the global `--unattended` (`3.5.0-F21`), or run with no terminal. The error for an `oldconfig` or `localmodconfig` step in `kconfig_targets` now says it needs the config review, and lists what turns the review off. + ## Removed - **`3.5.0-F15` — one spelling per flag across every command.** **Breaking:** these spellings no longer parse; each has a direct replacement. @@ -304,7 +320,7 @@ https://keepachangelog.com/en/1.1.0/ | `--no-confirm` | `--yes` | `doctor pkg`, `state orphans` | | `--force` | `--yes` | `revert-to-stock` (`--force` elsewhere overrides a guard; skipping a question is `--yes`, as on `update`) | | `--quiet`, `-q` | `--findings-only` | `doctor system`, `doctor pkg` | - | `--non-interactive` | `--no-interactive` | `run kernel` | + | `--non-interactive` | `--no-review-config` (`3.5.0-F22`) | `run kernel` | | `--list` | `--dry-run` | `config merge` (they were aliases) | | `--auto-pgo` | `--yes` | `run toolchain` | | `--force-retry` | `--retry-failed` | `run pipeline`, `run packages` | diff --git a/etc/sysforge/kernel.toml b/etc/sysforge/kernel.toml index bc7e6d29..a3fb5e6b 100644 --- a/etc/sysforge/kernel.toml +++ b/etc/sysforge/kernel.toml @@ -77,12 +77,13 @@ pkgname = "linux-sysforge" # default: unset (sysforge-.conf) #boot_entries_prefix = "97" -# interactive — default: true — the PKGBUILD's `make nconfig` / `menuconfig` -# runs as written so the user can review/edit the resolved kconfig. Requires -# a real TTY: piped/captured runs downgrade to unattended with a warning. -# Set to false (or pass `--no-interactive`) to patch interactive targets to -# `make olddefconfig` for unattended runs. -#interactive = true +# review_config — default: true — the PKGBUILD's `make nconfig` / `menuconfig` +# runs as written so you can review/edit the resolved kconfig. Needs a +# terminal: a run without one, or with --unattended, skips it. Set to false +# (or pass `--no-review-config` for one run) to patch those targets to +# `make olddefconfig`. Called `interactive` before 3.5.0; the old name is +# still read, and `sysforge config clean` renames it. +#review_config = true # compiler — "gcc" | "llvm" — kernel-stage compiler override. Independent of # the global toolchain stage, so a system that keeps gcc as its default can @@ -209,10 +210,10 @@ pkgname = "linux-sysforge" # first, so the sequence shapes the merged config. Silent targets # (olddefconfig, defconfig, allmodconfig, alldefconfig, savedefconfig, # listnewconfig) run in listed order. Prompting targets (oldconfig, -# localmodconfig, localyesconfig, mod2yesconfig) require an interactive -# stage run — non-interactive runs reject them (oldconfig names its silent -# equivalent olddefconfig; the local*/mod2yes targets say to run -# interactively). At most one UI target (config, nconfig, menuconfig, +# localmodconfig, localyesconfig, mod2yesconfig) need the config review +# (review_config above) — a run without it rejects them (oldconfig names its +# silent equivalent olddefconfig; the local*/mod2yes targets say to turn the +# review on). At most one UI target (config, nconfig, menuconfig, # xconfig, gconfig) is allowed and it always runs last. randconfig is never # allowed — a randomized config is a boot-safety hazard. # localmodconfig/localyesconfig over-minimize to modules currently loaded diff --git a/etc/sysforge/packages.toml b/etc/sysforge/packages.toml index d2499548..85d347f1 100644 --- a/etc/sysforge/packages.toml +++ b/etc/sysforge/packages.toml @@ -157,8 +157,39 @@ # bootstrap.toml [desktop] environment for unattended installs). # ============================================================================= +# Example: the core COSMIC desktop (every member of the repo `cosmic` group) +# tracked as AUR -git packages. #[group.cosmic] -#packages = ["cosmic-session-git", "cosmic-comp-git", "cosmic-settings-git"] +#packages = [ +# "cosmic-app-library-git", +# "cosmic-applets-git", +# "cosmic-bg-git", +# "cosmic-comp-git", +# "cosmic-edit-git", +# "cosmic-files-git", +# "cosmic-greeter-git", +# "cosmic-idle-git", +# "cosmic-initial-setup-git", +# "cosmic-launcher-git", +# "cosmic-monitor-git", +# "cosmic-notifications-git", +# "cosmic-osd-git", +# "cosmic-osk-git", +# "cosmic-panel-git", +# "cosmic-player-git", +# "cosmic-randr-git", +# "cosmic-screenshot-git", +# "cosmic-session-git", +# "cosmic-settings-daemon-git", +# "cosmic-settings-git", +# "cosmic-sound-theme-git", +# "cosmic-store-git", +# "cosmic-term-git", +# "cosmic-viewer-git", +# "cosmic-wallpapers-git", +# "cosmic-workspaces-git", +# "xdg-desktop-portal-cosmic-git", +#] # ============================================================================= diff --git a/etc/sysforge/profiles.toml b/etc/sysforge/profiles.toml index c1be22f5..08d6149a 100644 --- a/etc/sysforge/profiles.toml +++ b/etc/sysforge/profiles.toml @@ -2,7 +2,7 @@ # profiles.toml — compiler flag profiles, package matching rules, and # flag-resolution reference data (conflict groups + consumes inference) # -# Everything down to line 226 ("END OF HEADER") is documentation; +# Everything down to line 228 ("END OF HEADER") is documentation; # the settings themselves start below it. # # SysForge resolves a profile for each package by evaluating [[rules]] in @@ -132,11 +132,13 @@ # PKG_CONFIG_PATH = "/usr/local/lib/pkgconfig" # # # makepkg invocation flags -# makepkg_flags — common: --noconfirm --noprogressbar --log --cleanbuild -# --force. sysforge installs dependencies and the built packages itself, -# so it strips --syncdeps/-s and --install/-i from every build, and -# --rmdeps has nothing to remove. -# makepkg_flags = ["--noconfirm"] +# makepkg_flags — common: --noprogressbar --log --cleanbuild --force. +# sysforge installs dependencies and the built packages itself, so it +# strips --syncdeps/-s and --install/-i from every build, and --rmdeps has +# nothing to remove. --noconfirm is not needed: sysforge adds it to every +# build except an interactive one (`sysforge build --interactive`, or an +# interactive kernel configuration). +# makepkg_flags = ["--cleanbuild"] # # # SysForge behaviour keys (not written to conf or env) # build_mode — "source_built" | "pgo_llvm_toolchain" | "kernel"; omit for @@ -259,7 +261,6 @@ CXXFLAGS = "$CFLAGS" LDFLAGS = "-Wl,-O1,--sort-common,--as-needed,-z,relro,-z,now" CARGO_NET_GIT_FETCH_WITH_CLI = "true" RUSTC_WRAPPER = "sccache" -makepkg_flags = ["--noconfirm"] [profiles.optimized] extends = "standard" @@ -267,7 +268,7 @@ CFLAGS = "-march=native -O3 -pipe -fno-plt" # --icf=all (fold identical functions) is an lld option, so it only takes # effect under toolchain = "llvm"; under gcc's linker it is stripped at build. LDFLAGS = "-Wl,-O1,--sort-common,--as-needed,-z,relro,-z,now,--icf=all" -makepkg_flags = ["--noconfirm", "--cleanbuild"] +makepkg_flags = ["--cleanbuild"] # hardened: standard's -O2 build plus compiler hardening beyond what # [preserved_system_tokens] restores from /etc/makepkg.conf, from the OpenSSF @@ -304,13 +305,13 @@ build_mode = "source_built" # extracts the PKGBUILD's embedded profile extends = "bare" build_mode = "kernel" batch = true -makepkg_flags = ["--noconfirm", "-f", "-c"] +makepkg_flags = ["-f", "-c"] [profiles.batch] extends = "standard" batch = true clean_builddir = true -makepkg_flags = ["--noconfirm", "--noprogressbar", "--log", "--cleanbuild"] +makepkg_flags = ["--noprogressbar", "--log", "--cleanbuild"] # ============================================================================= # Rules — add your own below diff --git a/etc/sysforge/sysforge.toml b/etc/sysforge/sysforge.toml index c5ef9235..105c19e4 100644 --- a/etc/sysforge/sysforge.toml +++ b/etc/sysforge/sysforge.toml @@ -25,6 +25,15 @@ color = "auto" #merge = "vimdiff" # Diff/merge tool for `sysforge config merge` (.sfnew # # adoption). Resolution: SYSFORGE_MERGE > this > $DIFFPROG # # > vimdiff. Accepts args, e.g. "nvim -d" or "meld". +# +# Never prompt, even at a terminal. Every question gets the answer it gets +# with no terminal (a timer, cron, a pipe): a safe default where there is one, +# such as skipping the kernel config review, refusing an unreviewed PKGBUILD +# change or importing no PGP key, and otherwise a refusal that names the flag +# to pass (--yes for a removal). It never answers yes on your behalf. +# --unattended turns it on for one run; SYSFORGE_UNATTENDED=1 or =0 overrides +# this key either way. +#unattended = false [log] # Default stderr verbosity when no -v/--quiet flag is passed (0–3). The shipped diff --git a/man/sysforge.1 b/man/sysforge.1 index 055187f5..9836869a 100644 --- a/man/sysforge.1 +++ b/man/sysforge.1 @@ -5,7 +5,7 @@ .nh .ad l .\" Begin generated content: -.TH "sysforge" "1" "2026\-10\-24" +.TH "sysforge" "1" "2026\-10\-25" .PP .PP .SH NAME @@ -231,7 +231,7 @@ Used by: \fBbuild\fR, \fBupdate\fR, \fBstate list\fR, \fBstate repair\fR, \fBsta .RE \fB\-\-yes\fR .RS 4 -Write without asking (needed without a terminal).\& +Write without asking (needed without a terminal or with \-\-unattended).\& Used by: \fBconfig clean\fR, \fBconfig reset\fR.\& .PP .RE @@ -255,7 +255,7 @@ Additional makepkg flags, appended after profile makepkg_flags.\& Usually option .RE \fB\-\-interactive\fR .RS 4 -Strip \-\-noconfirm from profile makepkg_flags and hand stdout/stderr to makepkg'\&s terminal so pacman conflict prompts and other unbuffered interactive output appear immediately (disables line\-based output classification while set).\& +Run makepkg without \-\-noconfirm and hand stdout/stderr to makepkg'\&s terminal so pacman conflict prompts and other unbuffered interactive output appear immediately (disables line\-based output classification while set).\& .PP .RE \fB\-\-persist\-log\fR @@ -422,7 +422,7 @@ Purge each package'\&s src dir and re\-clone before building.\& Per\-package fat .RE \fB\-\-review\fR .RS 4 -Review changed sources of every type, including trusted ones (by default Arch'\&s own packaging repos).\& Without this flag, update prompts at a terminal for untrusted changes and refuses them with no terminal.\& +Review changed sources of every type, including trusted ones (by default Arch'\&s own packaging repos).\& Without this flag, update prompts at a terminal for untrusted changes and refuses them with no terminal or with \-\-unattended.\& .PP .RE \fB\-\-no\-review\fR @@ -852,7 +852,7 @@ Delete one profile store, METHOD or METHOD/TARGET as listed (e.\&g.\& pgo/mesa, .RE \fB\-\-yes\fR .RS 4 -With \-\-delete: delete without the y/N prompt (needed without a TTY).\& +With \-\-delete: delete without the y/N prompt (needed without a terminal or with \-\-unattended).\& .PP .RE \fB\-\-state\-dir\fR \fIDIR\fR @@ -1410,9 +1410,9 @@ Skip the source sync (fetch and fast\-forward) of the kernel tree and build it a Purge the kernel src dir and re\-clone before building.\& Refuses if the existing clone has uncommitted changes, ahead\-of\-upstream commits, or no upstream.\& .PP .RE -\fB\-\-no\-interactive\fR +\fB\-\-no\-review\-config\fR .RS 4 -Disable interactive kconfig (default is interactive: the PKGBUILD'\&s `make nconfig`/`menuconfig`/etc.\& runs as written).\& With this flag, interactive targets are patched to `make olddefconfig` for unattended runs.\& +Skip the kernel config review for this run: the PKGBUILD'\&s `make nconfig`/`menuconfig`/etc.\& is patched to `make olddefconfig`.\& kernel.\&toml review_config = false does the same for every run.\& The stage still asks its safety questions; \-\-unattended stops those too.\& .PP .RE \fB\-\-toolchain\fR \fIgcc\fR|\fIllvm\fR @@ -1512,7 +1512,7 @@ Package name(s) to remove (stock or \-sysforge name).\& .RE \fB\-\-yes\fR .RS 4 -Remove without pacman'\&s confirmation prompt (needed without a terminal).\& +Remove without pacman'\&s confirmation prompt (needed without a terminal or with \-\-unattended).\& .PP .RE \fBShared options:\fR \fB\-\-state\-dir\fR (see SHARED OPTIONS) @@ -1535,7 +1535,7 @@ Show the plan without changing anything.\& .RE \fB\-\-yes\fR .RS 4 -Do it without asking (needed without a terminal).\& +Do it without asking (needed without a terminal or with \-\-unattended).\& .PP .RE \fBShared options:\fR \fB\-\-state\-dir\fR (see SHARED OPTIONS) @@ -1624,6 +1624,13 @@ SYSFORGE_MERGE Merge tool for \fBconfig merge\fR.\& Checked first, then sysforge.\&toml [ui] merge, $DIFFPROG, then vimdiff.\& .PP .RE +SYSFORGE_UNATTENDED +.RS 4 +1/true/yes/on: never prompt, as \fB\-\-unattended\fR does.\& Any other +value, including empty, turns off sysforge.\&toml [ui] unattended +for this run.\& \fB\-\-unattended\fR wins over it.\& +.PP +.RE SYSFORGE_ASCII .RS 4 Any non\-empty value prints ASCII in place of decorative glyphs diff --git a/sysforge/CLAUDE.md b/sysforge/CLAUDE.md index 15575a91..d854634b 100644 --- a/sysforge/CLAUDE.md +++ b/sysforge/CLAUDE.md @@ -285,7 +285,9 @@ Mechanism lives in the cited §DESIGN section. (`_ensure_mesa_software_baseline`). Opt-in `[mesa] filter_drivers`; lib32-mesa is filtered. §Hardware detection. - **Kernel stage**: compiler independent of toolchain (`toolchain/identity.py::compiler_paths`); - **interactive by default**; subpackage toggles `kernel/config.py::resolve_subpackages` + + **config review on by default**, resolved only by `kernel/config.py::resolve_config_review`; + the stage's own questions ask `prompt.is_interactive()` and never re-derive it (`3.5.0-F22`); + subpackage toggles `kernel/config.py::resolve_subpackages` + `pkgbuild_patcher.patch_kernel_subpackages` (disabling headers keeps the Gate-1 DKMS warning); boot-safety in `kernel_safety.py`/`device_probe`. §Kernel. diff --git a/sysforge/build/makepkg_wrapper.py b/sysforge/build/makepkg_wrapper.py index 32f06c3a..cf26e93e 100644 --- a/sysforge/build/makepkg_wrapper.py +++ b/sysforge/build/makepkg_wrapper.py @@ -130,7 +130,7 @@ warn_artifacts_left, write_extracted_profile, ) -from sysforge.primitives.prompt import prompt_choice +from sysforge.primitives.prompt import is_interactive, prompt_choice from sysforge.primitives.source_sync import ( STATUS_DIVERGED, STATUS_FAILED, @@ -1702,7 +1702,7 @@ def run(pkgbuild_path, options: BuildOptions | None = None) -> Path | None: _build_log.info(f"Reusing profdata for PGO build: {pgo_info}") else: reason = pgo_info - if sys.stdin.isatty(): + if is_interactive(): choice = prompt_choice( f"{pkgname}: PGO profdata unavailable ({reason})." " [p]lain build or [s]kip? [S]: ", diff --git a/sysforge/build_cmd.py b/sysforge/build_cmd.py index e52b5135..a6d2be7e 100644 --- a/sysforge/build_cmd.py +++ b/sysforge/build_cmd.py @@ -233,7 +233,7 @@ def add_parser(cls, sub): "itself (-h, -V).", ) p.add_argument("--interactive", action="store_true", - help="Strip --noconfirm from profile makepkg_flags and hand stdout/stderr " + help="Run makepkg without --noconfirm and hand stdout/stderr " "to makepkg's terminal so pacman conflict prompts and other " "unbuffered interactive output appear immediately (disables " "line-based output classification while set).") diff --git a/sysforge/cli.py b/sysforge/cli.py index 7383a67c..5b8db2a3 100644 --- a/sysforge/cli.py +++ b/sysforge/cli.py @@ -95,6 +95,7 @@ def _resolve_verbosity(args): "--py-profile-out": True, "--timings": False, "--color": True, + "--unattended": False, "--no-throttle": False, "--turbo": False, "--frozen": False, @@ -131,6 +132,31 @@ def _resolve_color_mode(flag_value): return cfg if cfg in ("auto", "always", "never") else "auto" +_ENV_TRUE = ("1", "true", "yes", "on") + + +def _resolve_unattended(flag_value): + """Resolve whether this run may prompt (3.5.0-F21): ``--unattended`` > + ``SYSFORGE_UNATTENDED`` > ``[ui] unattended`` > off. + + The env var works in both directions, so ``SYSFORGE_UNATTENDED=0`` gets + prompts back for one run when the config turns them off; any value other + than 1/true/yes/on (case-insensitive) counts as off. A non-bool config + value is ignored, and an unreadable config never aborts startup. + """ + if flag_value: + return True + env = os.environ.get("SYSFORGE_UNATTENDED") + if env is not None: + return env.strip().lower() in _ENV_TRUE + try: + from sysforge.primitives.config import load_sysforge_toml + cfg = (load_sysforge_toml().get("ui", {}) or {}).get("unattended") + except Exception: + cfg = None + return cfg is True + + def _hoist_global_flags(argv): """ Move the global flags in _GLOBAL_HOIST_FLAGS (and their value tokens / @@ -361,6 +387,8 @@ def _main(): args = parser.parse_args() log.set_verbosity(_resolve_verbosity(args)) log.set_color_mode(_resolve_color_mode(getattr(args, "color", None))) + from sysforge.primitives import prompt + prompt.set_unattended(_resolve_unattended(getattr(args, "unattended", False))) # --json (3.4.0-F7): stdout carries only the verb's document, so the guard # starts before any startup logging (which --dry-run sends to stdout). from sysforge.primitives import json_out diff --git a/sysforge/config_cmd.py b/sysforge/config_cmd.py index 0ef485ba..53f6df0e 100644 --- a/sysforge/config_cmd.py +++ b/sysforge/config_cmd.py @@ -290,7 +290,7 @@ def _write_all(args, writes: list[tuple[Path, str, str]], question: str) -> int: return 0 answer = _consent(args, question) if answer is None: - _log.error("refusing to edit config files without a terminal to confirm on; " + _log.error(f"refusing to edit config files ({prompt.no_prompt_reason()}); " "pass --yes (or --dry-run to only look)") return 2 if not answer: @@ -364,7 +364,7 @@ def cmd_config_reset(args) -> int: return 0 answer = _consent(args, f"Replace {path} with the shipped default?") if answer is None: - _log.error("refusing to replace a config file without a terminal to confirm on; " + _log.error(f"refusing to replace a config file ({prompt.no_prompt_reason()}); " "pass --yes (or --dry-run to only look)") return 2 if not answer: @@ -428,7 +428,7 @@ def add_parser(cls, sub): p.add_argument("--dry-run", action="store_true", dest="dry_run", help="Show the diff without writing anything.") p.add_argument("--yes", action="store_true", dest="yes", - help="Write without asking (needed without a terminal).") + help="Write without asking (needed without a terminal or with --unattended).") p.set_defaults(verb_cls=cls) return p @@ -462,7 +462,7 @@ def add_parser(cls, sub): p.add_argument("--dry-run", action="store_true", dest="dry_run", help="Show the diff without writing anything.") p.add_argument("--yes", action="store_true", dest="yes", - help="Write without asking (needed without a terminal).") + help="Write without asking (needed without a terminal or with --unattended).") p.set_defaults(verb_cls=cls) return p diff --git a/sysforge/pipeline/stages/base.py b/sysforge/pipeline/stages/base.py index 4b48e855..9e9f9bdd 100644 --- a/sysforge/pipeline/stages/base.py +++ b/sysforge/pipeline/stages/base.py @@ -44,8 +44,8 @@ class RunOptions: # Profile / build configuration profile_conf: str | None = None # path to alternate profiles.toml for PKGBUILD patching # Kernel stage - # opt out of interactive kconfig (kernel default is interactive) - non_interactive: bool = False + # --no-review-config: skip the kernel config review this run (3.5.0-F22) + no_review_config: bool = False # CLI override for kernel.toml bootloader (systemd-boot|grub|none) bootloader: str | None = None compiler: str | None = None # kernel-stage compiler override ("gcc" or "llvm") diff --git a/sysforge/pipeline/stages/kernel/config.py b/sysforge/pipeline/stages/kernel/config.py index df015946..6d332070 100644 --- a/sysforge/pipeline/stages/kernel/config.py +++ b/sysforge/pipeline/stages/kernel/config.py @@ -313,7 +313,9 @@ class KernelConfig: raw: dict = field(default_factory=dict, repr=False) enabled: bool = False - interactive: bool = True + # Run the PKGBUILD's own config UI (nconfig/menuconfig); was `interactive` + # before 3.5.0-F22. Resolved per run by resolve_config_review. + review_config: bool = True # None means 'unset', which is load-bearing: resolve_compiler falls # through to the pipeline state's toolchain result only when the user # did not name a compiler here. @@ -365,7 +367,8 @@ def from_toml(cls, data: dict | None) -> "KernelConfig | None": return cls( raw=data, enabled=bool(data.get("enabled", False)), - interactive=bool(data.get("interactive", True)), + review_config=(bool(data.get("review_config")) if "review_config" in data + else _legacy_review_config(data)), compiler=data.get("compiler") or None, pkgname=data.get("pkgname") or None, upstream_pkgname=data.get("upstream_pkgname") or None, @@ -419,3 +422,44 @@ def load() -> "KernelConfig | None": ``load_kernel_config`` remains as the raw-dict reader beneath this. """ return KernelConfig.from_toml(load_kernel_config()) + + +def _legacy_review_config(data: dict) -> bool: + """``review_config`` when kernel.toml does not set it: its pre-3.5.0-F22 + name ``interactive`` if present, else the default (on). + + Ignoring the old key would turn the review back on for someone who had + turned it off, and nconfig would open on their next run. ``config check`` + reports the old name and ``config clean`` renames it. + """ + if "interactive" in data: + _log.warn("kernel.toml: `interactive` is now `review_config`; using its value. " + "Run `sysforge config clean` to rename it.") + return bool(data["interactive"]) + return True + + +def resolve_config_review(kernel_cfg: KernelConfig, options) -> bool: + """Whether this run opens the PKGBUILD's config UI (3.5.0-F22). + + The one home for the answer: ``review_config`` in kernel.toml, not + ``--no-review-config``, and sysforge may ask (``prompt.is_interactive``: + a terminal, and not ``--unattended``). The stage's own questions do not + come through here; they ask ``prompt.is_interactive`` directly. + """ + from sysforge.primitives import prompt + + wanted = kernel_cfg.review_config and not getattr(options, "no_review_config", False) + if not wanted: + return False + if prompt.is_interactive(): + return True + if prompt.unattended(): + # Asked for, so narration rather than a warning (3.5.0-F21). + _log.info("unattended run: skipping the kernel config review") + else: + _log.warn("kernel config review requested (kernel.toml review_config = " + "true) but no terminal is attached: skipping it. Pass " + "--no-review-config to make this explicit.") + return False + diff --git a/sysforge/pipeline/stages/kernel/kconfig.py b/sysforge/pipeline/stages/kernel/kconfig.py index e47cc92c..e68a1951 100644 --- a/sysforge/pipeline/stages/kernel/kconfig.py +++ b/sysforge/pipeline/stages/kernel/kconfig.py @@ -328,7 +328,7 @@ def resolve_kconfig_targets(kernel_cfg, *, interactive): Raises ValueError on: an unknown target, more than one UI target, or a prompting target (oldconfig/localmodconfig/localyesconfig/mod2yesconfig) - requested when interactive=False. + requested when interactive=False (the config review is off). """ targets = kernel_cfg.kconfig_targets if not targets: @@ -352,12 +352,13 @@ def resolve_kconfig_targets(kernel_cfg, *, interactive): if not interactive and target in KCONFIG_PROMPTING_TARGETS: if target in KCONFIG_SILENT_EQUIVALENT: raise ValueError( - f"kconfig_targets: {target!r} requires interactive input; " - f"use {KCONFIG_SILENT_EQUIVALENT[target]} instead" + f"kconfig_targets: {target!r} needs the config review, which " + f"this run skips; use {KCONFIG_SILENT_EQUIVALENT[target]} instead" ) raise ValueError( - f"kconfig_targets: {target!r} requires interactive input; " - f"run the stage interactively" + f"kconfig_targets: {target!r} needs the config review, which " + f"this run skips (kernel.toml review_config, --no-review-config, " + f"--unattended or no terminal)" ) if target in KCONFIG_UI_TARGETS: diff --git a/sysforge/pipeline/stages/kernel/source.py b/sysforge/pipeline/stages/kernel/source.py index 01fe0268..59f0d050 100644 --- a/sysforge/pipeline/stages/kernel/source.py +++ b/sysforge/pipeline/stages/kernel/source.py @@ -104,7 +104,7 @@ def check_pkgname_repo_collision(pkgname, options): (the safe default); ``--dry-run`` warns without prompting. """ from sysforge.primitives.aur import is_repo_package - from sysforge.primitives.prompt import is_interactive, prompt_choice + from sysforge.primitives.prompt import is_interactive, no_prompt_reason, prompt_choice if not is_repo_package(pkgname): return @@ -118,11 +118,10 @@ def check_pkgname_repo_collision(pkgname, options): _log.ui(f"{msg} (dry-run: not prompting)") return _log.warn(msg) - unattended = bool(getattr(options, "non_interactive", False)) or not is_interactive() - if unattended: + if not is_interactive(): raise RuntimeError( - f"[KERNEL] {msg} Aborting unattended: rename pkgname in kernel.toml, " - "or run interactively to confirm the override." + f"[KERNEL] {msg} Not asking ({no_prompt_reason()}): rename pkgname in " + "kernel.toml, or confirm the override at a terminal." ) choice = prompt_choice( "Continue and shadow the repo package? [y/N]: ", @@ -141,7 +140,7 @@ def check_pkgname_repo_collision(pkgname, options): ) -def resolve_already_built_action(options, interactive): +def resolve_already_built_action(config_review): """Decide what an AlreadyBuilt (makepkg exit 13) kernel build does next (B5). Policy lives in the one-home seam (2.5.1-F2): @@ -158,8 +157,7 @@ def resolve_already_built_action(options, interactive): action = resolve_already_built( "review-gated", - interactive=interactive, - non_interactive=bool(getattr(options, "non_interactive", False)), + interactive=config_review, tag="KERNEL", abort_hint=( "the kconfig review did not run. Rebuild with `-f` (choice r), " @@ -226,14 +224,14 @@ def warn_and_confirm_diverged(pkgbuild_dir, options): case where the old silent WARN was too easy to miss, so: * interactive run → require an explicit y/N confirmation; - * unattended run (``--no-interactive`` or no TTY) → abort (safe default). + * sysforge may not ask (``--unattended`` or no TTY) → abort (safe default). ``classify_head_vs_upstream`` enriches the message with ahead/behind counts so the common "upstream has new commits but the local repo is dirty" case is spelled out rather than hidden behind a bare "diverged". """ from sysforge.primitives.aur import classify_head_vs_upstream - from sysforge.primitives.prompt import is_interactive, prompt_choice + from sysforge.primitives.prompt import is_interactive, no_prompt_reason, prompt_choice state, n_local, n_upstream = classify_head_vs_upstream(pkgbuild_dir) detail = "" @@ -247,12 +245,11 @@ def warn_and_confirm_diverged(pkgbuild_dir, options): "edits and rebuild from upstream." ) - unattended = bool(getattr(options, "non_interactive", False)) or not is_interactive() - if unattended: + if not is_interactive(): raise RuntimeError( f"[KERNEL] {pkgbuild_dir.name}: refusing to build a kernel from " - "diverged source unattended. Resolve the divergence, rerun with " - "--cleansrc to discard local edits, or run interactively to confirm." + f"diverged source ({no_prompt_reason()}). Resolve the divergence, rerun " + "with --cleansrc to discard local edits, or confirm it at a terminal." ) choice = prompt_choice( "Continue building the kernel from local (diverged) source? [y/N]: ", diff --git a/sysforge/pipeline/stages/kernel/stage.py b/sysforge/pipeline/stages/kernel/stage.py index 7d9b35c5..fcabad6c 100644 --- a/sysforge/pipeline/stages/kernel/stage.py +++ b/sysforge/pipeline/stages/kernel/stage.py @@ -55,7 +55,7 @@ def log_resolution_summary( """Emit one labelled block of the resolved kernel-build plan. Consolidates what the stage decided (compiler + its origin, inherited - toolchain variant, bootloader, source, interactive mode, kconfig counts, + toolchain variant, bootloader, source, config review, kconfig counts, and the boot-safety gate settings) so the operator can eyeball it before a long build — and so ``--dry-run`` has a readable summary rather than only scattered ``[dry-run] would …`` lines. @@ -250,36 +250,21 @@ def run(self, sysforge_config, state, options): entries_template = install.preflight_boot_entries(pkgname, manage=manage_entries) install.prune_boot_entries(manage=manage_entries, dry_run=options.dry_run) - # Interactive kconfig is the kernel-stage default — flipped off by - # --no-interactive (or `interactive = false` in kernel.toml). When - # interactive=True is passed into BuildOptions, makepkg_wrapper skips - # the plan's non-interactive rewrite so the user's PKGBUILD kconfig target - # (typically `make nconfig`) runs as written, and the makepkg - # subprocess inherits the parent's stdout/stderr so ncurses-driven - # kconfig UIs render on the controlling TTY. - # B8: interactive requires config ∧ ¬--no-interactive ∧ TTY — the - # same three-way resolution the stage's sibling prompts (repo-pkgname - # collision, diverged-source confirm) already use. Without the TTY leg - # the stage promised an nconfig review that a piped/captured run could - # never render, then silently EOF'd through it. - from sysforge.primitives.prompt import is_interactive as _tty - cfg_interactive = kernel_cfg.interactive - flag_non_interactive = bool(getattr(options, "non_interactive", False)) - interactive = cfg_interactive and not flag_non_interactive and _tty() - if cfg_interactive and not flag_non_interactive and not interactive: - _log.warn( - "interactive kconfig review requested (kernel.toml " - "interactive = true) but no TTY is attached: running " - "unattended; the config review will NOT run. Pass " - "--no-interactive to make this explicit." - ) + # The PKGBUILD's config UI runs unless kernel.toml review_config, + # --no-review-config, --unattended or a missing terminal turns it off + # (one resolver, 3.5.0-F22). When it runs, makepkg_wrapper skips the + # plan's olddefconfig rewrite so the PKGBUILD's target (typically + # `make nconfig`) runs as written on the controlling terminal. The + # stage's own questions do not depend on it: they ask + # prompt.is_interactive() directly. + config_review = config.resolve_config_review(kernel_cfg, options) # F37: configured kconfig_targets sequence — resolved/validated here # (config-load time) so a bad list (unknown target, two UI targets, a # prompting target requested non-interactively) raises ValueError and # aborts before any makepkg invocation, rather than failing mid-build. # None when the key is unset — zero behavior change. - kconfig_targets = kconfig.resolve_kconfig_targets(kernel_cfg, interactive=interactive) + kconfig_targets = kconfig.resolve_kconfig_targets(kernel_cfg, interactive=config_review) # lsmod snapshot — captured before build for localmodconfig # reproducibility. Opt-out via `capture_lsmod_snapshot = false` (Gate 1 @@ -430,19 +415,18 @@ def run(self, sysforge_config, state, options): if kconfig_targets: kconfig_target = f"{' → '.join(kconfig_targets)} (configured)" - elif interactive: + elif config_review: kconfig_target = "make nconfig (user-supplied)" else: kconfig_target = "make olddefconfig (patched)" _log.info(f"Kernel kconfig target: {kconfig_target}") - # C3: standalone interactive runs require the operator to drive the - # PKGBUILD's kconfig UI. Pipeline / --no-interactive / dry-run paths - # don't, so only nudge for a real interactive run. - if interactive and not options.dry_run: + # C3: a run with the config review open needs the operator to drive + # the PKGBUILD's kconfig UI, so say so; nothing to nudge otherwise. + if config_review and not options.dry_run: _log.info( - "Running interactively (the PKGBUILD's `make nconfig`/etc. runs " - "as written); pass --no-interactive for unattended builds." + "Opening the kernel config review (the PKGBUILD's `make nconfig`/" + "etc. runs as written); pass --no-review-config to skip it." ) allow_boot_audit_failure = bool(getattr(options, "allow_boot_audit_failure", False)) @@ -603,7 +587,7 @@ def _kernel_build_options(extra_flags=None): or sysforge_config.get("profile_conf") ), update=(not options.no_update) and not synced, - interactive=interactive, + interactive=config_review, cc_override=cc, cxx_override=cxx, source=src_kind, @@ -649,10 +633,10 @@ def _kernel_build_options(extra_flags=None): extra_flags=["-f"] if fdo_plan.force_rebuild else None)) except AlreadyBuilt as exc: # B5: makepkg exit 13 skipped the build — and with it the - # in-prepare() kconfig review an interactive run promised. + # in-prepare() kconfig review this run promised. # Ask the operator (install as-built / rebuild with -f to # review / abort); unattended runs keep the proceed path. - if source.resolve_already_built_action(options, interactive) == "rebuild": + if source.resolve_already_built_action(config_review) == "rebuild": built_dir = makepkg_run( pkgbuild, options=_kernel_build_options(extra_flags=["-f"]), @@ -730,7 +714,7 @@ def _kernel_build_options(extra_flags=None): else: try: install_built_packages( - pkgbuild.parent, noconfirm=not interactive) + pkgbuild.parent, noconfirm=not config_review) except RuntimeError as e: if already_built: # B7: AlreadyBuilt → install-as-built → install diff --git a/sysforge/pipeline/stages/toolchain/stage.py b/sysforge/pipeline/stages/toolchain/stage.py index 8e743b51..ae973a81 100644 --- a/sysforge/pipeline/stages/toolchain/stage.py +++ b/sysforge/pipeline/stages/toolchain/stage.py @@ -376,9 +376,9 @@ def _build_from_source(self, sysforge_config, state, options, tcfg, no_build): # Prompt for confirmation (interactive only) try: - import sys as _sys + from sysforge.primitives import prompt as _prompt - if _sys.stdin.isatty() and not options.dry_run: + if _prompt.is_interactive() and not options.dry_run: pkgbuilds.confirm_or_abort( options.state_dir, mode=no_build, standalone=bool(getattr(options, "standalone", False))) diff --git a/sysforge/primitives/already_built.py b/sysforge/primitives/already_built.py index d8a774ac..fee6013c 100644 --- a/sysforge/primitives/already_built.py +++ b/sysforge/primitives/already_built.py @@ -8,9 +8,10 @@ matching ``.pkg.tar`` and makepkg skipped the build. Every catch site routes its "what now?" decision through :func:`resolve_already_built` instead of interpreting the exception locally — 2.5.1-B3/B4/B5 were all divergences -between sites that each re-derived the answer (in particular the unattended -arbitration, which needs *three* signals: the caller's resolved interactive -flag, the ``--no-interactive`` override, and TTY presence). +between sites that each re-derived the answer. The review-gated caller now +passes the kernel stage's one resolved answer (``resolve_config_review``, +3.5.0-F22), which already folds in kernel.toml, ``--no-review-config``, +``--unattended`` and TTY presence, so nothing is re-derived here. Decide-only: callers execute the chosen action (artifact reuse, ``-f`` retry). The manifest capture in ``makepkg_wrapper``'s except-path is a @@ -43,20 +44,19 @@ def resolve_already_built( posture: str, *, interactive: bool, - non_interactive: bool = False, tag: str = "BUILD", abort_hint: str = "", ) -> AlreadyBuiltAction: """Decide what an ``AlreadyBuilt`` build does next. - ``interactive`` is the caller's resolved interactivity; ``non_interactive`` - the explicit CLI/config override; TTY presence is probed here. ``tag`` sets + ``interactive`` is the caller's resolved answer to "was a review promised?" + (for the kernel, ``resolve_config_review``); ``tag`` sets the log/prompt prefix; ``abort_hint`` is caller context folded into the abort message. """ # Late import: kernel-path tests patch these at the source module, and a # module-level from-import would bind before the patch applies. - from sysforge.primitives.prompt import is_interactive, prompt_choice + from sysforge.primitives.prompt import prompt_choice if posture == "reuse": return AlreadyBuiltAction.REUSE @@ -64,8 +64,7 @@ def resolve_already_built( raise ValueError(f"unknown AlreadyBuilt posture: {posture!r}") _log = log.get_logger(tag) - unattended = not interactive or bool(non_interactive) or not is_interactive() - if unattended: + if not interactive: _log.info("Kernel package already built: proceeding to audit + install") return AlreadyBuiltAction.REUSE _log.warn( diff --git a/sysforge/primitives/config_schema.py b/sysforge/primitives/config_schema.py index b09ed643..90183210 100644 --- a/sysforge/primitives/config_schema.py +++ b/sysforge/primitives/config_schema.py @@ -81,7 +81,7 @@ # deliberately absent here. "kernel.toml": {"enabled", "compiler", "pkgname", "upstream_pkgname", "srcdir", "boot_entries", "boot_entries_prefix", - "bootloader", "interactive", "pkgbuild_src_dir", + "bootloader", "review_config", "pkgbuild_src_dir", "source", "base_config", "base_config_merge", "require_fallback_kernel", "boot_audit", "min_boot_free_mb", "capture_lsmod_snapshot", "device_kconfig", @@ -117,6 +117,9 @@ class RetiredKey: key: str replacement: str | None retired_in: str + # The loader still honours the old key (with a warning) so a rename cannot + # silently flip a setting; the message must not claim it is ignored. + still_read: bool = False @dataclass(frozen=True) @@ -133,6 +136,8 @@ class RetiredValue: RetiredKey("sysforge.toml", "git", "pull_timeout", "fetch_timeout", "2.6.1-F5"), RetiredKey("packages.toml", "package", "pkgbuild_patch", "enable_build_from_source", "2.6.1-F5"), + RetiredKey("kernel.toml", "", "interactive", "review_config", "3.5.0-F22", + still_read=True), ) RETIRED_VALUES: tuple[RetiredValue, ...] = ( @@ -354,7 +359,9 @@ def inspect_text(name: str, text: str) -> list[Issue]: if rk: use = f"; use {rk.replacement}" if rk.replacement else "" issues.append(Issue(name, e.start + 1, "retired_key", where, - f"retired in {rk.retired_in}, no longer read{use}")) + f"retired in {rk.retired_in}, " + f"{'still read for now' if rk.still_read else 'no longer read'}" + f"{use}")) continue rv = _retired_value(name, e.table, e.key, _value(e.raw_value)) if rv: diff --git a/sysforge/primitives/env_vars.py b/sysforge/primitives/env_vars.py index c49fe769..facd7361 100644 --- a/sysforge/primitives/env_vars.py +++ b/sysforge/primitives/env_vars.py @@ -46,6 +46,10 @@ class EnvVar: EnvVar("SYSFORGE_MERGE", "user", "Merge tool for *config merge*. Checked first, then sysforge.toml [ui] " "merge, $DIFFPROG, then vimdiff."), + EnvVar("SYSFORGE_UNATTENDED", "user", + "1/true/yes/on: never prompt, as *--unattended* does. Any other value, " + "including empty, turns off sysforge.toml [ui] unattended for this run. " + "*--unattended* wins over it."), EnvVar("SYSFORGE_ASCII", "user", "Any non-empty value prints ASCII in place of decorative glyphs " "(arrows, bullets, progress fills), for terminals and fonts without them."), diff --git a/sysforge/primitives/makepkg_invoke.py b/sysforge/primitives/makepkg_invoke.py index bf547bd5..21bec928 100644 --- a/sysforge/primitives/makepkg_invoke.py +++ b/sysforge/primitives/makepkg_invoke.py @@ -257,6 +257,18 @@ def _append_flags(flags: list[str], extra: list[str]) -> list[str]: return out +def _base_flags(resolved_profile, interactive: bool) -> list[str]: + """The profile's ``makepkg_flags`` with sysforge's prompt policy applied. + + Whether makepkg may ask questions is decided by the run, not the profile + (3.5.0-F20): a batch build always gets ``--noconfirm``, an interactive one + never does. A profile written before that still carries the flag, so it is + deduplicated rather than doubled, and stripped for an interactive run. + """ + flags = [f for f in resolved_profile.get("makepkg_flags", []) if f != "--noconfirm"] + return flags if interactive else ["--noconfirm", *flags] + + def invoke_makepkg(pkgbuild_path, conf_path, resolved_profile, extra_env=None, extra_flags=None, interactive=False, strip_flags=None): @@ -328,10 +340,9 @@ def invoke_makepkg(pkgbuild_path, conf_path, resolved_profile, if _build_python is not None: env["PATH"] = str(_build_python.parent) + os.pathsep + env.get("PATH", "") - flags = list(resolved_profile.get("makepkg_flags", [])) + flags = _base_flags(resolved_profile, interactive) if interactive: - flags = [f for f in flags if f != "--noconfirm"] - _makepkg_log.info("--interactive: stripped --noconfirm from profile flags") + _makepkg_log.info("Interactive build: makepkg runs without --noconfirm") if extra_flags: flags = _append_flags(flags, extra_flags) _makepkg_log.info(f"Appending CLI flags: {extra_flags}") diff --git a/sysforge/primitives/pkgbuild_review.py b/sysforge/primitives/pkgbuild_review.py index 09faf748..17631703 100644 --- a/sysforge/primitives/pkgbuild_review.py +++ b/sysforge/primitives/pkgbuild_review.py @@ -61,7 +61,7 @@ _log = log.get_logger("REVIEW") from sysforge.primitives.pager import maybe_pager # noqa: E402 -from sysforge.primitives.prompt import prompt_key # noqa: E402 +from sysforge.primitives.prompt import is_interactive, no_prompt_reason, prompt_key # noqa: E402 from sysforge.primitives import render, run # git's well-known empty-tree object id: the diff base for a first review @@ -153,7 +153,7 @@ def resolve_review_policy(args, build_cfg: dict | None) -> ReviewPolicy: def _no_terminal() -> bool: - return not (sys.stdin.isatty() and sys.stdout.isatty()) + return not (is_interactive() and sys.stdout.isatty()) def _git(pkgbuild_dir: Path, *args: str) -> str | None: @@ -237,7 +237,7 @@ def review_target( _log.info(f"{pkgbase}: {what}; accepted without review") return DECISION_ACCEPT if _no_terminal(): - _log.warn(f"{pkgbase}: {what}; refused: no terminal to review it") + _log.warn(f"{pkgbase}: {what}; refused ({no_prompt_reason()})") return DECISION_REFUSED stat = _git(pkgbuild_dir, "diff", _color_flag(), "--stat", base, head) or "" @@ -346,8 +346,8 @@ def review_deps( f"without review: {names}") return DECISION_ACCEPT if _no_terminal(): - _log.warn(f"{len(changed)} dependency source change(s) refused: " - f"no terminal to review them: {names}") + _log.warn(f"{len(changed)} dependency source change(s) refused " + f"({no_prompt_reason()}): {names}") return DECISION_REFUSED print(f"\n{_tag()} {len(changed)} dependency source change(s):") diff --git a/sysforge/primitives/privilege.py b/sysforge/primitives/privilege.py index 393f3105..fa05a83f 100644 --- a/sysforge/primitives/privilege.py +++ b/sysforge/primitives/privilege.py @@ -45,10 +45,13 @@ from collections.abc import Callable, Generator from contextlib import contextmanager -from sysforge.primitives import progress_hooks +from sysforge import log +from sysforge.primitives import progress_hooks, prompt from sysforge.primitives.run import run_or_raise from sysforge.primitives.resource_guard import lift_for_child +_log = log.get_logger("SUDO") + def _credentials_cached() -> bool: """``sudo -n true``: would sudo run without asking? The auth probe.""" @@ -67,9 +70,18 @@ def ensure_credentials() -> bool: does not count as build time. Returns ``False`` when that prompt fails (wrong password, timeout) — the following command would prompt again and fail on its own, so callers may ignore it. + + An unattended run (3.5.0-F21) never asks: sudo reads the terminal itself, + so the prompt helpers' no-input path cannot reach it. Without cached + credentials or a NOPASSWD rule it returns ``False`` as a run with no + terminal does, and says why. """ if os.geteuid() == 0 or _credentials_cached(): return True + if prompt.unattended(): + _log.warn("unattended run: not asking for the sudo password; sudo needs " + "cached credentials or a NOPASSWD rule") + return False with progress_hooks.hooks().yield_terminal("prompt"): return subprocess.run(["sudo", "-v"], check=False, preexec_fn=lift_for_child).returncode == 0 @@ -98,7 +110,8 @@ def privileged_argv( When already root (euid 0) the argv is returned unchanged. Otherwise it is prefixed with ``sudo``; ``noninteractive=True`` inserts ``-n`` so sudo fails - fast instead of prompting (moot when already root). Unless ``mutates`` is + fast instead of prompting (moot when already root), as does an unattended + run (3.5.0-F21). Unless ``mutates`` is False (see the module docstring for when), :func:`on_mutation` listeners are told first, even when already root. """ @@ -107,7 +120,7 @@ def privileged_argv( listener(list(argv)) if os.geteuid() == 0: return list(argv) - return ["sudo", *(["-n"] if noninteractive else []), *argv] + return ["sudo", *(["-n"] if noninteractive or prompt.unattended() else []), *argv] def run_privileged( diff --git a/sysforge/primitives/prompt.py b/sysforge/primitives/prompt.py index e2f92dd4..7bd0cc32 100644 --- a/sysforge/primitives/prompt.py +++ b/sysforge/primitives/prompt.py @@ -20,6 +20,12 @@ Plus :func:`is_interactive` for stages that need to gate prompts on a TTY. +Unattended runs (3.5.0-F21): :func:`set_unattended` (called once by ``cli`` +from ``--unattended`` / ``SYSFORGE_UNATTENDED`` / ``[ui] unattended``) makes +:func:`is_interactive` false even at a terminal, and makes the three helpers +take their EOF path without reading, so a run behaves exactly as it does with +stdin closed. This module is the only place that asks whether stdin is a TTY. + Every helper reads its input inside ``progress_hooks.hooks().yield_terminal("prompt")``, so an interactive prompt never collides with stale status text. Call sites no longer manage this @@ -39,10 +45,32 @@ _log = log.get_logger("PROMPT") +_UNATTENDED = False + + +def set_unattended(on: bool) -> None: + """Never prompt for the rest of this run, even at a terminal.""" + global _UNATTENDED + _UNATTENDED = bool(on) + + +def unattended() -> bool: + """True when the run was set unattended (not merely missing a TTY).""" + return _UNATTENDED + + +def no_prompt_reason() -> str: + """Why this run will not ask, for a message that refuses or skips instead. + + An unattended run at a terminal is told so rather than "no terminal", which + would read as wrong to someone looking at one. + """ + return "unattended run" if _UNATTENDED else "no terminal" + def is_interactive() -> bool: - """True when stdin is attached to a TTY.""" - return sys.stdin.isatty() + """True when stdin is attached to a TTY and the run is not unattended.""" + return not _UNATTENDED and sys.stdin.isatty() def _format_prefix(tag: str | None, level: str) -> str: @@ -68,6 +96,8 @@ def prompt_text( stdin raises ``OSError("reading from stdin while output is captured")``, and any other unreadable-stdin scenario should fall back gracefully too. """ + if _UNATTENDED: + return default if eof_default is None else eof_default full = log.downgrade_glyphs(_format_prefix(tag, level) + msg) with progress_hooks.hooks().yield_terminal("prompt"): try: @@ -102,6 +132,8 @@ def prompt_choice( - ``False`` — return ``default`` immediately. Use this for destructive prompts where any non-confirming input should fall through to abort. """ + if _UNATTENDED: + return default if eof_default is None else eof_default choices_t = tuple(c.lower() for c in choices) full = log.downgrade_glyphs(_format_prefix(tag, level) + msg) with progress_hooks.hooks().yield_terminal("prompt"): @@ -145,8 +177,11 @@ def prompt_key( it as ``\\x03`` rather than a signal); Ctrl-D / EOF / unreadable stdin raise ``EOFError`` so callers hit the same abort path as line prompts. A bare Enter (or empty line in fallback mode) returns ``""`` — "no - answer", distinct from EOF — so callers can re-prompt. + answer", distinct from EOF — so callers can re-prompt. An unattended run + raises ``EOFError`` without reading. """ + if _UNATTENDED: + raise EOFError full = log.downgrade_glyphs(_format_prefix(tag, level) + msg) with progress_hooks.hooks().yield_terminal("prompt"): return _read_key(full) diff --git a/sysforge/primitives/stage_sentinel.py b/sysforge/primitives/stage_sentinel.py index 3e799d6e..f8cd4cea 100644 --- a/sysforge/primitives/stage_sentinel.py +++ b/sysforge/primitives/stage_sentinel.py @@ -88,7 +88,7 @@ from sysforge.primitives.build_lock import build_lock from sysforge.primitives.interrupt import CleanExitRequested, InterruptScope from sysforge.primitives.privilege import on_mutation, privileged_argv -from sysforge.primitives.prompt import is_interactive, prompt_choice +from sysforge.primitives.prompt import is_interactive, no_prompt_reason, prompt_choice from sysforge.primitives.resource_guard import lift_for_child _log = log.get_logger("SENTINEL") @@ -353,8 +353,8 @@ def check_and_recover_stale_sentinel(state_dir: Path | str | None = None) -> boo # Emit explicit instructions instead so the manual path is obvious. if not is_interactive(): _log.error( - "Cannot prompt for recovery: stdin is not a TTY. " - "Run sysforge in an interactive terminal to use the prompt, " + f"Cannot prompt for recovery ({no_prompt_reason()}). " + "Run sysforge at a terminal, without --unattended, to use the prompt, " f"or clear the sentinel manually: rm {sentinel.path}" ) if recovery_cmd: diff --git a/sysforge/revert_cmd.py b/sysforge/revert_cmd.py index 4e402a3d..a4fa531e 100644 --- a/sysforge/revert_cmd.py +++ b/sysforge/revert_cmd.py @@ -114,7 +114,7 @@ def execute(self, args, pre: PreCheckResult) -> ExecResult: if not getattr(args, "yes", False): if not prompt.is_interactive(): - _log.error("[revert] refusing without a TTY; pass --yes") + _log.error(f"[revert] refusing ({prompt.no_prompt_reason()}); pass --yes") return ExecResult(exit_code=2) ans = prompt.prompt_choice( "Proceed with revert? [y/N] ", ["y", "n"], diff --git a/sysforge/run_cmd.py b/sysforge/run_cmd.py index 88f5b839..770b166f 100644 --- a/sysforge/run_cmd.py +++ b/sysforge/run_cmd.py @@ -423,10 +423,11 @@ def add_parser(cls, sub): "ahead-of-upstream commits, or no upstream.") p.add_argument("--cleansrc-force", action="store_true", dest="cleansrc_force", help=CLEANSRC_FORCE_HELP) - p.add_argument("--no-interactive", action="store_true", dest="non_interactive", - help="Disable interactive kconfig (default is interactive: the PKGBUILD's " - "`make nconfig`/`menuconfig`/etc. runs as written). With this flag, " - "interactive targets are patched to `make olddefconfig` for unattended runs.") + p.add_argument("--no-review-config", action="store_true", dest="no_review_config", + help="Skip the kernel config review for this run: the PKGBUILD's " + "`make nconfig`/`menuconfig`/etc. is patched to `make olddefconfig`. " + "kernel.toml review_config = false does the same for every run. The " + "stage still asks its safety questions; --unattended stops those too.") p.add_argument("--toolchain", choices=TOOLCHAIN_CHOICES, dest="compiler", help="Build the kernel with this toolchain (gcc or llvm). Independent of " "`run toolchain`, so you can keep gcc system-wide but build the " @@ -526,7 +527,7 @@ def execute(self, args, pre: PreCheckResult) -> ExecResult: abi_check=args.abi_check, state_dir=Path(args.state_dir) if args.state_dir else None, profile_conf=getattr(args, "profile_conf", None), - non_interactive=getattr(args, "non_interactive", False), + no_review_config=getattr(args, "no_review_config", False), bootloader=getattr(args, "bootloader", None), compiler=getattr(args, "compiler", None), base_config=getattr(args, "base_config", None), diff --git a/sysforge/self_uninstall_cmd.py b/sysforge/self_uninstall_cmd.py index fb97799f..93f9c06c 100644 --- a/sysforge/self_uninstall_cmd.py +++ b/sysforge/self_uninstall_cmd.py @@ -201,7 +201,7 @@ def add_parser(cls, sub): p.add_argument("--dry-run", action="store_true", dest="dry_run", help="Show the plan without changing anything.") p.add_argument("--yes", action="store_true", dest="yes", - help="Do it without asking (needed without a terminal).") + help="Do it without asking (needed without a terminal or with --unattended).") p.add_argument("--state-dir", metavar="DIR", dest="state_dir", help="Override state directory.") p.set_defaults(verb_cls=cls) @@ -236,7 +236,7 @@ def execute(self, args, pre: PreCheckResult) -> ExecResult: if not args.yes: if not prompt.is_interactive(): - _log.error("refusing without a terminal; pass --yes") + _log.error(f"refusing ({prompt.no_prompt_reason()}); pass --yes") return ExecResult(exit_code=2) ans = prompt.prompt_choice( "Remove sysforge as above? [y/N] ", ["y", "n"], diff --git a/sysforge/setup_cmd.py b/sysforge/setup_cmd.py index 2bb9107e..0cc54c8d 100644 --- a/sysforge/setup_cmd.py +++ b/sysforge/setup_cmd.py @@ -67,7 +67,7 @@ MODE_PROMPT, resolve_review_policy, ) -from sysforge.primitives.prompt import prompt_choice +from sysforge.primitives.prompt import is_interactive, no_prompt_reason, prompt_choice from sysforge.primitives.render import arrow @@ -247,7 +247,7 @@ def _setup_ignore_group(conf_path: Path) -> None: def _has_terminal() -> bool: - return sys.stdin.isatty() and sys.stdout.isatty() + return is_interactive() and sys.stdout.isatty() def _read_table(path, section: str | None) -> dict: @@ -274,7 +274,7 @@ def _setup_preferences() -> None: ``config.set_toml_key`` (the one runtime TOML writer). """ if not _has_terminal(): - print("[SYSFORGE] preferences: skipped (no terminal)") + print(f"[SYSFORGE] preferences: skipped ({no_prompt_reason()})") return from sysforge.primitives.build_sandbox import resolve_sandbox @@ -479,7 +479,7 @@ def _setup_preset(name: str | None) -> None: explicit = name is not None if not explicit: if not _has_terminal(): - print("[SYSFORGE] preset: skipped (no terminal)") + print(f"[SYSFORGE] preset: skipped ({no_prompt_reason()})") return name = _choose_preset() preset = config_presets.PRESETS[name] diff --git a/sysforge/state_cmd.py b/sysforge/state_cmd.py index f6fb26d7..1b9633a1 100644 --- a/sysforge/state_cmd.py +++ b/sysforge/state_cmd.py @@ -775,9 +775,9 @@ def cmd_state_profiles(args) -> int: if not getattr(args, "yes", False): if not prompt.is_interactive(): _log.error( - f"refusing to delete {target} without a TTY: a collected profile " - "needs a workload to regenerate; run it from a terminal, or " - "pass --yes" + f"refusing to delete {target} ({prompt.no_prompt_reason()}): " + "a collected profile needs a workload to regenerate; answer " + "at a terminal, or pass --yes" ) return 2 answer = prompt.prompt_choice( @@ -867,7 +867,8 @@ def add_parser(cls, sub): "(e.g. pgo/mesa, autofdo/linux-sysforge); asks for confirmation. " "A later rebuild is then unprofiled until you collect again.") p.add_argument("--yes", action="store_true", dest="yes", - help="With --delete: delete without the y/N prompt (needed without a TTY).") + help="With --delete: delete without the y/N prompt (needed without a " + "terminal or with --unattended).") p.add_argument("--no-pager", action="store_true", dest="no_pager", help="Don't pipe output through $PAGER (default: paginate when stdout is a TTY).") p.add_argument("--state-dir", metavar="DIR", dest="state_dir", diff --git a/sysforge/uninstall_cmd.py b/sysforge/uninstall_cmd.py index 7f87ddd9..0ea66e6d 100644 --- a/sysforge/uninstall_cmd.py +++ b/sysforge/uninstall_cmd.py @@ -64,7 +64,8 @@ def add_parser(cls, sub): p.add_argument("packages", nargs="+", metavar="PKG", help="Package name(s) to remove (stock or -sysforge name).") p.add_argument("--yes", action="store_true", dest="yes", - help="Remove without pacman's confirmation prompt (needed without a terminal).") + help="Remove without pacman's confirmation prompt (needed without a " + "terminal or with --unattended).") p.add_argument("--state-dir", metavar="DIR", dest="state_dir", help="Override state directory.") p.set_defaults(verb_cls=cls) @@ -91,7 +92,7 @@ def execute(self, args, pre: PreCheckResult) -> ExecResult: # pacman asks at a terminal; with none it reads EOF at its [Y/n] and # aborts after printing the transaction, so refuse first (3.5.0-B143). if not args.yes and not prompt.is_interactive(): - _log.error("[uninstall] refusing without a terminal; pass --yes") + _log.error(f"[uninstall] refusing ({prompt.no_prompt_reason()}); pass --yes") return ExecResult(exit_code=2) names = [it.installed_name for it in items] diff --git a/sysforge/update.py b/sysforge/update.py index 58282967..02bdb2d5 100644 --- a/sysforge/update.py +++ b/sysforge/update.py @@ -110,7 +110,7 @@ change_summary, resolve_review_policy, ) -from sysforge.primitives.prompt import prompt_choice +from sysforge.primitives.prompt import is_interactive, no_prompt_reason, prompt_choice from sysforge.primitives.privilege import privileged_argv from sysforge.primitives.resource_guard import lift_for_child from sysforge.primitives.profile import ( @@ -280,9 +280,7 @@ def _toolchain_preflight_for_batch(to_build, config, args) -> bool: report = run_toolchain_preflight(required) if report.failed: - non_interactive = getattr(args, "non_interactive", False) or \ - getattr(args, "noconfirm", False) - report = auto_remediate_toolchain(report, non_interactive=non_interactive) + report = auto_remediate_toolchain(report) if report.failed: _log.ui(render_toolchain_preflight(report, classify=True)) return False @@ -1579,8 +1577,8 @@ def _phase_confirm(run: UpdateRun) -> int | None: return None if getattr(run.args, "yes", False) or run.build_cfg.get("confirm_plan", True) is False: return None - if not (sys.stdin.isatty() and sys.stdout.isatty()): - _log.info("plan confirmation skipped: no terminal") + if not (is_interactive() and sys.stdout.isatty()): + _log.info(f"plan confirmation skipped: {no_prompt_reason()}") return None _ui_progress.phase(None) for line in update_plan.render_plan(run.plan): @@ -2077,7 +2075,7 @@ def add_parser(cls, sub): help="Review changed sources of every type, including trusted ones " "(by default Arch's own packaging repos). Without this flag, update " "prompts at a terminal for untrusted changes and refuses them with " - "no terminal.") + "no terminal or with --unattended.") review_group.add_argument("--no-review", action="store_true", dest="no_review", help="Skip the PKGBUILD review gate entirely. " "Also configurable via [build] review = false in packages.toml.") diff --git a/sysforge/verbs/registry.py b/sysforge/verbs/registry.py index f3d8d151..65c19fbb 100644 --- a/sysforge/verbs/registry.py +++ b/sysforge/verbs/registry.py @@ -223,6 +223,16 @@ def build_parser() -> argparse.ArgumentParser: "the [ui] color config key." ), ) + parser.add_argument( + "--unattended", + action="store_true", + help=( + "Never prompt, even at a terminal: every question takes the answer " + "it gets when there is no terminal (a safe default, or a refusal " + "that names the flag to pass). Overrides SYSFORGE_UNATTENDED and " + "the [ui] unattended config key." + ), + ) parser.add_argument( "--frozen", action="store_true", diff --git a/tests/conftest.py b/tests/conftest.py index fe29cda7..0a6d237b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -101,6 +101,16 @@ def _reset_narrated(): log._reset_narrated() +@pytest.fixture(autouse=True) +def _reset_unattended(monkeypatch): + """Start every test attended (3.5.0-F21): ``prompt.set_unattended`` is a + module global, and a test that runs ``cli.main`` would otherwise leave it + set for every later test's prompts.""" + from sysforge.primitives import prompt + monkeypatch.setattr(prompt, "_UNATTENDED", False, raising=False) + monkeypatch.delenv("SYSFORGE_UNATTENDED", raising=False) + + @pytest.fixture(autouse=True) def _reset_saved_user_pager(monkeypatch): """ @@ -404,6 +414,8 @@ def guard(argv, **kwargs): cmd = list(argv) while prefix and cmd[:len(prefix)] == prefix: cmd = cmd[len(prefix):] + if cmd[:1] == ["-n"]: # sudo -n on an unattended run (3.5.0-F21) + cmd = cmd[1:] done = subprocess.CompletedProcess(argv, 0, "", "") def inside(dst: str) -> bool: diff --git a/tests/data/etc/sysforge/kernel.toml b/tests/data/etc/sysforge/kernel.toml index 4feba918..1610dbaa 100644 --- a/tests/data/etc/sysforge/kernel.toml +++ b/tests/data/etc/sysforge/kernel.toml @@ -66,12 +66,13 @@ pkgname = "linux-sysforge" # default: unset (sysforge-.conf) # boot_entries_prefix = "97" -# interactive — default: true — the PKGBUILD's `make nconfig` / `menuconfig` -# runs as written so the user can review/edit the resolved kconfig. Requires -# a real TTY: piped/captured runs downgrade to unattended with a warning. -# Set to false (or pass `--no-interactive`) to patch interactive targets to -# `make olddefconfig` for unattended runs. -# interactive = true +# review_config — default: true — the PKGBUILD's `make nconfig` / `menuconfig` +# runs as written so you can review/edit the resolved kconfig. Needs a +# terminal: a run without one, or with --unattended, skips it. Set to false +# (or pass `--no-review-config` for one run) to patch those targets to +# `make olddefconfig`. Called `interactive` before 3.5.0; the old name is +# still read, and `sysforge config clean` renames it. +# review_config = true # compiler — "gcc" | "llvm" — kernel-stage compiler override. Independent of # the global toolchain stage, so a system that keeps gcc as its default can @@ -199,10 +200,10 @@ pkgname = "linux-sysforge" # first, so the sequence shapes the merged config. Silent targets # (olddefconfig, defconfig, allmodconfig, alldefconfig, savedefconfig, # listnewconfig) run in listed order. Prompting targets (oldconfig, -# localmodconfig, localyesconfig, mod2yesconfig) require an interactive -# stage run — non-interactive runs reject them (oldconfig names its silent -# equivalent olddefconfig; the local*/mod2yes targets say to run -# interactively). At most one UI target (config, nconfig, menuconfig, +# localmodconfig, localyesconfig, mod2yesconfig) need the config review +# (review_config above) — a run without it rejects them (oldconfig names its +# silent equivalent olddefconfig; the local*/mod2yes targets say to turn the +# review on). At most one UI target (config, nconfig, menuconfig, # xconfig, gconfig) is allowed and it always runs last. randconfig is never # allowed — a randomized config is a boot-safety hazard. # localmodconfig/localyesconfig over-minimize to modules currently loaded diff --git a/tests/data/etc/sysforge/profiles.toml b/tests/data/etc/sysforge/profiles.toml index a3724fc3..ce39acfc 100644 --- a/tests/data/etc/sysforge/profiles.toml +++ b/tests/data/etc/sysforge/profiles.toml @@ -81,9 +81,10 @@ # PKG_CONFIG_PATH = "/usr/local/lib/pkgconfig" # # # makepkg invocation flags -# makepkg_flags = ["--noconfirm"] -# # common flags: --noconfirm --noprogressbar --log --cleanbuild -# # --force (--syncdeps/--install are always stripped) +# makepkg_flags = ["--cleanbuild"] +# # common flags: --noprogressbar --log --cleanbuild --force +# # (--syncdeps/--install are always stripped; --noconfirm is +# # added by sysforge except on an interactive build) # # # SysForge behaviour keys (not written to conf or env) # build_mode = "source_built" # "source_built" | "pgo_llvm_toolchain" | "kernel" @@ -165,13 +166,12 @@ STRIP = "llvm-strip" CFLAGS = "-march=native -O2 -pipe" CXXFLAGS = "$CFLAGS" LDFLAGS = "-Wl,-O1,--sort-common,--as-needed,-z,relro,-z,now" -makepkg_flags = ["--noconfirm"] CARGO_NET_GIT_FETCH_WITH_CLI = "true" RUSTC_WRAPPER = "sccache" [profiles.clean] extends = "standard" -makepkg_flags = ["--noconfirm", "--cleanbuild"] +makepkg_flags = ["--cleanbuild"] [profiles.optimized] extends = "clean" @@ -202,12 +202,12 @@ CXX = "g++" extends = "bare" build_mode = "kernel" batch = true -makepkg_flags = ["--noconfirm", "-f", "-c"] +makepkg_flags = ["-f", "-c"] [profiles.batch] extends = "standard" batch = true -makepkg_flags = ["--noconfirm", "--noprogressbar", "--log", "--cleanbuild"] +makepkg_flags = ["--noprogressbar", "--log", "--cleanbuild"] clean_builddir = true [profiles.unattended] diff --git a/tests/data/etc/sysforge/sysforge.toml b/tests/data/etc/sysforge/sysforge.toml index 69f8520b..3a4ae05c 100644 --- a/tests/data/etc/sysforge/sysforge.toml +++ b/tests/data/etc/sysforge/sysforge.toml @@ -12,6 +12,8 @@ color = "auto" # merge = "vimdiff" # Diff/merge tool for `sysforge config merge` (.sfnew # # adoption). Resolution: SYSFORGE_MERGE > this > $DIFFPROG # # > vimdiff. Accepts args, e.g. "nvim -d" or "meld". +# unattended = false # Never prompt, even at a terminal; take the no-terminal +# # answer. --unattended > SYSFORGE_UNATTENDED > this. [log] # Default stderr verbosity when no -v/--quiet flag is passed (0–3). Shipped diff --git a/tests/data/surface.json b/tests/data/surface.json index 648e8b1f..fc974315 100644 --- a/tests/data/surface.json +++ b/tests/data/surface.json @@ -16,6 +16,7 @@ "--thaw": null, "--timings": null, "--turbo": null, + "--unattended": null, "--verbose": null, "--version": null }, @@ -354,9 +355,9 @@ "--log-dir": null, "--no-docs": null, "--no-headers": null, - "--no-interactive": null, "--no-keep-hotplug-drivers": null, "--no-pkg-log": null, + "--no-review-config": null, "--no-update": null, "--persist-log": null, "--profile-conf": null, @@ -582,7 +583,8 @@ "SYSFORGE_PROFILE_STORE", "SYSFORGE_PROGRESS_TRACE", "SYSFORGE_PTY_RAW_DUMP", - "SYSFORGE_STATE_DIR" + "SYSFORGE_STATE_DIR", + "SYSFORGE_UNATTENDED" ], "journal_fields": [ "SYSFORGE_EXIT", @@ -657,7 +659,8 @@ "ui": [ "color", "editor", - "merge" + "merge", + "unattended" ], "update": [ "rebuild_on_drift", @@ -720,7 +723,6 @@ "device_kconfig", "enabled", "hardening", - "interactive", "kconfig_merge", "kconfig_targets", "keep_hotplug_drivers", @@ -728,6 +730,7 @@ "pkgbuild_src_dir", "pkgname", "require_fallback_kernel", + "review_config", "source", "srcdir", "upstream_pkgname" diff --git a/tests/test_already_built.py b/tests/test_already_built.py index 0e26e68a..36718402 100644 --- a/tests/test_already_built.py +++ b/tests/test_already_built.py @@ -22,24 +22,12 @@ def test_unknown_posture_raises_value_error(): resolve_already_built("wat", interactive=False) -@pytest.mark.parametrize( - ("interactive", "non_interactive", "tty"), - [ - (False, False, True), # caller says non-interactive - (True, True, True), # --no-interactive override - (True, False, False), # no TTY - ], -) -def test_review_gated_unattended_signals_each_force_reuse( - interactive, non_interactive, tty -): - with patch("sysforge.primitives.prompt.is_interactive", return_value=tty), \ - patch("sysforge.primitives.prompt.prompt_choice") as mock_prompt: - action = resolve_already_built( - "review-gated", - interactive=interactive, - non_interactive=non_interactive, - ) +def test_review_gated_without_a_promised_review_reuses(): + """The caller's one resolved answer decides (3.5.0-F22): no review was + promised (review off, --no-review-config, --unattended or no TTY, all + folded in by resolve_config_review), so there is nothing to ask about.""" + with patch("sysforge.primitives.prompt.prompt_choice") as mock_prompt: + action = resolve_already_built("review-gated", interactive=False) assert action is AlreadyBuiltAction.REUSE mock_prompt.assert_not_called() diff --git a/tests/test_kernel_config_review.py b/tests/test_kernel_config_review.py new file mode 100644 index 00000000..fa444bb6 --- /dev/null +++ b/tests/test_kernel_config_review.py @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: 2026 Keith Raghubar +# +# SPDX-License-Identifier: MIT + +""" +Kernel config review (3.5.0-F22): two questions, each answered in one place. + +* Run the PKGBUILD's config UI? ``kernel/config.py::resolve_config_review`` — + ``review_config`` (kernel.toml) and not ``--no-review-config`` and sysforge + may ask. +* May sysforge ask its own questions? ``prompt.is_interactive()`` only, so + ``--no-review-config`` no longer turns the stage's safety questions into + refusals; ``--unattended`` (or no terminal) does. +""" +from types import SimpleNamespace + +import pytest + +from sysforge.pipeline.stages.kernel import config as kcfg +from sysforge.pipeline.stages.kernel import source +from sysforge.primitives import prompt + + +@pytest.fixture +def at_a_terminal(monkeypatch): + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: True, raising=False) + + +@pytest.fixture +def no_terminal(monkeypatch): + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: False, raising=False) + + +def _cfg(**data): + return kcfg.KernelConfig.from_toml({"enabled": True, **data}) + + +# --------------------------------------------------------------------------- +# kernel.toml review_config (was `interactive`) +# --------------------------------------------------------------------------- + +def test_review_config_defaults_on(): + assert _cfg().review_config is True + + +def test_review_config_is_read(): + assert _cfg(review_config=False).review_config is False + + +def test_legacy_interactive_key_still_applies_with_a_warning(monkeypatch): + """A kernel.toml from before 3.5.0-F22 that turned the review off must not + get nconfig back silently on upgrade.""" + warned = [] + monkeypatch.setattr(kcfg, "_log", SimpleNamespace( + warn=warned.append, info=lambda *_: None, debug=lambda *_: None)) + assert _cfg(interactive=False).review_config is False + assert any("review_config" in w and "config clean" in w for w in warned) + + +def test_review_config_wins_over_the_legacy_key(): + assert _cfg(interactive=False, review_config=True).review_config is True + + +# --------------------------------------------------------------------------- +# resolve_config_review +# --------------------------------------------------------------------------- + +def _opts(**kw): + return SimpleNamespace(**{"no_review_config": False, **kw}) + + +def test_review_runs_by_default_at_a_terminal(at_a_terminal): + assert kcfg.resolve_config_review(_cfg(), _opts()) is True + + +def test_review_config_false_turns_it_off(at_a_terminal): + assert kcfg.resolve_config_review(_cfg(review_config=False), _opts()) is False + + +def test_no_review_config_flag_turns_it_off(at_a_terminal): + assert kcfg.resolve_config_review(_cfg(), _opts(no_review_config=True)) is False + + +def test_unattended_turns_it_off(at_a_terminal): + prompt.set_unattended(True) + assert kcfg.resolve_config_review(_cfg(), _opts()) is False + + +def test_no_terminal_turns_it_off(no_terminal): + assert kcfg.resolve_config_review(_cfg(), _opts()) is False + + +# --------------------------------------------------------------------------- +# The stage's own questions follow prompt.is_interactive() only +# --------------------------------------------------------------------------- + +def test_no_review_config_still_asks_the_shadow_question(at_a_terminal, monkeypatch): + """Skipping the config UI is not consent to overwrite a repo package.""" + asked = [] + monkeypatch.setattr("sysforge.primitives.aur.is_repo_package", lambda name: True) + monkeypatch.setattr("sysforge.primitives.prompt.prompt_choice", + lambda *a, **k: asked.append(a) or "n") + with pytest.raises(RuntimeError): + source.check_pkgname_repo_collision("linux", _opts(no_review_config=True, dry_run=False)) + assert asked, "the shadow question was not asked" + + +def test_unattended_refuses_the_shadow_question(at_a_terminal, monkeypatch): + monkeypatch.setattr("sysforge.primitives.aur.is_repo_package", lambda name: True) + monkeypatch.setattr("sysforge.primitives.prompt.prompt_choice", + lambda *a, **k: pytest.fail("asked while unattended")) + prompt.set_unattended(True) + with pytest.raises(RuntimeError, match="unattended run"): + source.check_pkgname_repo_collision("linux", _opts(dry_run=False)) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def test_run_kernel_takes_no_review_config(): + from tests.cli_args import cli_args + args = cli_args("run", "kernel", "--no-review-config") + assert args.no_review_config is True + + +def test_run_kernel_no_longer_takes_no_interactive(): + from tests.cli_args import cli_args + with pytest.raises(SystemExit): + cli_args("run", "kernel", "--no-interactive") + + +def test_config_check_says_the_old_key_is_still_read(): + """The loader still honours `interactive`, so the check must not say it is ignored.""" + from sysforge.primitives import config_schema + issues = config_schema.inspect_text("kernel.toml", "interactive = false\nenabled = true\n") + retired = [i for i in issues if i.kind == "retired_key"] + assert retired and "still read for now; use review_config" in retired[0].message diff --git a/tests/test_makepkg_invoke_flags.py b/tests/test_makepkg_invoke_flags.py index b017adb0..14704d39 100644 --- a/tests/test_makepkg_invoke_flags.py +++ b/tests/test_makepkg_invoke_flags.py @@ -21,6 +21,26 @@ def test_append_flags_keeps_a_flag_that_takes_a_value(): "--config", "a.conf", "--config", "b.conf"] +def test_base_flags_add_noconfirm_when_the_profile_omits_it(): + """Unattended is sysforge's policy, not profile data (3.5.0-F20).""" + assert mi._base_flags({"makepkg_flags": ["--cleanbuild"]}, interactive=False) == [ + "--noconfirm", "--cleanbuild"] + assert mi._base_flags({}, interactive=False) == ["--noconfirm"] + + +def test_base_flags_do_not_repeat_a_profile_noconfirm(): + """A profile written before 3.5.0-F20 still carries the flag; it is not doubled.""" + assert mi._base_flags({"makepkg_flags": ["--noconfirm", "-f"]}, + interactive=False) == ["--noconfirm", "-f"] + + +def test_base_flags_leave_noconfirm_off_an_interactive_build(): + """`build --interactive` and an interactive kernel run get pacman's prompts.""" + assert mi._base_flags({"makepkg_flags": ["--noconfirm", "-f"]}, + interactive=True) == ["-f"] + assert mi._base_flags({"makepkg_flags": ["-f"]}, interactive=True) == ["-f"] + + def test_merge_extra_env_warns_only_when_the_value_changes(monkeypatch, capsys): from sysforge import log monkeypatch.setattr(log, "_VERBOSITY", 1) diff --git a/tests/test_privilege.py b/tests/test_privilege.py index 5aac809f..5a6691d8 100644 --- a/tests/test_privilege.py +++ b/tests/test_privilege.py @@ -181,3 +181,38 @@ def fake_run(argv, **kw): tick("a") privilege.ensure_credentials() assert paused == [1] + + +# --------------------------------------------------------------------------- +# Unattended runs (3.5.0-F21): sudo reads the terminal itself, so the prompt +# helpers' EOF path never reaches it. Never ask for a password. +# --------------------------------------------------------------------------- + +def test_ensure_credentials_unattended_never_runs_the_prompting_probe(monkeypatch): + from sysforge.primitives import prompt + events = _yield_recorder(monkeypatch) + monkeypatch.setattr(privilege.os, "geteuid", lambda: 1000) + monkeypatch.setattr(privilege, "_credentials_cached", lambda: False) + monkeypatch.setattr(privilege.subprocess, "run", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("ran"))) + prompt.set_unattended(True) + assert privilege.ensure_credentials() is False + assert events == [] + + +def test_ensure_credentials_unattended_uses_cached_credentials(monkeypatch): + """NOPASSWD or a warm timestamp still works unattended.""" + from sysforge.primitives import prompt + monkeypatch.setattr(privilege.os, "geteuid", lambda: 1000) + monkeypatch.setattr(privilege, "_credentials_cached", lambda: True) + prompt.set_unattended(True) + assert privilege.ensure_credentials() is True + + +def test_privileged_argv_unattended_adds_dash_n(monkeypatch): + from sysforge.primitives import prompt + monkeypatch.setattr(privilege.os, "geteuid", lambda: 1000) + assert privilege.privileged_argv(["pacman", "-U", "x"]) == ["sudo", "pacman", "-U", "x"] + prompt.set_unattended(True) + assert privilege.privileged_argv(["pacman", "-U", "x"]) == [ + "sudo", "-n", "pacman", "-U", "x"] diff --git a/tests/test_stage_kernel.py b/tests/test_stage_kernel.py index dca42d5d..af68236a 100644 --- a/tests/test_stage_kernel.py +++ b/tests/test_stage_kernel.py @@ -327,7 +327,7 @@ def test_resolve_kconfig_targets_prompting_target_rejected_when_non_interactive( def test_resolve_kconfig_targets_local_target_rejected_when_non_interactive(): - with pytest.raises(ValueError, match="interactively"): + with pytest.raises(ValueError, match="needs the config review"): resolve_kconfig_targets( kcfg({"kconfig_targets": ["localmodconfig"]}), interactive=False ) @@ -1621,7 +1621,7 @@ def test_kernel_stage_non_interactive_flag_flips_to_false(tmp_path): state = PipelineState(tmp_path / "state") opts = make_options(state_dir=tmp_path / "state") - opts.non_interactive = True + opts.no_review_config = True with patch.object(_km.config, "KERNEL_PATH", p), \ patch(_MAKEPKG_RUN, return_value=None) as mock_build, \ @@ -1633,6 +1633,31 @@ def test_kernel_stage_non_interactive_flag_flips_to_false(tmp_path): assert build_opts.interactive is False +def test_kernel_stage_unattended_skips_review_without_warning(tmp_path, monkeypatch): + """An unattended run at a terminal drops the review as asked (3.5.0-F21): + narration, not the no-TTY warning meant for a run that wanted the review.""" + import sysforge.pipeline.stages.kernel as _km + from sysforge.pipeline.stages.kernel import config as _kc + from sysforge.primitives import prompt + builds = tmp_path / "builds" + make_pkgbuild(builds, "linux-git") + p = make_kernel_toml(tmp_path, builds) + state = PipelineState(tmp_path / "state") + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: True, raising=False) + prompt.set_unattended(True) + + with patch.object(_km.config, "KERNEL_PATH", p), \ + patch(_MAKEPKG_RUN, return_value=None) as mock_build, \ + patch.object(_kc, "_log") as mock_log, \ + patch("sysforge.pipeline.stages.kernel.install.subprocess.run") as mock_sub: + mock_sub.return_value = MagicMock(returncode=0, stdout="") + KernelStage().run({}, state, make_options(state_dir=tmp_path / "state")) + + assert mock_build.call_args.kwargs["options"].interactive is False + assert not any("config review" in str(c) for c in mock_log.warn.call_args_list) + assert any("unattended run" in str(c) for c in mock_log.info.call_args_list) + + def test_kernel_stage_kernel_toml_interactive_false(tmp_path): import sysforge.pipeline.stages.kernel as _km builds = tmp_path / "builds" @@ -1904,7 +1929,7 @@ def test_kernel_stage_sync_diverged_aborts_unattended(tmp_path): patch("sysforge.primitives.prompt.is_interactive", return_value=False), \ patch(_MAKEPKG_RUN, return_value=None) as mock_build, \ patch("sysforge.pipeline.stages.kernel.install.subprocess.run"), \ - pytest.raises(RuntimeError, match="diverged source unattended"): + pytest.raises(RuntimeError, match="diverged source \\(no terminal\\)"): KernelStage().run({}, state, opts) mock_build.assert_not_called() @@ -3051,7 +3076,7 @@ def test_interactive_run_emits_nudge(tmp_path): p = make_kernel_toml(tmp_path, builds) # interactive defaults to True logs = _run_kernel_with_state(tmp_path, (state, p)) - assert any("Running interactively" in m for m in _info_messages(logs)), \ + assert any("Opening the kernel config review" in m for m in _info_messages(logs)), \ f"expected interactive nudge, got: {_info_messages(logs)}" @@ -3061,10 +3086,10 @@ def test_non_interactive_run_omits_nudge(tmp_path): state = PipelineState(tmp_path / "state") p = make_kernel_toml(tmp_path, builds) opts = make_options(state_dir=tmp_path / "state") - opts.non_interactive = True + opts.no_review_config = True logs = _run_kernel_with_state(tmp_path, (state, p), opts_override=opts) - assert not any("Running interactively" in m for m in _info_messages(logs)) + assert not any("Opening the kernel config review" in m for m in _info_messages(logs)) def test_dry_run_omits_interactive_nudge(tmp_path): @@ -3075,7 +3100,7 @@ def test_dry_run_omits_interactive_nudge(tmp_path): opts = make_options(state_dir=tmp_path / "state", dry_run=True) logs = _run_kernel_with_state(tmp_path, (state, p), opts_override=opts) - assert not any("Running interactively" in m for m in _info_messages(logs)) + assert not any("Opening the kernel config review" in m for m in _info_messages(logs)) def test_kernel_stage_invokes_pre_build_snapshot(tmp_path): @@ -3122,7 +3147,7 @@ def test_pkgname_collision_unattended_aborts(): opts = make_options() with patch("sysforge.primitives.aur.is_repo_package", return_value=True), \ patch("sysforge.primitives.prompt.is_interactive", return_value=False), \ - pytest.raises(RuntimeError, match="Aborting unattended"): + pytest.raises(RuntimeError, match="Not asking \\(no terminal\\)"): check_pkgname_repo_collision("linux", opts) @@ -3601,7 +3626,7 @@ def test_cli_keep_hotplug_flag_parses(): # --------------------------------------------------------------------------- def _run_stage_already_built(tmp_path, *, prompt_ret=None, tty=True, - non_interactive=False, rebuild_raises=False): + no_review_config=False, rebuild_raises=False): """Drive KernelStage.run() with makepkg raising AlreadyBuilt on the first call. Returns (mock_build, mock_prompt, mock_install, logs, excinfo).""" import sysforge.pipeline.stages.kernel as _km @@ -3612,8 +3637,8 @@ def _run_stage_already_built(tmp_path, *, prompt_ret=None, tty=True, p = make_kernel_toml(tmp_path, builds) state = PipelineState(tmp_path / "state") opts = make_options(state_dir=tmp_path / "state") - if non_interactive: - opts.non_interactive = True + if no_review_config: + opts.no_review_config = True calls = [] @@ -3688,7 +3713,7 @@ def test_already_built_interactive_install_proceeds(tmp_path): def test_already_built_unattended_proceeds_without_prompt(tmp_path): """--no-interactive keeps today's behaviour: proceed, never prompt.""" mock_build, mock_prompt, mock_install, logs, exc = _run_stage_already_built( - tmp_path, non_interactive=True) + tmp_path, no_review_config=True) assert exc is None mock_prompt.assert_not_called() assert mock_build.call_count == 1 @@ -3716,7 +3741,7 @@ def test_already_built_install_failure_suggests_fresh_build(tmp_path): p = make_kernel_toml(tmp_path, builds) state = PipelineState(tmp_path / "state") opts = make_options(state_dir=tmp_path / "state") - opts.non_interactive = True + opts.no_review_config = True with patch.object(_km.config, "KERNEL_PATH", p), \ patch("sysforge.pipeline.stages.kernel.stage.makepkg_run", @@ -3770,7 +3795,7 @@ def test_kernel_stage_no_tty_warns_review_downgraded(tmp_path): """The TTY-forced downgrade of a config-requested review is WARNed.""" _, logs = _run_stage_tty(tmp_path, tty=False) warned = " ".join(str(c.args[1]) for c in logs.warn.call_args_list) - assert "no TTY" in warned and "review" in warned + assert "no terminal" in warned and "review" in warned def test_kernel_stage_tty_keeps_interactive(tmp_path): @@ -3787,7 +3812,7 @@ def test_kernel_stage_non_interactive_flag_no_tty_warn(tmp_path): p = make_kernel_toml(tmp_path, builds) state = PipelineState(tmp_path / "state") opts = make_options(state_dir=tmp_path / "state") - opts.non_interactive = True + opts.no_review_config = True with patch.object(_km.config, "KERNEL_PATH", p), \ patch(_MAKEPKG_RUN, return_value=None), \ patch("sysforge.primitives.prompt.is_interactive", return_value=False), \ @@ -3796,7 +3821,7 @@ def test_kernel_stage_non_interactive_flag_no_tty_warn(tmp_path): mock_sub.return_value = MagicMock(returncode=0, stdout="") KernelStage().run({}, state, opts) warned = " ".join(str(c.args[1]) for c in logs.warn.call_args_list) - assert "no TTY" not in warned + assert "no terminal" not in warned # --------------------------------------------------------------------------- @@ -3983,7 +4008,7 @@ def test_kernel_config_from_toml_none_is_none(): def test_kernel_config_empty_toml_gets_documented_defaults(): cfg = KernelConfig.from_toml({}) assert cfg.enabled is False # opt-in - assert cfg.interactive is True + assert cfg.review_config is True assert cfg.build_headers is True # headers are useful; docs are not, by default assert cfg.build_docs is False assert cfg.base_config == "pkgbuild" @@ -5348,7 +5373,7 @@ def test_already_built_install_resolves_reused_tree_from_exception(tmp_path): p = make_kernel_toml(tmp_path, builds) state = PipelineState(tmp_path / "state") opts = make_options(state_dir=tmp_path / "state") - opts.non_interactive = True + opts.no_review_config = True tree_root = tmp_path / "profile-builds" / "linux-git" def fake_makepkg(pkgbuild, *, options): diff --git a/tests/test_stage_sentinel.py b/tests/test_stage_sentinel.py index b8f3195b..a0a70247 100644 --- a/tests/test_stage_sentinel.py +++ b/tests/test_stage_sentinel.py @@ -194,7 +194,7 @@ def test_check_stale_non_tty_emits_loud_error_and_blocks(tmp_path, capsys): # Sentinel left in place; user must clear manually. assert StageSentinel(tmp_path).get_active() is not None err = capsys.readouterr().err - assert "stdin is not a TTY" in err + assert "Cannot prompt for recovery (no terminal)" in err assert str(tmp_path / "stage_in_progress.toml") in err assert "sudo pacman -S -- llvm" in err @@ -416,3 +416,18 @@ def test_declined_restore_is_false_and_runs_nothing(tmp_path, capsys): assert result is False run.assert_not_called() assert StageSentinel(tmp_path).get_active() is not None + + +def test_unattended_recovery_refusal_names_the_unattended_run(tmp_path, monkeypatch, capsys): + """At a terminal, "no terminal" would read as wrong (3.5.0-F21).""" + from sysforge.primitives import prompt + StageSentinel(tmp_path).mark_started( + "toolchain", **Recovery.pacman_sync(['llvm']).record_fields(), + ) + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: True, raising=False) + prompt.set_unattended(True) + with patch("sysforge.primitives.stage_sentinel.subprocess.run") as run_mock: + assert check_and_recover_stale_sentinel(tmp_path) is False + run_mock.assert_not_called() + err = capsys.readouterr().err + assert "Cannot prompt for recovery (unattended run)" in err diff --git a/tests/test_unattended.py b/tests/test_unattended.py new file mode 100644 index 00000000..f969bd8d --- /dev/null +++ b/tests/test_unattended.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 Keith Raghubar +# +# SPDX-License-Identifier: MIT + +""" +Unattended runs (3.5.0-F21): one switch that makes every prompt behave as if +stdin were closed, even at a terminal. + +Resolution is ``--unattended`` > ``SYSFORGE_UNATTENDED`` > ``[ui] unattended`` +> off, applied once in ``cli`` through ``prompt.set_unattended``. The prompt +helpers then take their EOF path without reading, so a call site that forgot +to check ``is_interactive()`` still cannot block. +""" +import builtins +import re +from pathlib import Path + +import pytest + +from sysforge.primitives import prompt + + +@pytest.fixture +def at_a_terminal(monkeypatch): + monkeypatch.setattr(prompt.sys.stdin, "isatty", lambda: True, raising=False) + + +@pytest.fixture +def no_reads(monkeypatch): + def _fail(*_a, **_k): + raise AssertionError("an unattended prompt read stdin") + monkeypatch.setattr(builtins, "input", _fail) + + +# --------------------------------------------------------------------------- +# prompt.py behaviour +# --------------------------------------------------------------------------- + +def test_is_interactive_is_false_when_unattended_even_at_a_terminal(at_a_terminal): + assert prompt.is_interactive() is True + prompt.set_unattended(True) + assert prompt.is_interactive() is False + assert prompt.unattended() is True + + +def test_prompt_choice_returns_its_eof_answer_without_reading(no_reads): + prompt.set_unattended(True) + assert prompt.prompt_choice("Go? [y/N] ", ("y", "n"), default="y", + eof_default="n") == "n" + assert prompt.prompt_choice("Go? [Y/n] ", ("y", "n"), default="y") == "y" + + +def test_prompt_text_returns_its_eof_answer_without_reading(no_reads): + prompt.set_unattended(True) + assert prompt.prompt_text("Name: ", default="a", eof_default="b") == "b" + assert prompt.prompt_text("Name: ", default="a") == "a" + + +def test_prompt_key_raises_eof_without_reading(no_reads): + """prompt_key's no-answer contract is EOFError, so callers hit their abort path.""" + prompt.set_unattended(True) + with pytest.raises(EOFError): + prompt.prompt_key("[r]etry [a]bort? ") + + +def test_no_prompt_reason_names_the_cause(at_a_terminal): + assert prompt.no_prompt_reason() == "no terminal" + prompt.set_unattended(True) + assert prompt.no_prompt_reason() == "unattended run" + + +# --------------------------------------------------------------------------- +# cli resolution +# --------------------------------------------------------------------------- + +def _config(monkeypatch, value): + monkeypatch.setattr( + "sysforge.primitives.config.load_sysforge_toml", + lambda: {"ui": {} if value is None else {"unattended": value}}, + ) + + +def test_resolve_unattended_defaults_off(monkeypatch): + import sysforge.cli as cli + monkeypatch.delenv("SYSFORGE_UNATTENDED", raising=False) + _config(monkeypatch, None) + assert cli._resolve_unattended(False) is False + + +def test_resolve_unattended_reads_the_config(monkeypatch): + import sysforge.cli as cli + monkeypatch.delenv("SYSFORGE_UNATTENDED", raising=False) + _config(monkeypatch, True) + assert cli._resolve_unattended(False) is True + + +def test_resolve_unattended_ignores_a_non_bool_config_value(monkeypatch): + import sysforge.cli as cli + monkeypatch.delenv("SYSFORGE_UNATTENDED", raising=False) + _config(monkeypatch, "yes") + assert cli._resolve_unattended(False) is False + + +@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "TRUE"]) +def test_resolve_unattended_env_turns_it_on(monkeypatch, value): + import sysforge.cli as cli + monkeypatch.setenv("SYSFORGE_UNATTENDED", value) + _config(monkeypatch, False) + assert cli._resolve_unattended(False) is True + + +@pytest.mark.parametrize("value", ["0", "false", "no", "off", ""]) +def test_resolve_unattended_env_turns_a_configured_on_off(monkeypatch, value): + """The env var is the way to get prompts back for one run.""" + import sysforge.cli as cli + monkeypatch.setenv("SYSFORGE_UNATTENDED", value) + _config(monkeypatch, True) + assert cli._resolve_unattended(False) is False + + +def test_resolve_unattended_flag_wins_over_env_and_config(monkeypatch): + import sysforge.cli as cli + monkeypatch.setenv("SYSFORGE_UNATTENDED", "0") + _config(monkeypatch, False) + assert cli._resolve_unattended(True) is True + + +def test_unattended_is_a_hoisted_global_flag(): + from sysforge.cli import _build_parser, _hoist_global_flags + assert _hoist_global_flags(["update", "--unattended"]) == ["--unattended", "update"] + assert _build_parser().parse_args(["--unattended", "doctor"]).unattended is True + assert _build_parser().parse_args(["doctor"]).unattended is False + + +# --------------------------------------------------------------------------- +# One home: no stdin TTY check outside prompt.py +# --------------------------------------------------------------------------- + +def test_no_stdin_isatty_outside_prompt(): + """A direct check would ignore the unattended switch (3.5.0-F21).""" + root = Path(__file__).resolve().parent.parent / "sysforge" + pattern = re.compile(r"stdin\.isatty\(") + offenders = [ + f"{p.relative_to(root.parent)}:{n}" + for p in root.rglob("*.py") + if p.name != "prompt.py" + for n, line in enumerate(p.read_text().splitlines(), 1) + if pattern.search(line) and not line.lstrip().startswith("#") + ] + assert offenders == [], f"use prompt.is_interactive(): {offenders}" diff --git a/tests/test_uninstall_cmd.py b/tests/test_uninstall_cmd.py index c716e6b9..3bd2b52b 100644 --- a/tests/test_uninstall_cmd.py +++ b/tests/test_uninstall_cmd.py @@ -126,3 +126,20 @@ def test_at_a_terminal_pacman_asks(tmp_path, monkeypatch): res, calls = _run(tmp_path, monkeypatch, yes=False, tty=True) assert res.exit_code == 0 assert calls == [[]] + + +def test_unattended_refusal_says_unattended_not_no_terminal(tmp_path, monkeypatch, capsys): + """At a terminal, "no terminal" would read as wrong (3.5.0-F21).""" + monkeypatch.setattr(uninstall_cmd.prompt.sys.stdin, "isatty", lambda: True, + raising=False) + uninstall_cmd.prompt.set_unattended(True) + monkeypatch.setattr(uninstall_cmd.pacman, "uninstall_pkgs", + lambda names, extra_flags=None: None) + monkeypatch.setattr(uninstall_cmd.install_reconcile, "consume_external_installs", + lambda bs: uninstall_cmd.install_reconcile.Reconciled([], [])) + args = cli_args("uninstall", "nano", "--state-dir", str(tmp_path)) + verb = uninstall_cmd.UninstallVerb() + res = verb.execute(args, verb.pre_check(args)) + err = capsys.readouterr().err + assert res.exit_code == 2 + assert "unattended run" in err and "no terminal" not in err diff --git a/tools/retired_names.toml b/tools/retired_names.toml index 8d91ea3c..58b7e731 100644 --- a/tools/retired_names.toml +++ b/tools/retired_names.toml @@ -38,7 +38,7 @@ use = "--yes" [[retired]] name = "--non-interactive" id = "3.5.0-F15" -use = "--no-interactive" +use = "--no-review-config" [[retired]] name = "--auto-pgo" @@ -120,3 +120,14 @@ allow = [ name = "build_mode_for" id = "3.5.0-F17" use = "mesa_pgo.BUILD_MODE" + +# --- 3.5.0-F22: the kernel config review says what it is ------------------- + +[[retired]] +name = "--no-interactive" +id = "3.5.0-F22" +use = "--no-review-config" +allow = [ + "tests/test_kernel_config_review.py", +] +