diff --git a/backend/cli.py b/backend/cli.py index dd735cf..56142dc 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -2777,6 +2777,36 @@ def fail(e): return +def cmd_env(args): + """Manage secrets/settings in the global .env.""" + from services.env_settings import run_env_action + + accent = "\033[38;2;212;135;74m" + green = "\033[38;2;74;222;128m" + gray = "\033[38;5;245m" + reset = "\033[0m" + action = getattr(args, "env_action", None) or "list" + try: + if action == "set": + run_env_action("set", args.key, args.value) + print(f" {green}✓{reset} {args.key} set") + elif action == "unset": + run_env_action("unset", args.key) + print(f" {green}✓{reset} {args.key} removed") + else: + data = run_env_action("list") + print(f"\n {gray}Settings ({data['path']}){reset}\n") + for s in data["settings"]: + mark = f"{green}set{reset}" if s["set"] else f"{gray}not set{reset}" + val = f" {gray}{s['preview']}{reset}" if s["set"] else "" + print(f" {accent}{s['key']}{reset} — {s['label']} [{mark}]{val}") + print(f" {gray}{s['help']}{reset}") + print(f" {gray}{s['url']}{reset}\n") + except ValueError as e: + print(f" ✗ {e}", file=sys.stderr) + sys.exit(1) + + def cmd_config(args): """Export, import, and activate config profiles.""" from config_bundle import run_config_action @@ -3334,6 +3364,16 @@ def main(): cfg_use = cfg_sub.add_parser("use", help="Activate a config root for future runs") cfg_use.add_argument("home", help="Path to the config root to activate") + # ── env (secrets / settings) ── + env_p = sub.add_parser("env", help="Manage secrets/settings stored in .env (e.g. HF_TOKEN)") + env_sub = env_p.add_subparsers(dest="env_action") + env_sub.add_parser("list", help="Show known settings and whether they're set") + env_set = env_sub.add_parser("set", help="Set a setting") + env_set.add_argument("key", help="Setting key, e.g. HF_TOKEN") + env_set.add_argument("value", help="Value") + env_unset = env_sub.add_parser("unset", help="Remove a setting") + env_unset.add_argument("key", help="Setting key, e.g. HF_TOKEN") + # ── cache ── cache_p = sub.add_parser("cache", help="Manage transcription cache") cache_sub = cache_p.add_subparsers(dest="cache_action") @@ -3387,6 +3427,8 @@ def main(): cmd_youtube(args) elif args.command == "config": cmd_config(args) + elif args.command == "env": + cmd_env(args) elif args.command == "cache": cmd_cache(args) elif args.command == "info": diff --git a/backend/main.py b/backend/main.py index 5812ad5..b8d2a85 100644 --- a/backend/main.py +++ b/backend/main.py @@ -377,6 +377,18 @@ def handle_suggest_clips(task_id: str, params: dict): emit_result(task_id, "success", data={"clips": clips}) +def handle_manage_env(task_id: str, params: dict): + """List/set/unset user secrets in the global .env (e.g. HF_TOKEN).""" + from services.env_settings import run_env_action + + try: + data = run_env_action(params.get("action", "list"), params.get("key"), params.get("value")) + except ValueError as e: + emit_result(task_id, "error", error=str(e)) + return + emit_result(task_id, "success", data=data) + + def handle_find_moment(task_id: str, params: dict): """Locate user-pasted/described moments in the transcript via the AI CLI.""" from services.claude_suggest import find_moments_from_text @@ -512,6 +524,7 @@ def handle_run_integration_tool(task_id: str, params: dict): "corrections": handle_corrections, "suggest_clips": handle_suggest_clips, "find_moment": handle_find_moment, + "manage_env": handle_manage_env, "generate_content": handle_generate_content, "manage_integrations": handle_manage_integrations, "run_integration_tool": handle_run_integration_tool, diff --git a/backend/services/env_settings.py b/backend/services/env_settings.py new file mode 100644 index 0000000..523c8a2 --- /dev/null +++ b/backend/services/env_settings.py @@ -0,0 +1,136 @@ +"""Manage user secrets/settings stored in the global .env (PODCLI_ENV_FILE), +the file dotenv loads at startup. Currently just HF_TOKEN; the registry keeps it +easy to add more.""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +SETTINGS = [ + { + "key": "HF_TOKEN", + "label": "HuggingFace token", + "help": "Enables speaker detection (pyannote), which makes face tracking " + "speaker-aware. Create a token and accept the model terms for " + "pyannote/speaker-diarization-3.1, segmentation-3.0, and " + "speaker-diarization-community-1.", + "url": "https://huggingface.co/settings/tokens", + "secret": True, + }, +] + +_KEYS = {s["key"] for s in SETTINGS} + + +def _env_path() -> str: + return os.environ.get("PODCLI_ENV_FILE") or os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "..", ".env" + ) + + +def _read_pairs() -> dict[str, str]: + path = _env_path() + pairs: dict[str, str] = {} + if not os.path.exists(path): + return pairs + with open(path, encoding="utf-8") as f: + for line in f: + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k, v = s.split("=", 1) + pairs[k.strip()] = v.strip() + return pairs + + +def _mask(value: str) -> str: + if not value: + return "" + return value[:3] + "…" + value[-4:] if len(value) > 10 else "•" * len(value) + + +def list_settings() -> list[dict[str, Any]]: + pairs = _read_pairs() + out = [] + for s in SETTINGS: + raw = pairs.get(s["key"], "") + out.append({ + "key": s["key"], + "label": s["label"], + "help": s["help"], + "url": s["url"], + "secret": s["secret"], + "set": bool(raw), + "preview": _mask(raw) if s["secret"] else raw, + }) + return out + + +def _write_pairs(pairs: dict[str, str]) -> None: + """Rewrite the .env preserving comments/unknown lines; upsert known keys in + place, append new ones. Atomic via temp+rename; mode 0600 (holds secrets).""" + path = os.path.abspath(_env_path()) + os.makedirs(os.path.dirname(path), exist_ok=True) + existing_lines = [] + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + existing_lines = f.read().splitlines() + + seen = set() + out_lines = [] + for line in existing_lines: + s = line.strip() + if s and not s.startswith("#") and "=" in s: + k = s.split("=", 1)[0].strip() + if k in pairs: + seen.add(k) + if pairs[k] is None: + continue # unset: drop the line + out_lines.append(f"{k}={pairs[k]}") + continue + out_lines.append(line) + for k, v in pairs.items(): + if k not in seen and v is not None: + out_lines.append(f"{k}={v}") + + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(out_lines).rstrip("\n") + "\n") + os.replace(tmp, path) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def set_setting(key: str, value: str) -> None: + if key not in _KEYS: + raise ValueError(f"unknown setting {key!r} (known: {', '.join(sorted(_KEYS))})") + value = (value or "").strip() + if not value: + raise ValueError("value is empty") + _write_pairs({key: value}) + + +def unset_setting(key: str) -> None: + if key not in _KEYS: + raise ValueError(f"unknown setting {key!r} (known: {', '.join(sorted(_KEYS))})") + _write_pairs({key: None}) + + +def run_env_action(action: str, key: Optional[str] = None, value: Optional[str] = None) -> dict[str, Any]: + act = (action or "list").strip().lower() + if act == "list": + return {"settings": list_settings(), "path": os.path.abspath(_env_path())} + if act == "set": + if not key: + raise ValueError("key is required") + set_setting(key, value or "") + return {"ok": True, "key": key} + if act == "unset": + if not key: + raise ValueError("key is required") + unset_setting(key) + return {"ok": True, "key": key} + raise ValueError(f"unknown env action: {action}") diff --git a/backend/services/speaker_detection.py b/backend/services/speaker_detection.py index 57848f2..b7106e9 100644 --- a/backend/services/speaker_detection.py +++ b/backend/services/speaker_detection.py @@ -88,7 +88,8 @@ def run_diarization( " → https://huggingface.co/pyannote/speaker-diarization-3.1\n" " → https://huggingface.co/pyannote/segmentation-3.0\n" " → https://huggingface.co/pyannote/speaker-diarization-community-1\n" - " 3. Add to your .env file: HF_TOKEN=hf_your_token_here" + " 3. Set it: podcli env set HF_TOKEN hf_your_token_here\n" + " (or in the Web UI: Config → Secrets)" ) if progress_callback: progress_callback(0, msg) diff --git a/src/models/index.ts b/src/models/index.ts index 2c94a02..d92d9ca 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,7 +2,7 @@ export interface TaskRequest { task_id: string; - task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env"; params: Record; } diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index 471ff68..bac332e 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -8,6 +8,9 @@ export default function ConfigPage() { const [activate, setActivate] = useState(false); const [importing, setImporting] = useState(false); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); + const [settings, setSettings] = useState([]); + const [secretInputs, setSecretInputs] = useState>({}); + const [savingKey, setSavingKey] = useState(null); const fileRef = useRef(null); async function loadStatus() { @@ -15,10 +18,33 @@ export default function ConfigPage() { setStatusError(null); } + async function loadSettings() { + try { + const data = await api("/settings"); + setSettings(data.settings || []); + } catch { /* settings are optional */ } + } + useEffect(() => { loadStatus().catch((e) => setStatusError(e.message)); + loadSettings(); }, []); + async function saveSetting(key: string) { + setSavingKey(key); + setMsg(null); + try { + await api("/settings", { method: "POST", body: JSON.stringify({ key, value: secretInputs[key] ?? "" }) }); + setSecretInputs((p) => ({ ...p, [key]: "" })); + setMsg({ text: `${key} saved.`, ok: true }); + await loadSettings(); + } catch (e: any) { + setMsg({ text: e.message, ok: false }); + } finally { + setSavingKey(null); + } + } + async function onMigrate() { setMsg({ text: "Migrating…", ok: true }); try { @@ -83,6 +109,43 @@ export default function ConfigPage() { )} + {settings.length > 0 && ( +
+
Secrets
+ {settings.map((s) => ( +
+ +
+ setSecretInputs((p) => ({ ...p, [s.key]: e.target.value }))} + style={{ fontSize: 13, flex: 1 }} + /> + +
+
+ {s.help}{" "} + Get token → +
+
+ ))} +
+ )} +
Actions
diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index fd9d3f8..51cc0a8 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -1426,6 +1426,33 @@ app.post("/api/clips/:id/thumbnail/select", async (req, res) => { res.json({ ok: true, preview_path: pick }); }); +// --- Secrets/settings stored in the global .env (e.g. HF_TOKEN) --- + +app.get("/api/settings", async (_req, res) => { + try { + const result = await executor.execute<{ settings?: unknown[] }>("manage_env", { action: "list" }); + res.json(result.data ?? { settings: [] }); + } catch (err: unknown) { + res.status(500).json({ error: errMsg(err) }); + } +}); + +app.post("/api/settings", async (req, res) => { + const key = typeof req.body?.key === "string" ? req.body.key : ""; + const value = typeof req.body?.value === "string" ? req.body.value : ""; + if (!key) { + res.status(400).json({ error: "key is required" }); + return; + } + try { + const action = value.trim() ? "set" : "unset"; + const result = await executor.execute("manage_env", { action, key, value }); + res.json(result.data ?? { ok: true }); + } catch (err: unknown) { + res.status(500).json({ error: errMsg(err) }); + } +}); + app.get("/api/youtube/config", (_req, res) => { try { const all = JSON.parse(readFileSync(paths.integrations, "utf-8")); diff --git a/tests/test_env_settings.py b/tests/test_env_settings.py new file mode 100644 index 0000000..2bb1783 --- /dev/null +++ b/tests/test_env_settings.py @@ -0,0 +1,67 @@ +"""env_settings: read/set/unset secrets in the global .env, preserving other lines.""" + +import os +import sys +import tempfile +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +BACKEND_ROOT = os.path.join(ROOT, "backend") +if BACKEND_ROOT not in sys.path: + sys.path.insert(0, BACKEND_ROOT) + +from services import env_settings + + +class EnvSettingsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.env = os.path.join(self.tmp, ".env") + self._saved = os.environ.get("PODCLI_ENV_FILE") + os.environ["PODCLI_ENV_FILE"] = self.env + + def tearDown(self): + import shutil + if self._saved is None: + os.environ.pop("PODCLI_ENV_FILE", None) + else: + os.environ["PODCLI_ENV_FILE"] = self._saved + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_list_unset(self): + s = env_settings.run_env_action("list")["settings"] + self.assertEqual(s[0]["key"], "HF_TOKEN") + self.assertFalse(s[0]["set"]) + + def test_set_masks_and_persists(self): + env_settings.set_setting("HF_TOKEN", "hf_abcd1234567890") + s = env_settings.list_settings()[0] + self.assertTrue(s["set"]) + self.assertNotIn("1234567890", s["preview"]) # masked + self.assertIn("HF_TOKEN=hf_abcd1234567890", open(self.env).read()) + + def test_set_preserves_other_lines(self): + with open(self.env, "w") as f: + f.write("EXISTING=keep\n# comment\n") + env_settings.set_setting("HF_TOKEN", "hf_x") + body = open(self.env).read() + self.assertIn("EXISTING=keep", body) + self.assertIn("# comment", body) + self.assertIn("HF_TOKEN=hf_x", body) + + def test_unset_removes(self): + env_settings.set_setting("HF_TOKEN", "hf_x") + env_settings.unset_setting("HF_TOKEN") + self.assertNotIn("HF_TOKEN", open(self.env).read()) + + def test_unknown_key_rejected(self): + with self.assertRaises(ValueError): + env_settings.set_setting("BOGUS_KEY", "x") + + def test_mode_is_600(self): + env_settings.set_setting("HF_TOKEN", "hf_x") + self.assertEqual(oct(os.stat(self.env).st_mode & 0o777), "0o600") + + +if __name__ == "__main__": + unittest.main()