Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,44 @@ Want to know more? Go to the [project website](https://thenullpigeons.org).
How to install Nihil: [Get started / install](https://thenullpigeons.org/docs/installation/linux).

The project documentation (images, CLI, usage) is available on the [documentation site](https://thenullpigeons.org/docs).

## Customize an image

The following command asks for GitHub CLI authentication, creates or reuses a
personal fork of `nihil-images`, and opens the interactive tool selector:

```bash
nihil image customize web
```

In the selector, use the arrow keys (or `j`/`k`) to move. Press `/` to search
by tool, category, or command. Press `v` to enter visual mode, move with
`j`/`k` to select a range, and press `Space` to toggle all selected tools.
Press `Enter` to save, and `q` or `Esc` to cancel.

To use a group repository, add `--repo owner/repo` or its full GitHub URL.

Git remotes use SSH by default. Use `--git-protocol https` when HTTPS remotes
are preferred.

Use `--git-del` to remove and re-clone the existing local source directory.
This does not delete the remote GitHub repository.

Changes are stored on a `nihil/<variant>-custom` branch in the fork. With
`--no-push`, the branch is only prepared locally. Switch the active source with:

```bash
nihil image switch personal
nihil image switch upstream
nihil image status
```

After pushing, trigger the fork's build workflow for a specific variant and
install the image published in its GHCR namespace:

```bash
nihil image build full --wait
nihil install web
```

Use `nihil image build` to build all variants.
203 changes: 203 additions & 0 deletions nihil/cli/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
import secrets
import subprocess
import sys
import time
from pathlib import Path
Expand Down Expand Up @@ -54,8 +55,11 @@ def run(self, args: Optional[list] = None) -> int:
return self._cmd_config(parsed_args)
if parsed_args.command == "resources":
return self._cmd_resources(parsed_args)
if parsed_args.command == "image":
return self._cmd_image(parsed_args)
try:
self.manager = NihilManager()
self._configure_image_registry()
except NihilError as e:
print(self.formatter.error(str(e)), file=sys.stderr)
return e.exit_code
Expand Down Expand Up @@ -87,6 +91,35 @@ def run(self, args: Optional[list] = None) -> int:
return self._cmd_completion(parsed_args)
return 0

def _configure_image_registry(self) -> None:
"""Point short image references to the currently active fork."""
if self.config.image_source_active != "personal" or not self.config.personal_image_repo:
return
owner = self.config.personal_image_repo.split("/", 1)[0].lower()
branch = getattr(self.config, "personal_image_branch", "")
image_tag = branch.replace("/", "-") if branch else "latest"
self.manager.AVAILABLE_IMAGES = {
variant: f"ghcr.io/{owner}/{variant}:{image_tag}"
for variant in ("full", "ad", "web", "blueteam")
}
self.manager.DEFAULT_IMAGE = self.manager.AVAILABLE_IMAGES["full"]

def _login_personal_registry(self) -> None:
"""Use the GitHub CLI token for private GHCR packages in the fork."""
if self.config.image_source_active != "personal" or not self.config.personal_image_repo:
return
owner = self.config.personal_image_repo.split("/", 1)[0]
try:
token = subprocess.run(
["gh", "auth", "token"], check=True, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
).stdout.strip()
if token:
self.manager.client.login(username=owner, password=token, registry="ghcr.io")
except (FileNotFoundError, subprocess.CalledProcessError, AttributeError):
# Public images remain downloadable without authentication.
pass

def _cmd_start(self, args) -> int:
_NOT_CHECKED = object()
_update_cache = [_NOT_CHECKED]
Expand Down Expand Up @@ -843,6 +876,7 @@ def _cmd_install(self, args) -> int:
return 1
print(self.formatter.info(f"Pulling image '{image_tag}'..."))
try:
self._login_personal_registry()
self.manager._pull_with_progress(image_tag)
print(self.formatter.success(f"Image '{image_tag}' installed/updated successfully."))
return 0
Expand Down Expand Up @@ -1272,6 +1306,175 @@ def _cmd_build(self, args) -> int:
# Catalogue partagé : nihil-resources
# ------------------------------------------------------------------

def _cmd_image(self, args) -> int:
from nihil.features.image_sources import ImageSourceError, ImageSourceManager

manager = ImageSourceManager(
self.config, self.formatter, upstream_repo=getattr(args, "repo", None)
)
action = getattr(args, "image_action", None)
if action is None:
self.parser.parse_args(["image", "--help"])
return 0

if action == "status":
print(self.formatter.section_header("NIHIL IMAGE SOURCES"))
print(f"Active source: {self.config.image_source_active}")
print(f"Upstream repo: {manager.upstream_repo}")
print(f"Upstream path: {self.config.image_sources_upstream_path}")
print(f"Personal path: {self.config.personal_image_path or '-'}")
print(f"Personal repo: {self.config.personal_image_repo or '-'}")
print(f"Personal branch: {self.config.personal_image_branch or '-'}")
return 0

try:
if action == "switch":
path = manager.switch(args.source)
print(self.formatter.success(f"Active image source: {args.source}"))
print(self.formatter.info(f"Source path: {path}"))
return 0

if action == "customize":
return self._customize_image(args, manager)
if action == "build":
manager.trigger_build(variant=args.variant or "all", wait=args.wait)
print(self.formatter.success(
f"Docker build workflow dispatched for {manager.config.personal_image_repo}:"
f"{manager.config.personal_image_branch}"
))
print(self.formatter.info("Use 'nihil install <variant>' when the workflow has finished."))
return 0
except ImageSourceError as exc:
print(self.formatter.error(str(exc)), file=sys.stderr)
return 1
return 1

def _customize_image(self, args, source_manager) -> int:
from contextlib import nullcontext
from rich.prompt import Confirm
from nihil.features.image_sources import ImageSourceError
import json
import subprocess

if not Confirm.ask(
"Create or use your GitHub fork of nihil-images?",
default=True,
):
print("Aborted.")
return 0

console = getattr(self.formatter, "console", None)
loading = (
console.status("[cyan]Preparing the GitHub fork and image source...[/]", spinner="dots")
if console
else nullcontext()
)
try:
with loading:
path, fork_repo, branch = source_manager.ensure_personal_fork(
variant=args.variant,
git_protocol=args.git_protocol,
delete_existing=args.git_del,
)
except ImageSourceError as exc:
print(self.formatter.error(str(exc)), file=sys.stderr)
return 1

manifest_path = path / "build" / "config" / "tools.json"
if not manifest_path.is_file():
print(self.formatter.error(f"Tools manifest not found: {manifest_path}"), file=sys.stderr)
return 1
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(self.formatter.error(f"Could not read tools manifest: {exc}"), file=sys.stderr)
return 1

selection_path = path / "build" / "config" / "tool-selection.json"
disabled: set[str] = set()
if selection_path.is_file():
try:
selection = json.loads(selection_path.read_text(encoding="utf-8"))
if "enabled_tools" in selection:
enabled = {str(name) for name in selection.get("enabled_tools", [])}
selected_names = {name.lower() for name in enabled}
disabled = {
tool["name"] for tool in tools
if not tool["mandatory"] and tool["name"].lower() not in selected_names
}
else:
disabled = {
name for name in selection.get("disabled_tools", [])
if str(name).lower() != "nihil-history"
}
except (OSError, json.JSONDecodeError):
disabled = set()

tools = []
for category, entries in manifest.items():
for entry in entries:
tools.append({
"name": entry["name"],
"cmd": entry.get("cmd") or "-",
"category": category,
"mandatory": category == "core_tools",
})
tools.sort(key=lambda item: (item["category"], item["name"].lower()))
mandatory_names = {tool["name"].lower() for tool in tools if tool["mandatory"]}
disabled = {name for name in disabled if str(name).lower() not in mandatory_names}

while True:
selected = self._select_tools_tui(tools, disabled, title=f"{fork_repo}:{branch}")
if selected is None:
print("Tool selection cancelled.")
return 0
disabled = selected

enabled = sorted(
tool["name"] for tool in tools
if tool["mandatory"] or tool["name"] not in disabled
)
selection_path.write_text(
json.dumps({"version": 2, "enabled_tools": enabled}, indent=2) + "\n",
encoding="utf-8",
)
print(self.formatter.success(f"Saved tool selection: {len(enabled)} enabled"))

if args.no_push:
print(self.formatter.info(f"Prepared branch {branch} locally at {path}."))
return 0

if Confirm.ask(f"Commit and push {branch} to {fork_repo}?", default=True):
try:
subprocess.run(["git", "add", "build/config/tool-selection.json"], cwd=path, check=True)
subprocess.run(
["git", "commit", "-m", f"Customize {args.variant} image tools"],
cwd=path,
check=True,
)
subprocess.run(["git", "push", "--set-upstream", "origin", branch], cwd=path, check=True)
except subprocess.CalledProcessError as exc:
print(self.formatter.error(f"Git operation failed (exit {exc.returncode})."), file=sys.stderr)
return exc.returncode or 1
print(self.formatter.success(f"Customization pushed to {fork_repo}:{branch}"))
return 0

print(self.formatter.info(f"Changes remain local on {branch}: {path}"))
if not Confirm.ask("Relaunch the tool selector?", default=True):
return 0

def _select_tools_tui(self, tools: list[dict], disabled: set[str], *, title: str) -> set[str] | None:
"""Run the Textual selector and return the disabled tools."""
try:
from nihil.features.tool_selector import ToolSelectorApp
except ImportError as exc:
print(self.formatter.error(f"Textual is required for the tool selector: {exc}"), file=sys.stderr)
return None

app = ToolSelectorApp(tools, disabled, title)
app.run()
return app.return_value

def _cmd_resources(self, args) -> int:
from nihil.config import NIHIL_RESOURCES_REPO

Expand Down
41 changes: 41 additions & 0 deletions nihil/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ def create_parser() -> argparse.ArgumentParser:
nihil resources update git pull the local nihil-resources catalog
nihil resources sync Fetch tools listed in catalog/resources.toml
nihil resources status Show local nihil-resources status
nihil image customize Create a personal nihil-images fork and select tools
nihil image switch upstream Use the upstream nihil-images source
nihil image switch personal Use the personal fork source

"""
)
Expand Down Expand Up @@ -118,6 +121,44 @@ def create_parser() -> argparse.ArgumentParser:
resources_sync.add_argument("--profile", default=None, help="Restrict sync to a profile (full|ad|web|blueteam)")
resources_subparsers.add_parser("status", help="Show local nihil-resources status (path, branch, last commit)")

image_parser = subparsers.add_parser("image", help="Manage custom nihil-images sources")
image_subparsers = image_parser.add_subparsers(dest="image_action", metavar="ACTION")
customize_parser = image_subparsers.add_parser(
"customize", help="Fork nihil-images, select tools, commit and push a personal branch"
)
customize_parser.add_argument(
"variant", choices=["full", "ad", "web", "blueteam"], default="full", nargs="?",
help="Image variant whose source branch should be customized (default: full)",
)
customize_parser.add_argument(
"--no-push", action="store_true", help="Prepare the branch locally without committing or pushing"
)
customize_parser.add_argument(
"--repo", default=None, metavar="REPO",
help="GitHub repository (owner/repo or URL; default: TheNullPigeons/nihil-images)",
)
customize_parser.add_argument(
"--git-protocol", choices=["ssh", "https"], default="ssh",
help="Git remote protocol (default: ssh; use https for HTTPS remotes)",
)
customize_parser.add_argument(
"--git-del", action="store_true",
help="Delete the existing local image source clone before cloning it again",
)
switch_parser = image_subparsers.add_parser("switch", help="Switch the active image source")
switch_parser.add_argument("source", choices=["upstream", "personal"])
image_subparsers.add_parser("status", help="Show configured upstream and personal image sources")
build_image_parser = image_subparsers.add_parser(
"build", help="Trigger the Docker image workflow on the active personal branch"
)
build_image_parser.add_argument(
"variant", choices=["all", "full", "ad", "web", "blueteam"], nargs="?", default=None,
help="Image variant to build (default: all)",
)
build_image_parser.add_argument(
"--wait", action="store_true", help="Wait until the GitHub Actions build finishes"
)

completion_parser = subparsers.add_parser("completion", help="Generate shell completion script")
completion_parser.add_argument("shell", choices=["bash", "zsh"], help="Target shell for completion script (bash or zsh)")

Expand Down
Loading
Loading