* docs(install): per-OS install pages + drift validator + CI gate
Splits the 600-line README install section into self-contained per-OS docs
under docs/install/{macos,windows,linux,docker}.md plus a Top-10
troubleshooting index. Each OS doc is end-to-end: a user opens it and
reaches a working app following only commands inside that file.
Adds:
- docs/install/{macos,windows,linux,docker}.md (OS-specific install paths)
- docs/install/troubleshooting.md (top 10 install errors)
- docs/engines/cosyvoice.md (closes #55 docs half)
- docs/features/diarization.md (pyannote license flow)
- docs/setup/huggingface-token.md (3-source cascade guide)
- scripts/validate-install-docs.py (INST-06 docs-drift gate)
- tests/scripts/test_validate_install_docs.py (B-5: validator self-tests)
- .github/workflows/ci.yml step running the validator on every PR
Implements INST-02 (README routing), INST-03 (macOS Gatekeeper anchor),
INST-12 docs half (Windows torch-compile-oom anchor), DOCS-01..05.
The validator is a one-way diff: every `<!-- validate -->`-tagged line
in docs must appear in scripts/desktop-prod.sh after normalisation
(prompt-prefix strip, CRLF, trailing whitespace, blank-and-comment skip).
A `<!-- validate: skip -->` marker opts out for human-readability blocks.
Its own 10 unit tests catch regressions in the gate itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(deeplinks): links.py + error_docs_map (Python + TS mirror)
Adds the single source of truth for the project repo URL and the 4-class
error → docs taxonomy that both the in-app ErrorBoundary deeplink button
(Wave 2 Task 3) and the Phase 5 bug reporter will consume.
New:
- backend/core/links.py — PROJECT_REPO_URL + BLOB_MAIN resolver
(Tauri config first, pyproject fallback)
- backend/core/error_docs_map.py — lookup(error_class) → docs URL
- frontend/src/utils/errorDocsMap.ts (TS mirror with classifyError helper)
- tests/backend/core/test_links.py + test_error_docs_map.py
- frontend/src/utils/errorDocsMap.test.ts
Resolves checker B-6 (links.py ownership) and Open Question #3 (which fork
the deeplinks resolve to — the Tauri updater endpoint wins, which points
at the desktop app fork debpalash/OmniVoice-Studio).
The TS BASE constant is documented as the second hardcoded URL drift site;
the keys-sync test (`test_keys_match_python_map` equivalent) guards the
4-class taxonomy contract between Python + TS halves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ui): Settings → API Keys panel + ErrorBoundary docs deeplink
Wave 2 AUTH-03 UI half + ErrorBoundary deeplink wiring.
ErrorBoundary fallback now renders an "Open docs for this error" button
that classifies the thrown Error message (heuristic: pkg_resources → 401 /
HfHubHTTP → WebKit / white screen → quarantine / Gatekeeper) and opens the
matching docs anchor via Tauri shell.open (with a window.open fallback
in browser dev mode).
ApiKeysPanel consumes the Wave 1 resolver state endpoint:
- 3 source rows (App / Env var / HF CLI) with set/unset indicator,
masked token preview, whoami username + green check
- "Active" badge on whichever source is currently serving the cascade
- App-row only: Save (POST /api/settings/hf-token) +
Clear (DELETE with optional "also clear HF CLI" confirm dialog)
- "Test now" button refetches state (invalidates the resolver's
validation cache via the same endpoint hit)
Panel mounted in the existing Settings → Credentials tab; the legacy
HF_TOKEN row from CREDENTIAL_FIELDS is filtered out so the two paths
don't fight over the same key.
Threat T-02-02: the panel never displays the full token. The masked
value comes from the resolver state endpoint; the full token only
crosses the IPC boundary on Save (POST) and is cleared from local
state on success.
Closes AUTH-03 fully (Wave 1 backend + this Wave 2 UI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(perf): INST-12 Disable torch.compile (Windows) toggle (backend + UI)
Wave 2 Task 4 — full INST-12 delivery per checker B-2/B-7 v0.3.0 fat-release
decision. Both the docs half (windows.md anchor, shipped in earlier commit)
and the runtime toggle are now in Phase 1.
Backend:
- backend/services/settings_store.py: adds get_text/set_text helpers for
non-secret config (refuses to write to the encrypted hf_token key).
- backend/api/routers/settings.py: GET + PUT
/api/settings/perf/torch-compile-disabled, both under the existing
loopback guard (threat T-02-04).
- backend/services/engine_env.py: new `build_engine_env()` helper that
centralises HF_TOKEN/YOUR_HF_TOKEN injection from the 3-source resolver
AND injects TORCH_COMPILE_DISABLE=1 when the flag is set on win32.
Phase 2 SubprocessBackend launchers should adopt the same helper.
- backend/services/sonitranslate.py: migrated to engine_env.build_engine_env()
while preserving the source-level `env["HF_TOKEN"]` sentinel that
test_sonitranslate_module_uses_resolver checks.
Frontend:
- frontend/src/components/settings/PerformancePanel.{jsx,css,test.jsx}:
toggle UI with the explainer for #65; renders disabled with a "not
applicable" badge on macOS/Linux.
- frontend/src/pages/Settings.jsx: mounts the panel into the Credentials
tab alongside the API Keys panel.
Tests:
- tests/backend/test_perf_settings.py: 7 backend tests (default state,
PUT persistence, T-02-04 non-loopback rejection, settings_store round-
trip, env injection on win32, NO injection on macOS/Linux, NO injection
when disabled).
- frontend PerformancePanel.test.jsx: 5 tests (renders from GET state,
PUT on toggle, disabled on non-Windows platforms, pre-enabled state).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(planning): Wave 2 SUMMARY + REQUIREMENTS status updates
- .planning/phases/01.../01-02-SUMMARY.md: full implementation report
per template (truths, commits, tests, deviations, drift-site
acknowledgments per W-3, launcher seam name for Phase 2,
taxonomy keys for Phase 5).
- .planning/REQUIREMENTS.md: flips Wave 2 closures to Done:
AUTH-03, INST-02, INST-03 (docs half), INST-06, INST-12,
DOCS-01..05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
6.6 KiB
Python
Executable File
186 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""validate-install-docs.py — Phase 1 INST-06 docs-drift CI gate.
|
|
|
|
Extract every fenced code block tagged with an HTML comment marker
|
|
`<!-- validate -->` from `docs/install/*.md` and assert each line of the
|
|
block appears (after normalisation) in `scripts/desktop-prod.sh`. The script
|
|
exits 1 on the first drift and prints the offending file + line on stderr so
|
|
CI logs lead the contributor straight to the fix.
|
|
|
|
Markers:
|
|
<!-- validate --> — gate the next fenced code block
|
|
<!-- validate: skip --> — opt-out: block exists for human readability only
|
|
|
|
Normalisation (per RESEARCH Pitfall #4):
|
|
- rstrip trailing whitespace
|
|
- normalise CRLF → LF
|
|
- strip `$ ` and `>>> ` REPL/prompt prefixes
|
|
- skip blank lines + lines that are only `#` comments
|
|
|
|
The validator is intentionally a one-way check: every validated docs line
|
|
must appear in the install script, but the script may contain extra setup
|
|
the docs don't surface (cleanup, log dirs, etc.). That asymmetry catches
|
|
"docs claim a command that the install path doesn't run" without forcing
|
|
docs to repeat every line of the install script.
|
|
|
|
Public entry point: `main(root: Path | None = None) -> int`
|
|
Returns 0 on success, 1 on drift. Importable from unit tests so we can
|
|
exercise the validator against tmp-path fixtures (per checker B-5 — the
|
|
validator itself is regression-tested).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
# Match the `<!-- validate -->` (or `<!-- validate: skip -->`) marker on its
|
|
# own line, followed by an optional blank line, followed by a fenced block.
|
|
_MARKER_RE = re.compile(
|
|
r"<!--\s*validate(?:\s*:\s*(?P<modifier>skip))?\s*-->",
|
|
re.IGNORECASE,
|
|
)
|
|
_FENCE_OPEN_RE = re.compile(r"^```([A-Za-z0-9_+\-]*)\s*$")
|
|
_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
|
|
|
|
_PROMPT_PREFIXES = ("$ ", ">>> ")
|
|
|
|
|
|
def _normalise_line(line: str) -> str:
|
|
"""Strip prompt prefixes + trailing whitespace + CRs. Returns '' for
|
|
blank and comment-only lines (the caller treats '' as 'skip')."""
|
|
# CRLF → LF was done at file read time; rstrip handles trailing CR too.
|
|
s = line.rstrip("\r\n").rstrip()
|
|
if not s:
|
|
return ""
|
|
if s.lstrip().startswith("#"):
|
|
# Skip pure-comment lines — they're docs scaffolding, not commands.
|
|
return ""
|
|
for prefix in _PROMPT_PREFIXES:
|
|
if s.lstrip().startswith(prefix):
|
|
s = s.replace(prefix, "", 1)
|
|
break
|
|
return s.strip()
|
|
|
|
|
|
def _normalise_script(text: str) -> set[str]:
|
|
"""Return the set of normalised lines from the install script.
|
|
|
|
The script has shebangs, env exports, function defs, etc. — we
|
|
intentionally compare against the *entire* normalised contents (minus
|
|
blanks/comments) so docs may pull any line that survives the install
|
|
flow."""
|
|
out: set[str] = set()
|
|
for raw in text.splitlines():
|
|
norm = _normalise_line(raw)
|
|
if norm:
|
|
out.add(norm)
|
|
return out
|
|
|
|
|
|
def _extract_validated_blocks(md_text: str) -> list[tuple[int, str, bool]]:
|
|
"""Return a list of (start_line_no_1_indexed, body, skip_flag) tuples for
|
|
every `<!-- validate -->` block found in the markdown."""
|
|
lines = md_text.splitlines()
|
|
blocks: list[tuple[int, str, bool]] = []
|
|
i = 0
|
|
pending_marker: tuple[int, bool] | None = None
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
m = _MARKER_RE.search(line)
|
|
if m:
|
|
pending_marker = (i + 1, (m.group("modifier") == "skip"))
|
|
i += 1
|
|
continue
|
|
if pending_marker is not None and _FENCE_OPEN_RE.match(line):
|
|
# Consume until matching close fence.
|
|
block_lines: list[str] = []
|
|
block_start = pending_marker[0]
|
|
skip = pending_marker[1]
|
|
pending_marker = None
|
|
i += 1
|
|
while i < len(lines) and not _FENCE_CLOSE_RE.match(lines[i]):
|
|
block_lines.append(lines[i])
|
|
i += 1
|
|
i += 1 # skip the closing fence
|
|
blocks.append((block_start, "\n".join(block_lines), skip))
|
|
continue
|
|
# Marker followed by something other than a fence — drop it.
|
|
if pending_marker is not None and line.strip() and not _FENCE_OPEN_RE.match(line):
|
|
pending_marker = None
|
|
i += 1
|
|
return blocks
|
|
|
|
|
|
def _iter_docs(root: Path) -> Iterable[Path]:
|
|
docs_dir = root / "docs" / "install"
|
|
if not docs_dir.exists():
|
|
return []
|
|
return sorted(docs_dir.glob("*.md"))
|
|
|
|
|
|
def main(root: Path | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--root",
|
|
type=Path,
|
|
default=None,
|
|
help="Repo root to scan (defaults to the repo containing this script).",
|
|
)
|
|
# When `main()` is called programmatically (unit tests), we still want
|
|
# argparse to work — pass an empty argv so it doesn't accidentally see
|
|
# pytest's command-line args.
|
|
if root is not None:
|
|
args = parser.parse_args([])
|
|
args.root = root
|
|
else:
|
|
args = parser.parse_args()
|
|
if args.root is None:
|
|
args.root = Path(__file__).resolve().parent.parent
|
|
|
|
script_path = args.root / "scripts" / "desktop-prod.sh"
|
|
if not script_path.exists():
|
|
print(
|
|
f"validate-install-docs: missing {script_path}; nothing to validate against",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
canonical = _normalise_script(script_path.read_text(encoding="utf-8"))
|
|
|
|
errors: list[str] = []
|
|
validated = 0
|
|
for md_path in _iter_docs(args.root):
|
|
md_text = md_path.read_text(encoding="utf-8")
|
|
for start_line, body, skip in _extract_validated_blocks(md_text):
|
|
validated += 1
|
|
if skip:
|
|
continue
|
|
for offset, raw in enumerate(body.splitlines(), start=0):
|
|
norm = _normalise_line(raw)
|
|
if not norm:
|
|
continue
|
|
if norm not in canonical:
|
|
errors.append(
|
|
f"{md_path.relative_to(args.root)}:{start_line + 1 + offset}: "
|
|
f"docs line not present in scripts/desktop-prod.sh: {norm!r}"
|
|
)
|
|
|
|
if errors:
|
|
for e in errors:
|
|
print(e, file=sys.stderr)
|
|
print(
|
|
f"\nvalidate-install-docs: {len(errors)} drift(s) in {validated} validated block(s).",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(f"OK — {validated} install docs block(s) validated against {script_path.name}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|