diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 0000000000..4724011329 --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,279 @@ +name: Demo + +on: + workflow_dispatch: + pull_request: + types: [labeled, opened, synchronize] + paths: + - ".github/workflows/demo.yml" + +permissions: + contents: write + id-token: write + pull-requests: write + +env: + TERM: "xterm-256color" + COLORTERM: "truecolor" + LANG: "en_US.UTF-8" + ATMOS_LOGS_LEVEL: "Info" + TERRAFORM_VERSION: "1.9.7" + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get Atmos version + id: get-version + run: | + VERSION=$(curl -s https://api.github.com/repos/cloudposse/atmos/releases/latest | jq -r .tag_name) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + outputs: + version: ${{ steps.get-version.outputs.version }} + + screengrabs: + needs: [prepare] + runs-on: ubuntu-latest + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y aha util-linux make jq bat + sudo ln -s /usr/bin/batcat /usr/bin/bat + + - name: Set Git Preferences for windows + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Atmos + uses: jaxxstorm/action-install-gh-release@v2.1.0 + with: + repo: cloudposse/atmos + tag: ${{ needs.prepare.outputs.version }} + chmod: 0755 + extension-matching: disable + rename-to: atmos + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + terraform_wrapper: false + + - name: Run make build-all install + run: | + make -C demo/screengrabs build-all install + git add -A + git status + + - name: Create or update PR + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: "chore/update-build-screengrabs-for-${{ needs.prepare.outputs.version }}" + title: "Update screengrabs for ${{ needs.prepare.outputs.version }}" + delete-branch: true + sign-commits: true + commit-message: | + chore: update screengrabs for ${{ needs.prepare.outputs.version }} + body: | + This PR updates the screengrabs for Atmos version ${{ needs.prepare.outputs.version }}. + base: main + labels: "no-release" + + vhs: + needs: [prepare] + runs-on: ubuntu-24.04 + #runs-on: + # - runs-on=${{ github.run_id }} + # - runner=large + + # If VHS takes longer than 20 minutes, it indicate typically that VHS has hung + timeout-minutes: 60 + environment: demo + env: + AWS_REGION: us-east-2 + steps: + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + with: + detached: true + limit-access-to-actor: true + # Determine environment-specific settings + - name: Set Environment Variables + id: set-env + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + echo "IAM_ROLE_ARN=arn:aws:iam::557075604627:role/cplive-plat-ue2-prod-atmos-docs-gha" >> $GITHUB_ENV + echo "IAM_ROLE_SESSION_NAME=cloudposse-atmos-ci-deploy-release" >> $GITHUB_ENV + echo "S3_BUCKET_NAME=cplive-plat-ue2-prod-atmos-docs-origin" >> $GITHUB_ENV + echo "S3_PATH=assets" >> $GITHUB_ENV + echo "BASE_URL=https://atmos.tools/assets" >> $GITHUB_ENV + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "IAM_ROLE_ARN=arn:aws:iam::068007702576:role/cplive-plat-ue2-dev-atmos-docs-gha" >> $GITHUB_ENV + echo "IAM_ROLE_SESSION_NAME=cloudposse-atmos-ci-deploy-${{ github.run_id }}" >> $GITHUB_ENV + echo "S3_BUCKET_NAME=cplive-plat-ue2-dev-atmos-docs-origin" >> $GITHUB_ENV + echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV + echo "S3_PATH=pr-${{ github.event.pull_request.number }}/assets" >> $GITHUB_ENV + echo "BASE_URL=https://pr-${{ github.event.pull_request.number }}.atmos-docs.ue2.dev.plat.cloudposse.org/assets" >> $GITHUB_ENV + fi + + # https://github.com/marketplace/actions/configure-aws-credentials-action-for-github-actions + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ env.AWS_REGION }} + role-to-assume: ${{ env.IAM_ROLE_ARN }} + role-session-name: ${{ env.IAM_ROLE_SESSION_NAME }} + + - name: Test AWS + run: | + aws sts get-caller-identity + + - uses: actions/create-github-app-token@v1 + id: github-app + with: + app-id: ${{ vars.BOT_GITHUB_APP_ID }} + private-key: ${{ secrets.BOT_GITHUB_APP_PRIVATE_KEY }} + owner: cloudposse-corp + repositories: assets + + - uses: actions/checkout@v4 + - name: Checkout cloudposse-corp/assets + uses: actions/checkout@v4 + with: + repository: cloudposse-corp/assets + token: ${{ steps.github-app.outputs.token }} + path: assets + + - name: Install audio track + run: | + cp assets/artlist/background-1.mp3 demo/recordings/background.mp3 + + - name: Set Swap Space + uses: pierotofy/set-swap-space@0404882bc4666c0ff2f6fd8b3d32af69a730183c + with: + swap-size-gb: 15 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + terraform_wrapper: false + + - name: Install Atmos + uses: jaxxstorm/action-install-gh-release@v2.1.0 + with: + repo: cloudposse/atmos + tag: ${{ needs.prepare.outputs.version }} + chmod: 0755 + extension-matching: disable + rename-to: atmos + + - name: Install VHS + uses: jaxxstorm/action-install-gh-release@v2.1.0 + with: + repo: charmbracelet/vhs + tag: v0.9.0 + chmod: 0755 + extension-matching: disable + rename-to: vhs + + - name: Test Atmos + run: | + which atmos + atmos version + atmos --help + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y ttyd + + - name: Prepare variables + id: vars + run: | + VERSION="${{ needs.prepare.outputs.version }}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "job_name=${JOB_NAME}" >> $GITHUB_OUTPUT + echo "branch_name=chore/update-${JOB_NAME}-for-${VERSION}" >> $GITHUB_OUTPUT + + #- name: Setup VHS + # uses: charmbracelet/vhs-action@v2 + # with: + # token: ${{ secrets.GITHUB_TOKEN }} + # install-fonts: true + + - name: Set up FFmpeg (Latest release) + uses: FedericoCarboni/setup-ffmpeg@v3.1 + with: + ffmpeg-version: "release" + github-token: ${{ secrets.GITHUB_TOKEN }} # Optional: helps prevent rate limit errors + + - name: Set up Go + uses: actions/setup-go@v4 + + - name: Add Go bin to PATH + run: echo "$HOME/go/bin" >> "$GITHUB_PATH" + + - name: Install font-install + run: | + go install github.com/Crosse/font-install@latest + + - name: Install Nerd Fonts & Google Fonts + run: | + font-install -fromFile demo/recordings/fonts.txt + + - name: Record screencast + working-directory: demo/recordings + run: | + echo "PATH=$PATH" > /tmp/path.sh + ffmpeg -version + go run studio.go build + ls -al demo/recordings/gif + ls -al demo/recordings/mp4 + #cp demo/recordings/gif/atmos.gif docs/demo.gif + #git add docs/demo.gif + + - name: Upload Assets to S3 (atmos.tools) + run: | + cd demo/recordings + aws s3 ls s3://${{ env.S3_BUCKET_NAME }} + for ext in mp4 gif; do + aws s3 sync $ext/ s3://${{ env.S3_BUCKET_NAME }}/${{ env.S3_PATH }}/${ext}/ + aws s3 ls s3://${{ env.S3_BUCKET_NAME }}/${{ env.S3_PATH }}/${ext}/ --recursive --human-readable --summarize + done + + - name: Create or update PR + uses: peter-evans/create-pull-request@v7 + id: auto-commit + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ steps.vars.outputs.branch_name }} + sign-commits: true + commit-message: | + chore: update ${{ steps.vars.outputs.job_name }} for ${{ steps.vars.outputs.version }} + title: Update ${{ steps.vars.outputs.job_name }} for ${{ steps.vars.outputs.version }} + body: | + This PR updates the demo gif for ${{ steps.vars.outputs.job_name }} with Atmos version ${{ steps.vars.outputs.version }}. + base: main + labels: no-release + + - name: Add Image to Job Summary + if: steps.auto-commit.outputs.pull-request-operation == 'created' || steps.auto-commit.outputs.pull-request-operation == 'updated' + run: | + echo "## Demo GIF" >> $GITHUB_STEP_SUMMARY + echo "![Demo GIF](https://github.com/${{ github.repository }}/blob/${{ steps.auto-commit.outputs.pull-request-head-sha }}/docs/demo.gif?raw=true)" >> $GITHUB_STEP_SUMMARY + + echo "## Demo Video" >> $GITHUB_STEP_SUMMARY + echo "${{ env.BASE_URL }}/mp4/atmos-with-audio.mp4" >> $GITHUB_STEP_SUMMARY + + - name: No changes + if: steps.auto-commit.outputs.pull-request-operation == 'none' || steps.auto-commit.outputs.pull-request-operation == 'closed' + run: | + echo "No changes to demo" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/vhs.yaml b/.github/workflows/vhs.yaml deleted file mode 100644 index 73cb020889..0000000000 --- a/.github/workflows/vhs.yaml +++ /dev/null @@ -1,102 +0,0 @@ -name: vhs -on: - pull_request: - types: [labeled, opened, synchronize] - -env: - TERM: "xterm-256color" - COLORTERM: "truecolor" - LANG: "en_US.UTF-8" - ATMOS_LOGS_LEVEL: "Info" - -jobs: - prepare: - runs-on: ubuntu-latest - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - - steps: - - uses: actions/checkout@v4 - - - name: Check for vhs label - id: labeled - env: - GH_TOKEN: ${{ github.token }} - run: | - labels=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels | jq -r '.[].name') - if [[ $labels =~ "vhs" ]]; then - echo "labeled=true" >> $GITHUB_OUTPUT - else - echo "labeled=false" >> $GITHUB_OUTPUT - fi - - - name: Get modified .tape files - id: tapes - env: - GH_TOKEN: ${{ github.token }} - run: | - if [[ "${{ steps.labeled.outputs.labeled }}" == "true" ]]; then - echo "files=$(find . -path './build-harness' -prune -o -type f -name '*.tape' ! -name 'style.tape' ! -name 'defaults.tape' -print | cut -d/ -f2-)" >> $GITHUB_OUTPUT - else - files=$(gh pr diff --name-only ${{ github.event.pull_request.number }} | (grep '\.tape$' | grep -v '(style|defaults)\.tape$' || true) | cut -d/ -f2-) - echo "files=$files" >> $GITHUB_OUTPUT - fi - - - name: Set up matrix - id: create-matrix - run: | - echo "matrix=$(echo -n ${{ steps.tapes.outputs.files }} | jq -R -s -c 'split(" ")')" >> $GITHUB_OUTPUT - - outputs: - matrix: ${{ steps.create-matrix.outputs.matrix }} - labeled: ${{ steps.labeled.outputs.labeled }} - - vhs: - needs: [prepare] - if: needs.prepare.outputs.matrix != '[]' - runs-on: ubuntu-latest - strategy: - matrix: - file: ${{fromJson(needs.prepare.outputs.matrix)}} - concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.file }} - cancel-in-progress: true - steps: - - uses: actions/checkout@v4 - - - name: Install atmos - uses: jaxxstorm/action-install-gh-release@v1.11.0 - with: # Grab the latest version - repo: cloudposse/atmos - chmod: 0755 - extension-matching: disable - rename-to: atmos - - - uses: charmbracelet/vhs-action@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - path: ${{ matrix.file }} - install-fonts: true - - - uses: stefanzweifel/git-auto-commit-action@v4 - id: auto-commit - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - commit_message: "chore: update demo gif" - commit_user_name: vhs-action 📼 - commit_user_email: actions@github.com - commit_author: vhs-action 📼 - file_pattern: '*.gif' - - - name: Add Image to Job Summary - if: steps.auto-commit.outputs.changes_detected == 'true' - run: | - echo "## Demo GIF" >> $GITHUB_STEP_SUMMARY - echo "![Demo GIF](https://github.com/${{ github.repository }}/blob/${{ steps.auto-commit.outputs.commit_hash }}/docs/demo.gif?raw=true)" >> $GITHUB_STEP_SUMMARY - - - name: No changes - if: steps.auto-commit.outputs.changes_detected == 'false' - run: | - echo "No changes to demo" >> $GITHUB_STEP_SUMMARY diff --git a/demo/defaults.tape b/demo/defaults.tape new file mode 100644 index 0000000000..8a3b6b7082 --- /dev/null +++ b/demo/defaults.tape @@ -0,0 +1,31 @@ +# VHS Defaults for Recordings and Screenshots + +#Set Theme "Monokai Vivid" + +# It's not possible to combine named themes, with explicitly defined themes +Set Theme { "name": "Monokai Vivid", "black": "#121212", "red": "#fa2934", "green": "#98e123", "yellow": "#fff30a", "blue": "#0443ff", "magenta": "#f800f8", "cyan": "#01b6ed", "white": "#ffffff", "brightBlack": "#838383", "brightRed": "#f6669d", "brightGreen": "#b1e05f", "brightYellow": "#fff26d", "brightBlue": "#0443ff", "brightMagenta": "#f200f6", "brightCyan": "#51ceff", "brightWhite": "#ffffff", "background": "#121212", "foreground": "#f9f9f9", "cursor": "#b3b0d6", "selection": "#ffffff"} + +Set FontFamily "FiraCode Nerd Font" +#Set FontFamily "Hack Nerd Font" +Set FontSize 14 + +Set TypingSpeed 20ms + +Set WindowBar Colorful + +Set BorderRadius 8 + +Set Margin 0 + +Set Padding 10 + +# !! WARNING !! +# Large aspect ratios require more memory to process (and a factor of the framerate) +# Free-tier GitHub Action runners do not have sufficient memory to process long recordings +# at a high framerate before crashing. Therefore, for automation purposes we need to keep these small. + +# Use standard aspect ratios +Set Width 1280 +Set Height 720 + +Set Shell "bash" diff --git a/demo/recordings/.gitignore b/demo/recordings/.gitignore new file mode 100644 index 0000000000..f2a4f63c64 --- /dev/null +++ b/demo/recordings/.gitignore @@ -0,0 +1 @@ +background.mp3 diff --git a/demo/recordings/build.sh b/demo/recordings/build.sh new file mode 100755 index 0000000000..ca2750befd --- /dev/null +++ b/demo/recordings/build.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --------------------------------------------------------------------- +# build.sh +# Executes VHS from the root of the git repository +# Converts tapes/*.tape to mp4/*.mp4, processes scenes, and generates gifs +# --------------------------------------------------------------------- + +# Resolve absolute paths for key directories +REPO_ROOT="$(git rev-parse --show-toplevel)" +TAPES_DIR="$REPO_ROOT/demo/recordings/tapes" +SCENES_DIR="$REPO_ROOT/demo/recordings/scenes" +MP4_OUTDIR="$REPO_ROOT/demo/recordings/mp4" +GIF_OUTDIR="$REPO_ROOT/demo/recordings/gif" +AUDIO_FILE="$REPO_ROOT/demo/recordings/background.mp3" + +create_gif() { + local input_mp4="$1" + local output_gif="$2" + + # Extract the output directory and filename without extension + local output_dir + output_dir=$(dirname "$output_gif") + local scene_name + scene_name=$(basename "$output_gif" .gif) + + echo " Creating GIF -> $output_gif" + + # Generate the palette + ffmpeg -y -i "$input_mp4" \ + -vf palettegen "$output_dir/$scene_name-palette.png" + + # Create the GIF using the palette + ffmpeg -i "$input_mp4" \ + -i "$output_dir/$scene_name-palette.png" \ + -lavfi "fps=10 [video]; [video][1:v] paletteuse" \ + -y "$output_gif" +} + +# Handle "clean" argument +if [[ "${1:-}" == "clean" ]]; then + echo ">> Cleaning up generated files..." + rm -rf "$MP4_OUTDIR" "$GIF_OUTDIR" + exit 0 +fi + +# Ensure output directories exist +echo ">> Ensuring $MP4_OUTDIR and $GIF_OUTDIR exist" +mkdir -p "$MP4_OUTDIR" "$GIF_OUTDIR" + +# 1) Convert each tapes/*.tape => mp4/.mp4 +echo ">> Step 1: Converting $TAPES_DIR/*.tape to $MP4_OUTDIR/*.mp4 via VHS" +shopt -s nullglob +TAPEFILES=( "$TAPES_DIR"/*.tape ) +if [[ ${#TAPEFILES[@]} -eq 0 ]]; then + echo "No .tape files found in $TAPES_DIR. Exiting." + exit 1 +fi + +for tape in "${TAPEFILES[@]}"; do + base="$(basename "$tape" .tape)" + output_mp4="$MP4_OUTDIR/$base.mp4" + output_gif="$GIF_OUTDIR/$base.gif" + + # Skip processing if output file exists and is newer than the input file + if [[ -f "$output_mp4" && "$output_mp4" -nt "$tape" ]]; then + echo "Skipping $tape as $output_mp4 is up-to-date." + continue + fi + + # Run the vhs command in the background + (cd "$REPO_ROOT" && timeout 600 vhs "$tape" --output "$output_mp4") & + + # Get the PID of the background process + VHS_PID=$! + + echo "📼 VHS is running with PID $VHS_PID. Monitoring..." + + # Monitor the process + while kill -0 "$VHS_PID" 2>/dev/null; do + echo "⏳ VHS is still running..." + sleep 5 + done + + # Check exit status of the process + wait "$VHS_PID" + EXIT_CODE=$? + if [ "$EXIT_CODE" -eq 0 ]; then + echo "✅ VHS completed successfully for $tape." + else + echo "❌ VHS encountered an error (exit code: $EXIT_CODE) for $tape." + exit $EXIT_CODE + fi + + # Create GIF + create_gif "$output_mp4" "$output_gif" + + echo "📼 Done processing $tape." +done + + +# 2) Process scenes/*.txt +echo ">> Step 2: Building each scene from $SCENES_DIR/*.txt" +SCENE_FILES=( "$SCENES_DIR"/*.txt ) +if [[ ${#SCENE_FILES[@]} -eq 0 ]]; then + echo "No scene text files found in $SCENES_DIR. Skipping scene-building steps." + exit 0 +fi + +for scene_file in "${SCENE_FILES[@]}"; do + scene_name="$(basename "$scene_file" .txt)" + + echo " Scene: $scene_file => $scene_name" + + # Concatenate scene + echo " Concatenating -> $MP4_OUTDIR/$scene_name.mp4" + ffmpeg -f concat -safe 0 -i "$scene_file" -c copy "$MP4_OUTDIR/$scene_name.mp4" -y + + # Add audio fade + echo " Adding fade audio -> $MP4_OUTDIR/$scene_name-with-audio.mp4" + DURATION="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$MP4_OUTDIR/$scene_name.mp4")" + FADE_START=$(( ${DURATION%.*} - 5 )) + ffmpeg -i "$MP4_OUTDIR/$scene_name.mp4" -i "$AUDIO_FILE" \ + -filter_complex "[1:a]afade=t=out:st=${FADE_START}:d=5[aout]" \ + -map 0:v -map "[aout]" \ + -c:v copy -c:a aac "$MP4_OUTDIR/$scene_name-with-audio.mp4" -y + + # Create GIF + create_gif "$MP4_OUTDIR/$scene_name-with-audio.mp4" "$GIF_OUTDIR/$scene_name.gif" + + echo " Done with scene: $scene_name" +done + +echo +echo ">> Done building scenes!" +echo " Segments: $MP4_OUTDIR/.mp4" +echo " Scenes: $MP4_OUTDIR/.mp4" +echo " Audio: $MP4_OUTDIR/-with-audio.mp4" +echo " GIFs: $GIF_OUTDIR/.gif" +echo +echo "Use './build.sh clean' to remove them." diff --git a/demo/recordings/demo.tape b/demo/recordings/demo.tape new file mode 100644 index 0000000000..9a9f01b64c --- /dev/null +++ b/demo/recordings/demo.tape @@ -0,0 +1,104 @@ +# Demo of Atmos +Source demo/defaults.tape +#Source demo/recordings/style.tape + +Output docs/demo.gif + +Hide +Type "alias ls='ls --color=force'" Enter Sleep 500ms +Type "clear" Enter Sleep 500ms +Show + +Sleep 500ms +Type "# First check you have Atmos installed" Sleep 500ms Enter +Type "atmos version" Sleep 500ms Enter +Sleep 1s + +Type "# Now let's explore the Quick Start example" Sleep 500ms Enter +Type "cd examples/quick-start-advanced" Sleep 500ms Enter + +Type "ls -al" Sleep 500ms Enter +Sleep 1s + +Type "# We will start by installing some 3rd-party components and other artifacts..." Sleep 500ms Enter +Type "atmos vendor pull" Sleep 500ms Enter +Sleep 9s + +Type "# In Atmos you can easily explore components, stacks, and run commands..." Sleep 500ms Enter +Type "atmos" Sleep 500ms Enter +Sleep 2s +Down Sleep 500ms +Down Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Up Sleep 1s +Right Sleep 1s +Down Sleep 500ms +Right Sleep 1s +Down Sleep 500ms +Up Sleep 1s +Enter +Sleep 1s + +Type "# Let's see what components we have available!" Sleep 500ms Enter +Type "atmos list components" Sleep 500ms Enter +Sleep 1s + +Type "# Then we can learn about the VPC component" Sleep 500ms Enter +Type "atmos docs vpc" Sleep 500ms Enter +Sleep 2s +Type "q" Sleep 500ms Enter + +Type "# Now, let's see where they are deployed" Sleep 500ms Enter +Type "atmos list stacks" Sleep 500ms Enter +Sleep 2s + +Type "# And validate the stack configurations" Sleep 500ms Enter +Type "atmos validate stacks" Sleep 500ms Enter +Sleep 2s + +Type "# Let's review the production VPC configuration in the us-east-2 region..." Sleep 500ms Enter +Type "atmos describe stacks --components=vpc --stack=plat-ue2-prod --sections=vars" Sleep 500ms Enter +Sleep 2s + +Type "# Or checkout all VPCs" Sleep 500ms Enter +Type "atmos describe stacks --components=vpc --sections=vars | less" Sleep 500ms Enter +Sleep 1s + +Down 25 Sleep 500ms +Down 25 Sleep 500ms +Down 25 Sleep 500ms +Down 25 Sleep 500ms + +Type "q" Sleep 500ms +Sleep 1s + +Type "# In Atmos you can easily explore workflows and execute workflow commands..." Sleep 500ms Enter +Type "atmos workflow" Sleep 500ms Enter +Sleep 2s +Down Sleep 500ms +Right Sleep 500ms +Down Sleep 500ms +Right Sleep 500ms +Down Sleep 500ms +Down Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Enter +Sleep 3s + +Type "# Atmos has a lot of documented commands" Sleep 500ms Enter +Type "atmos --help" Sleep 500ms Enter +Sleep 3s + +Type "# Atmos has native workflows for Terraform" Sleep 500ms Enter +Type "atmos terraform --help" Sleep 500ms Enter +Sleep 5s + +Type "# Check out the docs at https://atmos.tools/" Sleep 500ms Enter +Sleep 2s + +Type "# or join us in #atmos at https://cloudposse.com/slack!" Sleep 500ms Enter +Sleep 3s diff --git a/demo/recordings/fonts.txt b/demo/recordings/fonts.txt new file mode 100644 index 0000000000..03d8622299 --- /dev/null +++ b/demo/recordings/fonts.txt @@ -0,0 +1,12 @@ +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/JetBrainsMono.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/BitstreamVeraSansMono.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/DejaVuSansMono.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/FiraCode.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/Hack.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/IBMPlexMono.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/Inconsolata.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/InconsolataGo.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/LiberationMono.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/SourceCodePro.zip +https://github.com/ryanoasis/nerd-fonts/releases/download/v3.1.1/UbuntuMono.zip +https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-2.1.5.tar.gz \ No newline at end of file diff --git a/demo/recordings/scenes/atmos.txt b/demo/recordings/scenes/atmos.txt new file mode 100644 index 0000000000..e699f1ef22 --- /dev/null +++ b/demo/recordings/scenes/atmos.txt @@ -0,0 +1,13 @@ +file '../mp4/atmos-version.mp4' +file '../mp4/ls-quick-start.mp4' +file '../mp4/atmos-vendor-pull.mp4' +file '../mp4/atmos-tui.mp4' +file '../mp4/atmos-list-components.mp4' +file '../mp4/atmos-docs.mp4' +file '../mp4/atmos-list-stacks.mp4' +file '../mp4/atmos-validate-stacks.mp4' +file '../mp4/atmos-describe-stacks.mp4' +file '../mp4/atmos-workflows.mp4' +file '../mp4/atmos-help.mp4' +file '../mp4/atmos-terraform-help.mp4' +file '../mp4/atmos-outro.mp4' diff --git a/demo/recordings/studio.go b/demo/recordings/studio.go new file mode 100644 index 0000000000..4d2fa40386 --- /dev/null +++ b/demo/recordings/studio.go @@ -0,0 +1,452 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "github.com/cloudposse/atmos/internal/tui/viewport" + "github.com/dustin/go-humanize" + "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" +) + +// Paths +var ( + repoRoot string + tapesDir string + scenesDir string + mp4OutDir string + gifOutDir string + audioFile string +) + +// Timeout for VHS processing +const vhsTimeout = 10 * time.Minute + +// Styles +var ( + successMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Bold(false).Render("✓") // Bright green + errorMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(false).Render("x") // Bright red + neutralMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).Bold(false).Render("-") // Dark gray +) + +func init() { + var err error + + log.SetDefault(log.NewWithOptions(os.Stderr, log.Options{ + ReportTimestamp: false, + ReportCaller: false, + })) + + log.SetLevel(log.DebugLevel) + + repoRoot, err = getGitRoot() + if err != nil { + log.Fatal("Error detecting Git root", "error", err) + } + + tapesDir = filepath.Join(repoRoot, "demo", "recordings", "tapes") + scenesDir = filepath.Join(repoRoot, "demo", "recordings", "scenes") + mp4OutDir = filepath.Join(repoRoot, "demo", "recordings", "mp4") + gifOutDir = filepath.Join(repoRoot, "demo", "recordings", "gif") + audioFile = filepath.Join(repoRoot, "demo", "recordings", "background.mp3") + + log.Info("Initialized", "repoRoot", ConvertToRelativeFromCWD(repoRoot), "tapesDir", ConvertToRelativeFromCWD(tapesDir), "scenesDir", ConvertToRelativeFromCWD(scenesDir), "mp4OutDir", ConvertToRelativeFromCWD(mp4OutDir), "gifOutDir", ConvertToRelativeFromCWD(gifOutDir), "audioFile", ConvertToRelativeFromCWD(audioFile)) +} + +func main() { + rootCmd := &cobra.Command{Use: "studio"} + + rootCmd.AddCommand(&cobra.Command{ + Use: "clean", + Short: "Clean up generated files", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := RunCmdWithSpinner("Cleaning up generated files...", exec.Command("rm", "-rf", mp4OutDir, gifOutDir)) + return err + }, + }) + + rootCmd.AddCommand(&cobra.Command{ + Use: "build", + Short: "Convert tapes, process scenes, and generate GIFs", + RunE: func(cmd *cobra.Command, args []string) error { + return Build() + }, + }) + + if err := rootCmd.Execute(); err != nil { + log.Fatal("Command execution failed", "error", err) + } +} + +// Run a Command with a Spinner +func RunCmdWithSpinner(title string, cmd *exec.Cmd) (int, error) { + // Ensure we run commands from the repo root + repoRoot, err := getGitRoot() + if err != nil { + return -1, fmt.Errorf("failed to get repo root: %w", err) + } + + cmd.Stdin = strings.NewReader("") // Explicitly detach input + cmd.Dir = repoRoot // Change working directory + + m, err := viewport.RunWithSpinner(title, func(output chan string, logLines *[]string) (int, error) { + return viewport.RunCommand(output, logLines, cmd) + }) + + exitCode := m.ExitCode // Extract correct exit code + + elapsed := time.Since(m.Start).Round(time.Second) + timer := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Render(fmt.Sprintf("(%s)", elapsed)) + + if exitCode == -1 { + fmt.Printf("%s %s. Aborted by user. %s\n", neutralMark, title, timer) + os.Exit(130) + } else if err != nil || exitCode != 0 { + fmt.Printf("%s %s. Error encountered. Command exited with code %d. %s\n", errorMark, title, exitCode, timer) + fmt.Println("=== Full Log Dump ===") + fmt.Println(strings.Join(*m.LogLines, "\n")) + return exitCode, err + } else { + fmt.Printf("%s %s %s\n", successMark, title, timer) + } + return exitCode, nil +} + +// ConvertToRelativeFromCWD converts an absolute file path to a relative path based on the current working directory. +func ConvertToRelativeFromCWD(absPath string) string { + // Get the current working directory + cwd, err := os.Getwd() + if err != nil { + log.Error("Error getting working directory", "error", err) + return absPath + } + + // Get absolute versions of both paths for consistency + absFile, err := filepath.Abs(absPath) + if err != nil { + log.Error("failed to get absolute file path", "error", err) + return absPath + } + + // Convert absolute path to relative path + relPath, err := filepath.Rel(cwd, absFile) + if err != nil { + log.Error("failed to compute relative path", "error", err) + return absPath + } + + return relPath +} + +// Git Root Detection +func getGitRoot() (string, error) { + currentDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + repo, err := git.PlainOpenWithOptions(currentDir, &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return "", fmt.Errorf("failed to open git repository: %w", err) + } + + worktree, err := repo.Worktree() + if err != nil { + return "", fmt.Errorf("failed to get worktree: %w", err) + } + + return worktree.Filesystem.Root(), nil +} + +// Ensure Directories Exist +func ensureDirs(dirs ...string) error { + for _, dir := range dirs { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return err + } + } + return nil +} + +// Build Process +func Build() error { + if err := ensureDirs(mp4OutDir, gifOutDir); err != nil { + return err + } + + if err := convertTapes(); err != nil { + return fmt.Errorf("error converting tapes: %w", err) + } + + if err := processScenes(); err != nil { + return fmt.Errorf("error processing scenes: %w", err) + } + + return nil +} + +// Convert Tapes +func convertTapes() error { + files, err := filepath.Glob(filepath.Join(tapesDir, "*.tape")) + if err != nil { + return fmt.Errorf("failed to list tape files: %w", err) + } + if len(files) == 0 { + log.Info("No .tape files found. Skipping conversion.") + return nil + } + + for _, tape := range files { + var exitCode int + var err error + + baseName := filepath.Base(tape[:len(tape)-len(filepath.Ext(tape))]) + outputMp4 := filepath.Join(mp4OutDir, baseName+".mp4") + outputGif := filepath.Join(gifOutDir, baseName+".gif") + + if isUpToDate(outputMp4, tape) { + log.Info("Skipping tape recording", "tape", ConvertToRelativeFromCWD(tape), "reason", "already up-to-date") + } else { + if exitCode, err = RunCmdWithSpinner(fmt.Sprintf("Recording %s to mp4...", baseName), exec.Command("vhs", tape, "--output", outputMp4)); err != nil || exitCode != 0 { + log.Error("Failed to record tape", "tape", ConvertToRelativeFromCWD(tape), "file", ConvertToRelativeFromCWD(outputMp4), "error", err) + os.Exit(exitCode) + } else { + log.Info("Recorded tape to mp4", "file", ConvertToRelativeFromCWD(outputMp4)) + } + } + + if isUpToDate(outputGif, outputMp4) { + log.Info("Skipping GIF generation", "file", ConvertToRelativeFromCWD(outputGif), "reason", "already up-to-date") + continue + } else { + if exitCode, err = RunCmdWithSpinner(fmt.Sprintf("Generating GIF for %s...", baseName), exec.Command("ffmpeg", "-i", outputMp4, "-y", outputGif)); err != nil || exitCode != 0 { + log.Error("Failed to generate GIF", "file", baseName, "error", err) + os.Exit(exitCode) + } else { + log.Info("Generated GIF", "file", ConvertToRelativeFromCWD(outputGif)) + } + } + } + + return nil +} + +// Process Scenes +func processScenes() error { + files, err := filepath.Glob(filepath.Join(scenesDir, "*.txt")) + if err != nil { + return fmt.Errorf("failed to list scene files: %w", err) + } + + if len(files) == 0 { + log.Info("No scene files found. Skipping processing.") + return nil + } + + log.Info("Processing scenes...", "scenes", len(files)) + + for _, sceneFile := range files { + sceneName := filepath.Base(sceneFile[:len(sceneFile)-len(filepath.Ext(sceneFile))]) + outputMp4 := filepath.Join(mp4OutDir, sceneName+".mp4") + outputMp4WithAudio := filepath.Join(mp4OutDir, sceneName+"-with-audio.mp4") + outputGif := filepath.Join(gifOutDir, sceneName+".gif") + + // Concatenate scene files into MP4 + if isSceneUpToDate(outputMp4, sceneFile) { + log.Info("Skipping concatenation", "scene", sceneName, "reason", "already up-to-date") + } else { + exitCode, err := RunCmdWithSpinner(fmt.Sprintf("Concatenating scenes for %s...", sceneName), + exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", sceneFile, "-c", "copy", "-y", outputMp4)) + if err != nil || exitCode != 0 { + log.Error("Failed to concatenate scenes", "scene", sceneName, "error", err) + os.Exit(exitCode) + } + log.Info("Concatenated scenes", "scene", sceneName, "file", ConvertToRelativeFromCWD(outputMp4), "duration", FormatDuration(GetMP4Duration(outputMp4)), "size", humanize.Bytes(uint64(GetFileSize(outputMp4)))) + } + + // Skip if the audio-enhanced MP4 is already up-to-date + if isUpToDate(outputMp4WithAudio, outputMp4) { + log.Info("Skipping audio fade", "scene", sceneName, "reason", "already up-to-date") + } else { + + // Get fade start time + fadeStart, err := getFadeStart(outputMp4) + if err != nil { + log.Warn("Failed to determine fade-out time, using default", "scene", sceneName, "error", err) + fadeStart = 0 + } + + // Apply audio fade effect + exitCode, err := RunCmdWithSpinner(fmt.Sprintf("Adding fade-out audio for scene %s...", sceneName), + exec.Command("ffmpeg", "-i", outputMp4, "-i", audioFile, + "-filter_complex", fmt.Sprintf("[1:a]afade=t=out:st=%d:d=5[aout]", fadeStart), + "-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-y", outputMp4WithAudio)) + if err != nil || exitCode != 0 { + log.Error("Failed to add audio to scene", "scene", sceneName, "error", err) + os.Exit(exitCode) + } else { + log.Info("Added audio to scene", "scene", sceneName, "file", ConvertToRelativeFromCWD(outputMp4WithAudio), "duration", FormatDuration(GetMP4Duration(outputMp4WithAudio)), "size", humanize.Bytes(uint64(GetFileSize(outputMp4WithAudio)))) + } + } + + // Skip if the GIF is already up-to-date + if isUpToDate(outputGif, outputMp4WithAudio) { + log.Info("Skipping GIF generation", "file", ConvertToRelativeFromCWD(outputGif), "reason", "already up-to-date") + return nil + } else { + // Generate GIF from the scene with mp4 + if err := createGif(outputMp4WithAudio, outputGif); err != nil { + log.Error("Failed to generate GIF", "scene", sceneName, "error", err) + os.Exit(1) + } else { + log.Info("Generated GIF", "scene", sceneName, "file", ConvertToRelativeFromCWD(outputGif)) + } + } + } + + return nil +} + +// FormatDuration converts seconds to hh:mm:ss format +func FormatDuration(seconds float64) string { + duration := time.Duration(seconds) * time.Second + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + secondsInt := int(duration.Seconds()) % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secondsInt) +} + +// GetMP4Duration returns the duration of an MP4 file in seconds +func GetMP4Duration(mp4File string) float64 { + cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", mp4File) + output, err := cmd.Output() + if err != nil { + log.Debug("Failed to get video duration", "file", ConvertToRelativeFromCWD(mp4File), "error", err) + return -1 + } + + duration, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64) + if err != nil { + log.Debug("Failed to parse video duration", "file", ConvertToRelativeFromCWD(mp4File), "error", err) + return -1 + } + + return duration +} + +// GetFileSize returns the size of an MP4 file in bytes +func GetFileSize(mp4File string) int64 { + fileInfo, err := os.Stat(mp4File) + if err != nil { + log.Debug("Failed to get file size", "file", ConvertToRelativeFromCWD(mp4File), "error", err) + return -1 + } + + return fileInfo.Size() +} + +func isUpToDate(output, input string) bool { + outputInfo, err := os.Stat(output) + if err != nil { + return false // Output file doesn't exist + } + inputInfo, err := os.Stat(input) + if err != nil { + return false // Input file doesn't exist (should not happen) + } + return outputInfo.ModTime().After(inputInfo.ModTime()) +} + +// isSceneUpToDate checks if outputMp4 is newer than both the sceneFile and all referenced mp4 files inside it +func isSceneUpToDate(outputMp4, sceneFile string) bool { + // If outputMp4 is outdated compared to sceneFile, return false + if !isUpToDate(outputMp4, sceneFile) { + return false + } + + // Regex to match "file '.mp4'" + sceneFileRegex := regexp.MustCompile(`file '([^']+\.mp4)'`) + + // Read sceneFile contents + data, err := os.ReadFile(sceneFile) + if err != nil { + log.Warn("Failed to read scene file", "file", ConvertToRelativeFromCWD(sceneFile), "error", err) + return false + } + + // Find all matching mp4 files in the scene file + matches := sceneFileRegex.FindAllStringSubmatch(string(data), -1) + + for _, match := range matches { + if len(match) < 2 { + continue // Skip invalid matches + } + + mp4File := match[1] // Extracted filename + mp4FilePath := filepath.Join(filepath.Dir(sceneFile), mp4File) + + // Check if outputMp4 is up-to-date with this mp4 file + if !isUpToDate(outputMp4, mp4FilePath) { + log.Info("Scene is outdated because an input mp4 is newer", + "scene", ConvertToRelativeFromCWD(sceneFile), + "mp4", ConvertToRelativeFromCWD(mp4FilePath)) + return false + } + } + + // If we got here, outputMp4 is newer than everything + return true +} + +// Get fade start time from video duration +func getFadeStart(mp4File string) (int, error) { + cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", mp4File) + output, err := cmd.Output() + if err != nil { + return 0, fmt.Errorf("failed to get video duration: %w", err) + } + + duration, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64) + if err != nil { + return 0, fmt.Errorf("failed to parse video duration: %w", err) + } + + fadeStart := int(duration) - 5 + if fadeStart < 0 { + fadeStart = 0 + } + return fadeStart, nil +} + +func createGif(inputMp4, outputGif string) error { + palette := outputGif + "-palette.png" + + // Generate palette for better GIF quality, only if outdated + if !isUpToDate(palette, inputMp4) { + exitCode, err := RunCmdWithSpinner("Generating palette for GIF...", exec.Command( + "ffmpeg", "-y", "-i", inputMp4, "-vf", "palettegen", palette)) + if err != nil || exitCode != 0 { + return fmt.Errorf("failed to generate GIF palette: %w", err) + } + } + + // Create GIF using the palette + exitCode, err := RunCmdWithSpinner("Creating GIF...", exec.Command( + "ffmpeg", "-i", inputMp4, "-i", palette, "-lavfi", "fps=10 [video]; [video][1:v] paletteuse", "-y", outputGif)) + if err != nil || exitCode != 0 { + return fmt.Errorf("failed to create GIF: %w", err) + } + + return nil +} diff --git a/demo/recordings/styles/defaults.tape b/demo/recordings/styles/defaults.tape new file mode 100644 index 0000000000..4d06aaed94 --- /dev/null +++ b/demo/recordings/styles/defaults.tape @@ -0,0 +1,20 @@ +# VHS Defaults for Recordings + +Set Framerate 10 +Set CursorBlink true +Set Margin 20 +Set MarginFill "#674EFF" + +#Set Width 1400 +#Set Height 800 + +Set PlaybackSpeed 3 + +Hide +# Force color ls +Type "alias ls='ls --color=force'" Enter Sleep 500ms +# Force color less +Type "alias less='less -R'" Enter Sleep 500ms +# Reset the screen +Type "clear" Enter Sleep 500ms +Show diff --git a/demo/recordings/styles/less.tape b/demo/recordings/styles/less.tape new file mode 100644 index 0000000000..72bdbd0ff0 --- /dev/null +++ b/demo/recordings/styles/less.tape @@ -0,0 +1,6 @@ +Hide +# Force color less +#Type "alias less='less -R'" Enter Sleep 500ms +# Reset the screen +Type "clear" Enter Sleep 500ms +Show diff --git a/demo/recordings/styles/ls.tape b/demo/recordings/styles/ls.tape new file mode 100644 index 0000000000..691c00907b --- /dev/null +++ b/demo/recordings/styles/ls.tape @@ -0,0 +1,6 @@ +Hide +# Force color ls +Type "alias ls='ls --color=force'" Enter Sleep 500ms +# Reset the screen +Type "clear" Enter Sleep 500ms +Show diff --git a/demo/recordings/styles/quick-start-advanced.tape b/demo/recordings/styles/quick-start-advanced.tape new file mode 100644 index 0000000000..a57ab9189a --- /dev/null +++ b/demo/recordings/styles/quick-start-advanced.tape @@ -0,0 +1,4 @@ +Hide +Type "cd $(git rev-parse --show-toplevel)/examples/quick-start-advanced" Sleep 500ms Enter +Type "clear" Enter Sleep 500ms +Show diff --git a/demo/recordings/styles/repo-root.tape b/demo/recordings/styles/repo-root.tape new file mode 100644 index 0000000000..246193d30c --- /dev/null +++ b/demo/recordings/styles/repo-root.tape @@ -0,0 +1,4 @@ +Hide +Type "cd $(git rev-parse --show-toplevel)" Sleep 500ms Enter +Type "clear" Enter Sleep 500ms +Show diff --git a/demo/recordings/tapes/atmos-describe-stacks.tape b/demo/recordings/tapes/atmos-describe-stacks.tape new file mode 100644 index 0000000000..99354498c2 --- /dev/null +++ b/demo/recordings/tapes/atmos-describe-stacks.tape @@ -0,0 +1,27 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape +Source demo/recordings/styles/less.tape + +Type "# Let's review the production VPC configuration in the us-east-2 region..." Sleep 500ms Enter +Type "atmos describe stacks --components=vpc --stack=plat-ue2-prod --sections=vars" Sleep 500ms Enter +#Down 5 Sleep 500ms +#Down 5 Sleep 500ms +#Ctrl+C +Sleep 2s + +Type "# Or checkout all VPCs" Sleep 500ms Enter +Type "atmos describe stacks --components=vpc --sections=vars | less" Sleep 500ms Enter +Sleep 1s + +Down 25 Sleep 500ms +Down 25 Sleep 500ms +Down 25 Sleep 500ms +Down 25 Sleep 500ms + +Type "q" Sleep 500ms +Sleep 1s + +Hide +Ctrl+C +Show diff --git a/demo/recordings/tapes/atmos-docs.tape b/demo/recordings/tapes/atmos-docs.tape new file mode 100644 index 0000000000..e5258a198d --- /dev/null +++ b/demo/recordings/tapes/atmos-docs.tape @@ -0,0 +1,18 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape + +Hide +Type "atmos vendor pull --component vpc" Sleep 500ms Enter +Sleep 6s +Type "clear" Enter Sleep 500ms +Show + +Type "# Then we can learn about the VPC component" Sleep 500ms Enter +Type "atmos docs vpc" Sleep 500ms Enter +Sleep 1s +#Down 25 Sleep 500ms +#Down 50 Sleep 500ms +#Down 75 Sleep 500ms +Sleep 2s +Type "q" Sleep 500ms Enter diff --git a/demo/recordings/tapes/atmos-help.tape b/demo/recordings/tapes/atmos-help.tape new file mode 100644 index 0000000000..f2daf3fb66 --- /dev/null +++ b/demo/recordings/tapes/atmos-help.tape @@ -0,0 +1,10 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape +Source demo/recordings/styles/less.tape + +Type "# Atmos has a lot of documented commands" Sleep 500ms Enter +Type "atmos --help" Sleep 500ms Enter +#Down 5 Sleep 500ms +#Down 5 Sleep 500ms +Sleep 3s diff --git a/demo/recordings/tapes/atmos-list-components.tape b/demo/recordings/tapes/atmos-list-components.tape new file mode 100644 index 0000000000..0cd972436d --- /dev/null +++ b/demo/recordings/tapes/atmos-list-components.tape @@ -0,0 +1,11 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape + +Type "# Let's see what components we have available!" Sleep 500ms Enter +Type "atmos list components" Sleep 500ms Enter +Sleep 1s + +Hide +Ctrl+C +Show diff --git a/demo/recordings/tapes/atmos-list-stacks.tape b/demo/recordings/tapes/atmos-list-stacks.tape new file mode 100644 index 0000000000..4ffcf5b01b --- /dev/null +++ b/demo/recordings/tapes/atmos-list-stacks.tape @@ -0,0 +1,11 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape + +Type "# Now, let's see where they are deployed" Sleep 500ms Enter +Type "atmos list stacks" Sleep 500ms Enter +Sleep 2s + +Hide +Ctrl+C +Show diff --git a/demo/recordings/tapes/atmos-outro.tape b/demo/recordings/tapes/atmos-outro.tape new file mode 100644 index 0000000000..e36e419ea2 --- /dev/null +++ b/demo/recordings/tapes/atmos-outro.tape @@ -0,0 +1,8 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape + +Type "# Check out the docs at https://atmos.tools/" Sleep 500ms Enter +Sleep 2s + +Type "# or join us in #atmos at https://cloudposse.com/slack!" Sleep 500ms Enter +Sleep 3s diff --git a/demo/recordings/tapes/atmos-terraform-help.tape b/demo/recordings/tapes/atmos-terraform-help.tape new file mode 100644 index 0000000000..ff1b556b60 --- /dev/null +++ b/demo/recordings/tapes/atmos-terraform-help.tape @@ -0,0 +1,6 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape + +Type "# Atmos has native workflows for Terraform" Sleep 500ms Enter +Type "atmos terraform --help" Sleep 500ms Enter +Sleep 5s diff --git a/demo/recordings/tapes/atmos-tui.tape b/demo/recordings/tapes/atmos-tui.tape new file mode 100644 index 0000000000..665d2806dc --- /dev/null +++ b/demo/recordings/tapes/atmos-tui.tape @@ -0,0 +1,21 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape + +Type "# In Atmos you can easily explore components, stacks, and run commands..." Sleep 500ms Enter +Type "atmos" Sleep 500ms Enter +Sleep 2s +Down Sleep 500ms +Down Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Up Sleep 1s +Right Sleep 1s +Down Sleep 500ms +Right Sleep 1s +Down Sleep 500ms +Up Sleep 1s +Enter +Sleep 1s diff --git a/demo/recordings/tapes/atmos-validate-stacks.tape b/demo/recordings/tapes/atmos-validate-stacks.tape new file mode 100644 index 0000000000..c5d6c7788d --- /dev/null +++ b/demo/recordings/tapes/atmos-validate-stacks.tape @@ -0,0 +1,7 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape + +Type "# And validate the stack configurations" Sleep 500ms Enter +Type "atmos validate stacks" Sleep 500ms Enter +Sleep 2s diff --git a/demo/recordings/tapes/atmos-vendor-pull.tape b/demo/recordings/tapes/atmos-vendor-pull.tape new file mode 100644 index 0000000000..26f221e437 --- /dev/null +++ b/demo/recordings/tapes/atmos-vendor-pull.tape @@ -0,0 +1,16 @@ +# Apparently first place to set Framerate, PlaybackSpeed wins +Set Framerate 24 +Set PlaybackSpeed 3 + +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape + + +Type "# We will start by installing some 3rd-party components and other artifacts..." Sleep 500ms Enter +Type "atmos vendor pull" Sleep 500ms Enter +Sleep 9s + +Hide +Ctrl+C +Show diff --git a/demo/recordings/tapes/atmos-version.tape b/demo/recordings/tapes/atmos-version.tape new file mode 100644 index 0000000000..7765471ad7 --- /dev/null +++ b/demo/recordings/tapes/atmos-version.tape @@ -0,0 +1,7 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape + +Sleep 500ms +Type "# First check you have Atmos installed" Sleep 500ms Enter +Type "atmos version" Sleep 500ms Enter +Sleep 2s diff --git a/demo/recordings/tapes/atmos-workflows.tape b/demo/recordings/tapes/atmos-workflows.tape new file mode 100644 index 0000000000..9740c83a79 --- /dev/null +++ b/demo/recordings/tapes/atmos-workflows.tape @@ -0,0 +1,17 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/quick-start-advanced.tape + +Type "# In Atmos you can easily explore workflows and execute workflow commands..." Sleep 500ms Enter +Type "atmos workflow" Sleep 500ms Enter +Sleep 2s +Down Sleep 500ms +Right Sleep 500ms +Down Sleep 500ms +Right Sleep 500ms +Down Sleep 500ms +Down Sleep 500ms +Up Sleep 500ms +Up Sleep 500ms +Enter +Sleep 3s diff --git a/demo/recordings/tapes/ls-quick-start.tape b/demo/recordings/tapes/ls-quick-start.tape new file mode 100644 index 0000000000..7a0822e19d --- /dev/null +++ b/demo/recordings/tapes/ls-quick-start.tape @@ -0,0 +1,14 @@ +Source demo/defaults.tape +Source demo/recordings/styles/defaults.tape +Source demo/recordings/styles/repo-root.tape +Source demo/recordings/styles/ls.tape + +Type "# Now let's explore the Quick Start example" Sleep 500ms Enter +Type "cd examples/quick-start-advanced" Sleep 500ms Enter + +Type "ls -al" Sleep 500ms Enter +Sleep 1s + +Hide +Ctrl+C +Show diff --git a/demo/screengrabs/Makefile b/demo/screengrabs/Makefile index 0293048af1..2362737dfb 100644 --- a/demo/screengrabs/Makefile +++ b/demo/screengrabs/Makefile @@ -1,8 +1,8 @@ -INSTALL_PATH ?= ../../website/src/components/screengrabs +INSTALL_PATH ?= ../../website/src/components/Screengrabs all: build-all install # Write to /website/static/screengrab/ -install: +install: @echo "Installing screengrabs to $(INSTALL_PATH)" @mkdir -p $(INSTALL_PATH) @cp -a artifacts/* $(INSTALL_PATH) diff --git a/demo/screengrabs/build-all.sh b/demo/screengrabs/build-all.sh index a54a222b41..a97f6ecf3c 100755 --- a/demo/screengrabs/build-all.sh +++ b/demo/screengrabs/build-all.sh @@ -2,61 +2,86 @@ set -e export TERM=xterm-256color +# Ensure that the output is not paginated +export LESS=-X + +# Determine the correct sed syntax based on the operating system +if [ "$(uname)" = "Darwin" ]; then + SED="$SED" # macOS requires '' for in-place editing +else + SED="sed -i" # Linux does not require '' +fi + function record() { - local demo=$1 + local demo=$1 local command=$2 - local extension="${command##*.}" # if any... - local demo_path=../../examples/$demo + local extension="${command##*.}" # if any... + local demo_path=../../examples/$demo local output_base_file=artifacts/$(echo "$command" | sed -E 's/ -/-/g' | sed -E 's/ +/-/g' | sed 's/---/--/g' | sed 's/scripts\///' | sed 's/\.sh$//') - local output_html=${output_base_file}.html - local output_ansi=${output_base_file}.ansi - local output_dir=$(dirname $output_base_file) + local output_html=${output_base_file}.html + local output_ansi=${output_base_file}.ansi + local output_dir=$(dirname $output_base_file) echo "Screengrabbing $command → $output_html" - mkdir -p "$output_dir" - rm -f $output_ansi - if [ "${extension}" == "sh" ]; then - script -q $output_ansi command $command > /dev/null - else - script -q $output_ansi bash -c "cd $demo_path && ($command)" > /dev/null - fi - postprocess_ansi $output_ansi - aha --no-header < $output_ansi > $output_html - postprocess_html $output_html - rm -f $output_ansi + mkdir -p "$output_dir" + rm -f $output_ansi + if [ "$(uname)" = "Darwin" ]; then + # macOS-specific syntax + if [ "${extension}" == "sh" ]; then + script -q $output_ansi command $command > /dev/null + else + script -q $output_ansi bash -c "cd $demo_path && ($command)" > /dev/null + fi + else + # Linux-specific syntax + if [ "${extension}" = "sh" ]; then + script -q -a $output_ansi -c "$command" > /dev/null + else + script -q -a $output_ansi -c "cd $demo_path && ($command)" > /dev/null + fi + fi + postprocess_ansi $output_ansi + aha --no-header < $output_ansi > $output_html + postprocess_html $output_html + rm -f $output_ansi if [ -n "$CI" ]; then sed -i -e '1,1d' -e '$d' $output_html fi } postprocess_ansi() { - local file=$1 - # Remove noise and clean up the output - sed -i '' '/- Finding latest version of/d' $file - sed -i '' '/- Installed hashicorp/d' $file - sed -i '' '/- Installing hashicorp/d' $file - sed -i '' '/Terraform has created a lock file/d' $file - sed -i '' '/Include this file in your version control repository/d' $file - sed -i '' '/guarantee to make the same selections by default when/d' $file - sed -i '' '/you run "terraform init" in the future/d' $file - sed -i '' 's/Resource actions are indicated with the following symbols.*//' $file - sed -i '' 's/^ *EOT/\n/g' $file - sed -i '' 's/ *<" - exit 1 + echo "Usage: $0 " + exit 1 fi demo=$(basename $manifest .txt) diff --git a/demo/screengrabs/scripts/demo-stacks/.demo.rc b/demo/screengrabs/scripts/demo-stacks/.demo.rc index 9a962c21fc..0c9aa6eb8f 100644 --- a/demo/screengrabs/scripts/demo-stacks/.demo.rc +++ b/demo/screengrabs/scripts/demo-stacks/.demo.rc @@ -11,7 +11,7 @@ demo_name=$(basename $(pwd)) shopt -s expand_aliases alias tree='tree -CF --gitignore -I ".git" -I "terraform.tfstate*" -I "*.tfvars.json" -I "cache*.txt"' -alias bat='bat --force-colorization --style header,numbers --theme="GitHub"' +alias bat='bat --force-colorization --style header,numbers --theme="GitHub" --paging=never' alias cat='bat' function clean() { diff --git a/docs/demo.gif b/docs/demo.gif index 443913443c..e69de29bb2 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/examples/quick-start-advanced/atmos.yaml b/examples/quick-start-advanced/atmos.yaml index 93a21f94b2..84754bd5cb 100644 --- a/examples/quick-start-advanced/atmos.yaml +++ b/examples/quick-start-advanced/atmos.yaml @@ -231,3 +231,6 @@ settings: # If the source and destination lists have the same length, all items in the destination lists are # deep-merged with all items in the source list. list_merge_strategy: replace + terminal: + max-width: 80 + pager: true diff --git a/examples/quick-start-advanced/components/terraform/vpc/main.tf b/examples/quick-start-advanced/components/terraform/vpc/main.tf index 93867186e9..da314dca33 100644 --- a/examples/quick-start-advanced/components/terraform/vpc/main.tf +++ b/examples/quick-start-advanced/components/terraform/vpc/main.tf @@ -67,12 +67,12 @@ locals { module "utils" { source = "cloudposse/utils/aws" - version = "1.4.0" + version = "1.3.0" } module "vpc" { source = "cloudposse/vpc/aws" - version = "2.1.1" + version = "2.1.0" ipv4_primary_cidr_block = var.ipv4_primary_cidr_block internet_gateway_enabled = var.public_subnets_enabled diff --git a/examples/quick-start-advanced/components/terraform/vpc/remote-state.tf b/examples/quick-start-advanced/components/terraform/vpc/remote-state.tf index b6bb604726..b9db2205ca 100644 --- a/examples/quick-start-advanced/components/terraform/vpc/remote-state.tf +++ b/examples/quick-start-advanced/components/terraform/vpc/remote-state.tf @@ -1,20 +1,13 @@ module "vpc_flow_logs_bucket" { - count = local.vpc_flow_logs_enabled ? 1 : 0 + count = var.vpc_flow_logs_enabled ? 1 : 0 source = "cloudposse/stack-config/yaml//modules/remote-state" version = "1.5.0" - # Specify the Atmos component name (defined in YAML stack config files) - # for which to get the remote state outputs - component = "vpc-flow-logs-bucket" - - # Override the context variables to point to a different Atmos stack if the - # `vpc-flow-logs-bucket` Atmos component is provisioned in another AWS account, OU or region - stage = try(coalesce(var.vpc_flow_logs_bucket_stage_name, module.this.stage), null) - environment = try(coalesce(var.vpc_flow_logs_bucket_environment_name, module.this.environment), null) + component = "vpc-flow-logs-bucket" + environment = var.vpc_flow_logs_bucket_environment_name + stage = var.vpc_flow_logs_bucket_stage_name tenant = try(coalesce(var.vpc_flow_logs_bucket_tenant_name, module.this.tenant), null) - # `context` input is a way to provide the information about the stack (using the context - # variables `namespace`, `tenant`, `environment`, and `stage` defined in the stack config) context = module.this.context } diff --git a/examples/quick-start-advanced/vendor.yaml b/examples/quick-start-advanced/vendor.yaml index cb152c5465..3ee599618c 100644 --- a/examples/quick-start-advanced/vendor.yaml +++ b/examples/quick-start-advanced/vendor.yaml @@ -25,6 +25,7 @@ spec: # https://github.com/bmatcuk/doublestar#patterns included_paths: - "**/*.tf" + - "**/README.md" excluded_paths: - "**/providers.tf" # Tags can be used to vendor component that have the specific tags @@ -39,6 +40,7 @@ spec: - "components/terraform/vpc-flow-logs-bucket" included_paths: - "**/*.tf" + - "**/README.md" excluded_paths: - "**/providers.tf" # Tags can be used to vendor component that have the specific tags diff --git a/go.mod b/go.mod index 304ec2a3f6..2897e2fba0 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/log v0.4.1 github.com/creack/pty v1.1.24 + github.com/dustin/go-humanize v1.0.1 github.com/editorconfig-checker/editorconfig-checker/v3 v3.2.1 github.com/elewis787/boa v0.1.2 github.com/fatih/color v1.18.0 @@ -159,7 +160,6 @@ require ( github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/docker/libkv v0.2.2-0.20180912205406-458977154600 // indirect github.com/dsnet/compress v0.0.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 // indirect github.com/elliotchance/orderedmap v1.7.1 // indirect diff --git a/internal/tui/viewport/tui.go b/internal/tui/viewport/tui.go new file mode 100644 index 0000000000..7add7887c2 --- /dev/null +++ b/internal/tui/viewport/tui.go @@ -0,0 +1,379 @@ +package viewport + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "golang.org/x/term" +) + +// Constants +const ( + viewportHeight = 4 + outerMargin = 3 +) + +const maxLogLines = 25 // 🔥 Track max number of lines + +// Message types +type ( + outputMsg string + doneMsg struct { + exitCode int + err error + } +) +type tickMsg time.Duration + +// Run a function with a spinner and viewport +func RunWithSpinner(title string, fn func(chan string, *[]string) (int, error)) (*Model, error) { + m := newModel(title, fn) + + // Conditionally disable input handling when there's no TTY + opts := []tea.ProgramOption{} + if !m.hasTTY { + log.Debug("degrading to headless input/output handling") + opts = append(opts, + tea.WithInput(strings.NewReader("")), // 🚀 Prevents input handling + tea.WithOutput(os.Stderr), // 🚀 Prevents UI rendering to TTY + ) + } + p := tea.NewProgram(m, opts...) + + updatedModel, err := p.Run() // ✅ Get the final updated model + finalModel := updatedModel.(Model) // ✅ Type assertion to model + + return &finalModel, err // ✅ Return the correct model instance +} + +// Execute a command, stream its output, and return exit code +func RunCommand(outputChan chan string, logLines *[]string, cmd *exec.Cmd) (int, error) { + stdout, err := cmd.StdoutPipe() + if err != nil { + log.Debug("failed to get stdout pipe", "error", err) + return 1, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + log.Debug("failed to get stderr pipe", "error", err) + return 1, err + } + + if err := cmd.Start(); err != nil { + log.Debug("failed to start command", "error", err) + return 1, err + } + + var wg sync.WaitGroup + output := make(chan string, 100) + + wg.Add(2) + go func() { + defer wg.Done() + streamOutput(stdout, output, logLines) + }() + go func() { + defer wg.Done() + streamOutput(stderr, output, logLines) + }() + + // 🔥 Dedicated goroutine to forward all output safely + // Send all output to the UI + go func() { + for line := range output { + outputChan <- line + } + }() + + go func() { + wg.Wait() + close(output) + }() + + err = cmd.Wait() + // ✅ Check if process was terminated by a signal + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + // 🔥 Use syscall.WaitStatus to get termination signal + if status, ok := exitError.Sys().(syscall.WaitStatus); ok { + if status.Signaled() { // Check if the process was killed by a signal + sig := status.Signal() + return -int(sig), fmt.Errorf("terminated by signal: %v", sig) + } + } + } + } + + // ✅ Ensure ProcessState is valid before calling ExitCode() + if cmd.ProcessState == nil { + return 1, fmt.Errorf("process did not start or was force-killed") + } + + return cmd.ProcessState.ExitCode(), err +} + +func streamOutput(r io.Reader, output chan string, logLines *[]string) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + + // 🔥 Push new line + *logLines = append(*logLines, line) + + // 🔥 Pop/Shift: Remove oldest if exceeding maxLogLines + if len(*logLines) > maxLogLines { + *logLines = (*logLines)[1:] // Remove the first (oldest) element + } + + output <- line // Send to viewport + } +} + +// Wait for the next output message +func waitForOutput(outputChan chan string) tea.Cmd { + return func() tea.Msg { + return outputMsg(<-outputChan) // 🔥 Blocks until a message is received + } +} + +// Bubble Tea model +type Model struct { + title string + spinner spinner.Model + fn func(chan string, *[]string) (int, error) + sub chan string + viewport viewport.Model + LogLines *[]string + Start time.Time + width int + ExitCode int + hasTTY bool + done bool +} + +// Create a new model +func newModel(title string, fn func(chan string, *[]string) (int, error)) Model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + + // Allocate logLines as a pointer + logLines := &[]string{} + + tty := (term.IsTerminal(int(os.Stdout.Fd())) || isTruthyEnv("FORCE_TTY")) && + !isTruthyEnv("CI") && + os.Getenv("TERM") != "dumb" + log.Debug("tty", "enabled", tty) + + var vp viewport.Model + if tty { + vp = viewport.New(80, viewportHeight) + vp.SetContent("") + } + + return Model{ + title: title, + spinner: s, + fn: fn, + sub: make(chan string), + LogLines: logLines, + viewport: vp, + Start: time.Now(), + ExitCode: 0, + hasTTY: tty, + width: 80, + } +} + +// Initialize the Bubble Tea program +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + func() tea.Msg { + code, err := m.fn(m.sub, m.LogLines) + m.ExitCode = code + return doneMsg{exitCode: code, err: err} + }, + waitForOutput(m.sub), + tickCmd(m.Start), + ) +} + +// Tick function to update duration timer +func tickCmd(start time.Time) tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return tickMsg(time.Since(start)) + }) +} + +// Update the UI +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if m.hasTTY { + // Adjust the viewport width + m.width = msg.Width - (2 * outerMargin) + m.viewport.Width = m.width - 4 + m.viewport.Height = viewportHeight + } else { + log.Debug("headless mode: ignoring window size message") + } + case tea.KeyMsg: + if msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == "esc" { + m.done = true + m.ExitCode = -1 // ✅ Set special exit code for user abort + return m, tea.Quit + } + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + case outputMsg: + m.appendOutput(string(msg)) + cmds = append(cmds, waitForOutput(m.sub)) + return m, tea.Batch(cmds...) // 🔥 Explicitly update the viewport + case doneMsg: + m.done = true + m.ExitCode = msg.exitCode + return m, tea.Quit + case tickMsg: + + if !m.hasTTY { + spinner := m.getSpinner() + log.Info(spinner, "status", (*m.LogLines)[len(*m.LogLines)-1]) + } else { + log.Debug("Process still running...", "time", time.Since(m.Start).Round(time.Second)) + } + cmds = append(cmds, tickCmd(m.Start)) + } + + var cmd tea.Cmd + // if m.hasTTY { + m.viewport, cmd = m.viewport.Update(msg) + //} + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +// Append new output to the viewport +func (m *Model) appendOutput(s string) { + // Pop/Shift: Keep only last `maxLogLines` lines + if len(*m.LogLines) > maxLogLines { + *m.LogLines = (*m.LogLines)[1:] + } + + // Update viewport content + if m.hasTTY { + // Manually wrap lines for viewport display + var wrappedLines []string + for _, line := range *m.LogLines { + wrappedLines = append(wrappedLines, wrapText(line, m.viewport.Width-4)...) // Adjust for borders + } + + // Set viewport content + m.viewport.SetContent(strings.Join(wrappedLines, "\n")) + m.viewport.GotoBottom() + } +} + +func wrapText(text string, width int) []string { + if width <= 0 { + return []string{text} // Prevent division by zero + } + + var lines []string + for len(text) > width { + lines = append(lines, text[:width]) // Store a chunk + text = text[width:] // Remove chunk from original text + } + lines = append(lines, text) // Add the last piece + return lines +} + +func (m Model) getSpinner() string { + // Compute elapsed time + elapsed := time.Since(m.Start).Round(time.Second) + timer := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Render(fmt.Sprintf("(%s)", elapsed)) + + // Right-align the timer + timerStyled := lipgloss.NewStyle(). + AlignHorizontal(lipgloss.Right). + Render(timer) + + // Header + spinner := lipgloss.NewStyle(). + Bold(true). + Render(m.spinner.View() + m.title + timerStyled) + return spinner +} + +// Render the UI +func (m Model) View() string { + if m.done { + return "" + } + + // If no TTY, only show spinner or error logs + if !m.hasTTY { + if m.done && m.ExitCode != 0 { + log.Error(strings.Join(*m.LogLines, "\n")) + } + return "" + } + + spinner := m.getSpinner() + + // Viewport box + viewportBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + PaddingLeft(1). + MarginLeft(2). + Width(m.width - (2 * outerMargin)). + Render(m.viewport.View()) + + // Footer + footer := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + MarginLeft(4). + Render("press `q` to quit") + + return lipgloss.NewStyle(). + Render(spinner + "\n" + viewportBox + "\n" + footer) +} + +// isTruthyEnv checks if an environment variable is set to a truthy value +func isTruthyEnv(key string) bool { + val, exists := os.LookupEnv(key) + if !exists { + return false + } + + // Convert to lowercase and check common truthy values + val = strings.ToLower(strings.TrimSpace(val)) + truthyValues := map[string]bool{ + "1": true, + "true": true, + "yes": true, + "on": true, + "enable": true, + } + + return truthyValues[val] +}