diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..800005f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig is awesome: https://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[*.{md,yaml,yml,tmpl}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATES/BugReport.yml b/.github/ISSUE_TEMPLATES/BugReport.yml new file mode 100644 index 0000000..7e4f808 --- /dev/null +++ b/.github/ISSUE_TEMPLATES/BugReport.yml @@ -0,0 +1,55 @@ +--- +name: Bug +description: File a bug/issue +labels: ["type: bug", "status: triage"] +assignees: + - michenriksen +body: + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true + - type: textarea + attributes: + label: Current behavior + description: A concise description of what you're experiencing. + validations: + required: true + - type: textarea + attributes: + label: Expected behavior + description: A concise description of what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Steps to reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: true + - type: textarea + attributes: + label: Environment + description: Paste output of the `tmpl --version` command. + value: | + tmpl: + Version: ... + validations: + required: true + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give more context about the issue you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATES/config.yml b/.github/ISSUE_TEMPLATES/config.yml new file mode 100644 index 0000000..1903ad4 --- /dev/null +++ b/.github/ISSUE_TEMPLATES/config.yml @@ -0,0 +1,9 @@ +--- +blank_issues_enabled: false +contact_links: + - name: 🛟 Help & Support + url: https://github.com/michenriksen/tmpl/discussions/categories/q-a + about: Please ask and answer questions here. + - name: 💡 Feature Requests & Ideas + url: https://github.com/michenriksen/tmpl/discussions/categories/ideas + about: Please add feature requests and ideas here. diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..73909b4 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,78 @@ +--- +name: docs:deploy +permissions: + contents: write + pages: write + id-token: write +on: + push: + branches: [main] + paths: + - "docs/**/*" + - "mkdocs.yml" + workflow_dispatch: + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: true + container: + image: node:20-alpine + env: + TERM: xterm-256color + steps: + - name: Install dependencies + run: | + apk add --no-cache bash wget ncurses + npm install -g markdownlint-cli + wget https://github.com/errata-ai/vale/releases/download/v2.30.0/vale_2.30.0_Linux_64-bit.tar.gz + tar -xvzf vale_2.30.0_Linux_64-bit.tar.gz -C /usr/local/bin + - name: Check out code + uses: actions/checkout@v3 + - name: Vale sync + run: vale sync + - name: Lint + run: ./scripts/lint-docs.sh + + deploy: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: true + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Python + uses: actions/setup-python@v5 + - name: Set up build cache + uses: actions/cache/restore@v3 + with: + key: mkdocs-tmpl-${{ hashfiles('.cache/**') }} + path: .cache + restore-keys: mkdocs-tmpl- + - name: Install Python dependencies + run: | + pip install mkdocs-material mkdocs-markdownextradata-plugin + - name: Build documentation + run: mkdocs build --clean + - name: Fix permissions + run: | + chmod -c -R +rX "site/" | while read -r line; do + echo "::warning title=Invalid file permissions automatically fixed::$line" + done + - name: Upload to GitHub Pages + uses: actions/upload-pages-artifact@v2 + with: + path: site + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@v3 + - name: Save build cache + uses: actions/cache/save@v3 + with: + key: mkdocs-tmpl-${{ hashfiles('.cache/**') }} + path: .cache diff --git a/.github/workflows/go-verify.yml b/.github/workflows/go-verify.yml new file mode 100644 index 0000000..9bde49e --- /dev/null +++ b/.github/workflows/go-verify.yml @@ -0,0 +1,54 @@ +--- +name: go:verify +permissions: + contents: read +on: + push: + branches: [main] + paths: + - "go.mod" + - "**/*.go" + pull_request: + branches: [main] + paths: + - "go.mod" + - "**/*.go" + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +env: + TERM: xterm-256color +jobs: + verify: + name: verify + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: true + steps: + - name: Check out code + uses: actions/checkout@v3 + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + cache-dependency-path: "go.sum" + go-version: "1.21.4" + - name: Vet + run: go vet ./... + - name: Tidy + run: | + go mod tidy + if ! git diff --exit-code --quiet; then + echo "go mod is not tidy" + exit 1 + fi + - name: Verify dependencies + run: go mod verify + - name: Test + run: ./scripts/test.sh + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.54 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1dcac7d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +--- +name: release +permissions: + contents: write +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g., v1.0.0)" + required: true +jobs: + run: + name: Release + runs-on: ubuntu-latest + container: + image: goreleaser/goreleaser:latest + steps: + - name: Install dependencies + run: apk add --no-cache bash ncurses git + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: | + git config --global --add safe.directory "$PWD" + git fetch --force --tags + - name: Create release tag + run: | + git config --global user.email "mchnrksn@gmail.com" + git config --global user.name "Michael Henriksen" + git tag -a "$VERSION" -m "$VERSION" + env: + VERSION: ${{ github.event.inputs.version }} + - run: echo "GO_VERSION=$(go env GOVERSION)" >> "$GITHUB_ENV" + - name: Run GoReleaser + run: goreleaser release --clean + env: + VERSION: ${{ github.event.inputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Notify Go proxy about new release + run: go list -m "github.com/michenriksen/tmpl@${VERSION}" || true + env: + GOPROXY: proxy.golang.org + VERSION: ${{ github.event.inputs.version }} diff --git a/.github/workflows/schema-verify.yml b/.github/workflows/schema-verify.yml new file mode 100644 index 0000000..ae81b36 --- /dev/null +++ b/.github/workflows/schema-verify.yml @@ -0,0 +1,41 @@ +--- +name: schema:verify +permissions: + contents: read +on: + push: + branches: [main] + paths: + - "config.schema.json" + - ".tmpl.reference.yaml" + - ".tmpl.example.yaml" + pull_request: + branches: [main] + paths: + - "config.schema.json" + - ".tmpl.reference.yaml" + - ".tmpl.example.yaml" + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +env: + TERM: xterm-256color +jobs: + verify: + name: verify + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: true + steps: + - name: Check out code + uses: actions/checkout@v3 + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + cache-dependency-path: "go.sum" + go-version: "1.21.4" + - name: Lint + run: ./scripts/lint-schema.sh diff --git a/.github/workflows/yaml-verify.yml b/.github/workflows/yaml-verify.yml new file mode 100644 index 0000000..1ab8b00 --- /dev/null +++ b/.github/workflows/yaml-verify.yml @@ -0,0 +1,39 @@ +--- +name: yaml:verify +permissions: + contents: read +on: + push: + branches: [main] + paths: + - "**/*.yml" + - "**/*.yaml" + - ".yamllint" + pull_request: + branches: [main] + paths: + - "**/*.yml" + - "**/*.yaml" + - ".yamllint" + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + name: verify + runs-on: ubuntu-latest + container: + image: pipelinecomponents/yamllint:amd64-0.29.0 + env: + TERM: xterm-256color + timeout-minutes: 5 + strategy: + fail-fast: true + steps: + - name: Install dependencies + run: apk add --no-cache bash ncurses + - name: Check out code + uses: actions/checkout@v3 + - name: Lint + run: ./scripts/lint-yaml.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fce8a35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,123 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go,vim,visualstudiocode,macos,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=go,vim,visualstudiocode,macos,linux + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/go,vim,visualstudiocode,macos,linux + +.env +/.tmpl.yaml +/.tmpl.yml +/.vale/Google +/.vale/Readability +/.vale/proselint +/dist +/out diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a63a286 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,191 @@ +--- +run: + tests: false + go: "1.21" +issues: + exclude: + - 'declaration of "err" shadows declaration at line' +linters: + disable-all: true + enable: + - bidichk + - depguard + - errcheck + - errname + - forcetypeassert + - gocheckcompilerdirectives + - gocritic + - godot + - gofumpt + - goimports + - gosec + - gosimple + - ineffassign + - makezero + - misspell + - nolintlint + - prealloc + - revive + - tenv + - testpackage + - thelper + - typecheck + - unused + - usestdlibvars + - wrapcheck + - wsl +linters-settings: + depguard: + rules: + main: + deny: + - pkg: github.com/go-ozzo/ozzo-validation + desc: "use maintained github.com/invopop/validation fork instead" + godot: + period: true + capital: true + gofumpt: + extra-rules: true + nolintlint: + require-explanation: true + require-specific: true + revive: + rules: + - name: argument-limit + severity: warning + disabled: false + arguments: [4] + - name: atomic + severity: warning + disabled: false + - name: bool-literal-in-expr + severity: warning + disabled: false + - name: comment-spacings + severity: warning + disabled: false + arguments: + - nolint + - "#nosec" + - name: constant-logical-expr + severity: warning + disabled: false + - name: context-as-argument + severity: warning + disabled: false + - name: context-keys-type + severity: warning + disabled: false + - name: datarace + severity: warning + disabled: false + - name: deep-exit + severity: warning + disabled: false + - name: defer + severity: warning + disabled: false + - name: duplicated-imports + severity: warning + disabled: false + - name: early-return + severity: warning + disabled: false + - name: empty-block + severity: warning + disabled: false + - name: error-return + severity: warning + disabled: false + - name: error-strings + severity: warning + disabled: false + - name: errorf + severity: warning + disabled: false + - name: function-result-limit + severity: warning + disabled: false + arguments: [3] + - name: identical-branches + severity: warning + disabled: false + - name: if-return + severity: warning + disabled: false + - name: increment-decrement + severity: warning + disabled: false + - name: import-shadowing + severity: warning + disabled: false + - name: line-length-limit + severity: warning + disabled: false + arguments: [120] + - name: modifies-parameter + severity: warning + disabled: false + - name: modifies-value-receiver + severity: warning + disabled: false + - name: optimize-operands-order + severity: warning + disabled: false + - name: range + severity: warning + disabled: false + - name: range-val-in-closure + severity: warning + disabled: false + - name: range-val-address + severity: warning + disabled: false + - name: redefines-builtin-id + severity: warning + disabled: false + - name: string-of-int + severity: warning + disabled: false + - name: struct-tag + severity: warning + disabled: false + - name: var-naming + severity: warning + disabled: false + - name: var-declaration + severity: warning + disabled: false + - name: unconditional-recursion + severity: warning + disabled: false + - name: unnecessary-stmt + severity: warning + disabled: false + - name: unreachable-code + severity: warning + disabled: false + - name: unused-parameter + severity: warning + disabled: false + - name: unused-receiver + severity: warning + disabled: false + - name: use-any + severity: warning + disabled: false + wrapcheck: + ignoreSigs: + - .Errorf( + - errors.New( + - errors.Unwrap( + - errors.Join( + - .Wrap( + - .Wrapf( + - .WithMessage( + - .WithMessagef( + - .WithStack( + - .Validate( + ignorePackageGlobs: + - context + - github.com/invopop/validation diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..df310e4 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,75 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj +--- +version: 1 +project_name: tmpl +report_sizes: true + +metadata: + mod_timestamp: "{{ .CommitTimestamp }}" + +before: + hooks: + - go mod tidy + - ./scripts/test.sh + +builds: + - id: tmpl + main: ./cmd/tmpl + binary: tmpl + flags: + - "-trimpath" + asmflags: + - "-D mysymbol" + - "all=-trimpath={{.Env.GOPATH}}" + ldflags: + - "-s -w" + - "-X github.com/michenriksen/tmpl/internal/cli.buildVersion={{ .Version }}" + - "-X github.com/michenriksen/tmpl/internal/cli.buildCommit={{ .Commit }}" + - "-X github.com/michenriksen/tmpl/internal/cli.buildTime={{ .Date }}" + - "-X github.com/michenriksen/tmpl/internal/cli.buildGoVersion={{ .Env.GO_VERSION }}" + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - "386" + - amd64 + - arm64 + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + include: + - "^feat[(:]" + - "^fix[(:]" + - "^build[(:]" + +release: + draft: true + replace_existing_draft: true + discussion_category_name: General + footer: | + ## macOS Gatekeeper + + macOS may prevent you from running the binary due to the built-in security + feature called Gatekeeper. You can find instructions on how to + [allow the binary here](https://michenriksen.com/tmpl/macos-gatekeeper/). diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..b1fd82d --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,14 @@ +--- +default: true + +MD013: + line_length: 120 + code_blocks: false + +MD014: false + +MD033: false + +MD041: false + +MD046: false diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..50c3030 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1 @@ +/docs/jsonschema.md diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000..9cc9a51 --- /dev/null +++ b/.vale.ini @@ -0,0 +1,19 @@ +StylesPath = .vale +MinAlertLevel = suggestion +Packages = Google, proselint + +Vocab = tmpl + +[*] +BasedOnStyles = Vale, Google, proselint + +Google.Passive = NO +Google.Parens = NO +Google.Colons = NO +Vale.Spelling = suggestion +Vale.Terms = warning +proselint.Typography = NO + +[docs/jsonschema.md] +BasedOnStyles = +Vale.Spelling = NO diff --git a/.vale/Vocab/tmpl/accept.txt b/.vale/Vocab/tmpl/accept.txt new file mode 100644 index 0000000..473793d --- /dev/null +++ b/.vale/Vocab/tmpl/accept.txt @@ -0,0 +1,17 @@ +(?i)JSON +APT +DNF +DTDesign +Gatekeeper +Homebrew +MIT +Neovim +Noun Project +Pacman +Portage +[Tt]mpl +fd +fzf +repo_url +tmux +unicity diff --git a/.vale/Vocab/tmpl/reject.txt b/.vale/Vocab/tmpl/reject.txt new file mode 100644 index 0000000..080189c --- /dev/null +++ b/.vale/Vocab/tmpl/reject.txt @@ -0,0 +1,18 @@ +(?i)blacklist(ed|s|ing)? +(?i)bugreport +(?i)clearly +(?i)crazy +(?i)disabled? +(?i)dumb +(?i)dummy +(?i)everyone knows +(?i)insane(ly)? +(?i)lame +(?i)masters? +(?i)obvious(ly)? +(?i)of course +(?i)sane +(?i)sanity +(?i)simply +(?i)slaves? +(?i)whitelist(ed|s|ing)? diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..ce74b20 --- /dev/null +++ b/.yamllint @@ -0,0 +1,12 @@ +--- +extends: default + +rules: + line-length: + max: 120 + truthy: + check-keys: false + +ignore: + - /.vale/ + - /dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..286eb3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Michael Henriksen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..877f1de --- /dev/null +++ b/Makefile @@ -0,0 +1,97 @@ +# Makefile adapted from https://github.com/thockin/go-build-template +# Released under the Apache-2.0 license. +DBG_MAKEFILE ?= +ifeq ($(DBG_MAKEFILE),1) + $(warning ***** starting Makefile for goal(s) "$(MAKECMDGOALS)") + $(warning ***** $(shell date)) +else + # If we're not debugging the Makefile, don't echo recipes. + MAKEFLAGS += -s +endif + +DBG ?= + +# We don't need make's built-in rules. +MAKEFLAGS += --no-builtin-rules +# Be pedantic about undefined variables. +MAKEFLAGS += --warn-undefined-variables + +OS := $(if $(GOOS),$(GOOS),$(shell go env GOOS)) +ARCH := $(if $(GOARCH),$(GOARCH),$(shell go env GOARCH)) + +GOBIN ?= "go" +GOFLAGS ?= +HTTP_PROXY ?= +HTTPS_PROXY ?= + +default: verify + +test: # @HELP run tests +test: + ./scripts/test.sh + +autotest: # @HELP run tests on file changes +autotest: + ./scripts/autotest.sh + +cover: # @HELP run tests with coverage and open HTML report +cover: + ./scripts/cover.sh + +lint: # @HELP run all linting +lint: lint-go lint-yaml lint-schema lint-ci lint-docs + +lint-go: # @HELP run Go linting +lint-go: + ./scripts/lint-go.sh + +lint-yaml: # @HELP run YAML linting +lint-yaml: + ./scripts/lint-yaml.sh + +lint-schema: # @HELP run JSON schema linting +lint-schema: + ./scripts/lint-schema.sh + +lint-ci: # @HELP run linting on CI workflow files +lint-ci: + ./scripts/lint-ci.sh + +lint-docs: # @HELP run documentation linting +lint-docs: + ./scripts/lint-docs.sh + +verify: # @HELP run all tests and linters (default target) +verify: test lint + +build: # @HELP build a snapshot binary for current OS and ARCH in ./dist/ +build: + ./scripts/build.sh + +next-version: # @HELP determine the next version for a release +next-version: + ${GOBIN} run github.com/caarlos0/svu@latest next + +gen-docs: # @HELP generate documentation +gen-docs: + ./scripts/gen-docs.sh + +reset-golden: # @HELP remove all generated golden test files +reset-golden: + ./scripts/reset-golden.sh + +help: # @HELP print this message +help: + echo "VARIABLES:" + echo " OS = $(OS)" + echo " ARCH = $(ARCH)" + echo " GOBIN = $(GOBIN)" + echo " GOFLAGS = $(GOFLAGS)" + echo " DBG = $(DBG)" + echo + echo "TARGETS:" + grep -E '^.*: *# *@HELP' $(MAKEFILE_LIST) \ + | awk ' \ + BEGIN {FS = ": *# *@HELP"}; \ + { printf " %-20s$ %s\n", $$1, $$2 }; \ + ' diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c28900 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ + +
+ + + + + +
+ +

+ tmpl  + + Build status + + + Latest release + + Project status: beta + + License: MIT + +

