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)
.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
+13 -7
View File
@@ -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
+1 -1
View File
@@ -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);
}
+9 -4
View File
@@ -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` |
+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:
# 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:
+7 -1
View File
@@ -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"
+44 -3
View File
@@ -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"
+1 -1
View File
@@ -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
+16
View File
@@ -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)."""
+19 -10
View File
@@ -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 <username> --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"
@@ -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 <username>