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 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:15:03 +02:00
co-authored by Claude Sonnet 5
parent ced99a8e75
commit 80c11d3bd3
12 changed files with 126 additions and 31 deletions
+6 -1
View File
@@ -13,8 +13,13 @@ venv/
# Runtime data (created by `triggershell start/dev` in whatever directory the config lives in) # Runtime data (created by `triggershell start/dev` in whatever directory the config lives in)
.triggershell/ .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 # Local config files a developer might create while testing against this repo
/triggershell.config.yaml /triggershell.yml
# Editors / OS # Editors / OS
.DS_Store .DS_Store
+13 -7
View File
@@ -25,12 +25,12 @@ API for automation.
```bash ```bash
pip install triggershell # or: pip install -e . from a checkout 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 users add admin # create a login (skip if you set auth.enabled: false)
triggershell dev # start in dev mode and open the browser 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. then run `triggershell start` for a production build.
## Requirements ## Requirements
@@ -41,8 +41,10 @@ then run `triggershell start` for a production build.
## Configuration ## Configuration
TriggerShell is driven entirely by one YAML file (default: `./triggershell.config.yaml`, override TriggerShell is driven entirely by one YAML file (default: `./triggershell.yml`, override with
with `-c/--config` or `TRIGGERSHELL_CONFIG_PATH`). Minimal example: `-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 ```yaml
server: server:
@@ -84,8 +86,9 @@ scripts:
argName: --dry-run argName: --dry-run
``` ```
A full, richly-commented example lives at [`examples/triggershell.config.yaml`](examples/triggershell.config.yaml), A full, richly-commented example lives at [`examples/triggershell.yml`](examples/triggershell.yml)
and the complete field-by-field reference is in [`docs/CONFIG_REFERENCE.md`](docs/CONFIG_REFERENCE.md). (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 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` 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 | | 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 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 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 | | `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. - 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 - Route Handlers check auth themselves (`requireAuth()`); `proxy.ts` is only a fast, optimistic
redirect layer, not the security boundary. 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 ## License
+1 -1
View File
@@ -38,7 +38,7 @@ export function resolveConfigPath(configPathArg?: string): string {
const candidate = const candidate =
configPathArg ?? configPathArg ??
process.env.TRIGGERSHELL_CONFIG_PATH ?? process.env.TRIGGERSHELL_CONFIG_PATH ??
"triggershell.config.yaml"; "triggershell.yml";
return path.resolve(/*turbopackIgnore: true*/ candidate); return path.resolve(/*turbopackIgnore: true*/ candidate);
} }
+9 -4
View File
@@ -1,9 +1,14 @@
# Configuration Reference # Configuration Reference
The config file is YAML, resolved from (in order): `-c/--config`, `TRIGGERSHELL_CONFIG_PATH`, or 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 `./triggershell.yml`. Values support `${VAR}` / `${VAR:-default}` interpolation, evaluated before
the CLI's environment, evaluated before YAML parsing. `database.path` and `logs.dir` are resolved YAML parsing. `database.path` and `logs.dir` are resolved relative to the config file's own
relative to the config file's own directory, not the current working directory. 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 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. 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 | | Field | Type | Default | Notes |
|---|---|---|---| |---|---|---|---|
| `enabled` | boolean | `true` | `false` disables login entirely | | `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 | | `sessionTtlHours` | number | `12` | Session cookie lifetime |
| `users` | array | `[]` | `{username, passwordHash}` — hash via `triggershell users add` | | `users` | array | `[]` | `{username, passwordHash}` — hash via `triggershell users add` |
| `tokens` | array | `[]` | `{name, tokenHash}` — hash via `triggershell users add-token` | | `tokens` | array | `[]` | `{name, tokenHash}` — hash via `triggershell users add-token` |
+5
View File
@@ -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
@@ -8,7 +8,8 @@ server:
auth: auth:
# Set to false for trusted/local-only use - no login required. # Set to false for trusted/local-only use - no login required.
enabled: true 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}" sessionSecret: "${TRIGGERSHELL_SESSION_SECRET:-dev-only-insecure-session-secret-change-me-before-deploying}"
sessionTtlHours: 12 sessionTtlHours: 12
users: users:
+7 -1
View File
@@ -10,7 +10,13 @@ readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
license = "MIT" license = "MIT"
authors = [{ name = "TriggerShell contributors" }] 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] [project.scripts]
triggershell = "triggershell.cli:app" triggershell = "triggershell.cli:app"
+44 -3
View File
@@ -7,6 +7,7 @@ from triggershell.bootstrap import (
BootstrapError, BootstrapError,
check_node, check_node,
ensure_dependencies_installed, ensure_dependencies_installed,
load_dotenv_for_config,
needs_build, needs_build,
record_build_stamp, 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: def test_needs_build_true_when_no_stamp(tmp_path: Path) -> None:
app_dir = tmp_path / "app" app_dir = tmp_path / "app"
(app_dir / "src").mkdir(parents=True) (app_dir / "src").mkdir(parents=True)
config_path = tmp_path / "triggershell.config.yaml" config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []") config_path.write_text("scripts: []")
assert needs_build(app_dir, config_path) is True 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: def test_needs_build_false_after_stamp_recorded(tmp_path: Path) -> None:
app_dir = tmp_path / "app" app_dir = tmp_path / "app"
(app_dir / "src").mkdir(parents=True) (app_dir / "src").mkdir(parents=True)
config_path = tmp_path / "triggershell.config.yaml" config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []") config_path.write_text("scripts: []")
record_build_stamp(app_dir, config_path) 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 = tmp_path / "app"
(app_dir / "src").mkdir(parents=True) (app_dir / "src").mkdir(parents=True)
config_path = tmp_path / "triggershell.config.yaml" config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []") config_path.write_text("scripts: []")
record_build_stamp(app_dir, config_path) 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)) os.utime(config_path, (future, future))
assert needs_build(app_dir, config_path) is True 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"
+1 -1
View File
@@ -6,7 +6,7 @@ from triggershell.config import ConfigError, preflight_check
def write(tmp_path: Path, content: str) -> Path: def write(tmp_path: Path, content: str) -> Path:
path = tmp_path / "triggershell.config.yaml" path = tmp_path / "triggershell.yml"
path.write_text(content) path.write_text(content)
return path return path
+16
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import os
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from dotenv import dotenv_values
from rich.console import Console from rich.console import Console
console = Console() console = Console()
@@ -17,6 +19,20 @@ class BootstrapError(Exception):
pass 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: def find_app_dir() -> Path:
"""Locates the bundled Next.js app: `_app` next to this package in an installed wheel, """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).""" or `../app` when running from the repo checkout (editable install / development)."""
+19 -10
View File
@@ -19,6 +19,7 @@ from triggershell.bootstrap import (
ensure_dependencies_installed, ensure_dependencies_installed,
ensure_pnpm, ensure_pnpm,
find_app_dir, find_app_dir,
load_dotenv_for_config,
needs_build, needs_build,
record_build_stamp, 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.") users_app = typer.Typer(help="Manage auth users and API tokens defined in your config file.")
app.add_typer(users_app, name="users") 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: 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."), 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."), force: bool = typer.Option(False, help="Overwrite an existing config file."),
) -> None: ) -> None:
"""Scaffold a new `triggershell.config.yaml`.""" """Scaffold a new `triggershell.yml`."""
target_dir = (path or Path.cwd()).resolve() target_dir = (path or Path.cwd()).resolve()
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
config_path = target_dir / DEFAULT_CONFIG_NAME config_path = target_dir / DEFAULT_CONFIG_NAME
@@ -97,19 +98,25 @@ def init(
template_path = Path(__file__).parent / "templates" / DEFAULT_CONFIG_NAME template_path = Path(__file__).parent / "templates" / DEFAULT_CONFIG_NAME
template = template_path.read_text(encoding="utf-8") template = template_path.read_text(encoding="utf-8")
session_secret = secrets.token_hex(32)
rendered = ( rendered = template.replace("__PORT__", str(port)).replace("__AUTH_ENABLED__", "true" if auth else "false")
template.replace("__PORT__", str(port))
.replace("__AUTH_ENABLED__", "true" if auth else "false")
.replace("__SESSION_SECRET__", session_secret)
)
config_path.write_text(rendered, encoding="utf-8") config_path.write_text(rendered, encoding="utf-8")
console.print(f"[green]Created[/green] {config_path}") console.print(f"[green]Created[/green] {config_path}")
env_path = target_dir / ".env"
if auth: 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 <username> --config {config_path}[/bold]") console.print(f" [bold]triggershell users add <username> --config {config_path}[/bold]")
console.print("\nStart the app with:") console.print("\nStart the app with:")
console.print(f" [bold]triggershell dev --config {config_path}[/bold]") console.print(f" [bold]triggershell dev --config {config_path}[/bold]")
@@ -120,6 +127,7 @@ def validate(
) -> None: ) -> None:
"""Validate a config file: a fast Python pre-flight check, then the full Zod schema in Node.""" """Validate a config file: a fast Python pre-flight check, then the full Zod schema in Node."""
config_path = _resolve_config_path(config) config_path = _resolve_config_path(config)
load_dotenv_for_config(config_path)
_require_config(config_path) _require_config(config_path)
console.print("[green]OK[/green] (pre-flight checks passed)") console.print("[green]OK[/green] (pre-flight checks passed)")
@@ -164,6 +172,7 @@ def _run_server(
skip_build: bool = False, skip_build: bool = False,
) -> None: ) -> None:
config_path = _resolve_config_path(config) config_path = _resolve_config_path(config)
load_dotenv_for_config(config_path)
config_data = _require_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" effective_host = host or (config_data.get("server") or {}).get("host") or "127.0.0.1"
@@ -7,8 +7,9 @@ server:
auth: auth:
enabled: __AUTH_ENABLED__ enabled: __AUTH_ENABLED__
# Must be >= 32 characters if auth is enabled. Generated by `triggershell init`. # Must be >= 32 characters if auth is enabled. Kept out of this file - `triggershell init`
sessionSecret: "__SESSION_SECRET__" # generated one into .env (TRIGGERSHELL_SESSION_SECRET) alongside this config.
sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}"
sessionTtlHours: 12 sessionTtlHours: 12
users: [] users: []
# Add a user with: triggershell users add <username> # Add a user with: triggershell users add <username>