+ +**Simple tmux session management.**
+ +Tmpl streamlines your tmux workflow by letting you describe your sessions in simple YAML files and have them +launched with all the tools your workflow requires set up and ready to go. If you often set up the same windows and +panes for tasks like coding, running unit tests, tailing logs, and using other tools, tmpl can automate that for you. + +## Highlights + +- **Simple and versatile configuration:** easily set up your tmux sessions using straightforward YAML files, allowing + you to create as many windows and panes as needed. Customize session and window names, working directories, and + start-up commands. + +- **Inheritable environment variables:** define environment variables for your entire session, a specific window, or a + particular pane. These variables cascade from session to window to pane, enabling you to set a variable once and + modify it at any level. + +- **Custom hook commands:** customize your setup with on-window and on-pane hook commands that run when new windows, + panes, or both are created. This feature is useful for initializing a virtual environment or switching between + language runtime versions. + +- **Non-intrusive workflow:** while there are many excellent session managers out there, some of them tend to be quite + opinionated about how you should work with them. Tmpl allows configurations to live anywhere in your filesystem and + focuses only on launching your session. It's intended as a secondary companion, and not a full workflow replacement. + +- **Stand-alone binary:** Tmpl is a single, stand-alone binary with no external dependencies, except for tmux. It's easy + to install and doesn't require you to have a specific language runtime or package manager on your system. + +## Getting started + +See the [Getting started guide](https://michenriksen.com/tmpl/getting-started/) for installation and usage instructions. diff --git a/cmd/tmpl/main.go b/cmd/tmpl/main.go new file mode 100644 index 0000000..993a7ee --- /dev/null +++ b/cmd/tmpl/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/michenriksen/tmpl/internal/cli" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + exitChan := make(chan int, 1) + + signalHandler(cancel, exitChan) + + app, err := cli.NewApp() + if err != nil { + panic(err) + } + + if err := app.Run(ctx, os.Args[1:]...); err != nil { + if errors.Is(err, cli.ErrHelp) || errors.Is(err, cli.ErrVersion) { + os.Exit(0) + } + + // Return exit code 2 when a configuration file is invalid. + if errors.Is(err, cli.ErrInvalidConfig) { + os.Exit(2) + } + + if !errors.Is(ctx.Err(), context.Canceled) { + os.Exit(1) + } + + exitCode := <-exitChan + os.Exit(exitCode) + } +} + +func signalHandler(cancel context.CancelFunc, exitChan chan<- int) { + sigchan := make(chan os.Signal, 1) + signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + for sig := range sigchan { + cancel() + + fmt.Fprintf(os.Stderr, "received signal: %s; exiting...\n", sig) + time.Sleep(1 * time.Second) + + exitChan <- 1 + } + }() +} diff --git a/config.schema.json b/config.schema.json new file mode 100644 index 0000000..10f7c04 --- /dev/null +++ b/config.schema.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/michenriksen/tmpl/config.schema.json", + "title": "Tmpl configuration", + "description": "A configuration file describing how a tmux session should be created.", + "version": "0.1.0", + "author": "Michael Henriksen", + "type": "object", + "$defs": { + "name": { + "title": "Name", + "description": "A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes", + "type": "string", + "pattern": "^[\\w._-]+$" + }, + "path": { + "title": "Path", + "description": "The directory path used as the working directory in a tmux session, window, or pane.\n\nThe paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory.", + "type": "string", + "examples": [ + "/path/to/project", + "~/path/to/project", + "relative/path/to/project" + ] + }, + "command": { + "title": "Shell command", + "description": "A shell command to run within a tmux window or pane.\n\nThe 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection.", + "type": "string", + "minLength": 1 + }, + "commands": { + "title": "Shell commands", + "description": "A list of shell commands to run within a tmux window or pane in the order they are listed.\n\nIf a command is also specified in the 'command' property, it will be run first.", + "type": "array", + "items": { + "$ref": "#/$defs/command" + }, + "examples": [ + [ + "ssh user@host", + "cd /var/logs", + "tail -f app.log" + ] + ] + }, + "env": { + "title": "Environment variables", + "description": "A list of environment variables to set in a tmux session, window, or pane.\n\nThese variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores.", + "type": "object", + "propertyNames": { + "pattern": "^[A-Z_][A-Z0-9_]+$" + }, + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + }, + "examples": [ + { + "APP_ENV": "development", + "DEBUG": true, + "HTTP_PORT": 8080 + } + ] + }, + "active": { + "title": "Active", + "description": "Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default.", + "type": "boolean", + "default": false + }, + "SessionConfig": { + "title": "Session configuration", + "description": "Session configuration describing how a tmux session should be created.", + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/name", + "default": "The current working directory base name." + }, + "path": { + "$ref": "#/$defs/path", + "default": "The current working directory." + }, + "env": { + "$ref": "#/$defs/env" + }, + "on_window": { + "$ref": "#/$defs/command", + "title": "On-Window shell command", + "description": "A shell command to run first in all created windows. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command." + }, + "on_pane": { + "$ref": "#/$defs/command", + "title": "On-Pane shell command", + "description": "A shell command to run first in all created panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command." + }, + "on_any": { + "$ref": "#/$defs/command", + "title": "On-Window/Pane shell command", + "description": "A shell command to run first in all created windows and panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command." + }, + "windows": { + "title": "Window configurations", + "description": "A list of tmux window configurations to create in the session. The first configuration will be used for the default window.", + "type": "array", + "items": { + "$ref": "#/$defs/WindowConfig" + } + } + }, + "additionalProperties": false + }, + "WindowConfig": { + "title": "Window configuration", + "description": "Window configuration describing how a tmux window should be created.", + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/name", + "default": "tmux default" + }, + "path": { + "$ref": "#/$defs/path", + "default": "The session path." + }, + "command": { + "$ref": "#/$defs/command" + }, + "commands": { + "$ref": "#/$defs/commands" + }, + "env": { + "$ref": "#/$defs/env", + "default": "The session env." + }, + "active": { + "$ref": "#/$defs/active" + }, + "panes": { + "title": "Pane configurations", + "description": "A list of tmux pane configurations to create in the window.", + "type": "array", + "items": { + "$ref": "#/$defs/PaneConfig" + } + }, + "additionalProperties": false + } + }, + "PaneConfig": { + "title": "Pane configuration", + "description": "Pane configuration describing how a tmux pane should be created.", + "type": "object", + "properties": { + "path": { + "$ref": "#/$defs/path", + "default": "The window path." + }, + "command": { + "$ref": "#/$defs/command" + }, + "commands": { + "$ref": "#/$defs/commands" + }, + "env": { + "$ref": "#/$defs/env", + "default": "The window env." + }, + "active": { + "$ref": "#/$defs/active" + }, + "horizontal": { + "title": "Horizontal split", + "description": "Whether to split the window horizontally. If false, the window will be split vertically.", + "type": "boolean", + "default": false + }, + "size": { + "title": "Size", + "description": "The size of the pane in lines for horizontal panes, or columns for vertical panes. The size can also be specified as a percentage of the available space.", + "type": "string", + "examples": [ + "20%", + "50", + "215" + ] + }, + "panes": { + "title": "Pane configurations", + "description": "A list of tmux pane configurations to create in the pane.", + "type": "array", + "items": { + "$ref": "#/$defs/PaneConfig" + } + } + }, + "additionalProperties": false + } + }, + "properties": { + "tmux": { + "title": "tmux executable", + "description": "The tmux executable to use. Must be an absolute path, or available in $PATH.", + "type": "string", + "default": "tmux" + }, + "tmux_options": { + "title": "tmux command line options", + "description": "Additional tmux command line options to add to all tmux command invocations.", + "type": "array", + "items": { + "type": "string", + "description": "A tmux command line flag and its value, if any. See `man tmux` for available options." + }, + "examples": [ + [ + "-f", + "/path/to/tmux.conf" + ], + [ + "-L", + "MySocket" + ] + ] + }, + "session": { + "$ref": "#/$defs/SessionConfig" + } + }, + "additionalProperties": false +} diff --git a/config/apply.go b/config/apply.go new file mode 100644 index 0000000..ef05cb3 --- /dev/null +++ b/config/apply.go @@ -0,0 +1,206 @@ +package config + +import ( + "context" + "errors" + "fmt" + + "github.com/michenriksen/tmpl/tmux" +) + +// Apply applies the provided tmux session configuration using the provided +// tmux command. +// +// If a session with the same name already exists, it is assumed to be in the +// correct state and the session is returned. Otherwise, a new session is +// created and returned. +// +// If the provided configuration is invalid, an error is returned. Caller can +// check for validity beforehand by calling [config.Config.Validate] if needed. +func Apply(ctx context.Context, cfg *Config, runner tmux.Runner) (*tmux.Session, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration file: %w", err) + } + + sCfg := cfg.Session + + sessions, err := tmux.GetSessions(ctx, runner) + if err != nil { + return nil, fmt.Errorf("getting current tmux sessions: %w", err) + } + + for _, s := range sessions { + if s.Name() == sCfg.Name { + return s, nil + } + } + + session, err := tmux.NewSession(runner, makeSessionOpts(cfg.Session)...) + if err != nil { + return fatalf(session, "creating session: %w", err) + } + + if err := session.Apply(ctx); err != nil { + return fatalf(session, "applying %s: %w", session, err) + } + + for _, wCfg := range sCfg.Windows { + if _, err := applyWindowCfg(ctx, runner, session, wCfg); err != nil { + return fatalf(session, "applying window configuration: %w", err) + } + } + + if err := session.SelectActive(ctx); err != nil { + return fatalf(session, "selecting active window: %w", err) + } + + return session, nil +} + +// fatalf is a helper function for [Apply] that constructs an error from +// provided format and args, and closes the provided tmux session if not nil. +func fatalf(sess *tmux.Session, format string, args ...any) (*tmux.Session, error) { + err := fmt.Errorf(format, args...) + + if sess != nil { + if closeErr := sess.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("closing failed session: %w", closeErr)) + } + } + + return nil, err +} + +// applyWindowCfg creates a new tmux window from the provided configuration on +// the provided tmux session. +func applyWindowCfg(ctx context.Context, r tmux.Runner, s *tmux.Session, cfg WindowConfig) (*tmux.Window, error) { + win, err := tmux.NewWindow(r, s, makeWindowOpts(cfg)...) + if err != nil { + return nil, fmt.Errorf("creating window: %w", err) + } + + if err := win.Apply(ctx); err != nil { + return nil, fmt.Errorf("applying %s: %w", win, err) + } + + for _, pCfg := range cfg.Panes { + if _, err := applyPaneCfg(ctx, r, win, nil, pCfg); err != nil { + return nil, err + } + } + + return win, nil +} + +func applyPaneCfg(ctx context.Context, r tmux.Runner, w *tmux.Window, pp *tmux.Pane, cfg PaneConfig) (*tmux.Pane, error) { //nolint:revive // more readable in one line. + pane, err := tmux.NewPane(r, w, pp, makePaneOpts(cfg)...) + if err != nil { + return nil, fmt.Errorf("creating pane: %w", err) + } + + if err := pane.Apply(ctx); err != nil { + return nil, fmt.Errorf("applying %s: %w", pane, err) + } + + for _, pCfg := range cfg.Panes { + if _, err := applyPaneCfg(ctx, r, w, pane, pCfg); err != nil { + return nil, err + } + } + + return pane, nil +} + +func makeSessionOpts(sCfg SessionConfig) []tmux.SessionOption { + opts := []tmux.SessionOption{} + + if sCfg.Name != "" { + opts = append(opts, tmux.SessionWithName(sCfg.Name)) + } + + if sCfg.Path != "" { + opts = append(opts, tmux.SessionWithPath(sCfg.Path)) + } + + if sCfg.OnWindow != "" { + opts = append(opts, tmux.SessionWithOnWindowCommand(sCfg.OnWindow)) + } + + if sCfg.OnPane != "" { + opts = append(opts, tmux.SessionWithOnPaneCommand(sCfg.OnPane)) + } + + if sCfg.OnAny != "" { + opts = append(opts, tmux.SessionWithOnAnyCommand(sCfg.OnAny)) + } + + if len(sCfg.Env) != 0 { + opts = append(opts, tmux.SessionWithEnv(sCfg.Env)) + } + + return opts +} + +func makeWindowOpts(wCfg WindowConfig) []tmux.WindowOption { + opts := []tmux.WindowOption{tmux.WindowWithName(wCfg.Name)} + + if wCfg.Path != "" { + opts = append(opts, tmux.WindowWithPath(wCfg.Path)) + } + + if wCfg.Command != "" { + opts = append(opts, tmux.WindowWithCommands(wCfg.Command)) + } + + if len(wCfg.Commands) != 0 { + opts = append(opts, tmux.WindowWithCommands(wCfg.Commands...)) + } + + if wCfg.Active { + opts = append(opts, tmux.WindowAsActive()) + } + + if len(wCfg.Env) != 0 { + opts = append(opts, tmux.WindowWithEnv(wCfg.Env)) + } + + return opts +} + +func makePaneOpts(pCfg PaneConfig) []tmux.PaneOption { + opts := []tmux.PaneOption{} + + if pCfg.Path != "" { + opts = append(opts, tmux.PaneWithPath(pCfg.Path)) + } + + if pCfg.Size != "" { + opts = append(opts, tmux.PaneWithSize(pCfg.Size)) + } + + if pCfg.Command != "" { + opts = append(opts, tmux.PaneWithCommands(pCfg.Command)) + } + + if len(pCfg.Commands) != 0 { + opts = append(opts, tmux.PaneWithCommands(pCfg.Commands...)) + } + + if pCfg.Horizontal { + opts = append(opts, tmux.PaneWithHorizontalDirection()) + } + + if pCfg.Active { + opts = append(opts, tmux.PaneAsActive()) + } + + if len(pCfg.Env) != 0 { + opts = append(opts, tmux.PaneWithEnv(pCfg.Env)) + } + + return opts +} diff --git a/config/apply_test.go b/config/apply_test.go new file mode 100644 index 0000000..e6c1e69 --- /dev/null +++ b/config/apply_test.go @@ -0,0 +1,101 @@ +package config_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/testutils" + "github.com/michenriksen/tmpl/tmux" + + "github.com/stretchr/testify/require" +) + +// stubCmd represents an entry for a stubbed command defined in +// testdata/apply-stubcmds.json. +// +// See [loadStubCmds] for more information. +type stubCmd struct { + Err error `json:"err"` + Output string `json:"output"` + seen bool +} + +func TestApply(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "project", "cmd"), 0755)) + + // Stub HOME and current working directory for consistent test results. + t.Setenv("HOME", dir) + t.Setenv("TMPL_PWD", dir) + + cfg, err := config.FromFile(filepath.Join("testdata", "apply.yaml")) + require.NoError(t, err) + + expectedCmds := loadStubCmds(t) + + var mockCmdRunner tmux.OSCommandRunner = func(_ context.Context, name string, args ...string) ([]byte, error) { + argStr := strings.Join(args, " ") + + cmd, ok := expectedCmds[argStr] + + if !ok { + t.Fatalf("unexpected command: %s %s", name, argStr) + } + + if cmd.seen { + t.Fatalf("received duplicate command: %s %s", name, argStr) + } + + t.Logf("received expected command: %s %s", name, argStr) + + cmd.seen = true + + return []byte(cmd.Output), cmd.Err + } + + cmd, err := tmux.NewRunner(tmux.WithOSCommandRunner(mockCmdRunner)) + require.NoError(t, err) + + session, err := config.Apply(context.Background(), cfg, cmd) + require.NoError(t, err) + + for args, cmd := range expectedCmds { + if !cmd.seen { + t.Fatalf("expected command was not run: %s", args) + } + } + + require.Equal(t, "tmpl_test_session", session.Name()) +} + +// loadStubCmds loads the stub commands defined in testdata/apply-stubcmds.json. +// +// Commands are defined as a map of expected tmux command line arguments mapped +// to a stubCmd struct containing optional stub output and error. +// +// The map keys and stub outputs are expanded using os.ExpandEnv before being +// returned. This allows for using environment variables in the stub commands to +// ensure consistent test results. +func loadStubCmds(t *testing.T) map[string]*stubCmd { + data := testutils.ReadFile(t, "testdata", "apply-stubcmds.json") + + var cmds map[string]*stubCmd + + if err := json.Unmarshal(data, &cmds); err != nil { + t.Fatalf("error decoding apply-stubcmds.json: %v", err) + } + + expanded := make(map[string]*stubCmd, len(cmds)) + for args, cmd := range cmds { + expanded[os.ExpandEnv(args)] = cmd + } + + t.Logf("loaded %d stub commands", len(cmds)) + + return expanded +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..8856714 --- /dev/null +++ b/config/config.go @@ -0,0 +1,264 @@ +// Package config loads, validates, and applies tmpl configurations. +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + + "gopkg.in/yaml.v3" + + "github.com/michenriksen/tmpl/internal/env" +) + +// DefaultConfigFile is the default configuration filename. +const DefaultConfigFile = ".tmpl.yaml" + +var specialCharsRegexp = regexp.MustCompile(`[^\w_]+`) + +// Config represents a session configuration loaded from a YAML file. +type Config struct { + path string + Session SessionConfig `yaml:"session"` // Session configuration. + Tmux string `yaml:"tmux"` // Path to tmux executable. + TmuxOptions []string `yaml:"tmux_options"` // Additional tmux options. +} + +// FromFile loads a session configuration from provided file path. +// +// File is expected to be in YAML format. +func FromFile(cfgPath string) (*Config, error) { + cfg, err := load(cfgPath) + if err != nil { + return nil, err + } + + return cfg, nil +} + +// Path returns the path to the configuration file from which the configuration +// was loaded. +func (c *Config) Path() string { + return c.path +} + +// NumWindows returns the number of window configurations for the session. +func (c *Config) NumWindows() int { + n := len(c.Session.Windows) + if n == 0 { + return 1 + } + + return n +} + +// NumPanes returns the number of pane configurations for the session. +func (c *Config) NumPanes() int { + n := 0 + + for _, w := range c.Session.Windows { + n += len(w.Panes) + + for _, p := range w.Panes { + n += len(p.Panes) + } + } + + return n +} + +// SessionConfig represents a tmux session configuration. It contains the name +// of the session, the path to the directory where the session will be created +// and the window configurations. +// +// Any environment variables defined in the session configuration will be +// inherited by all windows and panes. +type SessionConfig struct { + Name string `yaml:"name"` // Session name. + Path string `yaml:"path"` // Session directory. + OnWindow string `yaml:"on_window"` // Shell command to run in all windows. + OnPane string `yaml:"on_pane"` // Shell command to run in all panes. + OnAny string `yaml:"on_any"` // Shell command to run in all windows and panes. + Env map[string]string `yaml:"env"` // Session environment variables. + Windows []WindowConfig `yaml:"windows"` // Window configurations. +} + +// WindowConfig represents a tmux window configuration. It contains the name of +// the window, the path to the directory where the window will be created, the +// command to run in the window and pane configurations. +// +// If a path is not specified, a window will inherit the session path. +// +// Any environment variables defined in the window configuration will be +// inherited by all panes. If a variable is defined in both the session and +// window configuration, the window variable will take precedence. +type WindowConfig struct { + Name string `yaml:"name"` // Window name. + Path string `yaml:"path"` // Window directory. + Command string `yaml:"command"` // Command to run in the window. + Commands []string `yaml:"commands"` // Commands to run in the window. + Env map[string]string `yaml:"env"` // Window environment variables. + Panes []PaneConfig `yaml:"panes"` // Pane configurations. + Active bool `yaml:"active"` // Whether the window should be selected. +} + +// PaneConfig represents a tmux pane configuration. It contains the path to the +// directory where the pane will be created, the command to run in the pane, +// the size of the pane and whether the pane should be split horizontally or +// vertically. +// +// If a path is not specified, a pane will inherit the window path. +// +// Any inherited environment variables from the window or session will be +// overridden by variables defined in the pane configuration if they have the +// same name. +type PaneConfig struct { + Env map[string]string `yaml:"env"` // Pane environment variables. + Path string `yaml:"path"` // Pane directory. + Command string `yaml:"command"` // Command to run in the pane. + Commands []string `yaml:"commands"` // Commands to run in the pane. + Size string `yaml:"size"` // Pane size (cells or percentage) + Horizontal bool `yaml:"horizontal"` // Whether the pane should be split horizontally. + Panes []PaneConfig `yaml:"panes"` // Pane configurations. + Active bool `yaml:"active"` // Whether the pane should be selected. +} + +// FindConfigFile searches for a configuration file starting from the provided +// directory and going up until the root directory is reached. If no file is +// found, ErrConfigNotFound is returned. +// +// By default, the configuration file name is .tmpl.yaml. This can be changed +// by setting the TMPL_CONFIG_FILE environment variable. +func FindConfigFile(dir string) (string, error) { + name := ConfigFileName() + + for { + cfgPath := filepath.Join(dir, name) + + info, err := os.Stat(cfgPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + if dir == "/" { + return "", ErrConfigNotFound + } + + dir = filepath.Dir(dir) + + continue + } + + return "", fmt.Errorf("getting file info: %w", err) + } + + if info.IsDir() { + return "", fmt.Errorf("path %q is a directory", cfgPath) + } + + return cfgPath, nil + } +} + +// load reads and decodes a YAML configuration file into a Config struct and +// sets default values. +func load(cfgPath string) (*Config, error) { + var cfg Config + + f, err := os.Open(cfgPath) + if err != nil { + return nil, fmt.Errorf("opening configuration file: %w", err) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("getting configuration file info: %w", err) + } + + if info.Size() == 0 { + return nil, ErrEmptyConfig + } + + decoder := yaml.NewDecoder(f) + decoder.KnownFields(true) + + if err := decoder.Decode(&cfg); err != nil { + return nil, decodeError(err, cfgPath) + } + + cfg.path = cfgPath + + if err := setDefaults(&cfg); err != nil { + return nil, fmt.Errorf("setting default values: %w", err) + } + + return &cfg, nil +} + +// setDefaults sets default values for blank configuration fields. +// +// The following fields are set to default values: +// +// - Session.Name: defaults to . +// - Session.Path: defaults to current working directory. +// - Window.Path: defaults to Session.Path. +// - Pane.Path: defaults to Window.Path. +func setDefaults(cfg *Config) error { + wd, err := env.Getwd() + if err != nil { + return fmt.Errorf("getting current working directory: %w", err) + } + + if cfg.Session.Name == "" { + name := specialCharsRegexp.ReplaceAllString(filepath.Base(wd), "_") + cfg.Session.Name = name + } + + if cfg.Session.Path == "" { + cfg.Session.Path = wd + } else { + if cfg.Session.Path, err = env.AbsPath(cfg.Session.Path); err != nil { + return fmt.Errorf("expanding session path: %w", err) + } + } + + for i, w := range cfg.Session.Windows { + if w.Path == "" { + w.Path = cfg.Session.Path + } else { + if w.Path, err = env.AbsPath(w.Path); err != nil { + return fmt.Errorf("expanding window path: %w", err) + } + } + + for j, p := range w.Panes { + if p.Path == "" { + p.Path = w.Path + } else { + if p.Path, err = env.AbsPath(p.Path); err != nil { + return fmt.Errorf("expanding pane path: %w", err) + } + } + + w.Panes[j] = p + } + + cfg.Session.Windows[i] = w + } + + return nil +} + +// ConfigFileName returns the name of the configuration that. +// +// Returns the value of the TMPL_CONFIG_NAME environment variable if set, +// otherwise it returns [DefaultConfigFile]. +func ConfigFileName() string { + if name := env.Getenv(env.KeyConfigName); name != "" { + return name + } + + return DefaultConfigFile +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..c807fa6 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,93 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/testutils" + + "github.com/stretchr/testify/require" +) + +func TestFromFile(t *testing.T) { + // Stub HOME and current working directory for consistent test results. + t.Setenv("HOME", "/Users/johndoe") + t.Setenv("TMPL_PWD", "/Users/johndoe/project") + + tt := []struct { + name string + file string + assertErr testutils.ErrorAssertion + }{ + {"full config", "full.yaml", nil}, + {"minimal config", "minimal.yaml", nil}, + {"tilde home paths", "tilde.yaml", nil}, + {"empty config", "empty.yaml", testutils.RequireErrorIs(config.ErrEmptyConfig)}, + {"broken", "broken.yaml", testutils.RequireErrorContains("decoding error:")}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + cfg, err := config.FromFile(filepath.Join("testdata", tc.file)) + + if tc.assertErr != nil { + require.Error(t, err, "expected error") + require.Nil(t, cfg, "expected nil config on error") + + tc.assertErr(t, err) + + return + } + + require.NoError(t, err) + require.NotNil(t, cfg, "expected non-nil config on success") + + testutils.NewGolden(t).RequireMatch(cfg) + }) + } +} + +func TestFindConfigFile_TraverseDirectories(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir", "second subdir", "third subdir"), 0o744)) + + wantCfg := filepath.Join(dir, ".tmpl.yaml") + testutils.WriteFile(t, []byte("---\nsession:\n name: test\n"), wantCfg) + + cfg, err := config.FindConfigFile(filepath.Join(dir, "subdir", "second subdir", "third subdir")) + + require.NoError(t, err) + require.Equal(t, wantCfg, cfg) +} + +func TestFindConfigFile_NotFound(t *testing.T) { + dir := t.TempDir() + + cfg, err := config.FindConfigFile(dir) + + require.ErrorIs(t, err, config.ErrConfigNotFound) + require.Empty(t, cfg) +} + +func TestFindConfigFile_DirNotExist(t *testing.T) { + cfg, err := config.FindConfigFile("/path/to/non-existent/dir") + + require.ErrorIs(t, err, config.ErrConfigNotFound) + require.Empty(t, cfg) +} + +func TestFindConfigFile_CustomConfigFilename(t *testing.T) { + t.Setenv("TMPL_CONFIG_NAME", "tmpl-test-custom.yaml") + + dir := t.TempDir() + + wantCfg := filepath.Join(dir, "tmpl-test-custom.yaml") + testutils.WriteFile(t, []byte("---\nsession:\n name: test\n"), wantCfg) + + cfg, err := config.FindConfigFile(dir) + + require.NoError(t, err) + require.Equal(t, wantCfg, cfg) +} diff --git a/config/errors.go b/config/errors.go new file mode 100644 index 0000000..71c68da --- /dev/null +++ b/config/errors.go @@ -0,0 +1,42 @@ +package config + +import ( + "errors" + "fmt" +) + +var ( + // ErrConfigNotFound is returned when a configuration file is not found. + ErrConfigNotFound = errors.New("configuration file not found") + // ErrEmptyConfig is returned when a configuration file is empty. + ErrEmptyConfig = errors.New("configuration file is empty") + // ErrInvalidConfig is returned when a configuration file contains invalid + // and unparsable YAML. + ErrInvalidConfig = errors.New("configuration file is not parsable") +) + +// DecodeError is returned when a configuration file cannot be decoded. +type DecodeError struct { + err error + path string +} + +func decodeError(err error, path string) DecodeError { + return DecodeError{err: err, path: path} +} + +// Error implements the error interface. +func (e DecodeError) Error() string { + return fmt.Sprintf("decoding error: %s", e.err) +} + +// Unwrap implements the [errors.Wrapper] interface. +func (e DecodeError) Unwrap() error { + return e.err +} + +// Path returns the path to the configuration file that was attempted to be +// decoded. +func (e DecodeError) Path() string { + return e.path +} diff --git a/config/testdata/apply-stubcmds.json b/config/testdata/apply-stubcmds.json new file mode 100644 index 0000000..b46cb08 --- /dev/null +++ b/config/testdata/apply-stubcmds.json @@ -0,0 +1,39 @@ +{ + "list-sessions -F session_id:#{session_id},session_name:#{session_name},session_path:#{session_path}": { + "output": "session_id:$0,session_name:main,session_path:$HOME" + }, + "new-session -d -P -F session_id:#{session_id},session_name:#{session_name},session_path:#{session_path} -s tmpl_test_session": { + "output": "session_id:$1,session_name:tmpl_test_session,session_path:$HOME/project" + }, + "send-keys -t tmpl_test_session:code ~/project/scripts/boostrap.sh C-m": {}, + "send-keys -t tmpl_test_session:code echo 'on_window' C-m": {}, + "send-keys -t tmpl_test_session:code nvim . C-m": {}, + "new-window -P -F window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height} -k -t tmpl_test_session:^ -n code -c $HOME/project": { + "output": "window_id:@2,window_name:code,window_path:$HOME/project/cmd,window_index:1,window_width:80,window_height:24" + }, + "split-window -d -P -F pane_id:#{pane_id},pane_path:#{pane_path},pane_index:#{pane_index},pane_width:#{pane_width},pane_height:#{pane_height} -t tmpl_test_session:code -e APP_ENV=testing -c $HOME/project -h": { + "output": "pane_id:%3,pane_path:$HOME/project/cmd,pane_index:1,pane_width:80,pane_height:12" + }, + "send-keys -t tmpl_test_session:code.1 ~/project/scripts/boostrap.sh C-m": {}, + "send-keys -t tmpl_test_session:code.1 echo 'on_pane' C-m": {}, + "send-keys -t tmpl_test_session:code.1 ./scripts/autorun-tests.sh C-m": {}, + "new-window -P -F window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height} -t tmpl_test_session: -e APP_ENV=development -e PORT=8080 -n server -c $HOME/project/cmd": { + "output": "window_id:@3,window_name:server,window_path:$HOME/project/cmd,window_index:2,window_width:80,window_height:24" + }, + "send-keys -t tmpl_test_session:server ~/project/scripts/boostrap.sh C-m": {}, + "send-keys -t tmpl_test_session:server echo 'on_window' C-m": {}, + "send-keys -t tmpl_test_session:server ./server C-m": {}, + "new-window -P -F window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height} -t tmpl_test_session: -n prod_logs -c $HOME/project": { + "output": "window_id:@4,window_name:prod_logs,window_path:$HOME/project,window_index:3,window_width:80,window_height:24" + }, + "send-keys -t tmpl_test_session:prod_logs ~/project/scripts/boostrap.sh C-m": {}, + "send-keys -t tmpl_test_session:prod_logs echo 'on_window' C-m": {}, + "send-keys -t tmpl_test_session:prod_logs ssh user@host C-m": {}, + "send-keys -t tmpl_test_session:prod_logs cd /var/logs C-m": {}, + "send-keys -t tmpl_test_session:prod_logs tail -f app.log C-m": {}, + "select-window -t tmpl_test_session:code": {}, + "show-option -gqv pane-base-index": { + "output": "0" + }, + "select-pane -t tmpl_test_session:code.0": {} +} diff --git a/config/testdata/apply.yaml b/config/testdata/apply.yaml new file mode 100644 index 0000000..fca0224 --- /dev/null +++ b/config/testdata/apply.yaml @@ -0,0 +1,26 @@ +--- +session: + name: "tmpl_test_session" + path: "~/project" + on_any: "~/project/scripts/boostrap.sh" + on_window: "echo 'on_window'" + on_pane: "echo 'on_pane'" + windows: + - name: "code" + command: "nvim ." + panes: + - command: "./scripts/autorun-tests.sh" + horizontal: true + env: + APP_ENV: "testing" + - name: "server" + path: "~/project/cmd" + command: "./server" + env: + APP_ENV: "development" + PORT: "8080" + - name: "prod_logs" + commands: + - "ssh user@host" + - "cd /var/logs" + - "tail -f app.log" diff --git a/config/testdata/broken.yaml b/config/testdata/broken.yaml new file mode 100644 index 0000000..080d989 --- /dev/null +++ b/config/testdata/broken.yaml @@ -0,0 +1,6 @@ +# yamllint disable-file +# This file has an intentional syntax error for testing purposes. +--- +session: + name: "test" + path: "/Users/johndoe/project" diff --git a/config/testdata/empty.yaml b/config/testdata/empty.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/testdata/full.yaml b/config/testdata/full.yaml new file mode 100644 index 0000000..483857f --- /dev/null +++ b/config/testdata/full.yaml @@ -0,0 +1,32 @@ +--- +tmux: "/usr/bin/other_tmux" +tmux_options: ["-f", "/Users/johndoe/other_tmux.conf"] +session: + name: "tmpl_test" + path: "/Users/johndoe/project" + env: + TMPL_TEST_SESS_ENV: "true" + windows: + - name: "tmpl_test_window_1" + command: "echo 'window 1'" + env: + TMPL_TEST_WIN_1_ENV: "true" + panes: + - command: "echo 'window 1 pane 1'" + horizontal: true + size: "20%" + + - name: "tmpl_test_window_2" + path: "/Users/johndoe/project/subdir" + command: "echo 'window 2'" + env: + TMPL_TEST_SESS_ENV: "overwrite" + TMPL_TEST_WIN_2_ENV: "true" + panes: + - path: "/Users/johndoe/project/subdir/subdir2" + horizontal: true + env: + TMPL_TEST_WIN_2_PANE_1: "true" + TMPL_TEST_WIN_2: "overwrite" + - env: + TMPL_TEST_SESS_ENV: "overwrite" diff --git a/config/testdata/golden/TestFromFile/full_config.golden.json b/config/testdata/golden/TestFromFile/full_config.golden.json new file mode 100644 index 0000000..a83bf0d --- /dev/null +++ b/config/testdata/golden/TestFromFile/full_config.golden.json @@ -0,0 +1,79 @@ +{ + "Session": { + "Name": "tmpl_test", + "Path": "/Users/johndoe/project", + "OnWindow": "", + "OnPane": "", + "OnAny": "", + "Env": { + "TMPL_TEST_SESS_ENV": "true" + }, + "Windows": [ + { + "Name": "tmpl_test_window_1", + "Path": "/Users/johndoe/project", + "Command": "echo 'window 1'", + "Commands": null, + "Env": { + "TMPL_TEST_WIN_1_ENV": "true" + }, + "Panes": [ + { + "Env": null, + "Path": "/Users/johndoe/project", + "Command": "echo 'window 1 pane 1'", + "Commands": null, + "Size": "20%", + "Horizontal": true, + "Panes": null, + "Active": false + } + ], + "Active": false + }, + { + "Name": "tmpl_test_window_2", + "Path": "/Users/johndoe/project/subdir", + "Command": "echo 'window 2'", + "Commands": null, + "Env": { + "TMPL_TEST_SESS_ENV": "overwrite", + "TMPL_TEST_WIN_2_ENV": "true" + }, + "Panes": [ + { + "Env": { + "TMPL_TEST_WIN_2": "overwrite", + "TMPL_TEST_WIN_2_PANE_1": "true" + }, + "Path": "/Users/johndoe/project/subdir/subdir2", + "Command": "", + "Commands": null, + "Size": "", + "Horizontal": true, + "Panes": null, + "Active": false + }, + { + "Env": { + "TMPL_TEST_SESS_ENV": "overwrite" + }, + "Path": "/Users/johndoe/project/subdir", + "Command": "", + "Commands": null, + "Size": "", + "Horizontal": false, + "Panes": null, + "Active": false + } + ], + "Active": false + } + ] + }, + "Tmux": "/usr/bin/other_tmux", + "TmuxOptions": [ + "-f", + "/Users/johndoe/other_tmux.conf" + ] +} diff --git a/config/testdata/golden/TestFromFile/minimal_config.golden.json b/config/testdata/golden/TestFromFile/minimal_config.golden.json new file mode 100644 index 0000000..a5889bc --- /dev/null +++ b/config/testdata/golden/TestFromFile/minimal_config.golden.json @@ -0,0 +1,23 @@ +{ + "Session": { + "Name": "project", + "Path": "/Users/johndoe/project", + "OnWindow": "", + "OnPane": "", + "OnAny": "", + "Env": null, + "Windows": [ + { + "Name": "test", + "Path": "/Users/johndoe/project", + "Command": "", + "Commands": null, + "Env": null, + "Panes": null, + "Active": false + } + ] + }, + "Tmux": "", + "TmuxOptions": null +} diff --git a/config/testdata/golden/TestFromFile/tilde_home_paths.golden.json b/config/testdata/golden/TestFromFile/tilde_home_paths.golden.json new file mode 100644 index 0000000..7654332 --- /dev/null +++ b/config/testdata/golden/TestFromFile/tilde_home_paths.golden.json @@ -0,0 +1,34 @@ +{ + "Session": { + "Name": "tmpl_test", + "Path": "/Users/johndoe/project", + "OnWindow": "", + "OnPane": "", + "OnAny": "", + "Env": null, + "Windows": [ + { + "Name": "", + "Path": "/Users/johndoe/project/subdir", + "Command": "", + "Commands": null, + "Env": null, + "Panes": [ + { + "Env": null, + "Path": "/Users/johndoe/project/subdir/subdir2", + "Command": "", + "Commands": null, + "Size": "", + "Horizontal": false, + "Panes": null, + "Active": false + } + ], + "Active": false + } + ] + }, + "Tmux": "", + "TmuxOptions": null +} diff --git a/config/testdata/invalid-pane-bad-env.yaml b/config/testdata/invalid-pane-bad-env.yaml new file mode 100644 index 0000000..1122a26 --- /dev/null +++ b/config/testdata/invalid-pane-bad-env.yaml @@ -0,0 +1,8 @@ +# Invalid configuration: Environment variables must only contain uppercase +# alphanumeric and underscores. +--- +session: + windows: + - panes: + - env: + INVALID-SESSION-NAME: "true" diff --git a/config/testdata/invalid-pane-path-not-exist.yaml b/config/testdata/invalid-pane-path-not-exist.yaml new file mode 100644 index 0000000..fb95c12 --- /dev/null +++ b/config/testdata/invalid-pane-path-not-exist.yaml @@ -0,0 +1,7 @@ +# Invalid configuration: Pane specifies a path that does not exist. +--- +session: + windows: + - name: "window" + panes: + - path: "/tmp/tmpl/test/invalid-pane-path-notfound" diff --git a/config/testdata/invalid-session-bad-env.yaml b/config/testdata/invalid-session-bad-env.yaml new file mode 100644 index 0000000..442bac2 --- /dev/null +++ b/config/testdata/invalid-session-bad-env.yaml @@ -0,0 +1,8 @@ +# Invalid configuration: Environment variables must only contain uppercase +# alphanumeric and underscores. +--- +session: + windows: + - name: "window" + env: + "$INVALID_SESSION_NAME": "true" diff --git a/config/testdata/invalid-session-bad-name.yaml b/config/testdata/invalid-session-bad-name.yaml new file mode 100644 index 0000000..d06680a --- /dev/null +++ b/config/testdata/invalid-session-bad-name.yaml @@ -0,0 +1,7 @@ +# Invalid configuration: Session names can only consist of alphanumeric and +# underscores. +--- +session: + name: "Hello, World!" + windows: + - name: "window" diff --git a/config/testdata/invalid-session-path-not-exist.yaml b/config/testdata/invalid-session-path-not-exist.yaml new file mode 100644 index 0000000..a45a5b3 --- /dev/null +++ b/config/testdata/invalid-session-path-not-exist.yaml @@ -0,0 +1,4 @@ +# Invalid configuration: Session specifies a path that does not exist. +--- +session: + path: "/tmp/tmpl/test/invalid-session-path-notfound" diff --git a/config/testdata/invalid-tmux-not-exist.yaml b/config/testdata/invalid-tmux-not-exist.yaml new file mode 100644 index 0000000..c5849b5 --- /dev/null +++ b/config/testdata/invalid-tmux-not-exist.yaml @@ -0,0 +1,6 @@ +# Invalid configuration: Specified tmux executable does not exist. +--- +tmux: "/usr/local/bin/tmpl-test-tmux-not-exist" +session: + windows: + - name: "window" diff --git a/config/testdata/invalid-window-bad-env.yaml b/config/testdata/invalid-window-bad-env.yaml new file mode 100644 index 0000000..f32607e --- /dev/null +++ b/config/testdata/invalid-window-bad-env.yaml @@ -0,0 +1,7 @@ +# Invalid configuration: Environment variables must only contain uppercase +# alphanumeric and underscores. +--- +session: + windows: + - env: + invalid_session_name: "true" diff --git a/config/testdata/invalid-window-bad-name.yaml b/config/testdata/invalid-window-bad-name.yaml new file mode 100644 index 0000000..32aec0a --- /dev/null +++ b/config/testdata/invalid-window-bad-name.yaml @@ -0,0 +1,6 @@ +# Invalid configuration: Window names can only consist of alphanumeric and +# underscores. +--- +session: + windows: + - name: "Hello, World!" diff --git a/config/testdata/invalid-window-path-not-exist.yaml b/config/testdata/invalid-window-path-not-exist.yaml new file mode 100644 index 0000000..9c77f10 --- /dev/null +++ b/config/testdata/invalid-window-path-not-exist.yaml @@ -0,0 +1,5 @@ +# Invalid configuration: Window specifies a path that does not exist. +--- +session: + windows: + - path: "/tmp/tmpl/test/invalid-window-path-notfound" diff --git a/config/testdata/minimal.yaml b/config/testdata/minimal.yaml new file mode 100644 index 0000000..f069cd1 --- /dev/null +++ b/config/testdata/minimal.yaml @@ -0,0 +1,4 @@ +--- +session: + windows: + - name: "test" diff --git a/config/testdata/tilde.yaml b/config/testdata/tilde.yaml new file mode 100644 index 0000000..cf51e1f --- /dev/null +++ b/config/testdata/tilde.yaml @@ -0,0 +1,8 @@ +--- +session: + name: "tmpl_test" + path: "~/project" + windows: + - path: "~/project/subdir" + panes: + - path: "~/project/subdir/subdir2" diff --git a/config/validation.go b/config/validation.go new file mode 100644 index 0000000..af26cae --- /dev/null +++ b/config/validation.go @@ -0,0 +1,126 @@ +package config + +import ( + "fmt" + "regexp" + + "github.com/invopop/validation" + + "github.com/michenriksen/tmpl/internal/rulefuncs" +) + +const errorTag = "yaml" + +var envVarRE = regexp.MustCompile(`^[A-Z_][A-Z0-9_]+$`) + +var nameMatchRule = validation.Match(regexp.MustCompile(`^[\w._-]+$`)). + Error("must only contain alphanumeric characters, underscores, dots, and dashes") + +// Validate validates the configuration. +// +// It checks that: +// +// - tmux executable exists +// - session is valid (see [SessionConfig.Validate]) +// +// If any of the above checks fail, an error is returned. +func (c Config) Validate() error { + validation.ErrorTag = errorTag + + return validation.ValidateStruct(&c, + validation.Field(&c.Tmux, validation.By(rulefuncs.ExecutableExists)), + validation.Field(&c.Session, validation.Required), + ) +} + +// Validate validates the session configuration. +// +// It checks that: +// +// - session name only contains alphanumeric characters, underscores, dots, +// and dashes +// - session path exists +// - session environment variable names are valid +// - windows are valid (see [WindowConfig.Validate]) +// +// If any of the above checks fail, an error is returned. +func (s SessionConfig) Validate() error { + validation.ErrorTag = errorTag + + return validation.ValidateStruct(&s, + validation.Field(&s.Name, nameMatchRule), + validation.Field(&s.Path, validation.By(rulefuncs.DirExists)), + validation.Field(&s.Env, validation.By(envVarMapRule)), + validation.Field(&s.Windows), + ) +} + +// Validate validates the window configuration. +// +// It checks that: +// +// - window name only contains alphanumeric characters, underscores, dots, +// and dashes +// - window path exists +// - window environment variable names are valid +// - panes are valid (see [PaneConfig.Validate]) +// +// If any of the above checks fail, an error is returned. +func (w WindowConfig) Validate() error { + validation.ErrorTag = errorTag + + return validation.ValidateStruct(&w, + validation.Field(&w.Name, nameMatchRule), + validation.Field(&w.Path, validation.By(rulefuncs.DirExists)), + validation.Field(&w.Env, validation.By(envVarMapRule)), + validation.Field(&w.Command, validation.Length(1, 0)), + validation.Field(&w.Commands, + validation.Each(validation.Length(1, 0)), + ), + validation.Field(&w.Panes), + ) +} + +// Validate validates the pane configuration. +// +// It checks that: +// +// - pane path exists +// - pane environment variable names are valid +// - panes are valid +// +// If any of the above checks fail, an error is returned. +func (p PaneConfig) Validate() error { + validation.ErrorTag = errorTag + + return validation.ValidateStruct(&p, + validation.Field(&p.Path, validation.By(rulefuncs.DirExists)), + validation.Field(&p.Env, validation.By(envVarMapRule)), + validation.Field(&p.Command, validation.Length(1, 0)), + validation.Field(&p.Commands, + validation.Each(validation.Length(1, 0)), + ), + validation.Field(&p.Panes), + ) +} + +// envVarMapRule validates that all keys in a map are valid environment +// variable names (i.e. uppercase letters, numbers and underscores). +func envVarMapRule(val any) error { + if val == nil { + return nil + } + + m, ok := val.(map[string]string) + if !ok { + return validation.ErrNotMap + } + + for k := range m { + if !envVarRE.MatchString(k) { + return fmt.Errorf("%q is not a valid environment variable name", k) + } + } + + return nil +} diff --git a/config/validation_test.go b/config/validation_test.go new file mode 100644 index 0000000..96ff829 --- /dev/null +++ b/config/validation_test.go @@ -0,0 +1,93 @@ +package config_test + +import ( + "testing" + + "gopkg.in/yaml.v3" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/testutils" + + "github.com/stretchr/testify/require" +) + +func TestConfig_Validate(t *testing.T) { + tt := []struct { + name string + file string + assertErr testutils.ErrorAssertion + }{ + { + "non-existent tmux executable", + "invalid-tmux-not-exist.yaml", + testutils.RequireErrorContains("executable file was not found"), + }, + { + "session with invalid name", + "invalid-session-bad-name.yaml", + testutils.RequireErrorContains("must only contain alphanumeric characters, underscores, dots, and dashes"), + }, + { + "session with non-existent path", + "invalid-session-path-not-exist.yaml", + testutils.RequireErrorContains("directory does not exist"), + }, + { + "session with invalid env", + "invalid-session-bad-env.yaml", + testutils.RequireErrorContains("is not a valid environment variable name"), + }, + { + "window with invalid name", + "invalid-window-bad-name.yaml", + testutils.RequireErrorContains("must only contain alphanumeric characters, underscores, dots, and dashes"), + }, + { + "window with non-existent path", + "invalid-window-path-not-exist.yaml", + testutils.RequireErrorContains("directory does not exist"), + }, + { + "window with invalid env", + "invalid-window-bad-env.yaml", + testutils.RequireErrorContains("is not a valid environment variable name"), + }, + { + "pane with non-existent path", + "invalid-pane-path-not-exist.yaml", + testutils.RequireErrorContains("directory does not exist"), + }, + { + "pane with invalid env", + "invalid-pane-bad-env.yaml", + testutils.RequireErrorContains("is not a valid environment variable name"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + cfg := loadConfig(t, tc.file) + err := cfg.Validate() + + if tc.assertErr != nil { + tc.assertErr(t, err) + return + } + + require.NoError(t, err, "expected valid configuration") + }) + } +} + +func loadConfig(t *testing.T, file string) *config.Config { + t.Helper() + + data := testutils.ReadFile(t, "testdata", file) + + var cfg config.Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("error decoding content of %s: %v", file, err) + } + + return &cfg +} diff --git a/docs/.tmpl.reference.yaml b/docs/.tmpl.reference.yaml new file mode 100644 index 0000000..0916fa9 --- /dev/null +++ b/docs/.tmpl.reference.yaml @@ -0,0 +1,226 @@ +# An annotated reference configuration showing all possible options. +--- +## tmux executable. +# +# The tmux executable to use. Must be an absolute path, or available in $PATH. +# +# Default: "tmux" +tmux: /usr/bin/other_tmux + +## tmux command line options. +# +# Additional tmux command line options to add to all tmux command invocations. +# +# Default: none. +tmux_options: ["-L", "my_socket"] + +## Session configuration. +# +# Describes how the tmux session should be created. +session: + + ## Session name. + # + # Must only contain alphanumeric characters, underscores, and dashes. + # + # Default: current working directory base name. + name: "my_session" + + ## Session path. + # + # The directory path used as the working directory for the session. + # + # The path is passed down to windows and panes but can be overridden at any + # level. If the path begins with '~', it will be automatically expanded to + # the current user's home directory. + # + # Default: current working directory. + path: "~/projects/my_project" + + ## Session environment variables. + # + # Environment variables to automatically set up for the session. + # + # Environment variables are passed down to windows and panes, but can be + # overridden at any level. + # + # Default: none. + env: + APP_ENV: development + DEBUG: true + HTTP_PORT: 8080 + + ## On-window shell command. + # + # A shell command to run in every window after creation. + # + # This is intended for any kind of project setup that should be run before + # any other commands. The command is run using the `send-keys` tmux command. + # + # Default: none. + on_window: echo 'on_window' + + ## On-pane shell command. + # + # A shell command to run in every pane after creation. + # + # This is intended for any kind of project setup that should be run before + # any other commands. The command is run using the `send-keys` tmux command. + # + # Default: none. + on_pane: echo 'on_pane' + + ## On-window/pane shell command. + # + # A shell command to run in every window and pane after creation. + # + # This is intended for any kind of project setup that should be run before + # any other commands. The command is run using the `send-keys` tmux command. + # + # If on_window or on_pane commands are also specified, this command will run + # first. + # + # Default: none. + on_any: echo 'on_any' + + ## Window configurations. + # + # A list of configurations for tmux windows to create in the session. + # + # The first configuration will be used for the default window created when + # the session is created. + # + # Default: A single window using tmux defaults. + windows: + ## Window name. + # + # Must only contain alphanumeric characters, underscores, and dashes. + # + # Default: tmux default window name. + - name: my_window + + ## Window path. + # + # The directory path used as the working directory for the window. + # + # The path is passed down to panes but can be overridden. If the path + # begins with '~', it will be automatically expanded to the current + # user's home directory. + # + # Default: same as session. + path: "~/projects/my_project/subdir" + + ## Window shell command. + # + # A shell command to run in the window after creation. Useful for + # starting your editor or a script you want to have running right away. + # + # Default: none. + command: echo 'my_window' + + ## Window shell commands. + # + # A list of shell commands to run in the window in the order they are + # listed. + # + # Default: none. + commands: + - echo 'hello' + - echo 'from' + - echo 'my_window' + + ## Window environment variables. + # + # Additional environment variables to automatically set up for the window. + # + # Environment variables are passed down to panes, but can be overridden. + # + # Default: same as session. + env: + APP_ENV: testing + WARP_CORE: true + + ## Active window. + # + # Setting active to true will make it the active, selected window. + # + # If no windows are explicitly set as active, the first window will be + # selected + # + # Default: false + active: true + + ## Pane configurations. + # + # A list of configurations for panes to create in the window. + # + # Default: none. + panes: + ## Pane path. + # + # The directory path used as the working directory for the pane. + # + # Default: same as window. + - path: "~/projects/my_project/other/subdir" + + ## Pane environment variables. + # + # Additional environment variables to automatically set up for the + # pane. + # + # Default: same as window. + env: + WARP_CORE: false + + ## Active pane. + # + # Setting active to true will make it the active, selected pane. + # + # If no panes are explicitly set as active, the first pane will be + # selected + # + # Default: false + active: true + + ## Pane shell command. + # + # A shell command to run in the pane after creation. Useful for + # starting your editor or a script you want to have running right + # away. + # + # Default: none. + command: echo 'my_pane' + + ## Pane shell commands. + # + # A list of shell commands to run in the pane in the order they are + # listed. + # + # Default: none. + commands: + - echo 'hello' + - echo 'from' + - echo 'my_pane' + + ## Sub-pane configurations. + # + # A list of configurations for panes to create inside the pane. + # + # Nesting of panes can be as deep as you want, but you should probably + # stick to a sensible nesting level to keep it maintainable. + # + # Default: none. + panes: + - path: "~/projects/my_project/other/subdir" + env: + WARP_CORE: true + active: true + command: echo 'my_sub_pane' + commands: + - echo 'hello' + - echo 'from' + - echo 'sub_pane' + +## These lines configure editors to be more helpful (optional) +# yaml-language-server: $schema=https://raw.githubusercontent.com/michenriksen/tmpl/main/config.schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102.woff new file mode 100644 index 0000000..e7f8977 Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102.woff differ diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102a.woff2 new file mode 100644 index 0000000..19a58ea Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Bold-102a.woff2 differ diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102.woff new file mode 100644 index 0000000..d6421ac Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102.woff differ diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2 new file mode 100644 index 0000000..43f253e Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2 differ diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102.woff new file mode 100644 index 0000000..12d2d8c Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102.woff differ diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102a.woff2 new file mode 100644 index 0000000..d35d3a7 Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Italic-102a.woff2 differ diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102.woff b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102.woff new file mode 100644 index 0000000..bbe09c5 Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102.woff differ diff --git a/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102a.woff2 b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102a.woff2 new file mode 100644 index 0000000..99b3c6f Binary files /dev/null and b/docs/assets/fonts/Atkinson-Hyperlegible-Regular-102a.woff2 differ diff --git a/docs/assets/images/banner-dark.png b/docs/assets/images/banner-dark.png new file mode 100644 index 0000000..9679ccb Binary files /dev/null and b/docs/assets/images/banner-dark.png differ diff --git a/docs/assets/images/banner-light.png b/docs/assets/images/banner-light.png new file mode 100644 index 0000000..03b8454 Binary files /dev/null and b/docs/assets/images/banner-light.png differ diff --git a/docs/assets/images/launcher-icons.png b/docs/assets/images/launcher-icons.png new file mode 100644 index 0000000..e00852d Binary files /dev/null and b/docs/assets/images/launcher-icons.png differ diff --git a/docs/assets/images/social-preview-github.png b/docs/assets/images/social-preview-github.png new file mode 100644 index 0000000..bd566c1 Binary files /dev/null and b/docs/assets/images/social-preview-github.png differ diff --git a/docs/assets/images/social-preview.png b/docs/assets/images/social-preview.png new file mode 100644 index 0000000..c5a8330 Binary files /dev/null and b/docs/assets/images/social-preview.png differ diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..447838b --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/stylesheets/extra.css b/docs/assets/stylesheets/extra.css new file mode 100644 index 0000000..d886d7a --- /dev/null +++ b/docs/assets/stylesheets/extra.css @@ -0,0 +1,68 @@ +/* Atkinson Hyperlegible font: https://brailleinstitute.org/freefont. */ +@font-face { + font-family: "Atkinson Hyperlegible"; + src: + url("../fonts/Atkinson-Hyperlegible-Regular-102a.woff2") format("woff2"), + url("../fonts/Atkinson-Hyperlegible-Regular-102.woff") format("woff"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Atkinson Hyperlegible"; + src: + url("../fonts/Atkinson-Hyperlegible-Bold-102a.woff2") format("woff2"), + url("../fonts/Atkinson-Hyperlegible-Bold-102.woff") format("woff"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Atkinson Hyperlegible"; + src: + url("../fonts/Atkinson-Hyperlegible-Italic-102a.woff2") format("woff2"), + url("../fonts/Atkinson-Hyperlegible-Italic-102.woff") format("woff"); + font-weight: normal; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "Atkinson Hyperlegible"; + src: + url("../fonts/Atkinson-Hyperlegible-BoldItalic-102a.woff2") format("woff2"), + url("../fonts/Atkinson-Hyperlegible-BoldItalic-102.woff") format("woff"); + font-weight: bold; + font-style: italic; + font-display: swap; +} + +:root { + --md-text-font: "Atkinson Hyperlegible", sans-serif; + --md-code-font: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; +} + +.md-typeset video { + border-radius: 5px; +} + +.next-cta { + margin-top: 3rem; + text-align: right; +} + +.footer-banner { + width: 200px; + display: block; + margin: auto .6rem; + padding: .7rem 0 .4rem 0; +} + +.footer-banner svg { + width: 100%; + height: auto; + fill: var(--md-footer-fg-color--light); + stroke: 0; +} diff --git a/docs/assets/videos/demo.mp4 b/docs/assets/videos/demo.mp4 new file mode 100644 index 0000000..00e545a Binary files /dev/null and b/docs/assets/videos/demo.mp4 differ diff --git a/docs/assets/videos/demo.webm b/docs/assets/videos/demo.webm new file mode 100644 index 0000000..47d9b1f Binary files /dev/null and b/docs/assets/videos/demo.webm differ diff --git a/docs/assets/videos/launcher.mp4 b/docs/assets/videos/launcher.mp4 new file mode 100644 index 0000000..f84caf5 Binary files /dev/null and b/docs/assets/videos/launcher.mp4 differ diff --git a/docs/assets/videos/launcher.webm b/docs/assets/videos/launcher.webm new file mode 100644 index 0000000..a65d6ef Binary files /dev/null and b/docs/assets/videos/launcher.webm differ diff --git a/docs/attribution.md b/docs/attribution.md new file mode 100644 index 0000000..93448b6 --- /dev/null +++ b/docs/attribution.md @@ -0,0 +1,23 @@ +# Attribution + +Tmpl uses the following graphics in its logo and documentation: + +## *Layouts* by [DTDesign] from Noun Project + +- **Icon #1521277** + - Source: + - License: [CC BY 3.0 Deed] +- **Icon #1521304** + - Source: + - License: [CC BY 3.0 Deed] +- **Icon #1521306** + - Source: + - License: [CC BY 3.0 Deed] + - Modifications: Flipped horizontally +- **Icon #1521308** + - Source: + - License: [CC BY 3.0 Deed] + - Modifications: Rotated 90° counterclockwise + +[DTDesign]: https://thenounproject.com/dowt.design/ +[CC BY 3.0 Deed]: https://creativecommons.org/licenses/by/3.0/ diff --git a/docs/cli-usage.txt b/docs/cli-usage.txt new file mode 100644 index 0000000..b3ee31f --- /dev/null +++ b/docs/cli-usage.txt @@ -0,0 +1,28 @@ +Usage: tmpl [command] [options] [args] + +Simple tmux session management. + +Available commands: + + apply (default) apply configuration and attach session + check validate configuration file + init generate a new configuration file + +Global options: + + -d, --debug enable debug logging + -h, --help show this message and exit + -j, --json enable JSON logging + -q, --quiet enable quiet logging + -v, --version show the version and exit + +Examples: + + # apply nearest configuration file and attach/switch client to session: + $ tmpl + + # or explicitly: + $ tmpl -c /path/to/config.yaml + + # generate a new configuration file in the current working directory: + $ tmpl init diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a23a34e --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,169 @@ +--- +icon: material/cog +--- + +# Configuring your session + +After you've [installed tmpl](getting-started.md), you can create your session configuration using the `tmpl init` +command. This creates a basic `.tmpl.yaml` configuration file in the current directory. + +```console title="Creating a new configuration file" +user@host:~/project$ tmpl init +13:37:00 INF configuration file created path=/home/user/project/.tmpl.yaml + +``` + +The file sets you up with a session named after the current directory with a single window called main: + +```yaml title=".tmpl.yaml" +# Note: the real file has helpful comments which are omitted for brevity. +--- +session: + name: project + + windows: + - name: main +``` + +This may be all you need for a simple project, but to get the most out of tmpl you'll want to customize your session to +set up as much of your development environment as possible. The following sections describe how to use the options to +bootstrap a more interesting session. + +## Windows and panes + +Tmpl sessions can have as many windows and panes as you need for your workflow. This example configures the session +with two windows, one named `code` and another named `shell`. The `code` window is also configured to have a pane at the +bottom with a height of 20% of the available space. + +```yaml title=".tmpl.yaml" hl_lines="5 6 7 9" +session: + name: project + + windows: + - name: code + panes: + - size: 20% + + - name: shell +``` + +## Commands + +It's possible to configure commands to automatically run in each window and pane. This example builds on the previous by +configuring the `code` window to automatically start Neovim and its bottom pane to run a fictitious test watcher. The +`shell` window is configured to run git status. + +```yaml title=".tmpl.yaml" hl_lines="6 9 12" +session: + name: project + + windows: + - name: code + command: nvim . + panes: + - size: 20% + command: ./scripts/test-watcher + + - name: shell + command: git status +``` + +!!! tip "Tip: multiple commands" + Tmpl also supports running a sequence of commands if needed. Each command is sent to the window or pane using the + [tmux send-keys][send-keys] command. This means that it also works when connecting to remote systems: + + ```yaml title=".tmpl.yaml" + session: + windows: + - name: server-logs + commands: + - ssh user@remote.host + - cd /var/logs + - tail -f app.log + ``` + +## Environment variables + +Setting up a development environment often involves configuring environment variables. Tmpl allows you to set +environment variables at different levels, such as session, window, and pane. These variables cascade from session to +window to pane, making it easy to set a variable once and make changes at any level. + +This example builds on the previous by setting up a few environment variables at different levels. `APP_ENV` and `DEBUG` +are set at the session level and cascade to all windows and panes. The pane overrides `APP_ENV` to `test` to run tests +in the correct environment. Finally, the shell window overrides `HISTFILE` to maintain a project-specific command +history file. + +```yaml title=".tmpl.yaml" hl_lines="3 4 5 13 14 18 19" +session: + name: project + env: + APP_ENV: development + DEBUG: true + + windows: + - name: code + command: nvim . + panes: + - size: 20% + command: ./scripts/test-watcher + env: + APP_ENV: test + + - name: shell + command: git status + env: + HISTFILE: ~/project/command-history +``` + +## Hook commands + +Another frequent step in setting up a development environment involves executing project-specific initialization +commands. These commands can range from activating a virtual environment to switching between different language runtime +versions. Tmpl lets you configure commands that run in every window, pane, or both, when they're created. + +This example builds on the previous by setting up a hook command to run a fictitious script in every window and pane. + +```yaml title=".tmpl.yaml" hl_lines="7 8" +session: + name: project + env: + APP_ENV: development + DEBUG: true + + # Run the init-env script in every window and pane. + on_any: ./scripts/init-env + + windows: + - name: code + command: nvim . + panes: + - size: 20% + command: ./scripts/test-watcher + env: + APP_ENV: test + + - name: shell + command: git status + env: + HISTFILE: ~/project/command-history +``` + +!!! tip "Tip: on window and on pane hooks" + It's also possible to specify hooks that only run in window or pane contexts for more granular control: + + ```yaml title=".tmpl.yaml" + session: + on_window: ./scripts/init-window + on_pane: ./scripts/init-pane + ``` + +## More options + +This wraps up the basic configuration options for tmpl. You can find more details on the available options in the +[configuration reference](reference.md) section if you want to learn more. + +
+[Next: launching your session :material-arrow-right-circle:](usage.md){ .md-button .md-button--primary } +
+ +[send-keys]: https://man.archlinux.org/man/tmux.1#send-keys diff --git a/docs/env-variables.md b/docs/env-variables.md new file mode 100644 index 0000000..262609a --- /dev/null +++ b/docs/env-variables.md @@ -0,0 +1,3 @@ +--- +icon: material/application-variable-outline +--- diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..e2f0852 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,62 @@ +--- +icon: material/download +--- + +# Getting started with tmpl + +This guide walks you through the process of installing tmpl and creating your first session configuration file. Tmpl +is written in Go and distributed as a single, stand-alone binary with no external dependencies other than tmux, so +there is no language runtime or package managers to install. + +!!! info "Beta software" + Tmpl is currently in beta. It's usable, but there are still some rough edges. It's possible that breaking changes + are introduced while tmpl is below version 1.0.0. If you find any bugs, please [open an issue][new-issue], and if + you have any suggestions or feature requests, please start a new [idea discussion][new-idea]. + +## Installation + +### Binaries + +Go to the [releases page] and download the latest version for your operating system. Unpack the archive and move the +`tmpl` binary to a directory in your PATH, for example, `/usr/local/bin`: + +```console title="Installing the binary" +user@host:~$ tar xzf tmpl_*.tar.gz +user@host:~$ sudo mv tmpl /usr/local/bin/ +``` + + +??? failure "macOS *'cannot be opened'* dialog" + + macOS may prevent you from running the pre-compiled binary due to the built-in security feature called Gatekeeper. + This is because the binary isn't signed with an Apple Developer ID certificate. + + **If you get an error message saying that the binary is from an unidentified developer or something similar, you can + allow it to run by doing one of the following:** + + 1. :material-apple-finder: **Finder:** right-click the binary and select "Open" from the context menu and confirm + that you want to run the binary. Gatekeeper remembers your choice and allows you to run the binary in the + future. + 2. :material-console: **Terminal:** add the binary to the list of allowed applications by running the following + command: + + ```console + user@host:~$ spctl --add /path/to/tmpl + ``` + + +### From source + +If you have Go installed, you can also install tmpl from source: + +```console title="Installing from source" +user@host:~$ go install {{ package }}@latest +``` + +
+[Next: configuring your session :material-arrow-right-circle:](configuration.md){ .md-button .md-button--primary } +
+ +[new-issue]: <{{ repo_url }}/issues/new/choose> +[new-idea]: <{{ repo_url }}/discussions/categories/ideas> +[releases page]: <{{ repo_url }}/tmpl/releases> diff --git a/docs/hook-commands.md b/docs/hook-commands.md new file mode 100644 index 0000000..9f12f03 --- /dev/null +++ b/docs/hook-commands.md @@ -0,0 +1,3 @@ +--- +icon: material/application-cog-outline +--- diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..4d94ca6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,30 @@ +# tmpl - simple tmux session management + +Tmpl streamlines your tmux workflow by letting you describe your sessions in simple YAML files and have them +launched with all the tools your workflow requires set up and ready to go. If you often set up the same windows and +panes for tasks like coding, running unit tests, tailing logs, and using other tools, tmpl can automate that for you. + +## Highlights + +- **Simple and versatile configuration:** easily set up your tmux sessions using straightforward YAML files, allowing + you to create as many windows and panes as needed. Customize session and window names, working directories, and + start-up commands. + +- **Inheritable environment variables:** define environment variables for your entire session, a specific window, or a + particular pane. These variables cascade from session to window to pane, enabling you to set a variable once and + modify it at any level. + +- **Custom hook commands:** customize your setup with on-window and on-pane hook commands that run when new windows, + panes, or both are created. This feature is useful for initializing a virtual environment or switching between + language runtime versions. + +- **Non-intrusive workflow:** while there are many excellent session managers out there, some of them tend to be quite + opinionated about how you should work with them. Tmpl allows configurations to live anywhere in your filesystem and + focuses only on launching your session. It's intended as a secondary companion, and not a full workflow replacement. + +- **Stand-alone binary:** Tmpl is a single, stand-alone binary with no external dependencies, except for tmux. It's easy + to install and doesn't require you to have a specific language runtime or package manager on your system. + +
+[Get started with tmpl :material-arrow-right-circle:](getting-started.md){ .md-button .md-button--primary } +
diff --git a/docs/jsonschema.md b/docs/jsonschema.md new file mode 100644 index 0000000..7a37e1a --- /dev/null +++ b/docs/jsonschema.md @@ -0,0 +1,671 @@ +# Tmpl configuration + +**Title:** Tmpl configuration + +| | | +| ------------------------- | --------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") | + +**Description:** A configuration file describing how a tmux session should be created. + +| Property | Pattern | Type | Deprecated | Definition | Title/Description | +| ------------------------------- | ------- | --------------- | ---------- | ------------------------ | ---------------------------------------------------------------------- | +| - [tmux](#tmux) | No | string | No | - | tmux executable | +| - [tmux_options](#tmux_options) | No | array of string | No | - | tmux command line options | +| - [session](#session) | No | object | No | In #/$defs/SessionConfig | Session configuration describing how a tmux session should be created. | + +## 1. Property `Tmpl configuration > tmux` + +**Title:** tmux executable + +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | +| **Default** | `"tmux"` | + +**Description:** The tmux executable to use. Must be an absolute path, or available in $PATH. + +## 2. Property `Tmpl configuration > tmux_options` + +**Title:** tmux command line options + +| | | +| ------------ | ----------------- | +| **Type** | `array of string` | +| **Required** | No | + +**Description:** Additional tmux command line options to add to all tmux command invocations. + +**Examples:** + +```yaml +['-f', '/path/to/tmux.conf'] +``` + +```yaml +['-L', 'MySocket'] +``` + +| | Array restrictions | +| -------------------- | ------------------ | +| **Min items** | N/A | +| **Max items** | N/A | +| **Items unicity** | False | +| **Additional items** | False | +| **Tuple validation** | See below | + +| Each item of this array must be | Description | +| ----------------------------------------- | ------------------------------------------------------------------------------------- | +| [tmux_options items](#tmux_options_items) | A tmux command line flag and its value, if any. See 'man tmux' for available options. | + +### 2.1. Tmpl configuration > tmux_options > tmux_options items + +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** A tmux command line flag and its value, if any. See `man tmux` for available options. + +## 3. Property `Tmpl configuration > session` + +| | | +| ------------------------- | --------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") | +| **Defined in** | #/$defs/SessionConfig | + +**Description:** Session configuration describing how a tmux session should be created. + +| Property | Pattern | Type | Deprecated | Definition | Title/Description | +| --------------------------------- | ------- | ------ | ---------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| - [name](#session_name) | No | string | No | In #/$defs/name | A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes | +| - [path](#session_path) | No | string | No | In #/$defs/path | The directory path used as the working directory in a tmux session, window, or pane.The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. | +| - [env](#session_env) | No | object | No | In #/$defs/env | A list of environment variables to set in a tmux session, window, or pane.These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. | +| - [on_window](#session_on_window) | No | string | No | In #/$defs/command | On-Window shell command | +| - [on_pane](#session_on_pane) | No | string | No | In #/$defs/command | On-Pane shell command | +| - [on_any](#session_on_any) | No | string | No | In #/$defs/command | On-Window/Pane shell command | +| - [windows](#session_windows) | No | array | No | - | Window configurations | + +### 3.1. Property `Tmpl configuration > session > name` + +| | | +| -------------- | -------------------------------------------- | +| **Type** | `string` | +| **Required** | No | +| **Default** | `"The current working directory base name."` | +| **Defined in** | #/$defs/name | + +**Description:** A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes + +| Restrictions | | +| --------------------------------- | ----------------------------------------------------------------------- | +| **Must match regular expression** | `^[\w._-]+$` [Test](https://regex101.com/?regex=%5E%5B%5Cw._-%5D%2B%24) | + +### 3.2. Property `Tmpl configuration > session > path` + +| | | +| -------------- | ---------------------------------- | +| **Type** | `string` | +| **Required** | No | +| **Default** | `"The current working directory."` | +| **Defined in** | #/$defs/path | + +**Description:** The directory path used as the working directory in a tmux session, window, or pane. + +The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. + +**Examples:** + +```yaml +/path/to/project +``` + +```yaml +~/path/to/project +``` + +```yaml +relative/path/to/project +``` + +### 3.3. Property `Tmpl configuration > session > env` + +| | | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Should-conform\]](#session_env_additionalProperties "Each additional property must conform to the following schema") | +| **Defined in** | #/$defs/env | + +**Description:** A list of environment variables to set in a tmux session, window, or pane. + +These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. + +**Example:** + +```yaml +APP_ENV: development +DEBUG: true +HTTP_PORT: 8080 + +``` + +| Property | Pattern | Type | Deprecated | Definition | Title/Description | +| --------------------------------------- | ------- | ------------------------- | ---------- | ---------- | ----------------- | +| - [](#session_env_additionalProperties) | No | string, number or boolean | No | - | - | + +#### 3.3.1. Property `Tmpl configuration > session > env > additionalProperties` + +| | | +| ------------ | --------------------------- | +| **Type** | `string, number or boolean` | +| **Required** | No | + +### 3.4. Property `Tmpl configuration > session > on_window` + +**Title:** On-Window shell command + +| | | +| -------------- | --------------- | +| **Type** | `string` | +| **Required** | No | +| **Defined in** | #/$defs/command | + +**Description:** A shell command to run first in all created windows. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command. + +| Restrictions | | +| -------------- | --- | +| **Min length** | 1 | + +### 3.5. Property `Tmpl configuration > session > on_pane` + +**Title:** On-Pane shell command + +| | | +| -------------- | --------------- | +| **Type** | `string` | +| **Required** | No | +| **Defined in** | #/$defs/command | + +**Description:** A shell command to run first in all created panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command. + +| Restrictions | | +| -------------- | --- | +| **Min length** | 1 | + +### 3.6. Property `Tmpl configuration > session > on_any` + +**Title:** On-Window/Pane shell command + +| | | +| -------------- | --------------- | +| **Type** | `string` | +| **Required** | No | +| **Defined in** | #/$defs/command | + +**Description:** A shell command to run first in all created windows and panes. This is intended for any kind of project setup that should be run before any other commands. The command is run using the `send-keys` tmux command. + +| Restrictions | | +| -------------- | --- | +| **Min length** | 1 | + +### 3.7. Property `Tmpl configuration > session > windows` + +**Title:** Window configurations + +| | | +| ------------ | ------- | +| **Type** | `array` | +| **Required** | No | + +**Description:** A list of tmux window configurations to create in the session. The first configuration will be used for the default window. + +| | Array restrictions | +| -------------------- | ------------------ | +| **Min items** | N/A | +| **Max items** | N/A | +| **Items unicity** | False | +| **Additional items** | False | +| **Tuple validation** | See below | + +| Each item of this array must be | Description | +| -------------------------------------- | -------------------------------------------------------------------- | +| [WindowConfig](#session_windows_items) | Window configuration describing how a tmux window should be created. | + +#### 3.7.1. Tmpl configuration > session > windows > WindowConfig + +| | | +| ------------------------- | --------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Any type: allowed\]](# "Additional Properties of any type are allowed.") | +| **Defined in** | #/$defs/WindowConfig | + +**Description:** Window configuration describing how a tmux window should be created. + +| Property | Pattern | Type | Deprecated | Definition | Title/Description | +| --------------------------------------------------------------------- | ------- | ------- | ---------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| - [name](#session_windows_items_name) | No | string | No | In #/$defs/name | A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes | +| - [path](#session_windows_items_path) | No | string | No | In #/$defs/path | The directory path used as the working directory in a tmux session, window, or pane.The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. | +| - [command](#session_windows_items_command) | No | string | No | In #/$defs/command | A shell command to run within a tmux window or pane.The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. | +| - [commands](#session_windows_items_commands) | No | array | No | In #/$defs/commands | A list of shell commands to run within a tmux window or pane in the order they are listed.If a command is also specified in the 'command' property, it will be run first. | +| - [env](#session_windows_items_env) | No | object | No | In #/$defs/env | A list of environment variables to set in a tmux session, window, or pane.These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. | +| - [active](#session_windows_items_active) | No | boolean | No | In #/$defs/active | Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default. | +| - [panes](#session_windows_items_panes) | No | array | No | - | Pane configurations | +| - [additionalProperties](#session_windows_items_additionalProperties) | No | object | No | - | - | + +##### 3.7.1.1. Property `Tmpl configuration > session > windows > Window configuration > name` + +| | | +| -------------- | ---------------- | +| **Type** | `string` | +| **Required** | No | +| **Default** | `"tmux default"` | +| **Defined in** | #/$defs/name | + +**Description:** A name for the tmux session or window. Must only contain alphanumeric characters, underscores, dots, and dashes + +| Restrictions | | +| --------------------------------- | ----------------------------------------------------------------------- | +| **Must match regular expression** | `^[\w._-]+$` [Test](https://regex101.com/?regex=%5E%5B%5Cw._-%5D%2B%24) | + +##### 3.7.1.2. Property `Tmpl configuration > session > windows > Window configuration > path` + +| | | +| -------------- | --------------------- | +| **Type** | `string` | +| **Required** | No | +| **Default** | `"The session path."` | +| **Defined in** | #/$defs/path | + +**Description:** The directory path used as the working directory in a tmux session, window, or pane. + +The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. + +**Examples:** + +```yaml +/path/to/project +``` + +```yaml +~/path/to/project +``` + +```yaml +relative/path/to/project +``` + +##### 3.7.1.3. Property `Tmpl configuration > session > windows > Window configuration > command` + +| | | +| -------------- | --------------- | +| **Type** | `string` | +| **Required** | No | +| **Defined in** | #/$defs/command | + +**Description:** A shell command to run within a tmux window or pane. + +The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. + +| Restrictions | | +| -------------- | --- | +| **Min length** | 1 | + +##### 3.7.1.4. Property `Tmpl configuration > session > windows > Window configuration > commands` + +| | | +| -------------- | ---------------- | +| **Type** | `array` | +| **Required** | No | +| **Defined in** | #/$defs/commands | + +**Description:** A list of shell commands to run within a tmux window or pane in the order they are listed. + +If a command is also specified in the 'command' property, it will be run first. + +**Example:** + +```yaml +['ssh user@host', 'cd /var/logs', 'tail -f app.log'] +``` + +| | Array restrictions | +| -------------------- | ------------------ | +| **Min items** | N/A | +| **Max items** | N/A | +| **Items unicity** | False | +| **Additional items** | False | +| **Tuple validation** | See below | + +| Each item of this array must be | Description | +| ------------------------------------------------ | -------------------------------------------------------- | +| [command](#session_windows_items_commands_items) | A shell command to run within a tmux window or pane. ... | + +##### 3.7.1.4.1. Tmpl configuration > session > windows > Window configuration > commands > command + +| | | +| -------------- | --------------- | +| **Type** | `string` | +| **Required** | No | +| **Defined in** | #/$defs/command | + +**Description:** A shell command to run within a tmux window or pane. + +The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. + +| Restrictions | | +| -------------- | --- | +| **Min length** | 1 | + +##### 3.7.1.5. Property `Tmpl configuration > session > windows > Window configuration > env` + +| | | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Should-conform\]](#session_windows_items_env_additionalProperties "Each additional property must conform to the following schema") | +| **Default** | `"The session env."` | +| **Defined in** | #/$defs/env | + +**Description:** A list of environment variables to set in a tmux session, window, or pane. + +These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. + +**Example:** + +```yaml +APP_ENV: development +DEBUG: true +HTTP_PORT: 8080 + +``` + +| Property | Pattern | Type | Deprecated | Definition | Title/Description | +| ----------------------------------------------------- | ------- | ------------------------- | ---------- | ---------- | ----------------- | +| - [](#session_windows_items_env_additionalProperties) | No | string, number or boolean | No | - | - | + +##### 3.7.1.5.1. Property `Tmpl configuration > session > windows > Window configuration > env > additionalProperties` + +| | | +| ------------ | --------------------------- | +| **Type** | `string, number or boolean` | +| **Required** | No | + +##### 3.7.1.6. Property `Tmpl configuration > session > windows > Window configuration > active` + +| | | +| -------------- | -------------- | +| **Type** | `boolean` | +| **Required** | No | +| **Default** | `false` | +| **Defined in** | #/$defs/active | + +**Description:** Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default. + +##### 3.7.1.7. Property `Tmpl configuration > session > windows > Window configuration > panes` + +**Title:** Pane configurations + +| | | +| ------------ | ------- | +| **Type** | `array` | +| **Required** | No | + +**Description:** A list of tmux pane configurations to create in the window. + +| | Array restrictions | +| -------------------- | ------------------ | +| **Min items** | N/A | +| **Max items** | N/A | +| **Items unicity** | False | +| **Additional items** | False | +| **Tuple validation** | See below | + +| Each item of this array must be | Description | +| ------------------------------------------------ | ---------------------------------------------------------------- | +| [PaneConfig](#session_windows_items_panes_items) | Pane configuration describing how a tmux pane should be created. | + +##### 3.7.1.7.1. Tmpl configuration > session > windows > Window configuration > panes > PaneConfig + +| | | +| ------------------------- | --------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") | +| **Defined in** | #/$defs/PaneConfig | + +**Description:** Pane configuration describing how a tmux pane should be created. + +| Property | Pattern | Type | Deprecated | Definition | Title/Description | +| ------------------------------------------------------------- | ------- | ------- | ---------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| - [path](#session_windows_items_panes_items_path) | No | string | No | In #/$defs/path | The directory path used as the working directory in a tmux session, window, or pane.The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. | +| - [command](#session_windows_items_panes_items_command) | No | string | No | In #/$defs/command | A shell command to run within a tmux window or pane.The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. | +| - [commands](#session_windows_items_panes_items_commands) | No | array | No | In #/$defs/commands | A list of shell commands to run within a tmux window or pane in the order they are listed.If a command is also specified in the 'command' property, it will be run first. | +| - [env](#session_windows_items_panes_items_env) | No | object | No | In #/$defs/env | A list of environment variables to set in a tmux session, window, or pane.These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. | +| - [active](#session_windows_items_panes_items_active) | No | boolean | No | In #/$defs/active | Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default. | +| - [horizontal](#session_windows_items_panes_items_horizontal) | No | boolean | No | - | Horizontal split | +| - [size](#session_windows_items_panes_items_size) | No | string | No | - | Size | +| - [panes](#session_windows_items_panes_items_panes) | No | array | No | - | Pane configurations | + +##### 3.7.1.7.1.1. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > path` + +| | | +| -------------- | -------------------- | +| **Type** | `string` | +| **Required** | No | +| **Default** | `"The window path."` | +| **Defined in** | #/$defs/path | + +**Description:** The directory path used as the working directory in a tmux session, window, or pane. + +The paths are passed down from session to window to pane and can be customized at any level. If a path begins with '~', it will be automatically expanded to the current user's home directory. + +**Examples:** + +```yaml +/path/to/project +``` + +```yaml +~/path/to/project +``` + +```yaml +relative/path/to/project +``` + +##### 3.7.1.7.1.2. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > command` + +| | | +| -------------- | --------------- | +| **Type** | `string` | +| **Required** | No | +| **Defined in** | #/$defs/command | + +**Description:** A shell command to run within a tmux window or pane. + +The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. + +| Restrictions | | +| -------------- | --- | +| **Min length** | 1 | + +##### 3.7.1.7.1.3. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > commands` + +| | | +| -------------- | ---------------- | +| **Type** | `array` | +| **Required** | No | +| **Defined in** | #/$defs/commands | + +**Description:** A list of shell commands to run within a tmux window or pane in the order they are listed. + +If a command is also specified in the 'command' property, it will be run first. + +**Example:** + +```yaml +['ssh user@host', 'cd /var/logs', 'tail -f app.log'] +``` + +| | Array restrictions | +| -------------------- | ------------------ | +| **Min items** | N/A | +| **Max items** | N/A | +| **Items unicity** | False | +| **Additional items** | False | +| **Tuple validation** | See below | + +| Each item of this array must be | Description | +| ------------------------------------------------------------ | -------------------------------------------------------- | +| [command](#session_windows_items_panes_items_commands_items) | A shell command to run within a tmux window or pane. ... | + +##### 3.7.1.7.1.3.1. Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > commands > command + +| | | +| -------------- | --------------- | +| **Type** | `string` | +| **Required** | No | +| **Defined in** | #/$defs/command | + +**Description:** A shell command to run within a tmux window or pane. + +The 'send-keys' tmux command is used to simulate the key presses. This means it can be used even when connected to a remote system via SSH or a similar connection. + +| Restrictions | | +| -------------- | --- | +| **Min length** | 1 | + +##### 3.7.1.7.1.4. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > env` + +| | | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Should-conform\]](#session_windows_items_panes_items_env_additionalProperties "Each additional property must conform to the following schema") | +| **Default** | `"The window env."` | +| **Defined in** | #/$defs/env | + +**Description:** A list of environment variables to set in a tmux session, window, or pane. + +These variables are passed down from the session to the window and can be customized at any level. Please note that variable names should consist of uppercase alphanumeric characters and underscores. + +**Example:** + +```yaml +APP_ENV: development +DEBUG: true +HTTP_PORT: 8080 + +``` + +| Property | Pattern | Type | Deprecated | Definition | Title/Description | +| ----------------------------------------------------------------- | ------- | ------------------------- | ---------- | ---------- | ----------------- | +| - [](#session_windows_items_panes_items_env_additionalProperties) | No | string, number or boolean | No | - | - | + +##### 3.7.1.7.1.4.1. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > env > additionalProperties` + +| | | +| ------------ | --------------------------- | +| **Type** | `string, number or boolean` | +| **Required** | No | + +##### 3.7.1.7.1.5. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > active` + +| | | +| -------------- | -------------- | +| **Type** | `boolean` | +| **Required** | No | +| **Default** | `false` | +| **Defined in** | #/$defs/active | + +**Description:** Whether a tmux window or pane should be selected after session creation. The first window and pane will be selected by default. + +##### 3.7.1.7.1.6. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > horizontal` + +**Title:** Horizontal split + +| | | +| ------------ | --------- | +| **Type** | `boolean` | +| **Required** | No | +| **Default** | `false` | + +**Description:** Whether to split the window horizontally. If false, the window will be split vertically. + +##### 3.7.1.7.1.7. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > size` + +**Title:** Size + +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** The size of the pane in lines for horizontal panes, or columns for vertical panes. The size can also be specified as a percentage of the available space. + +**Examples:** + +```yaml +20% +``` + +```yaml +50 +``` + +```yaml +215 +``` + +##### 3.7.1.7.1.8. Property `Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > panes` + +**Title:** Pane configurations + +| | | +| ------------ | ------- | +| **Type** | `array` | +| **Required** | No | + +**Description:** A list of tmux pane configurations to create in the pane. + +| | Array restrictions | +| -------------------- | ------------------ | +| **Min items** | N/A | +| **Max items** | N/A | +| **Items unicity** | False | +| **Additional items** | False | +| **Tuple validation** | See below | + +| Each item of this array must be | Description | +| ------------------------------------------------------------ | ---------------------------------------------------------------- | +| [PaneConfig](#session_windows_items_panes_items_panes_items) | Pane configuration describing how a tmux pane should be created. | + +##### 3.7.1.7.1.8.1. Tmpl configuration > session > windows > Window configuration > panes > Pane configuration > panes > PaneConfig + +| | | +| ------------------------- | --------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Not allowed\]](# "Additional Properties not allowed.") | +| **Same definition as** | [Pane configuration](#session_windows_items_panes_items) | + +**Description:** Pane configuration describing how a tmux pane should be created. + +##### 3.7.1.8. Property `Tmpl configuration > session > windows > Window configuration > additionalProperties` + +| | | +| ------------------------- | --------------------------------------------------------------------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | [\[Any type: allowed\]](# "Additional Properties of any type are allowed.") | + +______________________________________________________________________ + +Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..0e8c6fc --- /dev/null +++ b/docs/license.md @@ -0,0 +1,3 @@ +# License + +--8<-- "LICENSE" diff --git a/docs/macos-gatekeeper.md b/docs/macos-gatekeeper.md new file mode 100644 index 0000000..364839f --- /dev/null +++ b/docs/macos-gatekeeper.md @@ -0,0 +1,15 @@ +# macOS Gatekeeper + +macOS may prevent you from running the pre-compiled binary due to the built-in security feature called Gatekeeper. +This is because the binary isn't signed with an Apple Developer ID certificate. + +**If you get an error message saying that the binary is from an unidentified developer or something similar, you can +allow it to run by doing one of the following:** + +1. :material-apple-finder: **Finder:** right-click the binary and select "Open" from the context menu and confirm that + you want to run the binary. Gatekeeper remembers your choice and allows you to run the binary in the future. +2. :material-console: **Terminal:** add the binary to the list of allowed applications by running the following command: + +```console +user@host:~$ spctl --add /path/to/tmpl +``` diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 0000000..3f1cc62 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block site_meta %} + {{ super() }} + + + + + + + + + + + +{% endblock %} + diff --git a/docs/overrides/partials/social.html b/docs/overrides/partials/social.html new file mode 100644 index 0000000..0c4057d --- /dev/null +++ b/docs/overrides/partials/social.html @@ -0,0 +1,5 @@ + diff --git a/docs/recipes/project-launcher.md b/docs/recipes/project-launcher.md new file mode 100644 index 0000000..83ee5b2 --- /dev/null +++ b/docs/recipes/project-launcher.md @@ -0,0 +1,197 @@ +--- +icon: material/rocket-launch +--- + +# Project launcher + +This is a recipe for a shell script that combines tmpl with a few other command-line tools to create a spiffy project +launcher that presents your projects in a selection menu with fuzzy search support. Pressing Enter launches a +tmpl session for the selected project: + +
+ +
Selecting and launching a project with the launcher.
+
+ +## Prerequisites + +This recipe assumes you have gone through the [getting started](../getting-started.md) guide and that you have a basic +understanding of shell scripting. It also assumes that your projects are located under a single directory and that +you are using a shell such as Bash or Zsh. + +## Dependencies + +The launcher makes use of a few other command-line tools, so you'll need to install those first. The tools are: + +- [fd] - Fast and user-friendly alternative to `find`. `fd` is used to find projects to launch. +- [fzf] - General-purpose command-line fuzzy finder. `fzf` is used for the selection menu. + + +=== ":material-apple: Homebrew" + + ```console title="" + user@host:~$ brew install fd fzf + ``` + +=== ":material-ubuntu: APT" + + ```console title="" + user@host:~$ sudo apt install fd-find fzf + ``` + +=== ":material-arch: Pacman" + + ```console title="" + user@host:~$ sudo pacman -S fd fzf + ``` + +=== ":material-fedora: DNF" + + ```console title="" + user@host:~$ sudo dnf install fd-find fzf + ``` + +=== ":material-gentoo: Portage" + + ```console title="" + user@host:~$ emerge --ask sys-apps/fd app-shells/fzf + ``` + + +If your package manager isn't listed, refer to the installation instructions for [fd][fd-install] and +[fzf][fzf-install]. + +## The script + +This is the project launcher script. The script is heavily commented to explain how it works and key configuration +options are highlighted to make it easier to modify the script to your needs. + +??? info "Installation instructions" + + 1. Copy the script and save it as a file named `tmpo` in a directory that is included in your `$PATH`. For example, + you could save it as `/usr/local/bin/tmpo` (may require `sudo` to write to that location). + 2. Modify `$projects_dir` to the directory where your projects are located. + 3. Make the script executable by running `chmod +x path/to/tmpo`. + 4. Verify that the script works by running `tmpo` in a terminal. + +``` { .bash .copy title="Project launcher" hl_lines="14-17 19-23 25-31 45-58 60-63" } +--8<-- "recipes/project-launcher.sh" +``` + +## Tips and tricks + +These are some optional tips and tricks to make the launcher even more useful. + +### Key binding shortcut + +Use the [bind-key tmux command][tmux-bind-key] to assign the launcher to a key for super quick access. Add the following +to your `tmux.conf` file to bring up the launcher with your prefix key (default Ctrl+b) followed +by f: + +``` { .bash .copy title="tmux.conf" } +bind-key f run-shell "/path/to/project-launcher" +``` + +### Default tmpl configuration + +The launcher script changes the working directory to the selected project root before launching tmpl. Because tmpl +traverses the directory tree upwards when looking for a configuration file, you can easily set up a default +configuration for all your projects by adding a `.tmpl.yaml` file in a shared parent directory. If some projects need +special configuration, you can override the default configuration by adding a `.tmpl.yaml` file in the project root. + +### fzf preview window + +A feature of fzf that's left out in the launcher script for simplicity, is the [preview window feature][fzf-preview] +which makes it possible to dynamically show the output of a command for the currently selected project. As an example, +modifying the fzf options to the following shows `git status` for the selected project: + +``` { .bash .copy title="Project launcher with preview window" hl_lines="11-13" } +selected_project="$( + get_project_data | + fzf \ + --delimiter="\t" \ + --nth=1 \ + --with-nth=2 \ + --scheme="path" \ + --no-info \ + --no-scrollbar \ + --ansi \ + --preview="git -c color.status=always -C {1} status" \ + --preview-window="right:40%:wrap" \ + --preview-label="GIT STATUS" | + cut -d $'\t' -f 1 +)" +``` + +Experiment with this and find the command that works best for you. + +### Project icons + +If you use a [Nerd Font] in your terminal, you can add helpful icons to the list of projects. The following example +modifies the `get_project_data` function to add a GitHub icon for projects with "github.com" in their path, and a GitLab +icon for projects with "gitlab.com" in their paths: + +``` { .bash .copy title="Project launcher with icons" hl_lines="11-20" } +function get_project_data() { + + ... # NOTE: lines removed for brevity + + while IFS= read -r path; do + pretty_path="${path#"$projects_dir"/}" + + name="$style_boldblue$(basename "$pretty_path")$style_reset" + pretty_path="$(dirname "$pretty_path")/$name" + + if [[ "$path" == *"github.com"* ]]; then + # Prepend white GitHub logo + pretty_path=" \e[38;5;15m\uf113$style_reset $pretty_path" + elif [[ "$path" == *"gitlab.com"* ]]; then + # Prepend orange GitLab logo + pretty_path=" \e[38;5;214m\uf296$style_reset $pretty_path" + else + # Prepend red Git logo for anything else + pretty_path=" \e[38;5;124m\uf1d3$style_reset $pretty_path" + fi + + project_data+="$path\t$pretty_path\n" + done <<< "$projects" + + ... # NOTE: lines removed for brevity + +} +``` + +
+ Project launcher with icons +
Project launcher with icons.
+
+ +Other ideas for icon usage: + +- :material-office-building: and :material-home: for work and personal projects +- :material-language-go: :material-language-javascript: :material-language-ruby: :material-language-rust: etc. for + project languages +- :material-star: or :material-heart: for important or favorite projects +- :material-account-hard-hat: for projects with uncommitted changes + +Have a look at the [Nerd Font cheat sheet][nf-cheat-sheet] for a complete list of available icons. + +!!! tip "Tip: emoji icons" + It's also possible to use emoji icons if you don't want to use Nerd Fonts, however, the selection of emoji icons is + quite limited compared to Nerd Fonts. + +[fd]: https://github.com/sharkdp/fd +[fzf]: https://github.com/junegunn/fzf +[fd-install]: https://github.com/sharkdp/fd#installation +[fzf-install]: https://github.com/junegunn/fzf#installation +[fzf-preview]: https://github.com/junegunn/fzf#preview-window +[tmux-bind-key]: https://man.archlinux.org/man/tmux.1#bind-key +[Nerd Font]: https://www.nerdfonts.com/ +[nf-cheat-sheet]: https://www.nerdfonts.com/cheat-sheet diff --git a/docs/recipes/project-launcher.sh b/docs/recipes/project-launcher.sh new file mode 100644 index 0000000..c6d3958 --- /dev/null +++ b/docs/recipes/project-launcher.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +set -euo pipefail + +style_boldblue="\033[1;34m" +style_reset="\033[0m" + +# if $NO_COLOR is set, set the style variables to empty strings. +if [ "${NO_COLOR:-}" != "" ]; then + style_boldblue="" + style_reset="" +fi + +## Absolute path to directory where projects are stored. +# +# Change this to point to the directory where you store your projects. +projects_dir="$HOME/projects" + +## Match pattern for identifying project root directories. +# +# This pattern finds directories that contain a .git directory. You can change +# this to match something else if you don't use git for your projects. +match_pattern='^\.git$' + +## Path patterns to exclude from the search. +# +# Add any directories that you want to exclude from the search to this array. +exclude_patterns=( + "node_modules" + "vendor" +) + +## Arguments to pass to fd. +# +# See `man fd` for more information. +fd_args=( + "-H" # Include hidden files and directories. This is needed to match .git + "-t" "d" # Only match directories. Change to "-t" "f" to match files instead. + "--prune" # Don't traverse matching directories. +) +for pattern in "${exclude_patterns[@]}"; do + fd_args+=("-E" "$pattern") +done + +## Use caching for project directories. +# +# Caches project directories to a file for faster startup time. Set to false if +# you don't want to use caching. +# +# You can clear the cache by setting $CLEAR_CACHE to any value when running +# the project launcher. +# +# You can also temporarily disable the cache by setting $NO_CACHE to any value +# when running the project launcher. +use_cache=true +if [ "${NO_CACHE:-}" != "" ]; then + use_cache=false +fi + +## Cache file location. +# +# Where the cache file is stored. Only used if $use_cache is true. +cache_file="${XDG_CACHE_HOME:-$HOME/Library/Caches}/tmpo/projects.cache" + +## Get project data. +# +# Function returns project data to be used by fzf for the project selection +# menu. +# +# Caching of data is also managed by this function. +function get_project_data() { + # Clear the cache if $CLEAR_CACHE is set. + if [ "${CLEAR_CACHE:-}" != "" ] && [ -f "$cache_file" ]; then + rm "$cache_file" + fi + + # Return the cached data if caching is enabled and the cache file exists. + if "$use_cache" && [[ -f "$cache_file" ]]; then + cat "$cache_file" + return + fi + + project_data="" + projects="$(fd "${fd_args[@]}" "$match_pattern" "$projects_dir" | xargs dirname)" + + while IFS= read -r path; do + # Remove the repeditive projects directory prefix from the path to make it + # more concise and readable. + pretty_path="${path#"$projects_dir"/}" + + # To make the project selection more user-friendly, the project name is + # extracted using the basename command and styled in bold and blue to make + # it stand out. + name="$style_boldblue$(basename "$pretty_path")$style_reset" + pretty_path="$(dirname "$pretty_path")/$name" + + # The project path and its pretty path are appended to the data, separated + # by a tab character ("\t"). This data will be used by fzf for the project + # selection menu. + project_data+="$path\t$pretty_path\n" + done <<< "$projects" + + # Save the data to the cache file if caching is enabled. + if "$use_cache"; then + mkdir -p "$(dirname "$cache_file")" + echo -e "$project_data" > "$cache_file" + fi + + echo -e "$project_data" +} + +# Present the selection menu using fzf and store the selected project path. +# +# Fzf is configured to split each line of project data by \t and use the first +# column for fuzzy matching and the second column for display. +selected_project="$( + get_project_data | + fzf \ + --delimiter="\t" \ + --nth=1 \ + --with-nth=2 \ + --scheme="path" \ + --no-info \ + --no-scrollbar \ + --ansi | + cut -d $'\t' -f 1 +)" + +if [ "$selected_project" = "" ]; then + # If no project was selected, exit the script. + exit 0 +fi + +# Change directory to selected project and run tmpl. +(cd "$selected_project" || exit 1; tmpl) + diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..0fd5a79 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,11 @@ +# Configuration reference + +Below is an annotated reference configuration showing all the available options +with example values. + +!!! tip + You can find more detailed documentation on each option in the [JSON schema reference](schema-reference.md). + +```yaml title=".tmpl.yaml" +--8<-- ".tmpl.reference.yaml" +``` diff --git a/docs/schema-reference.md b/docs/schema-reference.md new file mode 100644 index 0000000..97e3a28 --- /dev/null +++ b/docs/schema-reference.md @@ -0,0 +1,12 @@ +# JSON schema + +Tmpl's configuration is defined by a [JSON schema](https://json-schema.org/) which describes all the available +configuration options, their types, default values, validation rules, and more. + +The following is a detailed reference generated from [config.schema.json]. + +--- + +--8<-- "jsonschema.md" + +[config.schema.json]: {{ repo_url }}/blob/main/config.schema.json diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..3efb765 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,119 @@ +--- +icon: material/play-circle-outline +--- + +# Launching your session + +After you've [configured your session](configuration.md), you can spin it up with the `tmpl` command: + +```console title="Launching a session" +user@host:~/project$ tmpl +13:37:00 INF configuration file loaded path=/home/user/project/.tmpl.yaml +13:37:00 INF session created session=project +13:37:00 INF window created session=project window=project:code +13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:code +13:37:00 INF window send-keys cmd="nvim ." session=project window=project:code +13:37:00 INF pane created session=project window=project:code pane=project:code.1 pane_width=192 pane_height=11 +13:37:00 INF pane send-keys cmd=./scripts/init-env session=project window=project:code pane=project:code.1 pane_width=192 pane_height=11 +13:37:00 INF pane send-keys cmd=./scripts/test-watcher session=project window=project:code pane=project:code.1 pane_width=192 pane_height=11 +13:37:00 INF window created session=project window=project:shell +13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:shell +13:37:00 INF window send-keys cmd="git status" session=project window=project:shell +13:37:00 INF window selected session=project window=project:code +13:37:00 INF switching client to session windows=2 panes=1 session=project +``` + +Tmpl attaches to the new session quite quickly, so you likely won't see the output. This video shows how it looks when +running the command: + +
+ +
Tmpl creating the session with Neovim and test runner ready.
+
+ +## Shared and global configurations + +When tmpl searches for a configuration file, it scans the directory tree upward until it locates one or reaches the root +directory. This allows you to position configurations at different directory levels, serving as shared configurations if +needed. + +``` title="Example directory structure" hl_lines="2 6 13" +home/user/ +├── .tmpl.yaml (catch-all/default configuration) +└── projects/ + ├── work/ + │ ├── project_group/ + │ │ ├── .tmpl.yaml (used by projects in this group) + │ │ ├── project_a/ + │ │ ├── project_b/ + │ │ └── project_c/ + │ ├── project_d/ + │ └── project_e/ + └── private/ + ├── .tmpl.yaml (used by private projects) + ├── project_f/ + ├── project_g/ + └── project_h/ +``` + +## Testing and verifying configurations + +When creating a new configuration, it can be useful to ensure that it functions correctly without actually creating and +attaching a new session. Tmpl offers a dry-run mode for this purpose. + +```console title="Launching a session in dry-run mode" hl_lines="3" +user@host:~/project$ tmpl --dry-run +13:37:00 INF configuration file loaded path=/home/user/project/.tmpl.yaml +13:37:00 INF DRY-RUN MODE ENABLED: no tmux commands will be executed and output is simulated +13:37:00 INF session created session=project dry_run=true +13:37:00 INF window created session=project window=project:code dry_run=true +13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:code dry_run=true +13:37:00 INF window send-keys cmd="nvim ." session=project window=project:code dry_run=true +13:37:00 INF pane created session=project window=project:code pane=project:code.1 pane_width=40 pane_height=12 dry_run=true +13:37:00 INF pane send-keys cmd=./scripts/init-env session=project window=project:code pane=project:code.1 pane_width=40 pane_height=12 dry_run=true +13:37:00 INF pane send-keys cmd=./scripts/test-watcher session=project window=project:code pane=project:code.1 pane_width=40 pane_height=12 dry_run=true +13:37:00 INF window created session=project window=project:shell dry_run=true +13:37:00 INF window send-keys cmd=./scripts/init-env session=project window=project:shell dry_run=true +13:37:00 INF window send-keys cmd="git status" session=project window=project:shell dry_run=true +13:37:00 INF window selected session=project window=project:code dry_run=true +13:37:00 INF switching client to session windows=2 panes=1 session=project dry_run=true +``` + +!!! tip "Tip: debug mode" + To see even more information about what tmpl is doing, including exact tmux commands being run, use the `--debug` + flag. This also works in dry-run mode. + +If you just want to verify that your configuration is valid, you can use the `check` sub-command: + +```console title="Checking a configuration for errors" +user@host:~/project$ tmpl check +13:37:00 INF configuration file loaded path=/home/user/project/.tmpl.yaml +13:37:00 ERR configuration file is invalid errors=1 +13:37:00 WRN session.name must only contain alphanumeric characters, underscores, dots, and dashes field=session.name +13:37:00 WRN session.windows.0.panes.0.env "my-env" is not a valid environment variable name field=session.windows.0.panes.0.env +13:37:00 WRN session.windows.1.path directory does not exist field=session.windows.1.path +``` + +## Command usage help + +To see available commands, options, and usage examples for tmpl, you can use the `-h/--help` flag. This can also be used +on sub-commands. + +```console title="Getting usage information for tmpl" +user@host:~$ tmpl --help +--8<-- "cli-usage.txt" +``` + +## That's it + +This wraps up the getting started guide. Check out the recipe on [making a project launcher] for an example of how to +use tmpl in combination with other command-line tools to further streamline your workflow. + +[making a project launcher]: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e9eaa3 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/michenriksen/tmpl + +go 1.21.3 + +require ( + github.com/invopop/validation v0.3.0 + github.com/lmittmann/tint v1.0.3 + github.com/stretchr/testify v1.8.4 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5765bd --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/invopop/validation v0.3.0 h1:o260kbjXzoBO/ypXDSSrCLL7SxEFUXBsX09YTE9AxZw= +github.com/invopop/validation v0.3.0/go.mod h1:qIBG6APYLp2Wu3/96p3idYjP8ffTKVmQBfKiZbw0Hts= +github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ= +github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..59755d7 --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,179 @@ +// Package cli is the application entry point. +package cli + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/env" + "github.com/michenriksen/tmpl/tmux" +) + +const ( + cmdInit = "init" + cmdCheck = "check" +) + +// ErrInvalidConfig is returned when a configuration is invalid. +var ErrInvalidConfig = fmt.Errorf("invalid configuration") + +// App is the main command-line application. +// +// The application orchestrates the loading of options and configuration, and +// applying the configuration to a new tmux session. +// +// The application can be configured with options to facilitate testing, such as +// providing a writer for output instead of os.Stdout, or providing a pre-loaded +// configuration instead of loading it from a configuration file. +type App struct { + opts *options + cfg *config.Config + tmux tmux.Runner + sess *tmux.Session + logger *slog.Logger + attrReplacer func([]string, slog.Attr) slog.Attr + out io.Writer +} + +// NewApp creates a new command-line application. +func NewApp(opts ...AppOption) (*App, error) { + app := &App{out: os.Stdout} + + for _, opt := range opts { + if err := opt(app); err != nil { + return nil, fmt.Errorf("applying application option: %w", err) + } + } + + return app, nil +} + +// Run runs the command-line application. +// +// Different logic is performed dependening on the sub-command provided as the +// first command-line argument. +func (a *App) Run(ctx context.Context, args ...string) error { + var ( + cmd string + err error + ) + + if len(args) > 0 { + cmd = args[0] + } + + switch cmd { + case cmdCheck: + if a.opts == nil { + if a.opts, err = parseCheckOptions(args[1:], a.out); err != nil { + return a.handleErr(err) + } + } + + return a.handleErr(a.runCheck(ctx)) + case cmdInit: + if a.opts == nil { + if a.opts, err = parseInitOptions(args[1:], a.out); err != nil { + return a.handleErr(err) + } + } + + return a.handleErr(a.runInit(ctx)) + default: + if a.opts == nil { + if a.opts, err = parseApplyOptions(args, a.out); err != nil { + return a.handleErr(err) + } + } + + return a.handleErr(a.runApply(ctx)) + } +} + +func (a *App) loadConfig() (err error) { + if a.cfg != nil { + return nil + } + + wd, err := env.Getwd() + if err != nil { + return fmt.Errorf("getting current working directory: %w", err) + } + + if a.opts.ConfigPath == "" { + a.opts.ConfigPath, err = config.FindConfigFile(wd) + if err != nil { + return fmt.Errorf("finding configuration file: %w", err) + } + } + + if a.cfg, err = config.FromFile(a.opts.ConfigPath); err != nil { + return err //nolint:wrapcheck // Wrapping is done by caller. + } + + a.logger.Info("configuration file loaded", "path", a.opts.ConfigPath) + + return nil +} + +// AppOptions configures an [App]. +type AppOption func(*App) error + +// WithOptions configures the [App] to use provided options instead of +// parsing command-line flags. +// +// This option is intended for testing purposes only. +func WithOptions(opts *options) AppOption { + return func(a *App) error { + a.opts = opts + return nil + } +} + +// WithConfig configures the [App] to use provided configuration instead of +// loading it from a configuration file. +// +// This option is intended for testing purposes only. +func WithConfig(cfg *config.Config) AppOption { + return func(a *App) error { + a.cfg = cfg + return nil + } +} + +// WithOutputWriter configures the [App] to use provided writer for output +// instead of os.Stdout. +// +// This option is intended for testing purposes only. +func WithOutputWriter(w io.Writer) AppOption { + return func(a *App) error { + a.out = w + return nil + } +} + +// WithTmux configures the [App] to use provided tmux runner instead of the +// constructing a new one from configuration. +// +// This option is intended for testing purposes only. +func WithTmux(tm tmux.Runner) AppOption { + return func(a *App) error { + a.tmux = tm + return nil + } +} + +// WithSlogAttrReplacer configures the [App] to use provided function for +// replacing slog attributes. +// +// This option is intended for testing purposes only. +func WithSlogAttrReplacer(f func([]string, slog.Attr) slog.Attr) AppOption { + return func(a *App) error { + a.attrReplacer = f + return nil + } +} diff --git a/internal/cli/build.go b/internal/cli/build.go new file mode 100644 index 0000000..5be35d9 --- /dev/null +++ b/internal/cli/build.go @@ -0,0 +1,49 @@ +package cli + +import ( + "runtime" + "time" +) + +// AppName is the name of the CLI application. +const AppName = "tmpl" + +// Build information set by the compiler. +var ( + buildVersion = "0.0.0-dev" + buildCommit = "HEAD" + buildTime = "" + buildGoVersion = runtime.Version() +) + +// Version of tmpl. +// +// Returns `0.0.0-dev` if no version is set. +func Version() string { + return buildVersion +} + +// BuildCommit returns the git commit hash tmpl was built from. +// +// Returns `HEAD` if no build commit is set. +func BuildCommit() string { + return buildCommit +} + +// BuildTime returns the UTC time tmpl was built. +// +// Returns current time in UTC if not set. +func BuildTime() string { + if buildTime == "" { + return time.Now().UTC().Format(time.RFC3339) + } + + return buildTime +} + +// BuildGoVersion returns the go version tmpl was built with. +// +// Returns version from [runtime.Version] if not set. +func BuildGoVersion() string { + return buildGoVersion +} diff --git a/internal/cli/cmd_apply.go b/internal/cli/cmd_apply.go new file mode 100644 index 0000000..cab707b --- /dev/null +++ b/internal/cli/cmd_apply.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/tmux" +) + +// runApply loads the configuration, applies it to a new tmux session and +// attaches it. +func (a *App) runApply(ctx context.Context) error { + a.initLogger() + + if err := a.loadConfig(); err != nil { + return fmt.Errorf("loading configuration: %w", err) + } + + runner, err := a.newTmux() + if err != nil { + return fmt.Errorf("creating tmux runner: %w", err) + } + + a.sess, err = config.Apply(ctx, a.cfg, runner) + if err != nil { + return fmt.Errorf("applying configuration: %w", err) + } + + if err := a.sess.Attach(ctx); err != nil { + return fmt.Errorf("attaching session: %w", err) + } + + return nil +} + +func (a *App) newTmux() (tmux.Runner, error) { + if a.tmux != nil { + return a.tmux, nil + } + + cmdOpts := []tmux.RunnerOption{tmux.WithLogger(a.logger)} + + if a.cfg.Tmux != "" { + cmdOpts = append(cmdOpts, tmux.WithTmux(a.cfg.Tmux)) + } + + if len(a.cfg.TmuxOptions) > 0 { + cmdOpts = append(cmdOpts, tmux.WithTmuxOptions(a.cfg.TmuxOptions...)) + } + + if a.opts.DryRun { + a.logger.Info("DRY-RUN MODE ENABLED: no tmux commands will be executed and output is simulated") + + cmdOpts = append(cmdOpts, tmux.WithDryRunMode(true)) + } + + cmd, err := tmux.NewRunner(cmdOpts...) + if err != nil { + return nil, err //nolint:wrapcheck // Wrapping is done by caller. + } + + return cmd, nil +} diff --git a/internal/cli/cmd_apply_test.go b/internal/cli/cmd_apply_test.go new file mode 100644 index 0000000..c0f22e6 --- /dev/null +++ b/internal/cli/cmd_apply_test.go @@ -0,0 +1,310 @@ +package cli_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/michenriksen/tmpl/internal/cli" + "github.com/michenriksen/tmpl/internal/mock" + "github.com/michenriksen/tmpl/internal/testutils" + "github.com/michenriksen/tmpl/tmux" +) + +func TestApp_Run_Apply(t *testing.T) { + stubHome := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(stubHome, "project", "scripts"), 0o744)) + + t.Setenv("NO_COLOR", "1") + + // Stub HOME and current working directory for consistent test results. + t.Setenv("HOME", stubHome) + t.Setenv("TMPL_PWD", stubHome) + + // Stub the following environment variables used to determine if the app is + // running in a tmux session for consistent test results. + t.Setenv("TMUX", "") + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "xterm-256color") + + dataDir, err := filepath.Abs("testdata") + require.NoError(t, err) + + // Always include the --debug flag in tests to ensure that the output is + // included in the golden files. + alwaysArgs := []string{"--debug"} + + runner, err := tmux.NewRunner() + require.NoError(t, err) + + stubs := loadTmuxStubs(t) + + tt := []struct { + name string + args []string + setupMocks func(*testing.T, *mock.TmuxRunner) + assertErr testutils.ErrorAssertion + }{ + { + "create new session", + []string{"-c", filepath.Join(dataDir, "tmpl.yaml")}, + func(_ *testing.T, r *mock.TmuxRunner) { + // App gets the current sessions to check if the session already exists. + stub := stubs["ListSessions"] + listSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once() + + // App creates a new session, as it does not exist. + stub = stubs["NewSession"] + newSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(listSess) + + // App creates the first window named "code". + stub = stubs["NewWindowCode"] + newWinCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newSess) + + // App runs on_any hook command in the code window. + stub = stubs["SendKeysCodeOnAny"] + codeOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode) + + // App runs on_window hook command in the code window. + stub = stubs["SendKeysCodeOnWindow"] + codeOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codeOnAny) + + // App starts Neovim in the "code" window. + stub = stubs["SendKeysCode"] + sendKeysCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codeOnWindow) + + // App creates a horizontal pane in the "code" window. + stub = stubs["NewPaneCode"] + newPaneCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode) + + // App runs on_any hook command in the code pane. + stub = stubs["SendKeysCodePaneOnAny"] + codePaneOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newPaneCode) + + // App runs on_pane hook command in the code pane. + stub = stubs["SendKeysCodePaneOnPane"] + codePaneOnPane := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codePaneOnAny) + + // App starts automatic test run script in the code pane. + stub = stubs["SendKeysCodePane"] + r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(codePaneOnPane) + + // App creates the second window named "shell". + stub = stubs["NewWindowShell"] + newWinShell := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode) + + // App runs on_any hook command in the shell window. + stub = stubs["SendKeysShellOnAny"] + shellOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinShell) + + // App runs on_window hook command in the shell window. + stub = stubs["SendKeysShellOnWindow"] + shellOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(shellOnAny) + + // App runs `git status` in the shell window. + stub = stubs["SendKeysShell"] + r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(shellOnWindow) + + // App creates the third window named "server". + stub = stubs["NewWindowServer"] + newWinServer := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinShell) + + // App runs on_any hook command in the server window. + stub = stubs["SendKeysServerOnAny"] + serverOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinServer) + + // App runs on_window hook command in the server window. + stub = stubs["SendKeysServerOnWindow"] + serverOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(serverOnAny) + + // App starts development server script in the server window. + stub = stubs["SendKeysServer"] + r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(serverOnWindow) + + // App creates the fourth window named "prod_logs". + stub = stubs["NewWindowProdLogs"] + newWinProdLogs := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinServer) + + // App runs on_any hook command in the prod_logs window. + stub = stubs["SendKeysProdLogsOnAny"] + prodLogsOnAny := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinProdLogs) + + // App runs on_window hook command in the prod_logs window. + stub = stubs["SendKeysProdLogsOnWindow"] + prodLogsOnWindow := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsOnAny) + + // App connects to production host via SSH in the prod_logs window. + stub = stubs["SendKeysProdLogsSSH"] + prodLogsSSH := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsOnWindow) + + // App navigates to the logs directory in the prod_logs window. + stub = stubs["SendKeysProdLogsCdLogs"] + prodLogsCdLogs := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsSSH) + + // App tails the application log file in the prod_logs window. + stub = stubs["SendKeysProdLogsTail"] + r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(prodLogsCdLogs) + + // App selects the code window. + stub = stubs["SelectWindowCode"] + selectWinCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(sendKeysCode) + + // App determines the pane base index to select the initial pane. + stub = stubs["PaneBaseIndexOpt"] + paneBaseIndexOpt := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(selectWinCode) + + // App selects the initial code pane running Neovim. + stub = stubs["SelectPaneCode"] + selectPaneCode := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(paneBaseIndexOpt) + + // Finally, App attaches the new session. + stub = stubs["AttachSession"] + r.On("Execve", stub.Args).Return(nil).Once().NotBefore(selectPaneCode) + }, + nil, + }, + { + "session exists", + []string{"-c", filepath.Join(dataDir, "tmpl.yaml")}, + func(_ *testing.T, r *mock.TmuxRunner) { + // App gets the current sessions to check if the session already exists. + stub := stubs["ListSessionsExists"] + r.On("Run", stub.Args).Return(stub.Output(), nil).Once() + + // Since the session already exists, it attaches it. + stub = stubs["AttachSession"] + r.On("Execve", stub.Args).Return(nil).Once() + }, + nil, + }, + { + "new session fails", + []string{"-c", filepath.Join(dataDir, "tmpl.yaml")}, + func(_ *testing.T, r *mock.TmuxRunner) { + // App gets the current sessions to check if the session already exists. + stub := stubs["ListSessions"] + listSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once() + + // App creates a new session but it fails. + stub = stubs["NewSession"] + r.On("Run", stub.Args). + Return([]byte("failed to connect to server: Connection refused"), errors.New("exit status 1")).Once().NotBefore(listSess) + }, + testutils.RequireErrorContains("running new-session command: exit status 1"), + }, + { + "new window fails", + []string{"-c", filepath.Join(dataDir, "tmpl.yaml")}, + func(_ *testing.T, r *mock.TmuxRunner) { + // App gets the current sessions to check if the session already exists. + stub := stubs["ListSessions"] + listSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once() + + // App creates a new session, as it does not exist. + stub = stubs["NewSession"] + newSess := r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(listSess) + + // App creates the first window named "code" but it fails. + stub = stubs["NewWindowCode"] + newWinCode := r.On("Run", stub.Args). + Return([]byte("failed to connect to server: Connection refused"), errors.New("exit status 1")).Once().NotBefore(newSess) + + // App closes the failed session. + stub = stubs["CloseSession"] + r.On("Run", stub.Args).Return(stub.Output(), nil).Once().NotBefore(newWinCode) + }, + testutils.RequireErrorContains("running new-window command: exit status 1"), + }, + { + "broken config file", + []string{"-c", filepath.Join(dataDir, "tmpl-broken.yaml")}, + nil, + testutils.RequireErrorContains("invalid configuration"), + }, + { + "invalid config file", + []string{"-c", filepath.Join(dataDir, "tmpl-invalid.yaml")}, + nil, + testutils.RequireErrorContains("invalid configuration"), + }, + { + "show help", + []string{"-h"}, + nil, + testutils.RequireErrorIs(cli.ErrHelp), + }, + { + "show version", + []string{"-v"}, + nil, + testutils.RequireErrorIs(cli.ErrVersion), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + out := new(bytes.Buffer) + mockRunner := mock.NewTmuxRunner(t, runner) + + if tc.setupMocks != nil { + tc.setupMocks(t, mockRunner) + } + + app, err := cli.NewApp( + cli.WithOutputWriter(out), + cli.WithTmux(mockRunner), + cli.WithSlogAttrReplacer(testutils.NewSlogStabilizer(t)), + ) + require.NoError(t, err) + + args := append(alwaysArgs, tc.args...) + + err = app.Run(context.Background(), args...) + + if tc.assertErr != nil { + require.Error(t, err) + tc.assertErr(t, err) + } else { + require.NoError(t, err) + } + + if !mockRunner.AssertExpectations(t) { + t.FailNow() + } + + testutils.NewGolden(t).RequireMatch(testutils.Stabilize(t, out.Bytes())) + }) + } +} + +// loadTmuxStubs loads the expected tmux command arguments and stub output from +// the tmux-stubs.yaml file in the testdata directory. +func loadTmuxStubs(t *testing.T) map[string]tmuxStub { + t.Helper() + + data := testutils.ReadFile(t, "testdata", "tmux-stubs.yaml") + + stubs := make(map[string]tmuxStub) + + require.NoError(t, yaml.Unmarshal(data, stubs), "expected tmux-stubs.yaml to contain valid YAML") + + return stubs +} + +// tmuxStub contains expected tmux command arguments and stub output to use in +// tests. +type tmuxStub struct { + OutputString string `yaml:"output"` + Args []string `yaml:"args"` +} + +// Output returns the stub output as a byte slice. +func (s tmuxStub) Output() []byte { + return []byte(s.OutputString) +} diff --git a/internal/cli/cmd_check.go b/internal/cli/cmd_check.go new file mode 100644 index 0000000..6032e81 --- /dev/null +++ b/internal/cli/cmd_check.go @@ -0,0 +1,25 @@ +package cli + +import ( + "context" + "fmt" +) + +// runCheck loads the configuration and validates it, logging any validation +// errors and returning [ErrInvalidConfig] if any are found. +func (a *App) runCheck(_ context.Context) error { + a.initLogger() + + if err := a.loadConfig(); err != nil { + return fmt.Errorf("loading configuration: %w", err) + } + + err := a.cfg.Validate() + if err != nil { + return err + } + + a.logger.Info("configuration file is valid") + + return nil +} diff --git a/internal/cli/cmd_check_test.go b/internal/cli/cmd_check_test.go new file mode 100644 index 0000000..07a81ec --- /dev/null +++ b/internal/cli/cmd_check_test.go @@ -0,0 +1,95 @@ +package cli_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/cli" + "github.com/michenriksen/tmpl/internal/testutils" +) + +func TestApp_Run_Check(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + stubHome := t.TempDir() + t.Setenv("HOME", stubHome) + t.Setenv("TMPL_PWD", stubHome) + + require.NoError(t, os.MkdirAll(filepath.Join(stubHome, "project", "scripts"), 0o744)) + + testutils.WriteFile(t, + testutils.ReadFile(t, "testdata", "tmpl.yaml"), + stubHome, config.ConfigFileName(), + ) + + testutils.WriteFile(t, + testutils.ReadFile(t, "testdata", "tmpl.yaml"), + stubHome, "project", config.ConfigFileName(), + ) + + testutils.WriteFile(t, + testutils.ReadFile(t, "testdata", "tmpl-broken.yaml"), + stubHome, ".tmpl.broken.yaml", + ) + + testutils.WriteFile(t, + testutils.ReadFile(t, "testdata", "tmpl-invalid.yaml"), + stubHome, ".tmpl.invalid.yaml", + ) + + tt := []struct { + name string + args []string + assertErr testutils.ErrorAssertion + }{ + { + "check current config", + []string{"check"}, + nil, + }, + { + "check specific config", + []string{"check", "-c", filepath.Join(stubHome, "project", config.ConfigFileName())}, + nil, + }, + { + "unparsable config", + []string{"check", "-c", filepath.Join(stubHome, ".tmpl.broken.yaml")}, + testutils.RequireErrorIs(cli.ErrInvalidConfig), + }, + { + "invalid config", + []string{"check", "-c", filepath.Join(stubHome, ".tmpl.invalid.yaml")}, + testutils.RequireErrorIs(cli.ErrInvalidConfig), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + out := new(bytes.Buffer) + + app, err := cli.NewApp( + cli.WithOutputWriter(out), + cli.WithSlogAttrReplacer(testutils.NewSlogStabilizer(t)), + ) + require.NoError(t, err) + + err = app.Run(context.Background(), tc.args...) + + if tc.assertErr != nil { + require.Error(t, err) + tc.assertErr(t, err) + } else { + require.NoError(t, err) + } + + testutils.NewGolden(t).RequireMatch(out.Bytes()) + }) + } +} diff --git a/internal/cli/cmd_init.go b/internal/cli/cmd_init.go new file mode 100644 index 0000000..54db933 --- /dev/null +++ b/internal/cli/cmd_init.go @@ -0,0 +1,118 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" + "time" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/env" + "github.com/michenriksen/tmpl/internal/static" +) + +var cleanSessNameRE = regexp.MustCompile(`[^\w._-]+`) + +func (a *App) runInit(_ context.Context) error { + a.initLogger() + + dst := "" + if len(a.opts.args) != 0 { + dst = a.opts.args[0] + } + + if dst == "" { + wd, err := env.Getwd() + if err != nil { + return fmt.Errorf("getting current working directory: %w", err) + } + + dst = filepath.Join(wd, config.ConfigFileName()) + } + + info, err := os.Stat(dst) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("getting file info for destination path: %w", err) + } + } + + if info != nil { + if !info.IsDir() { + a.logger.Info("file already exists, skipping", + "path", info.Name(), "size", info.Size(), "modified", info.ModTime(), + ) + + return nil + } + + dst = filepath.Join(dst, config.ConfigFileName()) + } + + text := static.ConfigTemplate + if a.opts.Plain { + text = stripCfgComments(text) + } + + cfgTmpl, err := template.New(config.DefaultConfigFile).Parse(text) + if err != nil { + return fmt.Errorf("parsing embedded configuration template: %w", err) + } + + data := templateData{ + AppName: AppName, + Version: Version(), + Name: cleanSessionName(filepath.Base(filepath.Dir(dst))), + Time: time.Now(), + DocsURL: "https://github.com/michenriksen/tmpl", + } + + cfgFile, err := os.Create(dst) + if err != nil { + return fmt.Errorf("creating configuration file: %w", err) + } + defer cfgFile.Close() + + if err := cfgTmpl.Execute(cfgFile, data); err != nil { + return fmt.Errorf("writing configuration file: %w", err) + } + + a.logger.Info("configuration file created", "path", cfgFile.Name()) + + return nil +} + +func cleanSessionName(name string) string { + name = cleanSessNameRE.ReplaceAllString(strings.TrimSpace(name), "_") + return strings.Trim(name, "._-") +} + +func stripCfgComments(text string) string { + lines := strings.Split(text, "\n") + b := strings.Builder{} + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if len(trimmed) == 0 || strings.HasPrefix(trimmed, "#") { + continue + } + + b.WriteString(line + "\n") + } + + return strings.TrimSpace(b.String()) +} + +type templateData struct { + AppName string + Time time.Time + DocsURL string + Name string + Version string +} diff --git a/internal/cli/cmd_init_test.go b/internal/cli/cmd_init_test.go new file mode 100644 index 0000000..59c7cba --- /dev/null +++ b/internal/cli/cmd_init_test.go @@ -0,0 +1,164 @@ +package cli_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/cli" + "github.com/michenriksen/tmpl/internal/testutils" +) + +func TestApp_Run_Init(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + stubHome := t.TempDir() + stubwd := filepath.Join(stubHome, "test project (1)") + require.NoError(t, os.MkdirAll(stubwd, 0o744)) + + t.Setenv("HOME", stubHome) + t.Setenv("TMPL_PWD", stubwd) + + tt := []struct { + name string + args []string + wantCfgPath string + wantCfg *config.Config + }{ + { + "init current directory", + []string{"init"}, + filepath.Join(stubwd, config.ConfigFileName()), + &config.Config{ + Session: config.SessionConfig{ + Name: "test_project_1", + Windows: []config.WindowConfig{ + {Name: "main"}, + }, + }, + }, + }, + { + "init specific directory", + []string{"init", filepath.Join(stubHome, "test.project")}, + filepath.Join(stubHome, "test.project", config.ConfigFileName()), + &config.Config{ + Session: config.SessionConfig{ + Name: "test.project", + Windows: []config.WindowConfig{ + {Name: "main"}, + }, + }, + }, + }, + { + "init specific file", + []string{"init", filepath.Join(stubHome, "test-project", ".tmpl_config.yml")}, + filepath.Join(stubHome, "test-project", ".tmpl_config.yml"), + &config.Config{ + Session: config.SessionConfig{ + Name: "test-project", + Windows: []config.WindowConfig{ + {Name: "main"}, + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + out := new(bytes.Buffer) + + if tc.wantCfgPath != "" { + require.NoError(t, os.MkdirAll(filepath.Dir(tc.wantCfgPath), 0o744)) + } + + app, err := cli.NewApp( + cli.WithOutputWriter(out), + cli.WithSlogAttrReplacer(testutils.NewSlogStabilizer(t)), + ) + require.NoError(t, err) + + require.NoError(t, app.Run(ctx, tc.args...)) + + testutils.NewGolden(t).RequireMatch(out.Bytes()) + + cfg := unmarshalCfg(t, testutils.ReadFile(t, tc.wantCfgPath)) + + if tc.wantCfg != nil { + require.Equal(t, *tc.wantCfg, cfg) + } + }) + } +} + +func TestApp_Run_InitPlain(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + stubHome := filepath.Join(t.TempDir(), "Zer0_c00l") + require.NoError(t, os.MkdirAll(stubHome, 0o744)) + + t.Setenv("HOME", stubHome) + t.Setenv("TMPL_PWD", stubHome) + + app, err := cli.NewApp() + require.NoError(t, err) + + require.NoError(t, app.Run(context.Background(), "init", "-p")) + + wantCfgPath := filepath.Join(stubHome, config.ConfigFileName()) + want := config.Config{ + Session: config.SessionConfig{ + Name: "Zer0_c00l", + Windows: []config.WindowConfig{ + {Name: "main"}, + }, + }, + } + got := unmarshalCfg(t, testutils.ReadFile(t, wantCfgPath)) + + require.Equal(t, want, got) + require.NotContains(t, string(testutils.ReadFile(t, wantCfgPath)), "# ", + "expected configuration to contain no comments", + ) +} + +func TestApp_Run_InitFileExists(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + stubHome := t.TempDir() + + t.Setenv("HOME", stubHome) + t.Setenv("TMPL_PWD", stubHome) + + cfgPath := filepath.Join(stubHome, config.ConfigFileName()) + testutils.WriteFile(t, []byte("don't overwrite me"), cfgPath) + + app, err := cli.NewApp() + require.NoError(t, err) + + require.NoError(t, app.Run(context.Background(), "init")) + + require.Equal(t, "don't overwrite me", string(testutils.ReadFile(t, cfgPath)), + "expected configuration file to not be overwritten", + ) +} + +func unmarshalCfg(t *testing.T, data []byte) config.Config { + t.Helper() + + var cfg config.Config + + require.NoError(t, yaml.Unmarshal(data, &cfg), "expected configuration to be parsable YAML") + require.NoError(t, cfg.Validate(), "expected configuration to be valid") + + return cfg +} diff --git a/internal/cli/logging.go b/internal/cli/logging.go new file mode 100644 index 0000000..c829ed4 --- /dev/null +++ b/internal/cli/logging.go @@ -0,0 +1,155 @@ +package cli + +import ( + "errors" + "fmt" + "log/slog" + "os" + + "github.com/invopop/validation" + "github.com/lmittmann/tint" + + "github.com/michenriksen/tmpl/config" + "github.com/michenriksen/tmpl/internal/env" +) + +// skipLogErrors contains errors that should not be logged. +var skipLogErrors = []error{ErrVersion, ErrHelp} + +func (a *App) initLogger() { + a.logger = a.newLogger() + + if a.tmux != nil { + a.tmux.SetLogger(a.logger) + } +} + +// newLogger creates a new structured logger that writes to the provided writer +// configured with the provided options. +func (a *App) newLogger() *slog.Logger { + hOpts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: a.attrReplacer, + } + + if a.opts != nil { + if a.opts.Debug { + hOpts.Level = slog.LevelDebug + } + + if a.opts.Quiet { + hOpts.Level = slog.LevelWarn + } + } + + var handler slog.Handler + + if a.opts != nil && a.opts.JSON { + handler = a.newJSONHandler(hOpts) + } else { + handler = a.newTextHandler(hOpts) + } + + return slog.New(handler) +} + +func (a *App) newTextHandler(hOpts *slog.HandlerOptions) slog.Handler { + tOpts := &tint.Options{ + Level: hOpts.Level, + ReplaceAttr: hOpts.ReplaceAttr, + TimeFormat: "15:04:05", + NoColor: envNoColor(), + } + + handler := tint.NewHandler(a.out, tOpts) + + return handler +} + +func (a *App) newJSONHandler(hOpts *slog.HandlerOptions) *slog.JSONHandler { + return slog.NewJSONHandler(a.out, hOpts) +} + +// handleErr logs and returns the provided error. +// +// If err is nil, this method is a no-op. +// If err is in [skipLogErrors], logging is skipped. +func (a *App) handleErr(err error) error { + if err == nil { + return nil + } + + for _, skipErr := range skipLogErrors { + if errors.Is(err, skipErr) { + return err + } + } + + logger := a.logger + if logger == nil { + logger = a.newLogger() + } + + var decodeErr config.DecodeError + if errors.As(err, &decodeErr) { + logger.Error("configuration file cannot be decoded", "path", decodeErr.Path()) + + if wrapped := decodeErr.Unwrap(); wrapped != nil { + logger.Warn(wrapped.Error()) + } + + return ErrInvalidConfig + } + + var verrs validation.Errors + if errors.As(err, &verrs) { + logger.Error("configuration file is invalid", "errors", len(verrs)) + logValidationErrs(logger, verrs, "") + + return ErrInvalidConfig + } + + logger.Error(err.Error()) + + return err +} + +// logValidationErrs logs validation errors recursively. +func logValidationErrs(logger *slog.Logger, verrs validation.Errors, fieldPrfx string) { + for field, err := range verrs { + field = fieldPrfx + field + + if verr, ok := err.(validation.Errors); ok { + logValidationErrs(logger, verr, field+".") + continue + } + + logger.Warn(fmt.Sprintf("%s %v", field, err.Error()), "field", field) + } +} + +func envNoColor() bool { + // See https://no-color.org/ + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return true + } + + // Check application specific environment variable. + if _, ok := env.LookupEnv("NO_COLOR"); ok { + return true + } + + // See https://bixense.com/clicolors/ + if _, ok := os.LookupEnv("CLICOLOR_FORCE"); ok { + return false + } + + // $TERM is often set to `dumb` to indicate that the terminal is very basic + // and sometimes if the current command output is redirected to a file or + // piped to another command. + if os.Getenv("TERM") == "dumb" { + return true + } + + return false +} diff --git a/internal/cli/options.go b/internal/cli/options.go new file mode 100644 index 0000000..5f27816 --- /dev/null +++ b/internal/cli/options.go @@ -0,0 +1,310 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + "text/template" +) + +const globalOpts = `Global options: + + -d, --debug enable debug logging + -h, --help show this message and exit + -j, --json enable JSON logging + -q, --quiet enable quiet logging + -v, --version show the version and exit` + +const subCmds = `Available commands: + + apply (default) apply configuration and attach session + check validate configuration file + init generate a new configuration file` + +const usageTmpl = `Usage: {{ .AppName }} [command] [options] [args] + +Simple tmux session management. + +{{ .Commands }} + +{{ .GlobalOptions }} + +Examples: + + # apply nearest configuration file and attach/switch client to session: + $ {{ .AppName }} + + # or explicitly: + $ {{ .AppName }} -c /path/to/config.yaml + + # generate a new configuration file in the current working directory: + $ {{ .AppName }} init +` + +const applyUsageTmpl = `Usage: {{ .AppName }} apply [options] + +Creates a new tmux session from a {{ .AppName }} configuration file and then +connects to it. + +If the session already exists, the configuration process is skipped. + + +Options: + + -c, --config PATH configuration file path (default: find nearest) + -n, --dry-run enable dry-run mode + +{{ .GlobalOptions }} + +Examples: + + # apply nearest configuration file and attach/switch client to session: + $ {{ .AppName }} apply + + # or explicitly: + $ {{ .AppName }} apply -c /path/to/config.yaml + + # simulate applying configuration file. No tmux commands are executed: + $ {{ .AppName }} apply --dry-run +` + +const initUsageTmpl = `Usage: {{ .AppName }} init [options] [path] + +Generates a skeleton {{ .AppName }} configuration file to get you started. + + +Options: + -p, --plain make plain configuration with no comments + +{{ .GlobalOptions }} + +Examples: + + # create a configuration file in the current working directory: + $ {{ .AppName }} init + + # or at a specific location: + $ {{ .AppName }} init /path/to/config.yaml +` + +const checkUsageTmpl = `Usage: {{ .AppName }} check [options] [path] + +Performs validation of a {{ .AppName }} configuration file and reports whether +it is valid or not. + + +Options: + + -c, --config PATH configuration file path (default: find nearest) + +{{ .GlobalOptions }} + +Examples: + + # validate configuration file in the current working directory: + $ {{ .AppName }} check + + # or at a specific location: + $ {{ .AppName }} check -c /path/to/config.yaml +` + +const versionTmpl = `{{ .AppName }}: + Version: {{ .Version }} + Go version: {{ .GoVersion }} + Git commit: {{ .Commit }} + Released: {{ .BuildTime }} +` + +var ( + ErrHelp = errors.New("help requested") + ErrVersion = errors.New("version requested") +) + +// options represents the command-line options for the CLI application. +type options struct { + args []string + version bool + help bool + + // Global options. + Debug bool + Quiet bool + JSON bool + + // Options for apply sub-command. + ConfigPath string + DryRun bool + + // Options for init sub-command. + Plain bool +} + +// parseApplyOptions parses the command-line options for the apply sub-command. +func parseApplyOptions(args []string, output io.Writer) (*options, error) { + flagSet := flag.NewFlagSet("apply", flag.ContinueOnError) + isSubCmd := len(args) != 0 && args[0] == "apply" + + flagSet.SetOutput(output) + flagSet.Usage = func() { + var ( + usage string + err error + ) + + if isSubCmd { + usage, err = renderOptsTemplate(applyUsageTmpl) + } else { + usage, err = renderOptsTemplate(usageTmpl) + } + + if err != nil { + panic(err) + } + + fmt.Fprint(output, usage) + } + + opts := &options{} + initGlobalOpts(flagSet, opts) + + flagSet.StringVar(&opts.ConfigPath, "config", "", "path to the configuration file") + flagSet.StringVar(&opts.ConfigPath, "c", "", "path to the configuration file") + flagSet.BoolVar(&opts.DryRun, "dry-run", false, "enable dry-run mode") + flagSet.BoolVar(&opts.DryRun, "n", false, "enable dry-run mode") + + if isSubCmd { + args = args[1:] + } + + opts, err := parseFlagSet(args, flagSet, opts) + if err != nil { + return nil, err + } + + if len(opts.args) != 0 { + return nil, fmt.Errorf("unknown command: %s", opts.args[0]) + } + + return opts, nil +} + +// parseInitOptions parses the command-line options for the init sub-command. +func parseInitOptions(args []string, output io.Writer) (*options, error) { + flagSet := flag.NewFlagSet("init", flag.ContinueOnError) + + flagSet.SetOutput(output) + flagSet.Usage = func() { + usage, err := renderOptsTemplate(initUsageTmpl) + if err != nil { + panic(err) + } + + fmt.Fprint(output, usage) + } + + opts := &options{} + initGlobalOpts(flagSet, opts) + + flagSet.BoolVar(&opts.Plain, "plain", false, "make plain configuration with no comments") + flagSet.BoolVar(&opts.Plain, "p", false, "make plain configuration with no comments") + + return parseFlagSet(args, flagSet, opts) +} + +func parseCheckOptions(args []string, output io.Writer) (*options, error) { + flagSet := flag.NewFlagSet("check", flag.ContinueOnError) + + flagSet.SetOutput(output) + flagSet.Usage = func() { + usage, err := renderOptsTemplate(checkUsageTmpl) + if err != nil { + panic(err) + } + + fmt.Fprint(output, usage) + } + + opts := &options{} + initGlobalOpts(flagSet, opts) + + flagSet.StringVar(&opts.ConfigPath, "config", "", "path to the configuration file") + flagSet.StringVar(&opts.ConfigPath, "c", "", "path to the configuration file") + + return parseFlagSet(args, flagSet, opts) +} + +func initGlobalOpts(flagSet *flag.FlagSet, opts *options) { + flagSet.BoolVar(&opts.Debug, "debug", false, "enable debug logging") + flagSet.BoolVar(&opts.Debug, "d", false, "enable debug logging") + flagSet.BoolVar(&opts.Quiet, "quiet", false, "enable quiet logging") + flagSet.BoolVar(&opts.Quiet, "q", false, "enable quiet logging") + flagSet.BoolVar(&opts.JSON, "json", false, "enable JSON logging") + flagSet.BoolVar(&opts.JSON, "j", false, "enable JSON logging") + flagSet.BoolVar(&opts.version, "version", false, "show the version and exit") + flagSet.BoolVar(&opts.version, "v", false, "show the version and exit") + flagSet.BoolVar(&opts.help, "help", false, "show this message and exit") + flagSet.BoolVar(&opts.help, "h", false, "show this message and exit") +} + +func parseFlagSet(args []string, flagSet *flag.FlagSet, opts *options) (*options, error) { + if err := flagSet.Parse(args); err != nil { + return nil, fmt.Errorf("parsing flags: %w", err) + } + + if opts.help { + flagSet.Usage() + return nil, ErrHelp + } + + if opts.version { + info, err := renderOptsTemplate(versionTmpl) + if err != nil { + return nil, err + } + + fmt.Fprint(flagSet.Output(), info) + + return nil, ErrVersion + } + + opts.args = flagSet.Args() + + return opts, nil +} + +func renderOptsTemplate(s string) (string, error) { + data := optsTemplateData{ + AppName: AppName, + BuildTime: BuildTime(), + Commands: subCmds, + Commit: BuildCommit(), + GlobalOptions: globalOpts, + GoVersion: BuildGoVersion(), + Version: Version(), + } + + tmpl, err := template.New("usage").Parse(s) + if err != nil { + return "", fmt.Errorf("parsing options template: %w", err) + } + + b := strings.Builder{} + + if err := tmpl.Execute(&b, data); err != nil { + return "", fmt.Errorf("rendering options template: %w", err) + } + + return b.String(), nil +} + +type optsTemplateData struct { + AppName string + BuildTime string + Commands string + Commit string + GlobalOptions string + GoVersion string + Version string +} diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/broken_config_file.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/broken_config_file.golden.json new file mode 100644 index 0000000..2eb583b --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/broken_config_file.golden.json @@ -0,0 +1,5 @@ +[ + "00:00:00 ERR configuration file cannot be decoded path=/stabilized/path/tmpl-broken.yaml", + "00:00:00 WRN yaml: line 4: did not find expected key", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/create_new_session.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/create_new_session.golden.json new file mode 100644 index 0000000..6005c2a --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/create_new_session.golden.json @@ -0,0 +1,29 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml", + "00:00:00 INF session created session=my_project mock=true", + "00:00:00 INF window created session=my_project window=my_project:code mock=true", + "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:code mock=true", + "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:code mock=true", + "00:00:00 INF window send-keys cmd=\"nvim .\u003ccr\u003e\" session=my_project window=my_project:code mock=true", + "00:00:00 INF pane created session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true", + "00:00:00 INF pane send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true", + "00:00:00 INF pane send-keys cmd=\"echo 'on_pane'\u003ccr\u003e\" session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true", + "00:00:00 INF pane send-keys cmd=./autorun-tests.sh\u003ccr\u003e session=my_project window=my_project:code pane=my_project:code.1 pane_width=80 pane_height=5 mock=true", + "00:00:00 INF window created session=my_project window=my_project:shell mock=true", + "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:shell mock=true", + "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:shell mock=true", + "00:00:00 INF window send-keys cmd=\"git status\u003ccr\u003e\" session=my_project window=my_project:shell mock=true", + "00:00:00 INF window created session=my_project window=my_project:server mock=true", + "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:server mock=true", + "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:server mock=true", + "00:00:00 INF window send-keys cmd=./run-dev-server.sh\u003ccr\u003e session=my_project window=my_project:server mock=true", + "00:00:00 INF window created session=my_project window=my_project:prod_logs mock=true", + "00:00:00 INF window send-keys cmd=~/project/scripts/bootstrap.sh\u003ccr\u003e session=my_project window=my_project:prod_logs mock=true", + "00:00:00 INF window send-keys cmd=\"echo 'on_window'\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true", + "00:00:00 INF window send-keys cmd=\"ssh user@host\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true", + "00:00:00 INF window send-keys cmd=\"cd /var/logs\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true", + "00:00:00 INF window send-keys cmd=\"tail -f app.log\u003ccr\u003e\" session=my_project window=my_project:prod_logs mock=true", + "00:00:00 INF window selected session=my_project window=my_project:code mock=true", + "00:00:00 INF attaching client to session windows=4 panes=1 session=my_project mock=true", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/invalid_config_file.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/invalid_config_file.golden.json new file mode 100644 index 0000000..6b3e482 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/invalid_config_file.golden.json @@ -0,0 +1,6 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl-invalid.yaml", + "00:00:00 ERR configuration file is invalid errors=1", + "00:00:00 WRN session.windows.0.env \"invalid_session_name\" is not a valid environment variable name field=session.windows.0.env", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/new_session_fails.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/new_session_fails.golden.json new file mode 100644 index 0000000..4958f14 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/new_session_fails.golden.json @@ -0,0 +1,5 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml", + "00:00:00 ERR applying configuration: applying session my_project: running new-session command: exit status 1", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/new_window_fails.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/new_window_fails.golden.json new file mode 100644 index 0000000..eace841 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/new_window_fails.golden.json @@ -0,0 +1,7 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml", + "00:00:00 INF session created session=my_project mock=true", + "00:00:00 DBG session closed session=my_project mock=true", + "00:00:00 ERR applying configuration: applying window configuration: applying window my_project:code: running new-window command: exit status 1", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/session_exists.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/session_exists.golden.json new file mode 100644 index 0000000..6ec9a5b --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/session_exists.golden.json @@ -0,0 +1,5 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/tmpl.yaml", + "00:00:00 INF attaching client to session session=my_project mock=true", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/show_help.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/show_help.golden.json new file mode 100644 index 0000000..f03eae0 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/show_help.golden.json @@ -0,0 +1,31 @@ +[ + "Usage: tmpl [command] [options] [args]", + "", + "Simple tmux session management.", + "", + "Available commands:", + "", + " apply (default) apply configuration and attach session", + " check validate configuration file", + " init generate a new configuration file", + "", + "Global options:", + "", + " -d, --debug enable debug logging", + " -h, --help show this message and exit", + " -j, --json enable JSON logging", + " -q, --quiet enable quiet logging", + " -v, --version show the version and exit", + "", + "Examples:", + "", + " # apply nearest configuration file and attach/switch client to session:", + " $ tmpl", + "", + " # or explicitly:", + " $ tmpl -c /path/to/config.yaml", + "", + " # generate a new configuration file in the current working directory:", + " $ tmpl init", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Apply/show_version.golden.json b/internal/cli/testdata/golden/TestApp_Run_Apply/show_version.golden.json new file mode 100644 index 0000000..f3e7dbe --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Apply/show_version.golden.json @@ -0,0 +1,8 @@ +[ + "tmpl:", + " Version: 0.0.0-dev", + " Go version: go0.0.0", + " Git commit: HEAD", + " Released: 0001-01-01T00:00:00Z", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/check_current_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/check_current_config.golden.json new file mode 100644 index 0000000..3bf867d --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Check/check_current_config.golden.json @@ -0,0 +1,5 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/.tmpl.yaml", + "00:00:00 INF configuration file is valid", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/check_specific_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/check_specific_config.golden.json new file mode 100644 index 0000000..3bf867d --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Check/check_specific_config.golden.json @@ -0,0 +1,5 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/.tmpl.yaml", + "00:00:00 INF configuration file is valid", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/invalid_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/invalid_config.golden.json new file mode 100644 index 0000000..e7ba064 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Check/invalid_config.golden.json @@ -0,0 +1,6 @@ +[ + "00:00:00 INF configuration file loaded path=/stabilized/path/.tmpl.invalid.yaml", + "00:00:00 ERR configuration file is invalid errors=1", + "00:00:00 WRN session.windows.0.env \"invalid_session_name\" is not a valid environment variable name field=session.windows.0.env", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Check/unparsable_config.golden.json b/internal/cli/testdata/golden/TestApp_Run_Check/unparsable_config.golden.json new file mode 100644 index 0000000..7eb3cf3 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Check/unparsable_config.golden.json @@ -0,0 +1,5 @@ +[ + "00:00:00 ERR configuration file cannot be decoded path=/stabilized/path/.tmpl.broken.yaml", + "00:00:00 WRN yaml: line 4: did not find expected key", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Init/init_current_directory.golden.json b/internal/cli/testdata/golden/TestApp_Run_Init/init_current_directory.golden.json new file mode 100644 index 0000000..9e21c31 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Init/init_current_directory.golden.json @@ -0,0 +1,4 @@ +[ + "00:00:00 INF configuration file created path=/stabilized/path/.tmpl.yaml", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_directory.golden.json b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_directory.golden.json new file mode 100644 index 0000000..9e21c31 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_directory.golden.json @@ -0,0 +1,4 @@ +[ + "00:00:00 INF configuration file created path=/stabilized/path/.tmpl.yaml", + "" +] diff --git a/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_file.golden.json b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_file.golden.json new file mode 100644 index 0000000..19de892 --- /dev/null +++ b/internal/cli/testdata/golden/TestApp_Run_Init/init_specific_file.golden.json @@ -0,0 +1,4 @@ +[ + "00:00:00 INF configuration file created path=/stabilized/path/.tmpl_config.yml", + "" +] diff --git a/internal/cli/testdata/tmpl-broken.yaml b/internal/cli/testdata/tmpl-broken.yaml new file mode 100644 index 0000000..1f7e946 --- /dev/null +++ b/internal/cli/testdata/tmpl-broken.yaml @@ -0,0 +1,6 @@ +# yamllint disable-file +# This file has an intentional syntax error for testing purposes. +--- +session: + name: "invalid" + path: "/home/user" diff --git a/internal/cli/testdata/tmpl-invalid.yaml b/internal/cli/testdata/tmpl-invalid.yaml new file mode 100644 index 0000000..f32607e --- /dev/null +++ b/internal/cli/testdata/tmpl-invalid.yaml @@ -0,0 +1,7 @@ +# Invalid configuration: Environment variables must only contain uppercase +# alphanumeric and underscores. +--- +session: + windows: + - env: + invalid_session_name: "true" diff --git a/internal/cli/testdata/tmpl.yaml b/internal/cli/testdata/tmpl.yaml new file mode 100644 index 0000000..6289c4c --- /dev/null +++ b/internal/cli/testdata/tmpl.yaml @@ -0,0 +1,33 @@ +--- +session: + name: my_project + path: ~/project + on_any: ~/project/scripts/bootstrap.sh + on_window: echo 'on_window' + on_pane: echo 'on_pane' + env: + APP_ENV: development + DEBUG: true + windows: + - name: code + command: nvim . + active: true + panes: + - command: ./autorun-tests.sh + path: ~/project/scripts + horizontal: true + size: 20% + env: + APP_ENV: test + - name: shell + command: git status + - name: server + path: ~/project/scripts + command: ./run-dev-server.sh + env: + HTTP_PORT: 8080 + - name: prod_logs + commands: + - ssh user@host + - cd /var/logs + - tail -f app.log diff --git a/internal/cli/testdata/tmux-stubs.yaml b/internal/cli/testdata/tmux-stubs.yaml new file mode 100644 index 0000000..49fb9e4 --- /dev/null +++ b/internal/cli/testdata/tmux-stubs.yaml @@ -0,0 +1,118 @@ +# This file contains tmux command arguments expected to be given to the tmux +# runner, with optional stub command output to be returned on match. +# +# yamllint disable rule:line-length +--- +ListSessions: + args: &ListSessionArgs ["list-sessions", "-F", "session_id:#{session_id},session_name:#{session_name},session_path:#{session_path}"] + output: |- + session_id:$0,session_name:main,session_path:/home/user + session_id:$1,session_name:other,session_path:/home/user/other + session_id:$2,session_name:prod,session_path:/home/user + +ListSessionsExists: + args: *ListSessionArgs + output: |- + session_id:$0,session_name:main,session_path:/home/user + session_id:$1,session_name:my_project,session_path:/home/user/project + session_id:$2,session_name:prod,session_path:/home/user + +NewSession: + args: ["new-session", "-d", "-P", "-F", "session_id:#{session_id},session_name:#{session_name},session_path:#{session_path}", "-s", "my_project"] + output: |- + session_id:$3,session_name:my_project,session_path:/home/user/project + +NewWindowCode: + args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-k", "-t", "my_project:^", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-n", "code", "-c", "/tmp/path"] + output: |- + window_id:@5,window_name:code,window_path:/home/user/project,window_index:1,window_width:80,window_height:24 + +NewWindowShell: + args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-t", "my_project:", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-n", "shell", "-c", "/tmp/path"] + output: |- + window_id:@6,window_name:shell,window_path:/home/user/project/scripts,window_index:2,window_width:80,window_height:24 + +NewWindowServer: + args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-t", "my_project:", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-e", "HTTP_PORT=8080", "-n", "server", "-c", "/tmp/path"] + output: |- + window_id:@7,window_name:server,window_path:/home/user/project/scripts,window_index:3,window_width:80,window_height:24 + +NewWindowProdLogs: + args: ["new-window", "-P", "-F", "window_id:#{window_id},window_name:#{window_name},window_path:#{window_path},window_index:#{window_index},window_width:#{window_width},window_height:#{window_height}", "-t", "my_project:", "-e", "APP_ENV=development", "-e", "DEBUG=true", "-n", "prod_logs", "-c", "/tmp/path"] + output: |- + window_id:@8,window_name:prod_logs,window_path:/home/user/project,window_index:4,window_width:80,window_height:24 + +NewPaneCode: + args: ["split-window", "-d", "-P", "-F", "pane_id:#{pane_id},pane_path:#{pane_path},pane_index:#{pane_index},pane_width:#{pane_width},pane_height:#{pane_height}", "-t", "my_project:code", "-e", "APP_ENV=test", "-e", "DEBUG=true", "-c", "/tmp/path", "-l", "20%", "-h"] + output: |- + pane_id:@4,pane_path:/home/user/project,pane_index:1,pane_width:80,pane_height:5 + +SendKeysCodeOnAny: + args: ["send-keys", "-t", "my_project:code", "~/project/scripts/bootstrap.sh", "C-m"] + +SendKeysCodeOnWindow: + args: ["send-keys", "-t", "my_project:code", "echo 'on_window'", "C-m"] + +SendKeysCode: + args: ["send-keys", "-t", "my_project:code", "nvim .", "C-m"] + +SendKeysCodePaneOnAny: + args: ["send-keys", "-t", "my_project:code.1", "~/project/scripts/bootstrap.sh", "C-m"] + +SendKeysCodePaneOnPane: + args: ["send-keys", "-t", "my_project:code.1", "echo 'on_pane'", "C-m"] + +SendKeysCodePane: + args: ["send-keys", "-t", "my_project:code.1", "./autorun-tests.sh", "C-m"] + +SendKeysShellOnAny: + args: ["send-keys", "-t", "my_project:shell", "~/project/scripts/bootstrap.sh", "C-m"] + +SendKeysShellOnWindow: + args: ["send-keys", "-t", "my_project:shell", "echo 'on_window'", "C-m"] + +SendKeysShell: + args: ["send-keys", "-t", "my_project:shell", "git status", "C-m"] + +SendKeysServerOnAny: + args: ["send-keys", "-t", "my_project:server", "~/project/scripts/bootstrap.sh", "C-m"] + +SendKeysServerOnWindow: + args: ["send-keys", "-t", "my_project:server", "echo 'on_window'", "C-m"] + +SendKeysServer: + args: ["send-keys", "-t", "my_project:server", "./run-dev-server.sh", "C-m"] + +SendKeysProdLogsOnAny: + args: ["send-keys", "-t", "my_project:prod_logs", "~/project/scripts/bootstrap.sh", "C-m"] + +SendKeysProdLogsOnWindow: + args: ["send-keys", "-t", "my_project:prod_logs", "echo 'on_window'", "C-m"] + +SendKeysProdLogsSSH: + args: ["send-keys", "-t", "my_project:prod_logs", "ssh user@host", "C-m"] + +SendKeysProdLogsCdLogs: + args: ["send-keys", "-t", "my_project:prod_logs", "cd /var/logs", "C-m"] + +SendKeysProdLogsTail: + args: ["send-keys", "-t", "my_project:prod_logs", "tail -f app.log", "C-m"] + +SelectWindowCode: + args: ["select-window", "-t", "my_project:code"] + +PaneBaseIndexOpt: + args: ["show-option", "-gqv", "pane-base-index"] + output: "0" + +SelectPaneCode: + args: ["select-pane", "-t", "my_project:code.0"] + +SwitchClient: + args: ["switch-client", "-t", "my_project"] + +AttachSession: + args: ["attach-session", "-t", "my_project"] + +CloseSession: + args: ["kill-session", "-t", "my_project"] diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..1751457 --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,36 @@ +// Package env provides functionality for getting information and data from the +// environment. +package env + +import "os" + +const keyPrefix = "TMPL_" + +const ( + // KeyConfigName is the environment variable key for specifying a different + // configuration file name instead of the default. + KeyConfigName = "CONFIG_NAME" + // KeyPwd is the environment variable key for a stubbed working directory + // used by tests. + KeyPwd = "PWD" +) + +// Getenv retrieves the value of the environment variable named by the key. +// +// Works like [os.Getenv] except that it will prefix the key with an application +// specific prefix to avoid conflicts with other environment variables. +func Getenv(key string) string { + return os.Getenv(makeKey(key)) +} + +// LookupEnv retrieves the value of the environment variable named by the key. +// +// Works like [os.LookupEnv] except that it will prefix the key with an +// application specific prefix to avoid conflicts with other environment. +func LookupEnv(key string) (string, bool) { + return os.LookupEnv(makeKey(key)) +} + +func makeKey(key string) string { + return keyPrefix + key +} diff --git a/internal/env/env_test.go b/internal/env/env_test.go new file mode 100644 index 0000000..9f686fa --- /dev/null +++ b/internal/env/env_test.go @@ -0,0 +1,25 @@ +package env_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/michenriksen/tmpl/internal/env" +) + +func TestGetenv(t *testing.T) { + t.Setenv("TMPL_TEST_GET_ENV", "good") + t.Setenv("TEST_GET_ENV", "bad") + + require.Equal(t, "good", env.Getenv("TEST_GET_ENV")) +} + +func TestLookupEnv(t *testing.T) { + t.Setenv("TMPL_TEST_GET_ENV", "good") + t.Setenv("TEST_GET_ENV", "bad") + + val, ok := env.LookupEnv("TEST_GET_ENV") + require.True(t, ok) + require.Equal(t, "good", val) +} diff --git a/internal/env/fs.go b/internal/env/fs.go new file mode 100644 index 0000000..4cc2271 --- /dev/null +++ b/internal/env/fs.go @@ -0,0 +1,55 @@ +package env + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Getwd returns the current working directory. +// +// Works like [os.Getwd] except that it will check the environment variable for +// stubbing the working directory used by tests, and return the value if it's +// defined. This is intended for testing purposes, and should not be used as a +// feature. +func Getwd() (string, error) { + if dir, ok := LookupEnv(KeyPwd); ok && filepath.IsAbs(dir) { + return dir, nil + } + + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting current working directory: %w", err) + } + + return dir, nil +} + +// AbsPath returns the absolute path for the given path. +// +// Works like [filepath.Abs] except that it will expand the home directory if +// the path starts with "~". +// +// If the path is already absolute, it will be returned as-is. +func AbsPath(path string) (string, error) { + if filepath.IsAbs(path) { + return path, nil + } + + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting user home directory: %w", err) + } + + return filepath.Join(home, filepath.Clean(path[1:])), nil + } + + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("getting absolute path: %w", err) + } + + return abs, nil +} diff --git a/internal/env/fs_test.go b/internal/env/fs_test.go new file mode 100644 index 0000000..e3203f8 --- /dev/null +++ b/internal/env/fs_test.go @@ -0,0 +1,86 @@ +package env_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/michenriksen/tmpl/internal/env" +) + +func TestGetwd(t *testing.T) { + t.Run("no env stub", func(t *testing.T) { + want, err := os.Getwd() + require.NoError(t, err) + + got, err := env.Getwd() + require.NoError(t, err) + + require.Equal(t, want, got) + }) + + t.Run("with env stub", func(t *testing.T) { + want := t.TempDir() + t.Setenv("TMPL_PWD", want) + + got, err := env.Getwd() + require.NoError(t, err) + + require.Equal(t, want, got) + }) +} + +func TestAbsPath(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + + t.Setenv("HOME", "/home/user") + + tt := []struct { + name string + path string + want string + }{ + { + "absolute path", + "/home/user/project", + "/home/user/project", + }, + { + "relative path", + "project/scripts", + filepath.Join(wd, "project/scripts"), + }, + { + "relative path traversal", + "project/scripts/../tests", + filepath.Join(wd, "project/tests"), + }, + { + "relative path dot", + "./project/scripts", + filepath.Join(wd, "project/scripts"), + }, + { + "tilde path", + "~/project/scripts", + "/home/user/project/scripts", + }, + { + "tilde path traversal", + "~/../project/scripts", + "/home/user/project/scripts", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got, err := env.AbsPath(tc.path) + require.NoError(t, err) + + require.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/gen/docs/.tmpl.example.yaml b/internal/gen/docs/.tmpl.example.yaml new file mode 100644 index 0000000..d9d1753 --- /dev/null +++ b/internal/gen/docs/.tmpl.example.yaml @@ -0,0 +1,27 @@ +--- +session: + name: my-project + + env: + APP_ENV: development + DEBUG: true + + windows: + + # main window running Neovim with a horizontal bottom pane with 20% height, + # running tests. + - name: code + command: nvim . + panes: + - command: scripts/autorun-tests.sh + size: 20% + horizontal: true + env: + APP_ENV: testing + + # secondary window for arbitrary use. + - name: shell + +## These lines configure editors to be more helpful (optional) +# yaml-language-server: $schema=https://github.com/michenriksen/tmpl/blob/main/config.schema.json +# vim: ft=yaml syn=yaml ts=2 sts=2 sw=2 et diff --git a/internal/gen/docs/README.md.tmpl b/internal/gen/docs/README.md.tmpl new file mode 100644 index 0000000..1c670c6 --- /dev/null +++ b/internal/gen/docs/README.md.tmpl @@ -0,0 +1,61 @@ + +
+ + + + + +
+ +

