From 90673371d3fbbda2aa98a74d1107eb6c16e0b2d7 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 4 Jul 2026 02:08:49 -0400 Subject: [PATCH] Add repo integrations to create challenge repos with sync integrations --- ctfcli/__main__.py | 46 ++++++++++++++++++++++++--- ctfcli/utils/integrations/__init__.py | 8 +++++ ctfcli/utils/integrations/github.py | 41 ++++++++++++++++++++++++ ctfcli/utils/integrations/gitlab.py | 20 ++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 ctfcli/utils/integrations/__init__.py create mode 100644 ctfcli/utils/integrations/github.py create mode 100644 ctfcli/utils/integrations/gitlab.py diff --git a/ctfcli/__main__.py b/ctfcli/__main__.py index 76211c1..72801a3 100644 --- a/ctfcli/__main__.py +++ b/ctfcli/__main__.py @@ -22,6 +22,7 @@ ) from ctfcli.core.plugins import load_plugins from ctfcli.utils.git import check_if_dir_is_inside_git_repo +from ctfcli.utils.integrations import INTEGRATIONS # Init logging logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO").upper()) @@ -35,8 +36,12 @@ def init( directory: str | os.PathLike | None = None, no_git: bool = False, no_commit: bool = False, + github: bool = False, + gitlab: bool = False, ): - log.debug(f"init: (directory={directory}, no_git={no_git}, no_commit={no_commit})") + log.debug( + f"init: (directory={directory}, no_git={no_git}, no_commit={no_commit}, github={github}, gitlab={gitlab})" + ) project_path = Path.cwd() # Create our project directory if requested @@ -62,7 +67,26 @@ def init( ctf_token = click.prompt("Please enter CTFd Admin Access Token", default="", show_default=False) - # Confirm information with user + # Collect CI integrations chosen via switches + selected = [] + if github: + selected.append("github") + if gitlab: + selected.append("gitlab") + + # If no integration switch was passed, ask once for a comma-separated list + if not selected: + available = ", ".join(INTEGRATIONS) + response = click.prompt( + f"Any integrations to add: (comma-separated, leave blank for none)\n" + f"Available: {available} \n" + "Integrations", + default="", + show_default=False, + ) + selected = [name.strip().lower() for name in response.split(",") if name.strip()] + + # Confirm information with user before creating anything if not click.confirm(f"Do you want to continue with {ctf_url} and {ctf_token}", default=True): click.echo("Aborted!") return @@ -74,6 +98,20 @@ def init( with (project_path / ".ctf" / "config").open(mode="a+") as config_file: config.write(config_file) + # Generate integration files, collecting the paths to stage in the initial commit + paths_to_add = [".ctf/config"] + for name in selected: + entry = INTEGRATIONS.get(name) + if entry is None: + click.secho( + f"Unknown integration '{name}'. Available: {', '.join(INTEGRATIONS)}", + fg="yellow", + ) + continue + + _label, creator = entry + paths_to_add.extend(creator(project_path)) + # if git init is to be skipped we can return if no_git: click.secho("Skipping git init.", fg="yellow") @@ -88,7 +126,7 @@ def init( click.secho("Skipping git commit.", fg="yellow") return - subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + subprocess.call(["git", "add", *paths_to_add], cwd=project_path) subprocess.call(["git", "commit", "-m", "init ctfcli project"], cwd=project_path) return @@ -100,7 +138,7 @@ def init( click.secho("Skipping git commit.", fg="yellow") return - subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + subprocess.call(["git", "add", *paths_to_add], cwd=project_path) subprocess.call(["git", "commit", "-m", "init ctfcli project"], cwd=project_path) def config(self): diff --git a/ctfcli/utils/integrations/__init__.py b/ctfcli/utils/integrations/__init__.py new file mode 100644 index 0000000..a73ca1c --- /dev/null +++ b/ctfcli/utils/integrations/__init__.py @@ -0,0 +1,8 @@ +from ctfcli.utils.integrations.github import create_github_integration +from ctfcli.utils.integrations.gitlab import create_gitlab_integration + +# name -> (human label, creator). Add a future provider here + one switch in init(). +INTEGRATIONS = { + "github": ("GitHub Actions", create_github_integration), + "gitlab": ("GitLab CI/CD", create_gitlab_integration), +} diff --git a/ctfcli/utils/integrations/github.py b/ctfcli/utils/integrations/github.py new file mode 100644 index 0000000..cc60ffc --- /dev/null +++ b/ctfcli/utils/integrations/github.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import click + +GITHUB_ACTIONS_SYNC_WORKFLOW = """\ +name: Sync challenges to CTFd + +on: + push: + branches: + - main + - master + +jobs: + sync: + # Only run on the repository's default branch + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install ctfcli + run: pip install ctfcli + + - name: Install challenges to CTFd + run: ctf challenge install --force +""" + + +def create_github_integration(project_path: Path) -> list[str]: + workflows_dir = project_path / ".github" / "workflows" + workflows_dir.mkdir(parents=True, exist_ok=True) + (workflows_dir / "sync.yml").write_text(GITHUB_ACTIONS_SYNC_WORKFLOW) + click.secho("Created .github/workflows/sync.yml", fg="green") + return [".github/workflows/sync.yml"] diff --git a/ctfcli/utils/integrations/gitlab.py b/ctfcli/utils/integrations/gitlab.py new file mode 100644 index 0000000..910e78d --- /dev/null +++ b/ctfcli/utils/integrations/gitlab.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import click + +GITLAB_CI_SYNC_PIPELINE = """\ +sync-challenges: + image: python:3 + # Only run on the repository's default branch + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + script: + - pip install ctfcli + - ctf challenge install --force +""" + + +def create_gitlab_integration(project_path: Path) -> list[str]: + (project_path / ".gitlab-ci.yml").write_text(GITLAB_CI_SYNC_PIPELINE) + click.secho("Created .gitlab-ci.yml", fg="green") + return [".gitlab-ci.yml"]