From 80c11d3bd3ab10cc789e2253ad2977fc161cc852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 15 Aug 2026 19:15:03 +0200 Subject: [PATCH] Externalize auth secrets to .env and rename default config to triggershell.yml sessionSecret was previously baked directly into the scaffolded config file; `triggershell init` now generates a .env with TRIGGERSHELL_SESSION_SECRET instead and references it via ${VAR} interpolation, keeping the actual secret out of the (often committed) config file. `triggershell dev/start/ validate` load that .env automatically without overriding real env vars. Also renames the default config filename from triggershell.config.yaml to triggershell.yml throughout the CLI, app, docs, and examples. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 7 ++- README.md | 20 +++++--- app/src/lib/config/load.ts | 2 +- docs/CONFIG_REFERENCE.md | 13 +++-- examples/.env.example | 5 ++ ...ggershell.config.yaml => triggershell.yml} | 3 +- pyproject.toml | 8 +++- tests/test_bootstrap.py | 47 +++++++++++++++++-- tests/test_config.py | 2 +- triggershell/bootstrap.py | 16 +++++++ triggershell/cli.py | 29 ++++++++---- ...ggershell.config.yaml => triggershell.yml} | 5 +- 12 files changed, 126 insertions(+), 31 deletions(-) create mode 100644 examples/.env.example rename examples/{triggershell.config.yaml => triggershell.yml} (94%) rename triggershell/templates/{triggershell.config.yaml => triggershell.yml} (75%) diff --git a/.gitignore b/.gitignore index ad8ee91..3250938 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,13 @@ venv/ # Runtime data (created by `triggershell start/dev` in whatever directory the config lives in) .triggershell/ +# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these +.env +.env.* +!.env.example + # Local config files a developer might create while testing against this repo -/triggershell.config.yaml +/triggershell.yml # Editors / OS .DS_Store diff --git a/README.md b/README.md index 90eea1a..fa39f4b 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,12 @@ API for automation. ```bash pip install triggershell # or: pip install -e . from a checkout -triggershell init # scaffold triggershell.config.yaml in the current directory +triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory triggershell users add admin # create a login (skip if you set auth.enabled: false) triggershell dev # start in dev mode and open the browser ``` -Edit `triggershell.config.yaml` to add your own scripts (see [Configuration](#configuration) below), +Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then run `triggershell start` for a production build. ## Requirements @@ -41,8 +41,10 @@ then run `triggershell start` for a production build. ## Configuration -TriggerShell is driven entirely by one YAML file (default: `./triggershell.config.yaml`, override -with `-c/--config` or `TRIGGERSHELL_CONFIG_PATH`). Minimal example: +TriggerShell is driven entirely by one YAML file (default: `./triggershell.yml`, override with +`-c/--config` or `TRIGGERSHELL_CONFIG_PATH`). Secrets referenced via `${VAR}` (like +`auth.sessionSecret`) are meant to live in a `.env` file next to the config, not in the config +itself — `triggershell init` generates both. Minimal example: ```yaml server: @@ -84,8 +86,9 @@ scripts: argName: --dry-run ``` -A full, richly-commented example lives at [`examples/triggershell.config.yaml`](examples/triggershell.config.yaml), -and the complete field-by-field reference is in [`docs/CONFIG_REFERENCE.md`](docs/CONFIG_REFERENCE.md). +A full, richly-commented example lives at [`examples/triggershell.yml`](examples/triggershell.yml) +(with a matching [`.env.example`](examples/.env.example)), and the complete field-by-field +reference is in [`docs/CONFIG_REFERENCE.md`](docs/CONFIG_REFERENCE.md). Each variable's `type` (`string` / `number` / `boolean` / `enum` / `multiselect`) picks a sensible default UI control; set `control` explicitly to override it (e.g. `slider` for a `number`, `radio` @@ -96,7 +99,7 @@ the script — always as a discrete argv element or env var, never interpolated | Command | Description | |---|---| -| `triggershell init [PATH]` | Scaffold a new config file (`--port`, `--auth/--no-auth`, `--force`) | +| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) | | `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) | | `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) | | `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode | @@ -173,6 +176,9 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the pieces fit togeth - The server binds to `127.0.0.1` by default — set `server.host` explicitly to expose it further. - Route Handlers check auth themselves (`requireAuth()`); `proxy.ts` is only a fast, optimistic redirect layer, not the security boundary. +- `auth.sessionSecret` should live in `.env`, not the config file — `triggershell init` sets this + up for you. Only `passwordHash`/`tokenHash` values (one-way hashes, not the secrets themselves) + belong in the config. ## License diff --git a/app/src/lib/config/load.ts b/app/src/lib/config/load.ts index 6387e0c..669e1eb 100644 --- a/app/src/lib/config/load.ts +++ b/app/src/lib/config/load.ts @@ -38,7 +38,7 @@ export function resolveConfigPath(configPathArg?: string): string { const candidate = configPathArg ?? process.env.TRIGGERSHELL_CONFIG_PATH ?? - "triggershell.config.yaml"; + "triggershell.yml"; return path.resolve(/*turbopackIgnore: true*/ candidate); } diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 0784b87..aeed6d5 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -1,9 +1,14 @@ # Configuration Reference The config file is YAML, resolved from (in order): `-c/--config`, `TRIGGERSHELL_CONFIG_PATH`, or -`./triggershell.config.yaml`. Values support `${VAR}` / `${VAR:-default}` interpolation against -the CLI's environment, evaluated before YAML parsing. `database.path` and `logs.dir` are resolved -relative to the config file's own directory, not the current working directory. +`./triggershell.yml`. Values support `${VAR}` / `${VAR:-default}` interpolation, evaluated before +YAML parsing. `database.path` and `logs.dir` are resolved relative to the config file's own +directory, not the current working directory. + +Before interpolation, the CLI loads a `.env` file from the same directory as the config file (if +present) into its environment - without overriding any variable already set in the shell - so +secrets referenced via `${VAR}` don't have to be committed alongside the config. `triggershell +init` scaffolds both files together. The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors it. `triggershell validate` runs the Python pre-flight checks below, then that full schema. @@ -21,7 +26,7 @@ it. `triggershell validate` runs the Python pre-flight checks below, then that f | Field | Type | Default | Notes | |---|---|---|---| | `enabled` | boolean | `true` | `false` disables login entirely | -| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled` | +| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here | | `sessionTtlHours` | number | `12` | Session cookie lifetime | | `users` | array | `[]` | `{username, passwordHash}` — hash via `triggershell users add` | | `tokens` | array | `[]` | `{name, tokenHash}` — hash via `triggershell users add-token` | diff --git a/examples/.env.example b/examples/.env.example new file mode 100644 index 0000000..6e4e733 --- /dev/null +++ b/examples/.env.example @@ -0,0 +1,5 @@ +# Copy this file to `.env` (same directory as triggershell.yml) and set a real value. +# TriggerShell loads .env automatically from the config file's directory and makes its +# variables available to ${VAR} interpolation in the config - this is how secrets like +# sessionSecret should be kept out of the config file (and out of version control). +TRIGGERSHELL_SESSION_SECRET=dev-only-insecure-session-secret-change-me-before-deploying diff --git a/examples/triggershell.config.yaml b/examples/triggershell.yml similarity index 94% rename from examples/triggershell.config.yaml rename to examples/triggershell.yml index cf895a3..15a2c08 100644 --- a/examples/triggershell.config.yaml +++ b/examples/triggershell.yml @@ -8,7 +8,8 @@ server: auth: # Set to false for trusted/local-only use - no login required. enabled: true - # Must be >= 32 characters. Generate one with `triggershell init` or `openssl rand -hex 32`. + # Must be >= 32 characters. Loaded from .env (see .env.example) - the fallback after `:-` only + # exists so this example runs with zero setup; don't rely on it for anything real. sessionSecret: "${TRIGGERSHELL_SESSION_SECRET:-dev-only-insecure-session-secret-change-me-before-deploying}" sessionTtlHours: 12 users: diff --git a/pyproject.toml b/pyproject.toml index ec2f34a..da45088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,13 @@ readme = "README.md" requires-python = ">=3.9" license = "MIT" authors = [{ name = "TriggerShell contributors" }] -dependencies = ["typer>=0.12", "rich>=13.7", "pyyaml>=6.0", "argon2-cffi>=23.1"] +dependencies = [ + "typer>=0.12", + "rich>=13.7", + "pyyaml>=6.0", + "argon2-cffi>=23.1", + "python-dotenv>=1.0", +] [project.scripts] triggershell = "triggershell.cli:app" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 29dd32d..f23a6b3 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -7,6 +7,7 @@ from triggershell.bootstrap import ( BootstrapError, check_node, ensure_dependencies_installed, + load_dotenv_for_config, needs_build, record_build_stamp, ) @@ -73,7 +74,7 @@ def test_ensure_dependencies_installed_runs_when_stale(tmp_path: Path) -> None: def test_needs_build_true_when_no_stamp(tmp_path: Path) -> None: app_dir = tmp_path / "app" (app_dir / "src").mkdir(parents=True) - config_path = tmp_path / "triggershell.config.yaml" + config_path = tmp_path / "triggershell.yml" config_path.write_text("scripts: []") assert needs_build(app_dir, config_path) is True @@ -82,7 +83,7 @@ def test_needs_build_true_when_no_stamp(tmp_path: Path) -> None: def test_needs_build_false_after_stamp_recorded(tmp_path: Path) -> None: app_dir = tmp_path / "app" (app_dir / "src").mkdir(parents=True) - config_path = tmp_path / "triggershell.config.yaml" + config_path = tmp_path / "triggershell.yml" config_path.write_text("scripts: []") record_build_stamp(app_dir, config_path) @@ -95,7 +96,7 @@ def test_needs_build_true_after_config_touched(tmp_path: Path) -> None: app_dir = tmp_path / "app" (app_dir / "src").mkdir(parents=True) - config_path = tmp_path / "triggershell.config.yaml" + config_path = tmp_path / "triggershell.yml" config_path.write_text("scripts: []") record_build_stamp(app_dir, config_path) @@ -107,3 +108,43 @@ def test_needs_build_true_after_config_touched(tmp_path: Path) -> None: os.utime(config_path, (future, future)) assert needs_build(app_dir, config_path) is True + + +def test_load_dotenv_for_config_noop_when_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import os + + monkeypatch.delenv("TRIGGERSHELL_SESSION_SECRET", raising=False) + config_path = tmp_path / "triggershell.yml" + config_path.write_text("scripts: []") + + load_dotenv_for_config(config_path) + + assert "TRIGGERSHELL_SESSION_SECRET" not in os.environ + + +def test_load_dotenv_for_config_loads_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import os + + monkeypatch.delenv("TRIGGERSHELL_SESSION_SECRET", raising=False) + config_path = tmp_path / "triggershell.yml" + config_path.write_text("scripts: []") + (tmp_path / ".env").write_text("TRIGGERSHELL_SESSION_SECRET=from-dotenv-file\n") + + load_dotenv_for_config(config_path) + + assert os.environ["TRIGGERSHELL_SESSION_SECRET"] == "from-dotenv-file" + + +def test_load_dotenv_for_config_does_not_override_existing_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("TRIGGERSHELL_SESSION_SECRET", "from-shell") + config_path = tmp_path / "triggershell.yml" + config_path.write_text("scripts: []") + (tmp_path / ".env").write_text("TRIGGERSHELL_SESSION_SECRET=from-dotenv-file\n") + + load_dotenv_for_config(config_path) + + import os + + assert os.environ["TRIGGERSHELL_SESSION_SECRET"] == "from-shell" diff --git a/tests/test_config.py b/tests/test_config.py index 755c3c5..4518401 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,7 +6,7 @@ from triggershell.config import ConfigError, preflight_check def write(tmp_path: Path, content: str) -> Path: - path = tmp_path / "triggershell.config.yaml" + path = tmp_path / "triggershell.yml" path.write_text(content) return path diff --git a/triggershell/bootstrap.py b/triggershell/bootstrap.py index 17b6379..1e4d6f6 100644 --- a/triggershell/bootstrap.py +++ b/triggershell/bootstrap.py @@ -1,10 +1,12 @@ from __future__ import annotations import hashlib +import os import shutil import subprocess from pathlib import Path +from dotenv import dotenv_values from rich.console import Console console = Console() @@ -17,6 +19,20 @@ class BootstrapError(Exception): pass +def load_dotenv_for_config(config_path: Path) -> None: + """Loads a `.env` file next to the config file into this process's environment, so secrets + referenced as `${VAR}` in the config (e.g. `auth.sessionSecret`) don't have to be committed to + it. Merged into `os.environ` before the Node side spawns and interpolates the config, and + never overrides a variable the shell already exported.""" + env_path = config_path.parent / ".env" + if not env_path.is_file(): + return + + for key, value in dotenv_values(env_path).items(): + if value is not None and key not in os.environ: + os.environ[key] = value + + def find_app_dir() -> Path: """Locates the bundled Next.js app: `_app` next to this package in an installed wheel, or `../app` when running from the repo checkout (editable install / development).""" diff --git a/triggershell/cli.py b/triggershell/cli.py index 90abb95..8922580 100644 --- a/triggershell/cli.py +++ b/triggershell/cli.py @@ -19,6 +19,7 @@ from triggershell.bootstrap import ( ensure_dependencies_installed, ensure_pnpm, find_app_dir, + load_dotenv_for_config, needs_build, record_build_stamp, ) @@ -35,7 +36,7 @@ app = typer.Typer( users_app = typer.Typer(help="Manage auth users and API tokens defined in your config file.") app.add_typer(users_app, name="users") -DEFAULT_CONFIG_NAME = "triggershell.config.yaml" +DEFAULT_CONFIG_NAME = "triggershell.yml" def _resolve_config_path(config: Optional[Path]) -> Path: @@ -86,7 +87,7 @@ def init( auth: bool = typer.Option(True, help="Enable built-in login for the web app."), force: bool = typer.Option(False, help="Overwrite an existing config file."), ) -> None: - """Scaffold a new `triggershell.config.yaml`.""" + """Scaffold a new `triggershell.yml`.""" target_dir = (path or Path.cwd()).resolve() target_dir.mkdir(parents=True, exist_ok=True) config_path = target_dir / DEFAULT_CONFIG_NAME @@ -97,19 +98,25 @@ def init( template_path = Path(__file__).parent / "templates" / DEFAULT_CONFIG_NAME template = template_path.read_text(encoding="utf-8") - session_secret = secrets.token_hex(32) - rendered = ( - template.replace("__PORT__", str(port)) - .replace("__AUTH_ENABLED__", "true" if auth else "false") - .replace("__SESSION_SECRET__", session_secret) - ) + rendered = template.replace("__PORT__", str(port)).replace("__AUTH_ENABLED__", "true" if auth else "false") config_path.write_text(rendered, encoding="utf-8") - console.print(f"[green]Created[/green] {config_path}") + + env_path = target_dir / ".env" if auth: - console.print("Auth is enabled but no users are configured yet. Add one with:") + if env_path.exists(): + console.print( + f"[yellow]{env_path} already exists[/yellow] - make sure it sets TRIGGERSHELL_SESSION_SECRET " + "(>= 32 chars)." + ) + else: + session_secret = secrets.token_hex(32) + env_path.write_text(f"TRIGGERSHELL_SESSION_SECRET={session_secret}\n", encoding="utf-8") + console.print(f"[green]Created[/green] {env_path} [dim](keep this out of version control)[/dim]") + console.print("\nAuth is enabled but no users are configured yet. Add one with:") console.print(f" [bold]triggershell users add --config {config_path}[/bold]") + console.print("\nStart the app with:") console.print(f" [bold]triggershell dev --config {config_path}[/bold]") @@ -120,6 +127,7 @@ def validate( ) -> None: """Validate a config file: a fast Python pre-flight check, then the full Zod schema in Node.""" config_path = _resolve_config_path(config) + load_dotenv_for_config(config_path) _require_config(config_path) console.print("[green]OK[/green] (pre-flight checks passed)") @@ -164,6 +172,7 @@ def _run_server( skip_build: bool = False, ) -> None: config_path = _resolve_config_path(config) + load_dotenv_for_config(config_path) config_data = _require_config(config_path) effective_host = host or (config_data.get("server") or {}).get("host") or "127.0.0.1" diff --git a/triggershell/templates/triggershell.config.yaml b/triggershell/templates/triggershell.yml similarity index 75% rename from triggershell/templates/triggershell.config.yaml rename to triggershell/templates/triggershell.yml index c721894..464091c 100644 --- a/triggershell/templates/triggershell.config.yaml +++ b/triggershell/templates/triggershell.yml @@ -7,8 +7,9 @@ server: auth: enabled: __AUTH_ENABLED__ - # Must be >= 32 characters if auth is enabled. Generated by `triggershell init`. - sessionSecret: "__SESSION_SECRET__" + # Must be >= 32 characters if auth is enabled. Kept out of this file - `triggershell init` + # generated one into .env (TRIGGERSHELL_SESSION_SECRET) alongside this config. + sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}" sessionTtlHours: 12 users: [] # Add a user with: triggershell users add