+ tmpl  + + Build status + + + Latest release + + Project status: beta + + License: MIT + +

+ +**Simple tmux session management.**
+ +Tmpl streamlines your tmux workflow by letting you describe your sessions in simple YAML files and have them +launched with all the tools your workflow requires set up and ready to go. If you often set up the same windows and +panes for tasks like coding, running unit tests, tailing logs, and using other tools, tmpl can automate that for you. + +## Highlights + +- **Simple and versatile configuration:** easily set up your tmux sessions using straightforward YAML files, allowing + you to create as many windows and panes as needed. Customize session and window names, working directories, and + start-up commands. + +- **Inheritable environment variables:** define environment variables for your entire session, a specific window, or a + particular pane. These variables cascade from session to window to pane, enabling you to set a variable once and + modify it at any level. + +- **Custom hook commands:** customize your setup with on-window and on-pane hook commands that run when new windows, + panes, or both are created. This feature is useful for initializing a virtual environment or switching between + language runtime versions. + +- **Non-intrusive workflow:** while there are many excellent session managers out there, some of them tend to be quite + opinionated about how you should work with them. Tmpl allows configurations to live anywhere in your filesystem and + focuses only on launching your session. It's intended as a secondary companion, and not a full workflow replacement. + +- **Stand-alone binary:** Tmpl is a single, stand-alone binary with no external dependencies, except for tmux. It's easy + to install and doesn't require you to have a specific language runtime or package manager on your system. + +## Getting started + +See the [Getting started guide]({{ .DocsURL }}/getting-started/) for installation and usage instructions. + +{{/* vim: set syn=markdown : */}} diff --git a/internal/gen/docs/configuration.md.tmpl b/internal/gen/docs/configuration.md.tmpl new file mode 100644 index 0000000..8a10577 --- /dev/null +++ b/internal/gen/docs/configuration.md.tmpl @@ -0,0 +1 @@ +{{/* vim: set syn=markdown : */}} diff --git a/internal/gen/readme.go b/internal/gen/readme.go new file mode 100644 index 0000000..0b7f905 --- /dev/null +++ b/internal/gen/readme.go @@ -0,0 +1,61 @@ +//go:build gen + +//go:generate go run -tags=gen readme.go -f ../../README.md + +// Package gen contains code for generating documentation and other files. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "text/template" +) + +const ( + docsURL = "https://michenriksen.com/tmpl" + projectPackage = "github.com/michenriksen/tmpl" + projectURL = "https://github.com/michenriksen/tmpl" +) + +func main() { + writeOpt := flag.String("f", "", "write rendered template to file instead of stdout") + flag.Parse() + + tmplPath := filepath.Join("docs", "README.md.tmpl") + + tmpl, err := template.New("README.md.tmpl").Funcs(helpers).ParseFiles(tmplPath) + if err != nil { + log.Fatal(fmt.Errorf("parsing template: %w", err)) + } + + out := os.Stdout + if *writeOpt != "" { + out, err = os.Create(*writeOpt) + if err != nil { + log.Fatal(fmt.Errorf("creating output file: %w", err)) + } + } + + err = tmpl.Funcs(helpers).Execute(out, map[string]any{ + "DocsURL": docsURL, + "ProjectPackage": projectPackage, + "ProjectURL": projectURL, + }) + if err != nil { + log.Fatal(fmt.Errorf("rendering template: %w", err)) + } +} + +var helpers = template.FuncMap{ + "file": func(name string) (string, error) { + data, err := os.ReadFile(name) + if err != nil { + return "", fmt.Errorf("reading file: %w", err) + } + + return string(data), nil + }, +} diff --git a/internal/gen/usage.go b/internal/gen/usage.go new file mode 100644 index 0000000..082240b --- /dev/null +++ b/internal/gen/usage.go @@ -0,0 +1,42 @@ +//go:build gen + +//go:generate go run -tags=gen usage.go -f ../../docs/cli-usage.txt + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + + "github.com/michenriksen/tmpl/internal/cli" +) + +func main() { + writeOpt := flag.String("f", "", "write rendered template to file instead of stdout") + flag.Parse() + + w := os.Stdout + + if *writeOpt != "" { + var err error + + w, err = os.Create(*writeOpt) + if err != nil { + log.Fatal(fmt.Errorf("creating output file: %w", err)) + } + } + + app, err := cli.NewApp(cli.WithOutputWriter(w)) + if err != nil { + log.Fatal(fmt.Errorf("creating app: %w", err)) + } + + err = app.Run(context.Background(), "--help") + if !errors.Is(err, cli.ErrHelp) { + log.Fatal(fmt.Errorf("app did not return expected helper error: %w", err)) + } +} diff --git a/internal/mock/tmux.go b/internal/mock/tmux.go new file mode 100644 index 0000000..4e7a005 --- /dev/null +++ b/internal/mock/tmux.go @@ -0,0 +1,87 @@ +// Package mock provides mock implementations of components to use in tests. +package mock + +import ( + "context" + "log/slog" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/mock" + + "github.com/michenriksen/tmpl/tmux" +) + +type TmuxRunner struct { + tb testing.TB + wrapped tmux.Runner + + mock.Mock +} + +func NewTmuxRunner(tb testing.TB, wrapped tmux.Runner) *TmuxRunner { + tb.Helper() + + return &TmuxRunner{tb: tb, wrapped: wrapped} +} + +func (r *TmuxRunner) Run(ctx context.Context, args ...string) ([]byte, error) { + r.tb.Helper() + + ret := r.Called(cleanArgs(args)) + + if ret.Is(nil) { + r.tb.Log("Run call mock has nil return; delegating to wrapped runner") + return r.wrapped.Run(ctx, args...) //nolint:wrapcheck // Intentional. + } + + output, ok := ret.Get(0).([]byte) + if !ok { + r.tb.Fatalf("Run call mock has invalid output return type: %T", ret.Get(0)) + } + + return output, ret.Error(1) //nolint:wrapcheck // Intentional. +} + +func (r *TmuxRunner) Execve(args ...string) error { + r.tb.Helper() + + return r.Called(cleanArgs(args)).Error(0) //nolint:wrapcheck // Intentional. +} + +func (r *TmuxRunner) IsDryRun() bool { + r.tb.Helper() + return false +} + +func (r *TmuxRunner) Debug(msg string, args ...any) { + r.tb.Helper() + r.wrapped.Debug(msg, append(args, "mock", true)...) +} + +func (r *TmuxRunner) Log(msg string, args ...any) { + r.tb.Helper() + r.wrapped.Log(msg, append(args, "mock", true)...) +} + +func (r *TmuxRunner) SetLogger(logger *slog.Logger) { + r.tb.Helper() + r.wrapped.SetLogger(logger) +} + +func cleanArgs(args []string) []string { + tmpDir := os.TempDir() + res := make([]string, 0, len(args)) + + for _, arg := range args { + if !strings.HasPrefix(arg, tmpDir) { + res = append(res, arg) + continue + } + + res = append(res, "/tmp/path") + } + + return res +} diff --git a/internal/rulefuncs/fs.go b/internal/rulefuncs/fs.go new file mode 100644 index 0000000..7845938 --- /dev/null +++ b/internal/rulefuncs/fs.go @@ -0,0 +1,113 @@ +// Package rulefuncs provides custom validation rule functions. +package rulefuncs + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + + "github.com/invopop/validation" +) + +// DirExists validated that provided value is a valid path to an existing +// directory. +func DirExists(value any) error { + if value == nil { + return nil + } + + dir, err := validation.EnsureString(value) + if err != nil { + return err + } + + if dir == "" { + return nil + } + + dir, err = filepath.Abs(dir) + if err != nil { + return errors.New("invalid directory path") + } + + info, err := os.Stat(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return errors.New("directory does not exist") + } + + return validation.NewInternalError(fmt.Errorf("getting directory info: %w", err)) + } + + if !info.IsDir() { + return errors.New("not a directory") + } + + return nil +} + +// FileExists validates that provided value is a valid path to an existing file. +func FileExists(value any) error { + if value == nil { + return nil + } + + name, err := validation.EnsureString(value) + if err != nil { + return err + } + + if name == "" { + return nil + } + + name, err = filepath.Abs(name) + if err != nil { + return errors.New("invalid file path") + } + + info, err := os.Stat(name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return errors.New("file does not exist") + } + + return validation.NewInternalError(fmt.Errorf("getting file info: %w", err)) + } + + if info.IsDir() { + return errors.New("not a file") + } + + return nil +} + +// ExecutableExists validates that provided value is an executable command +// in PATH or an absolute path to an executable file. +func ExecutableExists(value any) error { + if value == nil { + return nil + } + + name, err := validation.EnsureString(value) + if err != nil { + return err + } + + if name == "" { + return nil + } + + if _, err := exec.LookPath(name); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return errors.New("executable file was not found") + } + + return validation.NewInternalError(fmt.Errorf("looking up command: %w", err)) + } + + return nil +} diff --git a/internal/rulefuncs/fs_test.go b/internal/rulefuncs/fs_test.go new file mode 100644 index 0000000..693c877 --- /dev/null +++ b/internal/rulefuncs/fs_test.go @@ -0,0 +1,189 @@ +package rulefuncs_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/michenriksen/tmpl/internal/rulefuncs" + "github.com/michenriksen/tmpl/internal/testutils" +) + +func TestDirExists(t *testing.T) { + dir := t.TempDir() + + file, err := os.CreateTemp(dir, "test") + require.NoError(t, err) + + tt := []struct { + name string + val any + assertErr testutils.ErrorAssertion + }{ + { + "empty string", + "", + nil, + }, + { + "nil value", + nil, + nil, + }, + { + "existing directory", + dir, + nil, + }, + { + "non-existing directory", + "/tmpl/test/not-exist", + testutils.AssertErrorContains("directory does not exist"), + }, + { + "file path", + file.Name(), + testutils.AssertErrorContains("not a directory"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + err := rulefuncs.DirExists(tc.val) + + if tc.assertErr != nil { + tc.assertErr(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestFileExists(t *testing.T) { + dir := t.TempDir() + + file, err := os.CreateTemp(dir, "test") + require.NoError(t, err) + + tt := []struct { + name string + val any + assertErr testutils.ErrorAssertion + }{ + { + "empty string", + "", + nil, + }, + { + "nil value", + nil, + nil, + }, + { + "existing file", + file.Name(), + nil, + }, + { + "non-existing file", + "/tmpl/test/not-exist", + testutils.AssertErrorContains("file does not exist"), + }, + { + "directory path", + dir, + testutils.AssertErrorContains("not a file"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + err := rulefuncs.FileExists(tc.val) + + if tc.assertErr != nil { + tc.assertErr(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestExecutableExists(t *testing.T) { + dir := t.TempDir() + + execPath := filepath.Join(dir, "tmpl_test_exec") + nonExecPath := filepath.Join(dir, "tmpl_test_non_exec") + + testutils.WriteFile(t, []byte{}, execPath) + testutils.WriteFile(t, []byte{}, nonExecPath) + os.Chmod(execPath, 0o700) + + t.Setenv("PATH", dir) + + tt := []struct { + name string + val any + assertErr testutils.ErrorAssertion + }{ + { + "empty string", + "", + nil, + }, + { + "nil value", + nil, + nil, + }, + { + "executable absolute path", + execPath, + nil, + }, + { + "executable name", + "tmpl_test_exec", + nil, + }, + { + "non-executable absolute path", + nonExecPath, + testutils.AssertErrorContains("permission denied"), + }, + { + "non-executable name", + "tmpl_test_non_exec", + testutils.AssertErrorContains("executable file not found in $PATH"), + }, + { + "non-existing absolute path", + "/tmpl/test/not-exist", + testutils.AssertErrorContains("executable file was not found"), + }, + { + "directory path", + dir, + testutils.AssertErrorContains("is a directory"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + err := rulefuncs.ExecutableExists(tc.val) + + if tc.assertErr != nil { + tc.assertErr(t, err) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/static/config.yaml.tmpl b/internal/static/config.yaml.tmpl new file mode 100644 index 0000000..073cc25 --- /dev/null +++ b/internal/static/config.yaml.tmpl @@ -0,0 +1,35 @@ +# {{ .AppName }} v{{ .Version}} configuration generated {{ .Time.Format "02 Jan 2006" }}. +# For more information, visit {{ .DocsURL }} +--- +session: + name: {{.Name}} + + ## Uncomment below to run an initial command in windows/panes on creation. + # on_window: echo 'on_window' + # on_pane: echo 'on_panel' + # on_any: echo 'on_any' + + ## Uncomment below to set any additional environment variables. + # env: + # APP_ENV: development + # PORT: 8080 + + windows: + - name: main + + ## Uncomment below to run a shell command in the window on creation. + # command: echo 'main window' + + ## Uncomment below to run multiple shell commands in the window. + # commands: + # - ssh user@host + # - cd /var/logs + # - tail -f app.log + + ## Uncomment below to add window panes. + # panes: + # - command: echo 'pane' + +## These lines configure editors to be more helpful (optional) +# yaml-language-server: $schema=https://raw.githubusercontent.com/michenriksen/tmpl/main/config.schema.json +# vim: ft=yaml syn=yaml ts=2 sts=2 sw=2 et diff --git a/internal/static/static.go b/internal/static/static.go new file mode 100644 index 0000000..3b95489 --- /dev/null +++ b/internal/static/static.go @@ -0,0 +1,7 @@ +// Package static provides static embedded assets for the application. +package static + +import _ "embed" + +//go:embed config.yaml.tmpl +var ConfigTemplate string diff --git a/internal/testutils/doc.go b/internal/testutils/doc.go new file mode 100644 index 0000000..f5b9d4e --- /dev/null +++ b/internal/testutils/doc.go @@ -0,0 +1,2 @@ +// Package testutils provides shared utilities for testing. +package testutils diff --git a/internal/testutils/errors.go b/internal/testutils/errors.go new file mode 100644 index 0000000..773b643 --- /dev/null +++ b/internal/testutils/errors.go @@ -0,0 +1,60 @@ +package testutils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// ErrorAssertion is a function that asserts an error. +// +// The function is passed the test context and the error to assert. If the error +// does not match the assertion, the test is failed and function returns false. +type ErrorAssertion func(testing.TB, error) bool + +// AssertErrorIs asserts that the given error is the target error. +func AssertErrorIs(target error) ErrorAssertion { + return func(tb testing.TB, err error) bool { + tb.Helper() + return assert.ErrorIs(tb, err, target) + } +} + +// AssertErrorContains asserts that the given error contains the provided +// substring. +func AssertErrorContains(substr string) ErrorAssertion { + return func(tb testing.TB, err error) bool { + tb.Helper() + return assert.ErrorContains(tb, err, substr) + } +} + +// RequireErrorIs is like [AssertErrorIs], but fails the test immediately if +// the assertion fails. +func RequireErrorIs(target error) ErrorAssertion { + return func(tb testing.TB, err error) bool { + tb.Helper() + + if !AssertErrorIs(target)(tb, err) { + tb.FailNow() + return false + } + + return true + } +} + +// RequireErrorContains is like [AssertErrorContains], but fails the test +// immediately if the assertion fails. +func RequireErrorContains(substr string) ErrorAssertion { + return func(tb testing.TB, err error) bool { + tb.Helper() + + if !AssertErrorContains(substr)(tb, err) { + tb.FailNow() + return false + } + + return true + } +} diff --git a/internal/testutils/fs.go b/internal/testutils/fs.go new file mode 100644 index 0000000..c963a4b --- /dev/null +++ b/internal/testutils/fs.go @@ -0,0 +1,40 @@ +package testutils + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "testing" +) + +func ReadFile(tb testing.TB, pathElems ...string) []byte { + tb.Helper() + + name := filepath.Join(pathElems...) + + data, err := os.ReadFile(name) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + tb.Fatalf("reading file: %v", err) + } + + tb.Fatalf("expected file %q to exist, but it does not", name) + } + + return data +} + +func WriteFile(tb testing.TB, data []byte, pathElems ...string) { + tb.Helper() + + name := filepath.Join(pathElems...) + + if err := os.MkdirAll(filepath.Dir(name), 0o744); err != nil { + tb.Fatalf("creating file directory: %v", err) + } + + if err := os.WriteFile(name, data, 0o600); err != nil { + tb.Fatalf("writing file: %v", err) + } +} diff --git a/internal/testutils/golden.go b/internal/testutils/golden.go new file mode 100644 index 0000000..87d66e7 --- /dev/null +++ b/internal/testutils/golden.go @@ -0,0 +1,231 @@ +package testutils + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const updateGoldenEnv = "UPDATE_GOLDEN" + +const ( + kindGolden = "golden" + kindActual = "actual" +) + +// gitDiffArgs are the arguments to git diff when generating a diff between +// golden and actual files. +var gitDiffArgs = []string{ + "--no-pager", "diff", "--no-index", "--color=always", "--src-prefix=./", + "--dst-prefix=./", +} + +// Golden provides functionality for testing with golden files. +// +// Read more on golden file testing: https://ieftimov.com/posts/testing-in-go-golden-files/ +type Golden struct { + tb testing.TB + isUpdate bool +} + +// NewGolden returns a new golden file test helper for the given testing.TB. +// +// Golden files are stored in the testdata/golden directory. Each test has a +// golden file with the same name as the test function. The golden file contains +// the expected output of the test. When the test is run, the actual output is +// compared to the golden file. If the actual output does not match the golden +// file, the actual output is written to a file in the same directory as the +// golden file for debugging purposes. +func NewGolden(tb testing.TB) *Golden { + tb.Helper() + + _, update := os.LookupEnv(updateGoldenEnv) + + return &Golden{tb: tb, isUpdate: update} +} + +// AssertMatch asserts that the provided value matches the expected, golden +// value. +// +// If the value does not match, the test will fail and the actual value will be +// written to a file in the same directory as the golden file for debugging +// purposes. If the values differ because of an expected change, the file can +// be renamed to the golden file, or the UPDATE_GOLDEN environment variable can +// be set to update the golden file. +// +// NOTE: the golden and actual values are compared by their marshaled JSON form +// and are also written to files in this format. This makes it easy to read and +// compare the file contents. +func (g *Golden) AssertMatch(got any) { + g.tb.Helper() + + actual := g.marshal(got) + expected := g.readOrWriteGolden(actual) + + // If the test has already failed, don't bother comparing the values. + if g.tb.Failed() { + return + } + + if !bytes.Equal(actual, expected) { + g.writeActual(actual) + + g.tb.Errorf("expected test data for %s to match golden data:\n\n%s\n", g.tb.Name(), g.diff()) + } +} + +// RequireMatch is like [AssertMatch], but will fail the test immediately if the +// provided value does not match the expected, golden value. +func (g *Golden) RequireMatch(got any) { + g.tb.Helper() + + if g.AssertMatch(got); g.tb.Failed() { + g.tb.FailNow() + } +} + +// readOrWriteGolden reads the golden file if it exists, otherwise it writes the +// actual value to the golden file if the UPDATE_GOLDEN environment variable is +// set. +func (g *Golden) readOrWriteGolden(actual []byte) []byte { + g.tb.Helper() + + gPath := g.path(kindGolden) + + info, err := os.Stat(gPath) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + g.tb.Fatalf("getting file info on golden file %q: %v", gPath, err) + } + + if !g.isUpdate { + g.tb.Errorf("golden file for %s does not exist; run with %s=1 to create it", g.tb.Name(), updateGoldenEnv) + return nil + } + } + + if g.isUpdate { + g.update(actual) + return actual + } + + golden, err := os.ReadFile(gPath) + if err != nil { + g.tb.Fatalf("reading golden file %q: %v", gPath, err) + } + + g.tb.Logf("read golden file %q (%dB)", gPath, info.Size()) + + return golden +} + +func (g *Golden) writeGolden(data []byte) { + g.tb.Helper() + WriteFile(g.tb, data, g.path(kindGolden)) +} + +func (g *Golden) writeActual(data []byte) { + g.tb.Helper() + WriteFile(g.tb, data, g.path(kindActual)) +} + +func (g *Golden) rmActual() { + g.tb.Helper() + + aPath := g.path(kindActual) + if err := os.Remove(aPath); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + g.tb.Fatalf("removing actual file %q: %v", aPath, err) + } + + return + } + + g.tb.Logf("removed actual file %q", aPath) +} + +// update updates the golden file with the provided data and removes the +// '*.actual.json' file if it exists. +func (g *Golden) update(data []byte) { + g.tb.Helper() + + g.writeGolden(data) + g.rmActual() +} + +func (g *Golden) path(kind string) string { + g.tb.Helper() + return filepath.Join("testdata", kindGolden, fmt.Sprintf("%s.%s.json", g.tb.Name(), kind)) +} + +func (g *Golden) marshal(v any) []byte { + g.tb.Helper() + + if b, ok := v.([]byte); ok { + v = g.comparableBytes(b) + } + + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + g.tb.Fatalf("marshaling golden data: %v", err) + } + + // Add a newline to the end to ensure a blank newline when writing to a file + // and to make the diff output more readable. + return append(b, '\n') +} + +// comparableBytes transforms the provided byte slice into a value that is more +// easily comparable. +func (g *Golden) comparableBytes(data []byte) any { + g.tb.Helper() + + ct := http.DetectContentType(data) + + if ct == "application/json" { + return data + } + + if strings.HasPrefix(ct, "text/") { + lines := bytes.Split(data, []byte("\n")) + linesStr := make([]string, len(lines)) + + for i, line := range lines { + linesStr[i] = string(line) + } + + return linesStr + } + + return data +} + +func (g *Golden) diff() string { + g.tb.Helper() + + git, err := exec.LookPath("git") + if err != nil { + g.tb.Fatal("git executable is required to diff golden files") + } + + cmd := exec.Command(git, append(gitDiffArgs, g.path(kindGolden), g.path(kindActual))...) + + out, err := cmd.CombinedOutput() + if err != nil { + exitErr := &exec.ExitError{} + if !errors.As(err, &exitErr) { + g.tb.Logf("command output: %s", out) + g.tb.Fatalf("running command %q: %v", cmd.String(), err) + } + } + + return string(out) +} diff --git a/internal/testutils/stable.go b/internal/testutils/stable.go new file mode 100644 index 0000000..f24bba6 --- /dev/null +++ b/internal/testutils/stable.go @@ -0,0 +1,109 @@ +package testutils + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +var ( + tempDir = os.TempDir() + homeDir = os.Getenv("HOME") +) + +var replacers = []struct { + re *regexp.Regexp + repl []byte +}{ + { + // RFC3339 timestamps. + regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\b`), + []byte("0001-01-01T00:00:00Z"), + }, + { + // Go version strings. + regexp.MustCompile(`\bgo1\.\d+\.\d+\b`), + []byte("go0.0.0"), + }, + { + // Temporary directory paths. + regexp.MustCompile(fmt.Sprintf(`\b\/%s\/[\w\/_-]+\b`, regexp.QuoteMeta(tempDir))), + []byte("/tmp/path"), + }, + { + // Home directory paths. + regexp.MustCompile(fmt.Sprintf(`\b\/%s\/[\w\/_-]+\b`, regexp.QuoteMeta(homeDir))), + []byte("/home/user"), + }, +} + +// Stabilize replaces non-deterministic and environment-dependent values in +// [data] with stable values. +// +// The function replaces all timestamps in RFC3339 format with the zero value +// and all Go version strings with "go0.0.0". +func Stabilize(tb testing.TB, data []byte) []byte { + tb.Helper() + + for _, r := range replacers { + data = r.re.ReplaceAll(data, r.repl) + } + + return data +} + +// NewSlogStabilizer returns an attribute replacer function for [slog.Logger] +// that replaces non-deterministic and environment-dependent attribute values +// with stable values. +// +// The function replaces all attribute values of type [time.Time] and +// [time.Duration] with their zero values. +// +// If a string value begins with the system's temporary directory path, or the +// current user's home directory, the path is replaced with a stable path. +// +// Usage: +// +// logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ +// ReplaceAttr: testutils.NewSlogStabilizer(t) +// })) +func NewSlogStabilizer(tb testing.TB) func([]string, slog.Attr) slog.Attr { + tb.Helper() + + return func(_ []string, a slog.Attr) slog.Attr { + tb.Helper() + + switch a.Value.Any().(type) { + case time.Time: + return slog.Attr{Key: a.Key, Value: slog.TimeValue(time.Time{})} + case time.Duration: + return slog.Attr{Key: a.Key, Value: slog.DurationValue(time.Duration(0))} + case string: + val := a.Value.String() + + if strings.HasPrefix(val, string(filepath.Separator)) && isPathKey(a.Key) { + val = filepath.Join("/stabilized/path", filepath.Base(val)) + return slog.Attr{Key: a.Key, Value: slog.StringValue(val)} + } + + return slog.Attr{Key: a.Key, Value: slog.StringValue(val)} + default: + return a + } + } +} + +func isPathKey(key string) bool { + for _, pk := range []string{"path", "dir", "file"} { + if strings.Contains(key, pk) { + return true + } + } + + return false +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..627a3c9 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,98 @@ +--- +site_name: tmpl documentation +site_url: https://michenriksen.com/tmpl/ +site_author: Michael Henriksen +site_description: >- + Streamline tmux session creation with simple configuration files for launching your workflow with all your tools set + up and ready to go. + +repo_name: michenriksen/tmpl +repo_url: https://github.com/michenriksen/tmpl + +copyright: Copyright © 2023 Michael Henriksen + +nav: + - Home: + - index.md + - Getting started: getting-started.md + - Configuration reference: reference.md + - Getting started: + - Installation: getting-started.md + - Configuring your session: configuration.md + - Launching your session: usage.md + - Recipes: + - Project launcher: recipes/project-launcher.md + - Other: + - Attribution: attribution.md + - License: license.md + - Configuration reference: + - .tmpl.yaml reference: reference.md + - JSON schema: schema-reference.md + +theme: + name: material + logo: assets/logo.svg + features: + - navigation.expand + - navigation.indexes + - navigation.sections + - navigation.tabs + - navigation.top + + icon: + repo: fontawesome/brands/github + palette: + - scheme: slate + primary: black + toggle: + icon: material/weather-night + name: Switch to light mode + - scheme: default + primary: black + toggle: + icon: material/weather-sunny + name: Switch to dark mode + font: false + custom_dir: docs/overrides + +extra_css: + - assets/stylesheets/extra.css + +extra: + package: github.com/michenriksen/tmpl + repo_url: https://github.com/michenriksen/tmpl + social: + - stub # To trigger social partial rendering. + +plugins: + - search + - markdownextradata: {} + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - toc: + title: On this page + permalink: true + - pymdownx.betterem: + smart_enable: all + - pymdownx.mark + - pymdownx.details + - pymdownx.highlight: + use_pygments: true + pygments_lang_class: true + auto_title: true + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.smartsymbols + - pymdownx.snippets: + base_path: ['LICENSE', 'docs'] + check_paths: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg diff --git a/scripts/autotest.sh b/scripts/autotest.sh new file mode 100755 index 0000000..ce63745 --- /dev/null +++ b/scripts/autotest.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +watchexec \ + -c clear \ + -o do-nothing \ + -d 100ms \ + --exts go \ + --shell=bash \ + 'pkg=".${WATCHEXEC_COMMON_PATH/$PWD/}/..."; echo "running tests for $pkg"; go test "$pkg"' diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..9dc92ba --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +gobin="${GOBIN:-go}" +goversion="$("$gobin" env GOVERSION)" + +export GO_VERSION="$goversion" +export GITLAB_TOKEN="" + +goreleaser build --clean --snapshot --single-target diff --git a/scripts/cover.sh b/scripts/cover.sh new file mode 100755 index 0000000..0441d78 --- /dev/null +++ b/scripts/cover.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +gobin="${GOBIN:-go}" +testflags=("-coverpkg=./..." "-covermode=atomic" "-coverprofile=coverage.out") + +if [[ "${DBG:-}" == 1 ]]; then + testflags+=("-v") +fi + +printf "$(tput bold)[go:test] running %s...$(tput sgr0)\n" "$(tput setaf 4)go test" +"$gobin" test "${testflags[@]}" ./... + +"$gobin" tool cover -html=coverage.out diff --git a/scripts/gen-docs.sh b/scripts/gen-docs.sh new file mode 100755 index 0000000..0f7c582 --- /dev/null +++ b/scripts/gen-docs.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +gobin="${GOBIN:-go}" + +prfx="[docs:gen]" + +schema_tmpdest="docs/jsonschema.md.tmp" +schema_dest="docs/jsonschema.md" + +printf "$(tput bold)%s running %s...$(tput sgr0)\n" "$prfx" "$(tput setaf 4)go generate" +"$gobin" generate -tags=gen ./... + +if [[ -f "$schema_dest" ]]; then + printf "$(tput bold)$prfx removing %s...$(tput sgr0)\n" "$(tput setaf 4)$schema_dest" + rm "$schema_dest" +fi + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)generate-schema-doc" +generate-schema-doc \ + --config template_name=md \ + --config link_to_reused_ref=false \ + --config show_toc=false \ + --config examples_as_yaml=true \ + --config footer_show_time=false \ + config.schema.json "$schema_tmpdest" > /dev/null + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)mdformat" +mdformat "$schema_tmpdest" + +printf "$(tput bold)%s removing HTML tags...$(tput sgr0)\n" "$prfx" +sed 's/<[^>]*>//g' "$schema_tmpdest" > "$schema_dest" +rm "$schema_tmpdest" diff --git a/scripts/lint-ci.sh b/scripts/lint-ci.sh new file mode 100755 index 0000000..eb6a6c3 --- /dev/null +++ b/scripts/lint-ci.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +gobin="${GOBIN:-go}" +prfx="[ci:lint]" + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)actionlint" +"$gobin" run github.com/rhysd/actionlint/cmd/actionlint@latest diff --git a/scripts/lint-docs.sh b/scripts/lint-docs.sh new file mode 100755 index 0000000..64f51e0 --- /dev/null +++ b/scripts/lint-docs.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +prfx="[docs:lint]" + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)markdownlint" +NODE_NO_WARNINGS=1 markdownlint ./**/*.md + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)vale" +vale ./**/*.md diff --git a/scripts/lint-go.sh b/scripts/lint-go.sh new file mode 100755 index 0000000..088a521 --- /dev/null +++ b/scripts/lint-go.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +gobin="${GOBIN:-go}" +prfx="[go:lint]" + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)go vet" +"$gobin" vet ./... + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)go mod verify" +"$gobin" mod verify > /dev/null + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)golangci-lint" +golangci-lint run diff --git a/scripts/lint-schema.sh b/scripts/lint-schema.sh new file mode 100755 index 0000000..815b69a --- /dev/null +++ b/scripts/lint-schema.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +gobin="${GOBIN:-go}" +schema="config.schema.json" +config_files=("docs/.tmpl.reference.yaml") +prfx="[schema:lint]" + +printf "$(tput bold)$prfx running %s...$(tput sgr0)\n" "$(tput setaf 4)jv on $schema" +"$gobin" run github.com/santhosh-tekuri/jsonschema/cmd/jv@latest -assertcontent --assertformat "$schema" + +for config_file in "${config_files[@]}"; do + if [ ! -f "$config_file" ]; then + printf "$(tput bold)$prfx %s not found$(tput sgr0)\n" "$config_file" + exit 1 + fi + + printf "$(tput bold)$prfx running $(tput setaf 4)jv on %s...$(tput sgr0)\n" "$config_file" + "$gobin" run github.com/santhosh-tekuri/jsonschema/cmd/jv@latest -assertcontent --assertformat config.schema.json "$config_file" +done diff --git a/scripts/lint-yaml.sh b/scripts/lint-yaml.sh new file mode 100755 index 0000000..61fb073 --- /dev/null +++ b/scripts/lint-yaml.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +printf "$(tput bold)[yaml:lint] running %s...$(tput sgr0)\n" "$(tput setaf 4)yamllint" +yamllint -s -f colored . diff --git a/scripts/reset-golden.sh b/scripts/reset-golden.sh new file mode 100755 index 0000000..9d1e323 --- /dev/null +++ b/scripts/reset-golden.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +dirs="$(find . -type d -name 'golden' -path '*/testdata/*')" + +if [[ -z "$dirs" ]]; then + echo "no golden test directories found" + exit 0 +fi + +printf "> remove %d golden test directories? [y/N] " "${#dirs[@]}" +read -r -s -n 1 answer + +if [[ "$answer" != "y" ]]; then + echo + echo "aborting" + exit 0 +fi + +printf "\n\n" + +for dir in "${dirs[@]}"; do + rm -rf "$dir" + echo "removed $dir" +done + diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..f7dd4c2 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -e + +if [[ "${DBG:-}" == 1 ]]; then + set -x +fi + +gobin="${GOBIN:-go}" +testflags=("-shuffle=on" "-race" "-cover" "-covermode=atomic") + +if [[ "${DBG:-}" == 1 ]]; then + testflags+=("-v") +fi + +printf "$(tput bold)[go:test] running %s...$(tput sgr0)\n" "$(tput setaf 4)go test" +"$gobin" test "${testflags[@]}" ./... diff --git a/tmux/errors.go b/tmux/errors.go new file mode 100644 index 0000000..096ec4c --- /dev/null +++ b/tmux/errors.go @@ -0,0 +1,20 @@ +package tmux + +import "errors" + +var ( + // ErrNilRunner is returned when a nil [Runner] argument is passed. + ErrNilRunner = errors.New("runner is nil") + // ErrNilSession is returned when a nil [Session] argument is passed. + ErrNilSession = errors.New("session is nil") + // ErrNilWindow is returned when a nil [Window] argument is passed. + ErrNilWindow = errors.New("window is nil") + // ErrNilPane is returned when a nil [Pane] argument is passed. + ErrSessionClosed = errors.New("session is closed") + // ErrSessionNotApplied is returned when an unapplied [Session] is used. + ErrSessionNotApplied = errors.New("session is not applied") + // ErrWindowNotApplied is returned when an unapplied [Window] is used. + ErrWindowNotApplied = errors.New("window is not applied") + // ErrPaneNotApplied is returned when an unapplied [Pane] is used. + ErrPaneNotApplied = errors.New("pane is not applied") +) diff --git a/tmux/output.go b/tmux/output.go new file mode 100644 index 0000000..734c44d --- /dev/null +++ b/tmux/output.go @@ -0,0 +1,95 @@ +package tmux + +import ( + "bytes" + "fmt" + "strings" +) + +var ( + newline = []byte("\n") + comma = []byte(",") + colon = []byte(":") +) + +// outputRecord represents a line of output from a tmux command, that follows +// a specific format for parsing it as key-value pairs. +type outputRecord map[string]string + +// String returns a string representation of the output record. +// +// NOTE: The order of the fields is not guaranteed to be consistent. +func (r outputRecord) String() string { + res := make([]string, 0, len(r)) + + for k, v := range r { + res = append(res, fmt.Sprintf("%s:%s", k, v)) + } + + return strings.Join(res, ",") +} + +// outputFormat returns a tmux command output format to be used with the -F +// flag. +// +// The vars are expected to be valid tmux variable names (e.g. session_id). +// +// The output format is a comma-separated list of key-value pairs, where the +// key is the variable name and the value is the variable placeholder: +// +// "session_id:#{session_id},session_name:#{session_name}" +func outputFormat(vars ...string) string { + res := make([]string, 0, len(vars)) + + for _, v := range vars { + res = append(res, fmt.Sprintf("%s:%s", v, outputFormatVar(v))) + } + + return strings.Join(res, ",") +} + +// parseOutput parses the tmux command output into a slice of output records. +// +// The output is expected to follow the format created by the [outputFormat] +// function. +func parseOutput(output []byte) ([]outputRecord, error) { + output = bytes.TrimSpace(output) + if len(output) == 0 { + return []outputRecord{}, nil + } + + lines := bytes.Split(output, newline) + res := make([]outputRecord, 0, len(lines)) + + for _, line := range lines { + line = bytes.TrimSpace(line) + + if len(lines) == 0 { + continue + } + + record := make(outputRecord) + keyvals := bytes.Split(line, comma) + + for _, kv := range keyvals { + key, val, ok := bytes.Cut(kv, colon) + if !ok { + return nil, fmt.Errorf("invalid key-value pair in command output: %s", kv) + } + + if _, ok := record[string(key)]; ok { + return nil, fmt.Errorf("duplicate key in command output: %s", key) + } + + record[string(key)] = string(val) + } + + res = append(res, record) + } + + return res, nil +} + +func outputFormatVar(name string) string { + return fmt.Sprintf("#{%s}", name) +} diff --git a/tmux/pane.go b/tmux/pane.go new file mode 100644 index 0000000..a21bf94 --- /dev/null +++ b/tmux/pane.go @@ -0,0 +1,375 @@ +package tmux + +import ( + "context" + "fmt" +) + +var paneOutputFormat = outputFormat("pane_id", "pane_path", "pane_index", "pane_width", "pane_height") + +// Pane represents a tmux window pane. +type Pane struct { + tmux Runner + sess *Session + win *Window + pane *Pane + env map[string]string + id string + path string + cmds []string + size string + width string + height string + index string + panes []*Pane + horizontal bool + active bool + state state +} + +// NewPane creates a new Pane instance configured with the provided options and +// belonging to the provided window and optional parent pane. +// +// The window must be applied before being passed to this function. +// If a parent pane is provided, it must be applied before being passed to this +// function. +// +// NOTE: The pane is not created until [Pane.Apply] is called. +func NewPane(runner Runner, window *Window, parentPane *Pane, opts ...PaneOption) (*Pane, error) { + if runner == nil { + return nil, ErrNilRunner + } + + if window == nil { + return nil, ErrNilWindow + } + + if window.sess == nil { + return nil, ErrNilSession + } + + if err := window.sess.checkState(); err != nil { + return nil, fmt.Errorf("checking session state: %w", err) + } + + if parentPane != nil { + if err := parentPane.checkState(); err != nil { + return nil, fmt.Errorf("checking parent pane state: %w", err) + } + } + + p := &Pane{ + tmux: runner, + sess: window.sess, + win: window, + pane: parentPane, + state: stateNew, + } + + for _, opt := range opts { + if err := opt(p); err != nil { + return nil, fmt.Errorf("applying pane option: %w", err) + } + } + + return p, nil +} + +// Apply creates the tmux pane by invoking the split-window command using its +// internal [Runner] instance. +// +// If the pane is already applied, this method is a no-op. +// +// https://man.archlinux.org/man/tmux.1#split-window +func (p *Pane) Apply(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if p.IsClosed() { + return ErrSessionClosed + } + + if p.IsApplied() { + return nil + } + + target := p.win.Name() + if p.pane != nil { + target = p.pane.Name() + } + + args := []string{"split-window", "-d", "-P", "-F", paneOutputFormat, "-t", target} + + args = append(args, p.envArgs()...) + + if p.path != "" { + args = append(args, "-c", p.path) + } + + if p.size != "" { + args = append(args, "-l", p.size) + } + + if p.horizontal { + args = append(args, "-h") + } + + output, err := p.tmux.Run(ctx, args...) + if err != nil { + return fmt.Errorf("running split-window command: %w", err) + } + + if p.tmux.IsDryRun() { + output = []byte(p.dryRunRecord()) + } + + records, err := parseOutput(output) + if err != nil { + return fmt.Errorf("parsing split-window command output: %w", err) + } + + if err := p.update(records[0]); err != nil { + return fmt.Errorf("updating pane data: %w", err) + } + + if p.pane != nil { + p.addPane(p) + } else { + p.win.addPane(p) + } + + p.log("pane created") + + cmds := append(p.sess.onPaneCommands(), p.cmds...) + + return p.RunCommands(ctx, cmds...) +} + +// RunCommands runs the provided commands inside the pane by invoking the +// send-keys tmux command using its internal [Runner] instance. +// +// The commands are automatically followed by a carriage return. +// +// If the pane is not applied, the method returns [ErrPaneNotApplied]. +// +// If no commands are provided, the method is a no-op. +// +// https://man.archlinux.org/man/tmux.1#send-keys +func (p *Pane) RunCommands(ctx context.Context, cmds ...string) error { + if err := ctx.Err(); err != nil { + return err + } + + if len(cmds) == 0 { + return nil + } + + if err := p.checkState(); err != nil { + return fmt.Errorf("checking pane state: %w", err) + } + + for _, cmd := range cmds { + args := []string{"send-keys", "-t", p.Name(), cmd, "C-m"} + if _, err := p.tmux.Run(ctx, args...); err != nil { + return fmt.Errorf("running send-keys command: %w", err) + } + + p.log("pane send-keys", "cmd", cmd+"") + } + + return nil +} + +// Select selects the pane by invoking the select-pane command using its +// internal [Runner] instance. +// +// If the pane is not applied, the method returns [ErrPaneNotApplied]. +// +// https://man.archlinux.org/man/tmux.1#select-pane +func (p *Pane) Select(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if err := p.checkState(); err != nil { + return fmt.Errorf("checking pane state: %w", err) + } + + if _, err := p.tmux.Run(ctx, "select-pane", "-t", p.Name()); err != nil { + return fmt.Errorf("running select-pane command: %w", err) + } + + p.log("pane selected") + + return nil +} + +// Name returns the pane's fully qualified name. +// +// The name is composed of the session name, the window name separated by a +// colon, followed by a dot and the pane index. +func (p *Pane) Name() string { + return fmt.Sprintf("%s.%s", p.win.Name(), p.index) +} + +// IsApplied returns true if the pane has been applied with [Pane.Apply]. +func (p *Pane) IsApplied() bool { + return p.state == stateApplied +} + +// IsClosed return true if the session has been closed. +func (p *Pane) IsClosed() bool { + return p.state == stateClosed || p.win.IsClosed() +} + +// IsActive returns true if the pane is configured as the active pane of its +// window. +func (p *Pane) IsActive() bool { + return p.active +} + +// NumPanes returns the number of panes in the pane. +func (p *Pane) NumPanes() int { + return len(p.panes) +} + +// String returns a string representation of the pane. +func (p *Pane) String() string { + return fmt.Sprintf("pane %s", p.Name()) +} + +// update updates the pane's internal state from the provided output record. +func (p *Pane) update(record outputRecord) error { + fieldsMap := map[string]*string{ + "pane_id": &p.id, + "pane_path": &p.path, + "pane_index": &p.index, + "pane_width": &p.width, + "pane_height": &p.height, + } + + for k, v := range record { + if v == "" { + continue + } + + if field, ok := fieldsMap[k]; ok { + *field = v + } + } + + p.state = stateApplied + + return nil +} + +func (p *Pane) checkState() error { + if p.IsClosed() { + return ErrPaneNotApplied + } + + if p.IsApplied() { + return nil + } + + return ErrPaneNotApplied +} + +func (p *Pane) addPane(pane *Pane) { + p.panes = append(p.panes, pane) + p.win.addPane(p) +} + +func (p *Pane) envArgs() []string { + if p.pane != nil { + return envArgs(p.sess.env, p.win.env, p.pane.env, p.env) + } + + return envArgs(p.sess.env, p.win.env, p.env) +} + +func (p *Pane) log(msg string, args ...any) { + p.tmux.Log(msg, append(args, + "session", p.sess.Name(), "window", p.win.Name(), "pane", p.Name(), + "pane_width", p.width, "pane_height", p.height)...) +} + +// dryRunRecord returns an output record string to use when running in dry-run +// mode. +func (p *Pane) dryRunRecord() string { + return outputRecord{ + "pane_id": fmt.Sprintf("%%%d", p.sess.NumPanes()+1), + "pane_path": p.path, + "pane_index": fmt.Sprintf("%d", p.win.NumPanes()+1), + "pane_width": "40", + "pane_height": "12", + }.String() +} + +// PaneOption configures a [Pane]. +type PaneOption func(*Pane) error + +// PaneWithSize configures the [Pane] size. +// +// The size can be specified as a percentage of the available space or as a +// number of lines or columns. +func PaneWithSize(size string) PaneOption { + return func(p *Pane) error { + p.size = size + return nil + } +} + +// PaneWithPath configures the [Pane] working directory. +// +// If a pane is not configured with a working directory, the window's working +// directory is used instead. +func PaneWithPath(s string) PaneOption { + return func(p *Pane) error { + p.path = s + return nil + } +} + +// PaneWithCommands configures the [Pane] with an initial shell commands. +// +// The commands will run in the pane after it has been created. +// +// NOTE: Commands are appended to the list of commands, so applying this option +// multiple times will add to the list of commands. +func PaneWithCommands(cmds ...string) PaneOption { + return func(p *Pane) error { + p.cmds = append(p.cmds, cmds...) + return nil + } +} + +// PaneWithHorizontalDirection configures the [Pane] to be horizontal instead of +// vertical. +func PaneWithHorizontalDirection() PaneOption { + return func(p *Pane) error { + p.horizontal = true + return nil + } +} + +// PaneAsActive configures the [Pane] to be the active pane of its window. +func PaneAsActive() PaneOption { + return func(p *Pane) error { + p.active = true + return nil + } +} + +// PaneWithEnv configures the [Pane] with environment variables. +// +// Environment variables are inherited from session to window to pane. If a +// an environment variable is is named the same as an inherited variable, it +// will take precedence. +func PaneWithEnv(env map[string]string) PaneOption { + return func(p *Pane) error { + p.env = env + return nil + } +} diff --git a/tmux/runner.go b/tmux/runner.go new file mode 100644 index 0000000..408c3db --- /dev/null +++ b/tmux/runner.go @@ -0,0 +1,251 @@ +package tmux + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// DefaultTmux is the default name of the tmux executable. +const DefaultTmux = "tmux" + +// Runner runs tmux commands and provide logging functionality. +type Runner interface { + // Run runs the tmux command in a context-aware manner with the provided + // arguments and return the output. + Run(ctx context.Context, args ...string) ([]byte, error) + // Execve runs the tmux command with the provided arguments using the execve + // syscall ([syscall.Exec]) to replace the current process with the tmux + // process. + Execve(args ...string) error + // IsDryRun returns true if the runner is in dry-run mode. + // + // Dry-run mode means that the runner will not actually run any tmux commands + // but only log the commands that would have been run and return empty output. + IsDryRun() bool + // Debug writes a debug message using a [slog.Logger]. + Debug(msg string, args ...any) + // Log writes an info message using a [slog.Logger]. + Log(msg string, args ...any) + // SetLogger sets the logger used by the runner. + SetLogger(logger *slog.Logger) +} + +// OSCommandRunner runs a command with the provided name and arguments and +// returns the output. +type OSCommandRunner func(ctx context.Context, name string, args ...string) (output []byte, err error) + +// SyscallExecRunner runs a command with the provided arguments using the execve +// syscall ([syscall.Exec]) to replace the current process with the new +// process. +type SyscallExecRunner func(string, []string, []string) error + +// defaultOSCmdRunner is the default [OSCommandRunner] implementation. +var defaultOSCmdRunner OSCommandRunner = func(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + + output, err := cmd.CombinedOutput() + if err != nil { + return output, err //nolint:wrapcheck // Wrapping is done by caller. + } + + return output, nil +} + +// state represents the state of a tmux session, window, or pane. +type state int + +const ( + stateNew state = iota // New and not yet applied. + stateApplied // Applied with a tmux command. + stateClosed // Closed or removed with a tmux command. +) + +// DefaultRunner is the default [Runner] implementation. +type DefaultRunner struct { + logger *slog.Logger + cmdRunner OSCommandRunner + execveRunner SyscallExecRunner + tmux string + tmuxOpts []string + dryRun bool +} + +// NewRunner creates a new [Runner] with the provided options. +func NewRunner(opts ...RunnerOption) (*DefaultRunner, error) { + c := &DefaultRunner{ + tmux: DefaultTmux, + cmdRunner: defaultOSCmdRunner, + execveRunner: syscall.Exec, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, fmt.Errorf("applying option: %w", err) + } + } + + return c, nil +} + +// Run runs the tmux command in a context-aware manner with the provided +// arguments and returns the output. +func (c *DefaultRunner) Run(ctx context.Context, args ...string) ([]byte, error) { + start := time.Now() + + args = append(c.tmuxOpts, args...) + + msg := "command successful" + + if c.dryRun { + c.Debug(msg, "name", c.tmux, "args", args, "dur", time.Since(start)) + return []byte{}, nil + } + + output, err := c.cmdRunner(ctx, c.tmux, args...) + if err != nil { + msg = "command failed" + } + + c.Debug(msg, "name", c.tmux, "args", args, "output", strings.TrimSpace(string(output)), "dur", time.Since(start)) + + return output, err +} + +// Execve runs the tmux command with the provided arguments using the execve +// syscall ([syscall.Exec]) to replace the current process with the tmux +// process. +func (c *DefaultRunner) Execve(args ...string) error { + start := time.Now() + + args = append(c.tmuxOpts, args...) + + absPath, err := exec.LookPath(c.tmux) + if err != nil { + return fmt.Errorf("looking up absolute path for %s executable: %w", c.tmux, err) + } + + args = append([]string{absPath}, args...) + + msg := "execve successful" + + if c.dryRun { + c.Debug(msg, "path", c.tmux, "args", args[1:], "dur", time.Since(start)) + return nil + } + + err = c.execveRunner(absPath, args, os.Environ()) + if err != nil { + msg = "execve failed" + } + + c.Debug(msg, "path", absPath, "args", args[1:], "dur", time.Since(start)) + + return err +} + +// IsDryRun returns true if the runner is in dry-run mode. +// +// Dry-run mode means that the runner will not actually run any tmux commands +// but only log the commands that would have been run and return empty output. +func (c *DefaultRunner) IsDryRun() bool { + return c.dryRun +} + +// Debug logs a debug message using a [slog.Logger]. +func (c *DefaultRunner) Debug(msg string, args ...any) { + if c.dryRun { + args = append(args, "dry_run", true) + } + + c.logger.Debug(msg, args...) +} + +// Log logs an info message using a [slog.Logger]. +func (c *DefaultRunner) Log(msg string, args ...any) { + if c.dryRun { + args = append(args, "dry_run", true) + } + + c.logger.Info(msg, args...) +} + +// SetLogger sets the logger used by the runner. +func (c *DefaultRunner) SetLogger(logger *slog.Logger) { + c.logger = logger +} + +// RunnerOption configures a [DefaultRunner]. +type RunnerOption func(*DefaultRunner) error + +// WithTmux configures the runner to use provided name as the tmux executable. +// +// The default is "tmux". +func WithTmux(name string) RunnerOption { + return func(c *DefaultRunner) error { + c.tmux = name + return nil + } +} + +// WithTmuxOptions configures the runner with additional tmux options to be +// added to all tmux command invocations. +func WithTmuxOptions(opts ...string) RunnerOption { + return func(c *DefaultRunner) error { + c.tmuxOpts = opts + return nil + } +} + +// WithOSCommandRunner configures the runner to use the provided +// [OSCommandRunner] for running tmux commands. +// +// This option is intended for testing purposes only. +func WithOSCommandRunner(runner OSCommandRunner) RunnerOption { + return func(c *DefaultRunner) error { + c.cmdRunner = runner + return nil + } +} + +// WithSyscallExecRunner configures the runner to use the provided +// [SyscallExecRunner] for running tmux commands. +// +// This option is intended for testing purposes only. +func WithSyscallExecRunner(runner SyscallExecRunner) RunnerOption { + return func(c *DefaultRunner) error { + c.execveRunner = runner + return nil + } +} + +// WithLogger configures the runner to use the provided [slog.Logger] for +// logging. +// +// The default is a no-op logger writing to [os.Discard]. +func WithLogger(logger *slog.Logger) RunnerOption { + return func(c *DefaultRunner) error { + c.logger = logger + return nil + } +} + +// WithDryRunMode configures the runner to run in dry-run mode. +// +// Dry-run mode means that the runner will not actually run any tmux commands +// but only log the commands that would have been run and return empty output. +// +// The default is false. +func WithDryRunMode(enable bool) RunnerOption { + return func(c *DefaultRunner) error { + c.dryRun = enable + return nil + } +} diff --git a/tmux/runner_test.go b/tmux/runner_test.go new file mode 100644 index 0000000..d8c4550 --- /dev/null +++ b/tmux/runner_test.go @@ -0,0 +1,249 @@ +package tmux_test + +import ( + "bytes" + "context" + "io/fs" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/michenriksen/tmpl/internal/testutils" + "github.com/michenriksen/tmpl/tmux" +) + +func TestDefaultRunner_ImplementsRunnerIface(t *testing.T) { + _, ok := any(&tmux.DefaultRunner{}).(tmux.Runner) + + require.True(t, ok, "expected DefaultRunner to implement Runner interface") +} + +func TestDefaultRunner_Run(t *testing.T) { + tt := []struct { + name string + args []string + opts []tmux.RunnerOption + assertRun func(t *testing.T, name string, args ...string) ([]byte, error) + assertErr testutils.ErrorAssertion + wantOutput *regexp.Regexp + }{ + { + "command runner success", + []string{"new-session", "-d", "-s", "test"}, + []tmux.RunnerOption{tmux.WithTmux("tmpl_test_tmux")}, + func(t *testing.T, name string, args ...string) ([]byte, error) { + require.Equal(t, "tmpl_test_tmux", name) + require.Equal(t, []string{"new-session", "-d", "-s", "test"}, args) + + return []byte{}, nil + }, + nil, nil, + }, + { + "command runner error", + []string{}, + nil, + func(*testing.T, string, ...string) ([]byte, error) { + return []byte("test output"), exec.ErrNotFound + }, + testutils.AssertErrorIs(exec.ErrNotFound), + regexp.MustCompile(`^test output$`), + }, + { + "dry-run mode", + []string{}, + []tmux.RunnerOption{tmux.WithDryRunMode(true)}, + func(t *testing.T, _ string, _ ...string) ([]byte, error) { + t.Fatal("did not expect command runner to be invoked in dry-run mode") + return []byte{}, nil + }, + nil, nil, + }, + { + "extra options", + []string{"test"}, + []tmux.RunnerOption{tmux.WithTmuxOptions("--extra", "options")}, + func(t *testing.T, _ string, args ...string) ([]byte, error) { + require.Contains(t, args, "--extra") + require.Contains(t, args, "options") + require.Contains(t, args, "test") + + return []byte{}, nil + }, nil, nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + output := bytes.Buffer{} + logger := slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{ + Level: slog.LevelDebug, + ReplaceAttr: testutils.NewSlogStabilizer(t), + })) + + cmdRunner := func(_ context.Context, name string, args ...string) ([]byte, error) { + require.NotNil(t, tc.assertRun, "expected assertRun callback function to be defined") + + t.Log("invoking assertRun callback function") + return tc.assertRun(t, name, args...) + } + + execveRunner := func(argv0 string, args, envv []string) error { + t.Fatalf("unexpected call to Execve: argv0=%q, args=%q, envv=%q", argv0, args, envv) + return nil + } + + opts := append([]tmux.RunnerOption{ + tmux.WithLogger(logger), + tmux.WithOSCommandRunner(cmdRunner), + tmux.WithSyscallExecRunner(execveRunner), + }, tc.opts...) + + runner, err := tmux.NewRunner(opts...) + require.NoError(t, err) + + out, err := runner.Run(context.Background(), tc.args...) + + if tc.assertErr != nil { + require.Error(t, err) + tc.assertErr(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, out, "expected non-nil output on success") + } + + if tc.wantOutput != nil { + require.Regexp(t, tc.wantOutput, string(out)) + } + + testutils.NewGolden(t).RequireMatch(output.Bytes()) + }) + } +} + +func TestDefaultRunner_Run_Integration(t *testing.T) { + runner, err := tmux.NewRunner(tmux.WithTmux("echo")) + require.NoError(t, err) + + out, err := runner.Run(context.Background(), "hello world") + require.NoError(t, err) + require.Equal(t, "hello world\n", string(out)) + + runner, err = tmux.NewRunner(tmux.WithTmux("false")) + require.NoError(t, err) + + out, err = runner.Run(context.Background()) + require.ErrorContains(t, err, "exit status 1") + require.Empty(t, out) +} + +func TestDefaultRunner_Execve(t *testing.T) { + stubPath := t.TempDir() + execPath := filepath.Join(stubPath, "tmux") + + testutils.WriteFile(t, []byte{}, execPath) + require.NoError(t, os.Chmod(execPath, 0o700)) + + t.Setenv("PATH", stubPath) + + tt := []struct { + name string + args []string + opts []tmux.RunnerOption + assertExecve func(t *testing.T, argv0 string, args, envv []string) error + assertErr testutils.ErrorAssertion + }{ + { + "syscall exec runner success", + []string{"attach-session", "-t", "test"}, + nil, + func(t *testing.T, argv0 string, args, envv []string) error { + require.Equal(t, execPath, argv0) + require.Equal(t, []string{execPath, "attach-session", "-t", "test"}, args) + require.Equal(t, os.Environ(), envv) + + return nil + }, + nil, + }, + { + "syscall exec runner error", + []string{}, + nil, + func(t *testing.T, argv0 string, args, envv []string) error { + return fs.ErrNotExist + }, + testutils.RequireErrorIs(fs.ErrNotExist), + }, + { + "dry-run mode", + []string{}, + []tmux.RunnerOption{tmux.WithDryRunMode(true)}, + func(t *testing.T, _ string, _, _ []string) error { + t.Fatal("did not expect command runner to be invoked in dry-run mode") + + return nil + }, + nil, + }, + { + "extra options", + []string{"test"}, + []tmux.RunnerOption{tmux.WithTmuxOptions("--extra", "options")}, + func(t *testing.T, argv0 string, args, _ []string) error { + require.Equal(t, argv0, args[0]) + require.Contains(t, args, "--extra") + require.Contains(t, args, "options") + require.Contains(t, args, "test") + + return nil + }, + nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + output := bytes.Buffer{} + logger := slog.New(slog.NewTextHandler(&output, &slog.HandlerOptions{ + Level: slog.LevelDebug, + ReplaceAttr: testutils.NewSlogStabilizer(t), + })) + + execveRunner := func(argv0 string, args, envv []string) error { + require.NotNil(t, tc.assertExecve, "expected assertExecve callback function to be defined") + + t.Log("invoking assertExecve callback function") + return tc.assertExecve(t, argv0, args, envv) + } + + cmdRunner := func(_ context.Context, name string, args ...string) ([]byte, error) { + t.Fatalf("unexpected call to Run: name=%q, args=%q", name, args) + return nil, nil + } + + opts := append([]tmux.RunnerOption{ + tmux.WithLogger(logger), + tmux.WithOSCommandRunner(cmdRunner), + tmux.WithSyscallExecRunner(execveRunner), + }, tc.opts...) + + runner, err := tmux.NewRunner(opts...) + require.NoError(t, err) + + err = runner.Execve(tc.args...) + + if tc.assertErr != nil { + require.Error(t, err) + tc.assertErr(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/tmux/session.go b/tmux/session.go new file mode 100644 index 0000000..07ddced --- /dev/null +++ b/tmux/session.go @@ -0,0 +1,424 @@ +package tmux + +import ( + "context" + "fmt" + "strings" +) + +var sessionOutputFormat = outputFormat("session_id", "session_name", "session_path") + +// Session represents a tmux session. +type Session struct { + tmux Runner + id string + path string + name string + winCmd string + paneCmd string + anyCmd string + env map[string]string + windows []*Window + state state +} + +// NewSession creates a new Session instance configured with the provided +// options. +// +// NOTE: The session is not created until [Session.Apply] is called. +func NewSession(runner Runner, opts ...SessionOption) (*Session, error) { + if runner == nil { + return nil, ErrNilRunner + } + + s := &Session{tmux: runner, state: stateNew} + + for _, opt := range opts { + if err := opt(s); err != nil { + return nil, fmt.Errorf("applying session option: %w", err) + } + } + + return s, nil +} + +// Apply creates the tmux session by invoking the new-session command using its +// internal [Runner] instance. +// +// If the session is already applied, this method is a no-op. +// +// https://man.archlinux.org/man/tmux.1#new-session +func (s *Session) Apply(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if s.IsClosed() { + return ErrSessionClosed + } + + if s.IsApplied() { + return nil + } + + args := []string{"new-session", "-d", "-P", "-F", sessionOutputFormat} + + if s.name != "" { + args = append(args, "-s", s.name) + } + + output, err := s.tmux.Run(ctx, args...) + if err != nil { + return fmt.Errorf("running new-session command: %w", err) + } + + if s.tmux.IsDryRun() { + output = []byte(s.dryRunRecord()) + } + + records, err := parseOutput(output) + if err != nil { + return fmt.Errorf("parsing new-session command output: %w", err) + } + + if err := s.update(records[0]); err != nil { + return fmt.Errorf("updating session data: %w", err) + } + + s.log("session created") + + return nil +} + +// Attach attaches the current client to the session by invoking a tmux command +// using its internal [Runner] instance. +// +// NOTE: [syscall.Exec] is used to replace the current process with the tmux +// client process. This means that this method will never return if it +// succeeds. +// +// If the TMUX environment variable is set, it is assumed that the current +// process is already attached to a tmux session. In this case, the +// switch-client command is used instead of the attach-session command. +// +// https://man.archlinux.org/man/tmux.1#attach-session +// https://man.archlinux.org/man/tmux.1#switch-client +func (s *Session) Attach(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if err := s.checkState(); err != nil { + return fmt.Errorf("checking session state: %w", err) + } + + if inTmux() { + s.log("switching client to session") + + if err := s.tmux.Execve("switch-client", "-t", s.name); err != nil { + return fmt.Errorf("running switch-client command: %w", err) + } + + return nil + } + + s.log("attaching client to session") + + if err := s.tmux.Execve("attach-session", "-t", s.name); err != nil { + return fmt.Errorf("running attach-session command: %w", err) + } + + return nil +} + +// SelectActive selects the window configured as the active window by invoking +// the select-window command using its internal [Runner] instance. +// +// If no window is configured as active, the first window is selected. +// +// https://man.archlinux.org/man/tmux.1#select-window +func (s *Session) SelectActive(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if err := s.checkState(); err != nil { + return fmt.Errorf("checking session state: %w", err) + } + + if s.NumWindows() == 1 { + return s.windows[0].selectPane(ctx) + } + + activeWin := s.windows[0] + + for _, w := range s.windows[1:] { + if w.IsActive() { + activeWin = w + } + } + + return activeWin.Select(ctx) +} + +// Name returns the session name. +func (s *Session) Name() string { + return s.name +} + +// NumWindows returns the number of windows in the session. +func (s *Session) NumWindows() int { + return len(s.windows) +} + +// NumPanes returns the number of panes across all windows in the session. +func (s *Session) NumPanes() int { + n := 0 + + for _, w := range s.windows { + n += len(w.panes) + } + + return n +} + +// Close closes the session by invoking the kill-session command using its +// internal [Runner] instance. +// +// If the session is already closed or not applied, this method is a no-op. +// +// Any subsequent calls to command-invoking methods on the session or any of its +// windows or panes will return an [ErrSessionClosed] error. +// +// https://man.archlinux.org/man/tmux.1#kill-session +func (s *Session) Close() error { + if s.IsClosed() || !s.IsApplied() { + return nil + } + + if _, err := s.tmux.Run(context.Background(), "kill-session", "-t", s.name); err != nil { + return fmt.Errorf("running kill-session command: %w", err) + } + + s.state = stateClosed + s.windows = nil + + s.tmux.Debug("session closed", "session", s.name) + + return nil +} + +// IsClosed returns true if the session has been closed with [Session.Close]. +func (s *Session) IsClosed() bool { + return s.state == stateClosed +} + +// IsApplied returns true if the session has been applied with [Session.Apply]. +func (s *Session) IsApplied() bool { + return s.state == stateApplied +} + +// String returns a string representation of the session. +func (s *Session) String() string { + return fmt.Sprintf("session %s", s.Name()) +} + +// update updates the session's internal state from the provided output record. +func (s *Session) update(record outputRecord) error { + fieldsMap := map[string]*string{ + "session_id": &s.id, + "session_name": &s.name, + "session_path": &s.path, + } + + for k, v := range record { + if v == "" { + continue + } + + if field, ok := fieldsMap[k]; ok { + *field = v + } + } + + s.state = stateApplied + + return nil +} + +// addWindow adds a window to the session. +func (s *Session) addWindow(w *Window) { + s.windows = append(s.windows, w) +} + +// checkState checks that the session is applied and not closed. +// +// Returns [ErrSessionClosed] if the session is closed. +// +// Returns [ErrSessionNotApplied] if the session is not applied. +func (s *Session) checkState() error { + if s.IsClosed() { + return ErrSessionClosed + } + + if !s.IsApplied() { + return ErrSessionNotApplied + } + + return nil +} + +func (s *Session) onWindowCommands() []string { + var cmds []string + + if s.anyCmd != "" { + cmds = append(cmds, s.anyCmd) + } + + if s.winCmd != "" { + cmds = append(cmds, s.winCmd) + } + + return cmds +} + +func (s *Session) onPaneCommands() []string { + var cmds []string + + if s.anyCmd != "" { + cmds = append(cmds, s.anyCmd) + } + + if s.paneCmd != "" { + cmds = append(cmds, s.paneCmd) + } + + return cmds +} + +func (s *Session) log(msg string, args ...any) { + if s.NumWindows() > 0 { + args = append(args, "windows", s.NumWindows()) + } + + if s.NumPanes() > 0 { + args = append(args, "panes", s.NumPanes()) + } + + s.tmux.Log(msg, append(args, "session", s.name)...) +} + +// dryRunRecord returns an output record string to use when running in dry-run +// mode. +func (s *Session) dryRunRecord() string { + return outputRecord{ + "session_id": "$0", + "session_name": s.name, + "session_path": s.path, + }.String() +} + +// SessionOption configures a [Session]. +type SessionOption func(*Session) error + +// SessionWithName configures the [Session] name. +func SessionWithName(name string) SessionOption { + return func(s *Session) error { + s.name = name + return nil + } +} + +// SessionWithPath configures the [Session] working directory. +func SessionWithPath(path string) SessionOption { + return func(s *Session) error { + s.path = path + + return nil + } +} + +// SessionWithOnWindowCommand configures a [Session] with a shell command that +// will be run in all created windows. +// +// If a command is also configured with [SessionWithOnAnyCommand], the window +// command will be run after the any command. +func SessionWithOnWindowCommand(cmd string) SessionOption { + return func(s *Session) error { + s.winCmd = strings.TrimSpace(cmd) + return nil + } +} + +// SessionWithOnPaneCommand configures a [Session] with a shell command that +// will be run in all created panes. +// +// If a command is also configured with [SessionWithOnAnyCommand], the pane +// command will be run after the any command. +func SessionWithOnPaneCommand(cmd string) SessionOption { + return func(s *Session) error { + s.paneCmd = strings.TrimSpace(cmd) + return nil + } +} + +// SessionWithOnAnyCommand configures a [Session] with a shell command that will +// be run in all created windows and panes. +// +// If a command is also configured with [SessionWithOnWindowCommand] or +// [SessionWithOnPaneCommand], the any command will be run first. +func SessionWithOnAnyCommand(cmd string) SessionOption { + return func(s *Session) error { + s.anyCmd = strings.TrimSpace(cmd) + return nil + } +} + +// SessionWithEnv configures the [Session] environment variables. +// +// Environment variables are inherited from session to window to pane. If a +// window or pane is confiured with a similarly named environment variable, it +// will take precedence over the session environment variable. +func SessionWithEnv(env map[string]string) SessionOption { + return func(s *Session) error { + s.env = env + return nil + } +} + +// GetSessions returns a list of current tmux sessions by invoking the +// list-sessions command using the provided [Runner] instance. +// +// https://man.archlinux.org/man/tmux.1#list-sessions +func GetSessions(ctx context.Context, runner Runner) ([]*Session, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + if runner == nil { + return nil, ErrNilRunner + } + + output, err := runner.Run(ctx, "list-sessions", "-F", sessionOutputFormat) + if err != nil { + return nil, fmt.Errorf("running list-sessions command: %w", err) + } + + records, err := parseOutput(output) + if err != nil { + return nil, fmt.Errorf("parsing list-sessions command output: %w", err) + } + + sessions := make([]*Session, len(records)) + + for i, record := range records { + s := &Session{tmux: runner, state: stateApplied} + if err := s.update(record); err != nil { + return nil, fmt.Errorf("updating session data: %w", err) + } + + sessions[i] = s + } + + return sessions, nil +} diff --git a/tmux/shared.go b/tmux/shared.go new file mode 100644 index 0000000..6a6278b --- /dev/null +++ b/tmux/shared.go @@ -0,0 +1,62 @@ +package tmux + +import ( + "fmt" + "os" + "sort" + "strings" +) + +// envArgs returns a slice of tmux command arguments to set the provided +// environment variables. +// +// The arguments are sorted by key to make the output deterministic. +func envArgs(envs ...map[string]string) []string { + m := mergeMaps(envs...) + + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Strings(keys) + + args := make([]string, 0, len(m)*2) + + for _, k := range keys { + eVal := fmt.Sprintf("%s=%s", k, os.ExpandEnv(m[k])) + args = append(args, "-e", eVal) + } + + return args +} + +// mergeMaps merges the provided maps into a single map. +func mergeMaps(maps ...map[string]string) map[string]string { + res := make(map[string]string) + + for _, m := range maps { + for k, v := range m { + res[k] = v + } + } + + return res +} + +// inTmux returns true if the application is running inside tmux. +func inTmux() bool { + if os.Getenv("TERM_PROGRAM") == "tmux" { + return true + } + + if os.Getenv("TMUX") != "" { + return true + } + + if strings.Contains(os.Getenv("TERM"), "tmux") { + return true + } + + return false +} diff --git a/tmux/testdata/golden/TestDefaultRunner_Run/command_runner_error.golden.json b/tmux/testdata/golden/TestDefaultRunner_Run/command_runner_error.golden.json new file mode 100644 index 0000000..895e4ee --- /dev/null +++ b/tmux/testdata/golden/TestDefaultRunner_Run/command_runner_error.golden.json @@ -0,0 +1,4 @@ +[ + "time=0001-01-01T00:00:00.000Z level=DEBUG msg=\"command failed\" name=tmux args=[] output=\"test output\" dur=0s", + "" +] diff --git a/tmux/testdata/golden/TestDefaultRunner_Run/command_runner_success.golden.json b/tmux/testdata/golden/TestDefaultRunner_Run/command_runner_success.golden.json new file mode 100644 index 0000000..d11e562 --- /dev/null +++ b/tmux/testdata/golden/TestDefaultRunner_Run/command_runner_success.golden.json @@ -0,0 +1,4 @@ +[ + "time=0001-01-01T00:00:00.000Z level=DEBUG msg=\"command successful\" name=tmpl_test_tmux args=\"[new-session -d -s test]\" output=\"\" dur=0s", + "" +] diff --git a/tmux/testdata/golden/TestDefaultRunner_Run/dry-run_mode.golden.json b/tmux/testdata/golden/TestDefaultRunner_Run/dry-run_mode.golden.json new file mode 100644 index 0000000..4c58539 --- /dev/null +++ b/tmux/testdata/golden/TestDefaultRunner_Run/dry-run_mode.golden.json @@ -0,0 +1,4 @@ +[ + "time=0001-01-01T00:00:00.000Z level=DEBUG msg=\"command successful\" name=tmux args=[] dur=0s dry_run=true", + "" +] diff --git a/tmux/testdata/golden/TestDefaultRunner_Run/extra_options.golden.json b/tmux/testdata/golden/TestDefaultRunner_Run/extra_options.golden.json new file mode 100644 index 0000000..130dfd7 --- /dev/null +++ b/tmux/testdata/golden/TestDefaultRunner_Run/extra_options.golden.json @@ -0,0 +1,4 @@ +[ + "time=0001-01-01T00:00:00.000Z level=DEBUG msg=\"command successful\" name=tmux args=\"[--extra options test]\" output=\"\" dur=0s", + "" +] diff --git a/tmux/window.go b/tmux/window.go new file mode 100644 index 0000000..b224fae --- /dev/null +++ b/tmux/window.go @@ -0,0 +1,391 @@ +package tmux + +import ( + "bytes" + "context" + "fmt" +) + +var windowOutputFormat = outputFormat( + "window_id", "window_name", "window_path", "window_index", + "window_width", "window_height", +) + +// Window represents a tmux window. +type Window struct { + tmux Runner + sess *Session + id string + name string + path string + cmds []string + index string + width string + height string + env map[string]string + panes []*Pane + active bool + state state +} + +// NewWindow creates a new Window instance configured with the provided options +// and belonging to the provided session. +// +// The session must be applied before being passed to this function. +// +// NOTE: The window is not created until [Window.Apply] is called. +func NewWindow(runner Runner, session *Session, opts ...WindowOption) (*Window, error) { + if runner == nil { + return nil, ErrNilRunner + } + + if session == nil { + return nil, ErrNilSession + } + + if err := session.checkState(); err != nil { + return nil, fmt.Errorf("checking session state: %w", err) + } + + w := &Window{tmux: runner, sess: session, state: stateNew} + + for _, opt := range opts { + if err := opt(w); err != nil { + return nil, fmt.Errorf("applying window option: %w", err) + } + } + + return w, nil +} + +// Apply creates the tmux window by invoking the new-window command using its +// internal [Runner] instance. +// +// If the window is already applied, this method is a no-op. +// +// If the window is the first window in the session, it is created with the -k +// flag to override the default initial window created by the new-session +// command. +// +// https://man.archlinux.org/man/tmux.1#new-window +func (w *Window) Apply(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if w.IsClosed() { + return ErrSessionClosed + } + + if w.IsApplied() { + return nil + } + + args := []string{"new-window", "-P", "-F", windowOutputFormat} + + if w.sess.NumWindows() == 0 { + args = append(args, "-k", "-t", fmt.Sprintf("%s:^", w.sess.Name())) + } else { + args = append(args, "-t", fmt.Sprintf("%s:", w.sess.Name())) + } + + args = append(args, envArgs(w.sess.env, w.env)...) + + if w.name != "" { + args = append(args, "-n", w.name) + } + + if w.path != "" { + args = append(args, "-c", w.path) + } + + output, err := w.tmux.Run(ctx, args...) + if err != nil { + return fmt.Errorf("running new-window command: %w", err) + } + + if w.tmux.IsDryRun() { + output = []byte(w.dryRunRecord()) + } + + records, err := parseOutput(output) + if err != nil { + return fmt.Errorf("parsing new-window command output: %w", err) + } + + if err := w.update(records[0]); err != nil { + return fmt.Errorf("updating window data: %w", err) + } + + w.sess.addWindow(w) + w.log("window created") + + cmds := append(w.sess.onWindowCommands(), w.cmds...) + + return w.RunCommands(ctx, cmds...) +} + +// Select selects the window by invoking the select-window command using its +// internal [Runner] instance. +// +// If the window is not applied, the method returns [ErrWindowNotApplied]. +// +// If the window has a pane configured to be the active pane, the pane is also +// selected. +// +// https://man.archlinux.org/man/tmux.1#select-window +func (w *Window) Select(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if err := w.checkState(); err != nil { + return fmt.Errorf("checking window state: %w", err) + } + + if _, err := w.tmux.Run(ctx, "select-window", "-t", w.Name()); err != nil { + return fmt.Errorf("running select-window command: %w", err) + } + + w.log("window selected") + + return w.selectPane(ctx) +} + +// selectPane selects the pane configured as the active pane. +// +// If no pane is configured as the active pane, the first pane is selected. +func (w *Window) selectPane(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if err := w.checkState(); err != nil { + return fmt.Errorf("checking window state: %w", err) + } + + if w.NumPanes() == 0 { + return nil + } + + var activePane *Pane + + for _, p := range w.panes { + if p.IsActive() { + activePane = p + } + } + + // If no pane is set as active, select the first pane. + if activePane == nil { + baseIndex, err := w.tmux.Run(ctx, "show-option", "-gqv", "pane-base-index") + if err != nil { + return fmt.Errorf("running show-option command: %w", err) + } + + pTarget := fmt.Sprintf("%s.%s", w.Name(), bytes.TrimSpace(baseIndex)) + if _, err := w.tmux.Run(ctx, "select-pane", "-t", pTarget); err != nil { + return fmt.Errorf("running select-pane command: %w", err) + } + + return nil + } + + if err := activePane.Select(ctx); err != nil { + return fmt.Errorf("selecting active window pane: %w", err) + } + + return nil +} + +// RunCommands runs the provided commands inside the window by invoking the +// send-keys tmux command using its internal [Runner] instance. +// +// The commands are automatically followed by a carriage return. +// +// If the window is not applied, the method returns [ErrWindowNotApplied]. +// +// If no commands are provided, the method is a no-op. +// +// https://man.archlinux.org/man/tmux.1#send-keys +func (w *Window) RunCommands(ctx context.Context, cmds ...string) error { + if err := ctx.Err(); err != nil { + return err + } + + if len(cmds) == 0 { + return nil + } + + if err := w.checkState(); err != nil { + return fmt.Errorf("checking window state: %w", err) + } + + for _, cmd := range cmds { + args := []string{"send-keys", "-t", w.Name(), cmd, "C-m"} + if _, err := w.tmux.Run(ctx, args...); err != nil { + return fmt.Errorf("running send-keys command: %w", err) + } + + w.log("window send-keys", "cmd", cmd+"") + } + + return nil +} + +// Name returns the window's fully qualified name. +// +// The name is composed of the session name and the window name separated by a +// colon. +func (w *Window) Name() string { + return fmt.Sprintf("%s:%s", w.sess.Name(), w.name) +} + +// IsApplied returns true if the window has been applied with [Window.Apply]. +func (w *Window) IsApplied() bool { + return w.state == stateApplied +} + +// IsClosed returns true if the session has been closed. +func (w *Window) IsClosed() bool { + return w.state == stateClosed || w.sess.IsClosed() +} + +// IsActive returns true if the window is configured as the active window of its +// session. +func (w *Window) IsActive() bool { + return w.active +} + +// NumPanes returns the number of panes in the window. +func (w *Window) NumPanes() int { + return len(w.panes) +} + +// String returns a string representation of the window. +func (w *Window) String() string { + return fmt.Sprintf("window %s", w.Name()) +} + +// update updates the window's internal state from the provided output record. +func (w *Window) update(record outputRecord) error { + fieldsMap := map[string]*string{ + "window_id": &w.id, + "window_name": &w.name, + "window_path": &w.path, + "session_path": &w.path, + "window_index": &w.index, + "window_width": &w.width, + "window_height": &w.height, + } + + for k, v := range record { + if v == "" { + continue + } + + if field, ok := fieldsMap[k]; ok { + *field = v + } + } + + w.state = stateApplied + + return nil +} + +func (w *Window) addPane(p *Pane) { + w.panes = append(w.panes, p) +} + +func (w *Window) checkState() error { + if w.IsClosed() { + return ErrSessionClosed + } + + if !w.IsApplied() { + return ErrWindowNotApplied + } + + return nil +} + +func (w *Window) log(msg string, args ...any) { + w.tmux.Log(msg, append(args, "session", w.sess.Name(), "window", w.Name())...) +} + +// dryRunRecord returns an output record string to use when running in dry-run +// mode. +func (w *Window) dryRunRecord() string { + wID := w.sess.NumWindows() + 1 + + return outputRecord{ + "window_id": fmt.Sprintf("@%d", wID), + "window_name": w.name, + "window_path": w.path, + "window_index": fmt.Sprintf("%d", wID), + "window_width": "80", + "window_height": "24", + }.String() +} + +// WindowOption configures a [Window]. +type WindowOption func(*Window) error + +// WindowWithName configures the [Window] with a name. +func WindowWithName(name string) WindowOption { + return func(w *Window) error { + if name == "" { + return fmt.Errorf("window name cannot be empty") + } + + w.name = name + + return nil + } +} + +// WindowWithPath configures the [Window] with a working directory. +// +// If a window is not configured with a working directory, the session's working +// directory is used instead. +func WindowWithPath(p string) WindowOption { + return func(w *Window) error { + w.path = p + return nil + } +} + +// WindowWithCommands configures the [Window] with an initial shell commands. +// +// The commands will run in the window after it has been created. +// +// NOTE: Commands are appended to the list of commands, so applying this option +// multiple times will add to the list of commands. +func WindowWithCommands(cmds ...string) WindowOption { + return func(w *Window) error { + w.cmds = append(w.cmds, cmds...) + return nil + } +} + +// WindowAsActive configures the [Window] to be the active window of its +// session. +func WindowAsActive() WindowOption { + return func(w *Window) error { + w.active = true + return nil + } +} + +// WindowWithEnv configures the [Window] with environment variables. +// +// Environment variables are inherited from session to window to pane. If a +// an environment variable is is named the same as an inherited variable, it +// will take precedence. +func WindowWithEnv(env map[string]string) WindowOption { + return func(w *Window) error { + w.env = env + return nil + } +}