diff --git a/.prompts/update-demos.md b/.prompts/update-demos.md new file mode 100644 index 0000000..c579bb5 --- /dev/null +++ b/.prompts/update-demos.md @@ -0,0 +1,51 @@ +Update demo recordings to match the current compose-farm.yaml configuration. + +## Key Gotchas + +1. **Never `git checkout` without asking** - check for uncommitted changes first +2. **Prefer `nas` stacks** - demos run locally on nas, SSH adds latency +3. **Terminal captures keyboard** - use `blur()` to release focus before command palette +4. **Clicking sidebar navigates away** - clicking h1 scrolls to top +5. **Buttons have icons, not text** - use `[data-tip="..."]` selectors +6. **`record.py` auto-restores config** - no manual cleanup needed after CLI demos + +## Stacks Used in Demos + +| Stack | CLI Demos | Web Demos | Notes | +|-------|-----------|-----------|-------| +| `audiobookshelf` | quickstart, migration, apply | - | Migrates nas→anton | +| `grocy` | update | navigation, stack, workflow, console | - | +| `immich` | logs, compose | shell | Multiple containers | +| `dozzle` | - | workflow | - | + +## CLI Demos + +**Files:** `docs/demos/cli/*.tape` + +Check: +- `quickstart.tape`: `bat -r` line ranges match current config structure +- `migration.tape`: nvim keystrokes work, stack exists on nas +- `compose.tape`: exec commands produce meaningful output + +Run: `python docs/demos/cli/record.py [demo]` + +## Web Demos + +**Files:** `docs/demos/web/demo_*.py` + +Check: +- Stack names in demos still exist in config +- Selectors match current templates (grep for IDs in `templates/`) +- Shell demo uses command palette for ALL navigation + +Run: `python docs/demos/web/record.py [demo]` + +## Before Recording + +```bash +# Check for uncommitted config changes +git -C /opt/stacks diff compose-farm.yaml + +# Verify stacks are running +cf ps audiobookshelf grocy immich dozzle +``` diff --git a/docs/assets/apply.gif b/docs/assets/apply.gif index ae172eb..4a1e19b 100644 --- a/docs/assets/apply.gif +++ b/docs/assets/apply.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb1372a59a4ed1ac74d3864d7a84dd5311fce4cb6c6a00bf3a574bc2f98d5595 -size 895927 +oid sha256:01dabdd8f62773823ba2b8dc74f9931f1a1b88215117e6a080004096025491b0 +size 901456 diff --git a/docs/assets/apply.webm b/docs/assets/apply.webm index 88fe8fb..d331e10 100644 --- a/docs/assets/apply.webm +++ b/docs/assets/apply.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f339a85f3d930db5a020c9f77e106edc5f44ea7dee6f68557106721493c24ef8 -size 205907 +oid sha256:134c903a6b3acfb933617b33755b0cdb9bac2a59e5e35b64236e248a141d396d +size 206883 diff --git a/docs/assets/compose.gif b/docs/assets/compose.gif new file mode 100644 index 0000000..28e574f --- /dev/null +++ b/docs/assets/compose.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8b3cdb3486ec79b3ddb2f7571c13d54ac9aed182edfe708eff76a966a90cfc7 +size 1132310 diff --git a/docs/assets/compose.webm b/docs/assets/compose.webm new file mode 100644 index 0000000..27aa229 --- /dev/null +++ b/docs/assets/compose.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3c4d4a62f062f717df4e6752efced3caea29004dc90fe97fd7633e7f0ded9db +size 341057 diff --git a/docs/assets/install.gif b/docs/assets/install.gif index 7c5f056..7b701f8 100644 --- a/docs/assets/install.gif +++ b/docs/assets/install.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:388aa49a1269145698f9763452aaf6b9c6232ea9229abe1dae304df558e29695 -size 403442 +oid sha256:6c1bb48cc2f364681515a4d8bd0c586d133f5a32789b7bb64524ad7d9ed0a8e9 +size 543135 diff --git a/docs/assets/install.webm b/docs/assets/install.webm index c41c3bf..ca54c3a 100644 --- a/docs/assets/install.webm +++ b/docs/assets/install.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b8bf4dcb8ee67270d4a88124b4dd4abe0dab518e73812ee73f7c66d77f146e2 -size 228025 +oid sha256:5f82d96137f039f21964c15c1550aa1b1f0bb2d52c04d012d253dbfbd6fad096 +size 268086 diff --git a/docs/assets/logs.gif b/docs/assets/logs.gif index 79b561b..676c4b9 100644 --- a/docs/assets/logs.gif +++ b/docs/assets/logs.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16b9a28137dfae25488e2094de85766a039457f5dca20c2d84ac72e3967c10b9 -size 164237 +oid sha256:2a4045b00d90928f42c7764b3c24751576cfb68a34c6e84d12b4e282d2e67378 +size 146467 diff --git a/docs/assets/logs.webm b/docs/assets/logs.webm index 769bb7e..0e3de02 100644 --- a/docs/assets/logs.webm +++ b/docs/assets/logs.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e0fbe697a1f8256ce3b9a6a64c7019d42769134df9b5b964e5abe98a29e918fd -size 68242 +oid sha256:f1b94416ed3740853f863e19bf45f26241a203fb0d7d187160a537f79aa544fa +size 60353 diff --git a/docs/assets/migration.gif b/docs/assets/migration.gif index 2666fae..9b93ce5 100644 --- a/docs/assets/migration.gif +++ b/docs/assets/migration.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:629b8c80b98eb996b75439745676fd99a83f391ca25f778a71bd59173f814c2f -size 1194931 +oid sha256:848d9c48fb7511da7996149277c038589fad1ee406ff2f30c28f777fc441d919 +size 1183641 diff --git a/docs/assets/migration.webm b/docs/assets/migration.webm index 40cdf9f..19b01ce 100644 --- a/docs/assets/migration.webm +++ b/docs/assets/migration.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33fd46f2d8538cc43be4cb553b3af9d8b412f282ee354b6373e2793fe41c799b -size 405057 +oid sha256:e747ee71bb38b19946005d5a4def4d423dadeaaade452dec875c4cb2d24a5b77 +size 407373 diff --git a/docs/assets/quickstart.gif b/docs/assets/quickstart.gif index 368e4dc..0aed6f6 100644 --- a/docs/assets/quickstart.gif +++ b/docs/assets/quickstart.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ccd96e33faba5f297999917d89834b29d58bd2a8929eea8d62875e3d8830bd5c -size 3198466 +oid sha256:d32c9a3eec06e57df085ad347e6bf61e323f8bd8322d0c540f0b9d4834196dfd +size 3589776 diff --git a/docs/assets/quickstart.webm b/docs/assets/quickstart.webm index 30b0a08..c9b9ebf 100644 --- a/docs/assets/quickstart.webm +++ b/docs/assets/quickstart.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:979a1a21303bbf284b3510981066ef05c41c1035b34392fecc7bee472116e6db -size 967564 +oid sha256:6c54eda599389dac74c24c83527f95cd1399e653d7faf2972c2693d90e590597 +size 1085344 diff --git a/docs/assets/update.gif b/docs/assets/update.gif index 228d599..dc85ceb 100644 --- a/docs/assets/update.gif +++ b/docs/assets/update.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2067f4967a93b7ee3a8db7750c435f41b1fccd2919f3443da4b848c20cc54f23 -size 124559 +oid sha256:62f9b5ec71496197a3f1c3e3bca8967d603838804279ea7dbf00a70d3391ff6c +size 127123 diff --git a/docs/assets/update.webm b/docs/assets/update.webm index 3355065..4905651 100644 --- a/docs/assets/update.webm +++ b/docs/assets/update.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5471bd94e6d1b9d415547fa44de6021fdad2e1cc5b8b295680e217104aa749d6 -size 98149 +oid sha256:ac2b93d3630af87b44a135723c5d10e8287529bed17c28301b2802cd9593e9e8 +size 98748 diff --git a/docs/assets/web-console.gif b/docs/assets/web-console.gif index 672676b..2084360 100644 --- a/docs/assets/web-console.gif +++ b/docs/assets/web-console.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dac5660cfe6574857ec055fac7822f25b7c5fcb10a836b19c86142515e2fbf75 -size 1816075 +oid sha256:7b50a7e9836c496c0989363d1440fa0a6ccdaa38ee16aae92b389b3cf3c3732f +size 2385110 diff --git a/docs/assets/web-console.webm b/docs/assets/web-console.webm index 0adba14..8c295e4 100644 --- a/docs/assets/web-console.webm +++ b/docs/assets/web-console.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4efec8ef5a99f2cb31d55cd71cdbf0bb8dd0cd6281571886b7c1f8b41c3f9da -size 1660764 +oid sha256:ccbb3d5366c7734377e12f98cca0b361028f5722124f1bb7efa231f6aeffc116 +size 2208044 diff --git a/docs/assets/web-navigation.gif b/docs/assets/web-navigation.gif index dd40a49..a2d96b8 100644 --- a/docs/assets/web-navigation.gif +++ b/docs/assets/web-navigation.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9348dd36e79192344476d61fbbffdb122a96ecc5829fbece1818590cfc521521 -size 3373003 +oid sha256:269993b52721ce70674d3aab2a4cd8c58aa621d4ba0739afedae661c90965b26 +size 3678371 diff --git a/docs/assets/web-navigation.webm b/docs/assets/web-navigation.webm index 70cee72..7a8a466 100644 --- a/docs/assets/web-navigation.webm +++ b/docs/assets/web-navigation.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bebbf8151434ba37bf5e46566a4e8b57812944281926f579d056bdc835ca26aa -size 2729799 +oid sha256:0098b55bb6a52fa39f807a01fa352ce112bcb446e2a2acb963fb02d21b28c934 +size 3088813 diff --git a/docs/assets/web-shell.gif b/docs/assets/web-shell.gif index 62d2cc7..d9927cd 100644 --- a/docs/assets/web-shell.gif +++ b/docs/assets/web-shell.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3712afff6fcde00eb951264bb24d4301deb085d082b4e95ed4c1893a571938ee -size 1528294 +oid sha256:4bf9d8c247d278799d1daea784fc662a22f12b1bd7883f808ef30f35025ebca6 +size 4166443 diff --git a/docs/assets/web-shell.webm b/docs/assets/web-shell.webm index 2d65b8c..e2ef636 100644 --- a/docs/assets/web-shell.webm +++ b/docs/assets/web-shell.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b218d400836a50661c9cdcce2d2b1e285cc5fe592cb42f58aae41f3e7d60684 -size 1327413 +oid sha256:02d5124217a94849bf2971d6d13d28da18c557195a81b9cca121fb7c07f0501b +size 3523244 diff --git a/docs/assets/web-stack.gif b/docs/assets/web-stack.gif index 196011a..e1886a8 100644 --- a/docs/assets/web-stack.gif +++ b/docs/assets/web-stack.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a232ddc1b9ddd9bf6b5d99c05153e1094be56f1952f02636ca498eb7484e096 -size 3808675 +oid sha256:412a0e68f8e52801cafbb9a703ca9577e7c14cc7c0e439160b9185961997f23c +size 4435697 diff --git a/docs/assets/web-stack.webm b/docs/assets/web-stack.webm index 294d19d..85fb4f9 100644 --- a/docs/assets/web-stack.webm +++ b/docs/assets/web-stack.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a7c9f5f6d47074a6af135190fda6d0a1936cd7a0b04b3aa04ea7d99167a9e05 -size 3333014 +oid sha256:0e600a1d3216b44497a889f91eac94d62ef7207b4ed0471465dcb72408caa28e +size 3764693 diff --git a/docs/assets/web-themes.gif b/docs/assets/web-themes.gif index c101c82..758d45a 100644 --- a/docs/assets/web-themes.gif +++ b/docs/assets/web-themes.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66f4547ed2e83b302d795875588d9a085af76071a480f1096f2bb64344b80c42 -size 5428670 +oid sha256:3c07a283f4f70c4ab205b0f0acb5d6f55e3ced4c12caa7a8d5914ffe3548233a +size 5768166 diff --git a/docs/assets/web-themes.webm b/docs/assets/web-themes.webm index fe224c2..2ae9b36 100644 --- a/docs/assets/web-themes.webm +++ b/docs/assets/web-themes.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75c8cdeefbbdcab2a240821d3410539f2a2cbe0a015897f4135404c80c3ac32c -size 6578366 +oid sha256:562228841de976d70ee80999b930eadf3866a13ff2867d900279993744c44671 +size 6667918 diff --git a/docs/assets/web-workflow.gif b/docs/assets/web-workflow.gif index e235bc5..9ea901f 100644 --- a/docs/assets/web-workflow.gif +++ b/docs/assets/web-workflow.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff2e3ca5a46397efcd5f3a595e7d3c179266cc4f3f5f528b428f5ef2a423028e -size 12649149 +oid sha256:845746ac1cb101c3077d420c4f3fda3ca372492582dc123ac8a031a68ae9b6b1 +size 12943150 diff --git a/docs/assets/web-workflow.webm b/docs/assets/web-workflow.webm index e7d1b5a..f549dd4 100644 --- a/docs/assets/web-workflow.webm +++ b/docs/assets/web-workflow.webm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d739c5f77ddd9d90b609e31df620b35988081b7341fe225eb717d71a87caa88 -size 12284953 +oid sha256:189259558b5760c02583885168d7b0b47cf476cba81c7c028ec770f9d6033129 +size 12415357 diff --git a/docs/commands.md b/docs/commands.md index 6965ed2..a562d16 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -290,6 +290,10 @@ cf pull immich --service database Run any docker compose command on a stack. This is a passthrough to docker compose for commands not wrapped by cf. + + ```bash cf compose [OPTIONS] STACK COMMAND [ARGS]... ``` diff --git a/docs/demos/cli/README.md b/docs/demos/cli/README.md index cfabecd..31b8243 100644 --- a/docs/demos/cli/README.md +++ b/docs/demos/cli/README.md @@ -10,10 +10,10 @@ VHS-based terminal demo recordings for Compose Farm CLI. ```bash # Record all demos -./docs/demos/cli/record.sh +python docs/demos/cli/record.py -# Record single demo -cd /opt/stacks && vhs docs/demos/cli/quickstart.tape +# Record specific demos +python docs/demos/cli/record.py quickstart migration ``` ## Demos @@ -23,6 +23,7 @@ cd /opt/stacks && vhs docs/demos/cli/quickstart.tape | `install.tape` | Installing with `uv tool install` | | `quickstart.tape` | `cf ps`, `cf up`, `cf logs` | | `logs.tape` | Viewing logs | +| `compose.tape` | `cf compose` passthrough (--help, images, exec) | | `update.tape` | `cf update` | | `migration.tape` | Service migration | | `apply.tape` | `cf apply` | diff --git a/docs/demos/cli/compose.tape b/docs/demos/cli/compose.tape new file mode 100644 index 0000000..3d9aa12 --- /dev/null +++ b/docs/demos/cli/compose.tape @@ -0,0 +1,50 @@ +# Compose Demo +# Shows that cf compose passes through ANY docker compose command + +Output docs/assets/compose.gif +Output docs/assets/compose.webm + +Set Shell "bash" +Set FontSize 14 +Set Width 900 +Set Height 550 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 50ms + +Type "# cf compose runs ANY docker compose command on the right host" +Enter +Sleep 500ms + +Type "# See ALL available compose commands" +Enter +Sleep 500ms + +Type "cf compose immich --help" +Enter +Sleep 4s + +Type "# Show images" +Enter +Sleep 500ms + +Type "cf compose immich images" +Enter +Wait+Screen /immich/ +Sleep 2s + +Type "# Open shell in a container" +Enter +Sleep 500ms + +Type "cf compose immich exec immich-machine-learning sh" +Enter +Wait+Screen /#/ +Sleep 1s + +Type "python3 --version" +Enter +Sleep 1s + +Type "exit" +Enter +Sleep 500ms diff --git a/docs/demos/cli/quickstart.tape b/docs/demos/cli/quickstart.tape index a6f31eb..0b73c6d 100644 --- a/docs/demos/cli/quickstart.tape +++ b/docs/demos/cli/quickstart.tape @@ -21,7 +21,7 @@ Type "# First, define your hosts..." Enter Sleep 500ms -Type "bat -r 1:11 compose-farm.yaml" +Type "bat -r 1:16 compose-farm.yaml" Enter Sleep 3s Type "q" @@ -31,7 +31,7 @@ Type "# Then map each stack to a host" Enter Sleep 500ms -Type "bat -r 13:30 compose-farm.yaml" +Type "bat -r 17:35 compose-farm.yaml" Enter Sleep 3s Type "q" diff --git a/docs/demos/cli/record.py b/docs/demos/cli/record.py new file mode 100755 index 0000000..bdd29ee --- /dev/null +++ b/docs/demos/cli/record.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Record CLI demos using VHS.""" + +import shutil +import subprocess +import sys +from pathlib import Path + +from rich.console import Console + +from compose_farm.config import load_config +from compose_farm.state import load_state + +console = Console() +SCRIPT_DIR = Path(__file__).parent +STACKS_DIR = Path("/opt/stacks") +CONFIG_FILE = STACKS_DIR / "compose-farm.yaml" +OUTPUT_DIR = SCRIPT_DIR.parent.parent / "assets" + +DEMOS = ["install", "quickstart", "logs", "compose", "update", "migration", "apply"] + + +def _run(cmd: list[str], **kw) -> bool: + return subprocess.run(cmd, check=False, **kw).returncode == 0 + + +def _set_config(host: str) -> None: + """Set audiobookshelf host in config file.""" + _run(["sed", "-i", f"s/audiobookshelf: .*/audiobookshelf: {host}/", str(CONFIG_FILE)]) + + +def _get_hosts() -> tuple[str | None, str | None]: + """Return (config_host, state_host) for audiobookshelf.""" + config = load_config() + state = load_state(config) + return config.stacks.get("audiobookshelf"), state.get("audiobookshelf") + + +def _setup_state(demo: str) -> bool: + """Set up required state for demo. Returns False on failure.""" + if demo not in ("migration", "apply"): + return True + + config_host, state_host = _get_hosts() + + if demo == "migration": + # Migration needs audiobookshelf on nas in BOTH config and state + if config_host != "nas": + console.print("[yellow]Setting up: config → nas[/yellow]") + _set_config("nas") + if state_host != "nas": + console.print("[yellow]Setting up: state → nas[/yellow]") + if not _run(["cf", "apply"], cwd=STACKS_DIR): + return False + + elif demo == "apply": + # Apply needs config=nas, state=anton (so there's something to apply) + if config_host != "nas": + console.print("[yellow]Setting up: config → nas[/yellow]") + _set_config("nas") + if state_host == "nas": + console.print("[yellow]Setting up: state → anton[/yellow]") + _set_config("anton") + if not _run(["cf", "apply"], cwd=STACKS_DIR): + return False + _set_config("nas") + + return True + + +def _record(name: str, index: int, total: int) -> bool: + """Record a single demo.""" + console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] {name}") + if _run(["vhs", str(SCRIPT_DIR / f"{name}.tape")], cwd=STACKS_DIR): + console.print("[green] ✓ Done[/green]") + return True + console.print("[red] ✗ Failed[/red]") + return False + + +def _reset_after(demo: str, next_demo: str | None) -> None: + """Reset state after demos that modify audiobookshelf.""" + if demo not in ("quickstart", "migration"): + return + _set_config("nas") + if next_demo != "apply": # Let apply demo show the migration + _run(["cf", "apply"], cwd=STACKS_DIR) + + +def _restore_config(original: str) -> None: + """Restore original config and sync state.""" + console.print("[yellow]Restoring original config...[/yellow]") + CONFIG_FILE.write_text(original) + _run(["cf", "apply"], cwd=STACKS_DIR) + + +def _main() -> int: + if not shutil.which("vhs"): + console.print("[red]VHS not found. Install: brew install vhs[/red]") + return 1 + + if not _run(["git", "-C", str(STACKS_DIR), "diff", "--quiet", "compose-farm.yaml"]): + console.print("[red]compose-farm.yaml has uncommitted changes[/red]") + return 1 + + demos = [d for d in sys.argv[1:] if d in DEMOS] or DEMOS + if sys.argv[1:] and not demos: + console.print(f"[red]Unknown demo. Available: {', '.join(DEMOS)}[/red]") + return 1 + + # Save original config to restore after recording + original_config = CONFIG_FILE.read_text() + + try: + for i, demo in enumerate(demos, 1): + if not _setup_state(demo): + return 1 + if not _record(demo, i, len(demos)): + return 1 + _reset_after(demo, demos[i] if i < len(demos) else None) + finally: + _restore_config(original_config) + + # Move outputs + OUTPUT_DIR.mkdir(exist_ok=True) + for f in (STACKS_DIR / "docs/assets").glob("*.[gw]*"): + shutil.move(str(f), str(OUTPUT_DIR / f.name)) + + console.print(f"\n[green]Done![/green] Saved to {OUTPUT_DIR}") + return 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/docs/demos/cli/record.sh b/docs/demos/cli/record.sh deleted file mode 100755 index f3d4c10..0000000 --- a/docs/demos/cli/record.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# Record all VHS demos -# Run this on a Docker host with compose-farm configured - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEMOS_DIR="$(dirname "$SCRIPT_DIR")" -DOCS_DIR="$(dirname "$DEMOS_DIR")" -REPO_DIR="$(dirname "$DOCS_DIR")" -OUTPUT_DIR="$DOCS_DIR/assets" - -# Colors -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[0;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Check for VHS -if ! command -v vhs &> /dev/null; then - echo "VHS not found. Install with:" - echo " brew install vhs" - echo " # or" - echo " go install github.com/charmbracelet/vhs@latest" - exit 1 -fi - -# Ensure output directory exists -mkdir -p "$OUTPUT_DIR" - -# Temp output dir (VHS runs from /opt/stacks, so relative paths go here) -TEMP_OUTPUT="/opt/stacks/docs/assets" -mkdir -p "$TEMP_OUTPUT" - -# Change to /opt/stacks so cf commands use installed version (not editable install) -cd /opt/stacks - -# Ensure compose-farm.yaml has no uncommitted changes (safety check) -if ! git diff --quiet compose-farm.yaml; then - echo -e "${RED}Error: compose-farm.yaml has uncommitted changes${NC}" - echo "Commit or stash your changes before recording demos" - exit 1 -fi - -echo -e "${BLUE}Recording VHS demos...${NC}" -echo "Output directory: $OUTPUT_DIR" -echo "" - -# Function to record a tape -record_tape() { - local tape=$1 - local name=$(basename "$tape" .tape) - echo -e "${GREEN}Recording:${NC} $name" - if vhs "$tape"; then - echo -e "${GREEN} ✓ Done${NC}" - else - echo -e "${RED} ✗ Failed${NC}" - return 1 - fi -} - -# Record demos in logical order -echo -e "${YELLOW}=== Phase 1: Basic demos ===${NC}" -record_tape "$SCRIPT_DIR/install.tape" -record_tape "$SCRIPT_DIR/quickstart.tape" -record_tape "$SCRIPT_DIR/logs.tape" - -echo -e "${YELLOW}=== Phase 2: Update demo ===${NC}" -record_tape "$SCRIPT_DIR/update.tape" - -echo -e "${YELLOW}=== Phase 3: Migration demo ===${NC}" -record_tape "$SCRIPT_DIR/migration.tape" -git -C /opt/stacks checkout compose-farm.yaml # Reset after migration - -echo -e "${YELLOW}=== Phase 4: Apply demo ===${NC}" -record_tape "$SCRIPT_DIR/apply.tape" - -# Move GIFs and WebMs from temp location to repo -echo "" -echo -e "${BLUE}Moving recordings to repo...${NC}" -mv "$TEMP_OUTPUT"/*.gif "$OUTPUT_DIR/" 2>/dev/null || true -mv "$TEMP_OUTPUT"/*.webm "$OUTPUT_DIR/" 2>/dev/null || true -rmdir "$TEMP_OUTPUT" 2>/dev/null || true -rmdir "$(dirname "$TEMP_OUTPUT")" 2>/dev/null || true - -echo "" -echo -e "${GREEN}Done!${NC} Recordings saved to $OUTPUT_DIR/" -ls -la "$OUTPUT_DIR"/*.gif "$OUTPUT_DIR"/*.webm 2>/dev/null || echo "No recordings found (check for errors above)" diff --git a/docs/demos/web/demo_console.py b/docs/demos/web/demo_console.py index b87624d..1fc43c9 100644 --- a/docs/demos/web/demo_console.py +++ b/docs/demos/web/demo_console.py @@ -60,10 +60,14 @@ def test_demo_console(recording_page: Page, server_url: str) -> None: page.keyboard.press("Enter") pause(page, 2500) # Wait for output - # Scroll down to show the Editor section with Compose Farm config - editor_section = page.locator(".collapse", has_text="Editor").first - editor_section.scroll_into_view_if_needed() - pause(page, 800) + # Smoothly scroll down to show the Editor section with Compose Farm config + page.evaluate(""" + const editor = document.getElementById('console-editor'); + if (editor) { + editor.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + """) + pause(page, 1200) # Wait for smooth scroll animation # Wait for Monaco editor to load with config content page.wait_for_selector("#console-editor .monaco-editor", timeout=10000) diff --git a/docs/demos/web/demo_shell.py b/docs/demos/web/demo_shell.py index a4e90b8..d4b5c75 100644 --- a/docs/demos/web/demo_shell.py +++ b/docs/demos/web/demo_shell.py @@ -1,9 +1,11 @@ -"""Demo: Container shell exec. +"""Demo: Container shell exec via command palette. -Records a ~25 second demo showing: -- Navigating to a stack page -- Clicking Shell button on a container -- Running top command inside the container +Records a ~35 second demo showing: +- Navigating to immich stack (multiple containers) +- Using command palette with fuzzy matching ("sh mach") to open shell +- Running a command +- Using command palette to switch to server container shell +- Running another command Run: pytest docs/demos/web/demo_shell.py -v --no-cov """ @@ -14,6 +16,7 @@ from typing import TYPE_CHECKING import pytest from conftest import ( + open_command_palette, pause, slow_type, wait_for_sidebar, @@ -33,39 +36,71 @@ def test_demo_shell(recording_page: Page, server_url: str) -> None: wait_for_sidebar(page) pause(page, 800) - # Navigate to a stack with a running container (grocy) - page.locator("#sidebar-stacks a", has_text="grocy").click() - page.wait_for_url("**/stack/grocy", timeout=5000) + # Navigate to immich via command palette (has multiple containers) + open_command_palette(page) + pause(page, 400) + slow_type(page, "#cmd-input", "immich", delay=100) + pause(page, 600) + page.keyboard.press("Enter") + page.wait_for_url("**/stack/immich", timeout=5000) pause(page, 1500) - # Wait for containers list to load (loaded via HTMX) + # Wait for containers list to load (so shell commands are available) page.wait_for_selector("#containers-list button", timeout=10000) pause(page, 800) - # Click Shell button on the first container - shell_btn = page.locator("#containers-list button", has_text="Shell").first - shell_btn.click() + # Use command palette with fuzzy matching: "sh mach" -> "Shell: immich-machine-learning" + open_command_palette(page) + pause(page, 400) + slow_type(page, "#cmd-input", "sh mach", delay=100) + pause(page, 600) + page.keyboard.press("Enter") pause(page, 1000) # Wait for exec terminal to appear page.wait_for_selector("#exec-terminal .xterm", timeout=10000) - # Scroll down to make the terminal visible - page.locator("#exec-terminal").scroll_into_view_if_needed() - pause(page, 2000) + # Smoothly scroll down to make the terminal visible + page.evaluate(""" + const terminal = document.getElementById('exec-terminal'); + if (terminal) { + terminal.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + """) + pause(page, 1200) - # Run top command - slow_type(page, "#exec-terminal .xterm-helper-textarea", "top", delay=100) + # Run python version command + slow_type(page, "#exec-terminal .xterm-helper-textarea", "python3 --version", delay=60) pause(page, 300) page.keyboard.press("Enter") - pause(page, 4000) # Let top run for a bit + pause(page, 1500) - # Press q to quit top - page.keyboard.press("q") + # Blur the terminal to release focus (won't scroll) + page.evaluate("document.activeElement?.blur()") + pause(page, 500) + + # Use command palette to switch to server container: "sh serv" -> "Shell: immich-server" + open_command_palette(page) + pause(page, 400) + slow_type(page, "#cmd-input", "sh serv", delay=100) + pause(page, 600) + page.keyboard.press("Enter") pause(page, 1000) - # Run another command to show it's interactive - slow_type(page, "#exec-terminal .xterm-helper-textarea", "ps aux | head", delay=60) + # Wait for new terminal + page.wait_for_selector("#exec-terminal .xterm", timeout=10000) + + # Scroll to terminal + page.evaluate(""" + const terminal = document.getElementById('exec-terminal'); + if (terminal) { + terminal.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + """) + pause(page, 1200) + + # Run ls command + slow_type(page, "#exec-terminal .xterm-helper-textarea", "ls /usr/src/app", delay=60) pause(page, 300) page.keyboard.press("Enter") pause(page, 2000) diff --git a/docs/demos/web/demo_stack.py b/docs/demos/web/demo_stack.py index 4586332..5c75415 100644 --- a/docs/demos/web/demo_stack.py +++ b/docs/demos/web/demo_stack.py @@ -55,9 +55,14 @@ def test_demo_stack(recording_page: Page, server_url: str) -> None: page.wait_for_selector("#compose-editor .monaco-editor", timeout=10000) pause(page, 2000) # Let viewer see the compose file - # Scroll down slightly to show more of the editor - page.locator("#compose-editor").scroll_into_view_if_needed() - pause(page, 1500) + # Smoothly scroll down to show more of the editor + page.evaluate(""" + const editor = document.getElementById('compose-editor'); + if (editor) { + editor.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + """) + pause(page, 1200) # Wait for smooth scroll animation # Close the compose file section compose_collapse.locator("input[type=checkbox]").click(force=True) diff --git a/docs/demos/web/demo_workflow.py b/docs/demos/web/demo_workflow.py index df6199b..8d8d3e9 100644 --- a/docs/demos/web/demo_workflow.py +++ b/docs/demos/web/demo_workflow.py @@ -5,7 +5,7 @@ Records a comprehensive demo (~60 seconds) combining all major features: 2. Editor showing Compose Farm YAML config 3. Command palette navigation to grocy stack 4. Stack actions: up, logs -5. Switch to mealie stack via command palette, run update +5. Switch to dozzle stack via command palette, run update 6. Dashboard overview 7. Theme cycling via command palette @@ -126,13 +126,13 @@ def _demo_stack_actions(page: Page) -> None: page.wait_for_selector("#terminal-output .xterm", timeout=5000) pause(page, 2500) - # Switch to mealie via command palette + # Switch to dozzle via command palette (on nas for lower latency) open_command_palette(page) pause(page, 300) - slow_type(page, "#cmd-input", "mealie", delay=100) + slow_type(page, "#cmd-input", "dozzle", delay=100) pause(page, 400) page.keyboard.press("Enter") - page.wait_for_url("**/stack/mealie", timeout=5000) + page.wait_for_url("**/stack/dozzle", timeout=5000) pause(page, 1000) # Run update action @@ -162,32 +162,20 @@ def _demo_dashboard_and_themes(page: Page, server_url: str) -> None: page.evaluate("window.scrollTo(0, 0)") pause(page, 600) - # Open theme picker and arrow down to Luxury (shows live preview) - # Theme order: light, dark, cupcake, bumblebee, emerald, corporate, synthwave, - # retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel, - # fantasy, wireframe, black, luxury (index 19) + # Open theme picker and arrow down to Dracula (shows live preview) page.locator("#theme-btn").click() page.wait_for_selector("#cmd-palette[open]", timeout=2000) pause(page, 400) - # Arrow down through themes with live preview until we reach Luxury + # Arrow down through themes with live preview until we reach Dracula for _ in range(19): page.keyboard.press("ArrowDown") pause(page, 180) - # Select Luxury theme + # Select Dracula theme and end on it pause(page, 400) page.keyboard.press("Enter") - pause(page, 1000) - - # Return to dark theme - page.locator("#theme-btn").click() - page.wait_for_selector("#cmd-palette[open]", timeout=2000) - pause(page, 300) - slow_type(page, "#cmd-input", " dark", delay=80) - pause(page, 400) - page.keyboard.press("Enter") - pause(page, 1000) + pause(page, 1500) @pytest.mark.browser # type: ignore[misc] diff --git a/docs/demos/web/record.py b/docs/demos/web/record.py index 2d55d12..d4ba57a 100755 --- a/docs/demos/web/record.py +++ b/docs/demos/web/record.py @@ -88,9 +88,9 @@ def patch_playwright_video_quality() -> None: console.print("[green]Patched Playwright for high-quality video recording[/green]") -def record_demo(name: str) -> Path | None: +def record_demo(name: str, index: int, total: int) -> Path | None: """Run a single demo and return the video path.""" - console.print(f"[green]Recording:[/green] web-{name}") + console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] web-{name}") demo_file = SCRIPT_DIR / f"demo_{name}.py" if not demo_file.exists(): @@ -227,9 +227,7 @@ def main() -> int: try: for i, demo in enumerate(demos_to_record, 1): - console.print(f"[yellow]=== Demo {i}/{len(demos_to_record)}: {demo} ===[/yellow]") - - video_path = record_demo(demo) + video_path = record_demo(demo, i, len(demos_to_record)) if video_path: webm, gif = move_recording(video_path, demo) results[demo] = (webm, gif) diff --git a/justfile b/justfile index 5c1470c..59736c3 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,14 @@ doc: kill-doc: lsof -ti :9002 | xargs kill -9 2>/dev/null || true +# Record CLI demos (all or specific: just record-cli quickstart) +record-cli *demos: + python docs/demos/cli/record.py {{demos}} + +# Record web UI demos (all or specific: just record-web navigation) +record-web *demos: + python docs/demos/web/record.py {{demos}} + # Clean up build artifacts and caches clean: rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build diff --git a/src/compose_farm/web/ws.py b/src/compose_farm/web/ws.py index a88a61e..826511b 100644 --- a/src/compose_farm/web/ws.py +++ b/src/compose_farm/web/ws.py @@ -37,7 +37,7 @@ def _parse_resize(msg: str) -> tuple[int, int] | None: """Parse a resize message, return (cols, rows) or None if not a resize.""" try: data = json.loads(msg) - if data.get("type") == "resize": + if isinstance(data, dict) and data.get("type") == "resize": return int(data["cols"]), int(data["rows"]) except (json.JSONDecodeError, KeyError, TypeError, ValueError): pass