Initial implementation of TriggerShell
A Python CLI (typer) that bootstraps Node/pnpm and launches a Next.js 16 web app for running configured shell scripts: YAML config validated by a shared Zod schema, dynamic per-script forms mapped to shadcn controls, argv-safe execa execution with live WebSocket streaming, SQLite/Drizzle run history, and optional argon2 session + API token auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from triggershell.bootstrap import (
|
||||
BootstrapError,
|
||||
check_node,
|
||||
ensure_dependencies_installed,
|
||||
needs_build,
|
||||
record_build_stamp,
|
||||
)
|
||||
|
||||
|
||||
def _completed(stdout: str = "", returncode: int = 0):
|
||||
class Result:
|
||||
pass
|
||||
|
||||
result = Result()
|
||||
result.stdout = stdout
|
||||
result.returncode = returncode
|
||||
return result
|
||||
|
||||
|
||||
def test_check_node_missing_raises() -> None:
|
||||
with patch("triggershell.bootstrap.shutil.which", return_value=None):
|
||||
with pytest.raises(BootstrapError, match="not found"):
|
||||
check_node()
|
||||
|
||||
|
||||
def test_check_node_too_old_raises() -> None:
|
||||
with patch("triggershell.bootstrap.shutil.which", return_value="/usr/bin/node"):
|
||||
with patch("triggershell.bootstrap.subprocess.run", return_value=_completed("v18.0.0\n")):
|
||||
with pytest.raises(BootstrapError, match="18"):
|
||||
check_node()
|
||||
|
||||
|
||||
def test_check_node_ok() -> None:
|
||||
with patch("triggershell.bootstrap.shutil.which", return_value="/usr/bin/node"):
|
||||
with patch("triggershell.bootstrap.subprocess.run", return_value=_completed("v20.11.0\n")):
|
||||
assert check_node() == "20.11.0"
|
||||
|
||||
|
||||
def test_ensure_dependencies_installed_skips_when_stamp_matches(tmp_path: Path) -> None:
|
||||
app_dir = tmp_path / "app"
|
||||
app_dir.mkdir()
|
||||
lockfile = app_dir / "pnpm-lock.yaml"
|
||||
lockfile.write_text("lockfile contents")
|
||||
|
||||
node_modules = app_dir / "node_modules"
|
||||
node_modules.mkdir()
|
||||
|
||||
import hashlib
|
||||
|
||||
stamp = node_modules / ".triggershell-install-stamp"
|
||||
stamp.write_text(hashlib.sha256(lockfile.read_bytes()).hexdigest())
|
||||
|
||||
with patch("triggershell.bootstrap.subprocess.run") as run:
|
||||
ensure_dependencies_installed(app_dir)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_dependencies_installed_runs_when_stale(tmp_path: Path) -> None:
|
||||
app_dir = tmp_path / "app"
|
||||
app_dir.mkdir()
|
||||
(app_dir / "pnpm-lock.yaml").write_text("v1")
|
||||
|
||||
with patch("triggershell.bootstrap.subprocess.run", return_value=_completed(returncode=0)) as run:
|
||||
ensure_dependencies_installed(app_dir)
|
||||
run.assert_called_once()
|
||||
|
||||
|
||||
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.write_text("scripts: []")
|
||||
|
||||
assert needs_build(app_dir, config_path) is True
|
||||
|
||||
|
||||
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.write_text("scripts: []")
|
||||
|
||||
record_build_stamp(app_dir, config_path)
|
||||
assert needs_build(app_dir, config_path) is False
|
||||
|
||||
|
||||
def test_needs_build_true_after_config_touched(tmp_path: Path) -> None:
|
||||
import os
|
||||
import time
|
||||
|
||||
app_dir = tmp_path / "app"
|
||||
(app_dir / "src").mkdir(parents=True)
|
||||
config_path = tmp_path / "triggershell.config.yaml"
|
||||
config_path.write_text("scripts: []")
|
||||
|
||||
record_build_stamp(app_dir, config_path)
|
||||
assert needs_build(app_dir, config_path) is False
|
||||
|
||||
time.sleep(0.01)
|
||||
config_path.write_text("scripts: []\n# touched")
|
||||
future = time.time() + 5
|
||||
os.utime(config_path, (future, future))
|
||||
|
||||
assert needs_build(app_dir, config_path) is True
|
||||
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from triggershell.config import ConfigError, preflight_check
|
||||
|
||||
|
||||
def write(tmp_path: Path, content: str) -> Path:
|
||||
path = tmp_path / "triggershell.config.yaml"
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
|
||||
def test_missing_file_raises(tmp_path: Path) -> None:
|
||||
with pytest.raises(ConfigError, match="not found"):
|
||||
preflight_check(tmp_path / "missing.yaml")
|
||||
|
||||
|
||||
def test_invalid_yaml_raises(tmp_path: Path) -> None:
|
||||
path = write(tmp_path, "scripts: [this is not: valid: yaml")
|
||||
with pytest.raises(ConfigError, match="parse YAML"):
|
||||
preflight_check(path)
|
||||
|
||||
|
||||
def test_non_mapping_root_raises(tmp_path: Path) -> None:
|
||||
path = write(tmp_path, "- just\n- a\n- list\n")
|
||||
with pytest.raises(ConfigError, match="mapping"):
|
||||
preflight_check(path)
|
||||
|
||||
|
||||
def test_empty_file_is_ok(tmp_path: Path) -> None:
|
||||
path = write(tmp_path, "")
|
||||
data = preflight_check(path)
|
||||
assert data == {}
|
||||
|
||||
|
||||
def test_script_missing_id_raises(tmp_path: Path) -> None:
|
||||
path = write(tmp_path, "scripts:\n - name: no id here\n")
|
||||
with pytest.raises(ConfigError, match="missing a non-empty 'id'"):
|
||||
preflight_check(path)
|
||||
|
||||
|
||||
def test_duplicate_script_id_raises(tmp_path: Path) -> None:
|
||||
path = write(
|
||||
tmp_path,
|
||||
"""
|
||||
scripts:
|
||||
- id: dup
|
||||
name: One
|
||||
- id: dup
|
||||
name: Two
|
||||
""",
|
||||
)
|
||||
with pytest.raises(ConfigError, match="duplicate script id"):
|
||||
preflight_check(path)
|
||||
|
||||
|
||||
def test_valid_minimal_config_passes(tmp_path: Path) -> None:
|
||||
path = write(
|
||||
tmp_path,
|
||||
"""
|
||||
scripts:
|
||||
- id: hello
|
||||
name: Hello
|
||||
command: echo
|
||||
""",
|
||||
)
|
||||
data = preflight_check(path)
|
||||
assert len(data["scripts"]) == 1
|
||||
assert data["scripts"][0]["id"] == "hello"
|
||||
Reference in New Issue
Block a user