commit 607e84665cc261ffb32a53c781c4bdeb6cc50d79 Author: wehub-resource-sync Date: Mon Jul 13 12:33:05 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.doc/create-termshots.sh b/.doc/create-termshots.sh new file mode 100755 index 0000000..27f89ba --- /dev/null +++ b/.doc/create-termshots.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +export CLICOLOR=1 + +termshot --clip-canvas --filename .doc/example-cmd-figlet.png -- lolcat -f <(figlet -f big termshot) +termshot --columns 128 --clip-canvas --filename .doc/example-ls-a.png -- ls -a +termshot --columns 128 --clip-canvas --show-cmd --filename .doc/example-cmd-ls-a.png -- ls -a +termshot --columns 128 --clip-canvas --show-cmd --filename .doc/example-cmd-ls-pipe-grep.png -- "ls -1 | grep go" diff --git a/.doc/example-cmd-figlet.png b/.doc/example-cmd-figlet.png new file mode 100644 index 0000000..66f3ece Binary files /dev/null and b/.doc/example-cmd-figlet.png differ diff --git a/.doc/example-cmd-ls-a.png b/.doc/example-cmd-ls-a.png new file mode 100644 index 0000000..d3f5553 Binary files /dev/null and b/.doc/example-cmd-ls-a.png differ diff --git a/.doc/example-cmd-ls-pipe-grep.png b/.doc/example-cmd-ls-pipe-grep.png new file mode 100644 index 0000000..df9b29b Binary files /dev/null and b/.doc/example-cmd-ls-pipe-grep.png differ diff --git a/.doc/example-ls-a.png b/.doc/example-ls-a.png new file mode 100644 index 0000000..253de15 Binary files /dev/null and b/.doc/example-ls-a.png differ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7d65c6b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +--- +version: 2 +updates: +- package-ecosystem: gomod + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 + allow: + - dependency-type: all +- package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..92a4db4 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,27 @@ +--- +name: Dependabot auto-merge + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v3.1.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Enable auto-merge for Dependabot PRs + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --merge "$PR_URL" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..f59f64c --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,31 @@ +--- +name: golangci-lint + +on: + push: + tags-ignore: + - '**' + branches: + - main + pull_request: + branches: + - main + +jobs: + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + only-new-issues: true diff --git a/.github/workflows/misspell.yml b/.github/workflows/misspell.yml new file mode 100644 index 0000000..1947437 --- /dev/null +++ b/.github/workflows/misspell.yml @@ -0,0 +1,30 @@ +--- +name: spellcheck + +on: + push: + tags-ignore: + - '**' + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: spellcheck + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + + - name: Spellcheck + run: | + go install github.com/client9/misspell/cmd/misspell@latest + find . -type f | xargs misspell -source=text -error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..00e6137 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +--- +name: Release +on: + push: + tags: + - "v*" + +jobs: + goreleaser: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + + - name: Run GoReleaser Build + uses: goreleaser/goreleaser-action@v7 + with: + version: latest + args: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..decd1e9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +--- +name: Tests + +on: + push: + tags-ignore: + - '**' + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + + - name: Test + run: make test + + - name: Upload Code Coverage Profile + uses: codecov/codecov-action@v7 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: unit.coverprofile + flags: unittests + fail_ci_if_error: true + verbose: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca2c6cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist +**/*.coverprofile diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..79b4303 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,6 @@ +--- +version: "2" +linters: + enable: + - gocritic + - gosec diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..a12bfd8 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,53 @@ +--- +version: 2 + +builds: +- env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + main: ./cmd/termshot/main.go + flags: + - -trimpath + ldflags: + - -s -w -extldflags "-static" -X github.com/homeport/termshot/internal/cmd.version={{.Version}} + mod_timestamp: '{{ .CommitTimestamp }}' + +checksum: + name_template: 'checksums.txt' + +snapshot: + version_template: "{{ .Tag }}-next" + +release: + name_template: "{{.ProjectName}} release v{{.Version}}" + +changelog: + disable: false + use: github + filters: + exclude: + - "Merge pull request" + - "dependabot" + +brews: +- repository: + owner: homeport + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + url_template: "https://github.com/homeport/termshot/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + download_strategy: CurlDownloadStrategy + commit_author: + name: GoReleaser Bot + email: goreleaser@carlosbecker.com + directory: HomebrewFormula + homepage: "https://github.com/homeport/termshot" + description: "Creates screenshots based on terminal command output." + license: "MIT" + skip_upload: false + test: | + system "#{bin}/termshot --version" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8235c8e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 The Homeport Team + +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..160c734 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Copyright © 2020 The Homeport Team +# +# 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. + +sources := $(wildcard cmd/*/*.go internal/*/*.go) + +.PHONY: all +all: test + +.PHONY: clean +clean: + @go clean -i $(shell go list ./...) + @rm -rf dist *.coverprofile + +.PHONY: test +test: $(sources) + go run -mod=mod github.com/onsi/ginkgo/v2/ginkgo run \ + --coverprofile=unit.coverprofile \ + --randomize-all \ + --randomize-suites \ + --fail-on-pending \ + --keep-going \ + --compilers=2 \ + --race \ + --trace \ + ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..e47d838 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# termshot + +[![License](https://img.shields.io/github/license/homeport/termshot.svg)](https://github.com/homeport/termshot/blob/main/LICENSE) +[![Go Report Card](https://goreportcard.com/badge/github.com/homeport/termshot)](https://goreportcard.com/report/github.com/homeport/termshot) +[![Tests](https://github.com/homeport/termshot/workflows/Tests/badge.svg)](https://github.com/homeport/termshot/actions?query=workflow%3A%22Tests%22) +[![Codecov](https://img.shields.io/codecov/c/github/homeport/termshot/main.svg)](https://codecov.io/gh/homeport/termshot) +[![Go Reference](https://pkg.go.dev/badge/github.com/homeport/termshot.svg)](https://pkg.go.dev/github.com/homeport/termshot) +[![Release](https://img.shields.io/github/release/homeport/termshot.svg)](https://github.com/homeport/termshot/releases/latest) + +Generate beautiful screenshots of your terminal, from your terminal. + +```sh +termshot lolcat -f <(figlet -f big termshot) +``` + +This command generates this screenshot: + +![example](.doc/example-cmd-figlet.png) + +## Installation + +To install with Homebrew on macOS or Linux: + +```sh +brew install homeport/tap/termshot +``` + +See [Releases](https://github.com/homeport/termshot/releases/) for pre-compiled binaries for Darwin and Linux. + +## Usage + +This tool reads the console output and renders an output image that resembles a user interface window. It's inspired by some other web-based tools like [carbon.now.sh](https://carbon.now.sh/), and [codekeep.io/screenshot](https://codekeep.io/screenshot). Unlike those tools, `termshot` does not blindly apply syntax highlighting to some provided text; instead it reads the ANSI escape codes ("rich text") logged by most command-line tools and uses it to generate a high-fidelity "screenshot" of your terminal output. + +Like `time`, `watch`, or `perf`, just prefix the command you want to screenshot with `termshot`. + +```sh +termshot ls -a +``` + +This will generate an image file called `out.png` in the current directory. + +![basic termshot](.doc/example-cmd-ls-a.png) + +In some cases, if your target command contains _pipes_—there may still be ambiguity, even with `--`. In these cases, wrap your command in double quotes. + +```sh +termshot -- "ls -1 | grep go" +``` + +![termshot with pipes](.doc/example-cmd-ls-pipe-grep.png) + +### Flags to control the look + +#### `--show-cmd`/`-c` + +Include the target command in the screenshot. + +```sh +termshot --show-cmd -- "ls -a" +``` + +![termshot that shows command](.doc/example-cmd-ls-a.png) + +#### `--columns`/`-C` + +Enforce that screenshot is wrapped after the provided number of columns. Use this flag to make sure that the screenshot does not exceed a certain horizontal length. + +#### `--no-decoration` + +Do not draw window decorations (minimize, maximize, and close button). + +#### `--no-shadow` + +Do not draw window shadow. + +#### `--margin`/`-m` + +Add extra space (margin) around the window. + +#### `--padding`/`-p` + +Add extra space (padding) around the content inside the window. + +### Flags for output related settings + +#### `--clipboard`/`-b` (only on selected platforms) + +Do not create an output file with the screenshot, but save the screenshot image into the operating system clipboard. + +_Note:_ Only available on some platforms. Check `termshot` help to see if flag is available. + +#### `--filename`/`-f` + +Specify a path where the screenshot should be generated. This can be an absolute path or a relative path; relative paths will be resolved relative to the current working directory. Defaults to `out.png`. + +```sh +termshot -- "ls -a" # defaults to /out.png +termshot --filename my-image.png -- "ls -a" +termshot --filename screenshots/my-image.png -- "ls -a" +termshot --filename /Desktop/my-image.png -- "ls -a" +``` + +### Flags to control content + +#### `--edit`/`-e` + +Edit the output before generating the screenshot. This will open the rich text output in the editor configured in `$EDITOR`, using `vi` as a fallback. Use this flag to remove unwanted or sensitive output. + +```sh +termshot --edit -- "ls -a" +``` + +### Miscellaneous flags + +#### `--raw-write ` + +Write command output as-is into the file that is specified as the flag argument. No screenshot is being created. The command-line flag `--filename` has no effect, when `--raw-write` is used. + +#### `--raw-read ` + +Read input from provided file instead of running a command. If this flag is being used, no pseudo terminal is being created to execute a command. The command-line flags `--show-cmd`, and `--edit` have no effect, when `--raw-read` is used. + +#### `--version`/`-v` + +Print the version of `termshot` installed. + +```sh +$ termshot --version +termshot version 0.2.5 +``` + +### Multiple commands + +In order to work, `termshot` uses a pseudo terminal for the command to be executed. For advanced use cases, you can invoke a fully interactive shell, run several commands, and capture the entire output. The screenshot will be created once you terminate the shell. + +```sh +termshot /bin/zsh +``` + +> _Please note:_ This project is work in progress. Although a lot of the ANSI sequences can be parsed, there are definitely commands in existence that create output that cannot be parsed correctly, yet. Also, commands that reset the cursor position are known to create issues. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..8d1bcb0 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`homeport/termshot` +- 原始仓库:https://github.com/homeport/termshot +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/cmd/termshot/main.go b/cmd/termshot/main.go new file mode 100644 index 0000000..be7b29b --- /dev/null +++ b/cmd/termshot/main.go @@ -0,0 +1,27 @@ +// Copyright © 2020 The Homeport Team +// +// 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. + +package main + +import "github.com/homeport/termshot/internal/cmd" + +func main() { + cmd.Execute() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9ea381c --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module github.com/homeport/termshot + +go 1.25.0 + +require ( + github.com/creack/pty v1.1.24 + github.com/esimov/stackblur-go v1.1.1 + github.com/fogleman/gg v1.3.0 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 + github.com/gonvenience/bunt v1.4.3 + github.com/gonvenience/font v0.0.4 + github.com/gonvenience/neat v1.3.20 + github.com/gonvenience/term v1.0.5 + github.com/mattn/go-isatty v0.0.22 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 + github.com/spf13/cobra v1.10.2 + golang.org/x/image v0.44.0 + golang.org/x/term v0.45.0 +) + +require ( + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..38805ff --- /dev/null +++ b/go.sum @@ -0,0 +1,113 @@ +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +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/esimov/stackblur-go v1.1.1 h1:jZhuCbyFBp34SxkMwCuuNQ+d42w+CE/WOlcJLOlPEag= +github.com/esimov/stackblur-go v1.1.1/go.mod h1:m0T0MjHYbo4Lib/R33XDUMbLBwyGf1/K48ZdqtXUYDA= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/gonvenience/bunt v1.4.3 h1:MLd8YWu1Vl1tiL+XfXJvVA9kL71yQT0N+x7gXVH9H7w= +github.com/gonvenience/bunt v1.4.3/go.mod h1:ggA6odP6FNOh50mGxxytSSJTs2Ghy5Veq9wIVSbuoAw= +github.com/gonvenience/font v0.0.4 h1:QAZKvx4PTQ8t6SL8yQhMP4p2OOeP4SsGlC9GH69xfu8= +github.com/gonvenience/font v0.0.4/go.mod h1:1OM64Q87u3jeCYHG34xR3WbchbG1fmKcbLOENJjndsQ= +github.com/gonvenience/neat v1.3.20 h1:KdevSy5GLb3h1U5AYGw3NwW9FAAU2MWfrtsDHzYaKOg= +github.com/gonvenience/neat v1.3.20/go.mod h1:GbVes855L3QYFkDg9pnxHe/FQVsr1Tl+ME0fyOZO4Lg= +github.com/gonvenience/term v1.0.5 h1:PYfBH7FB1V+tuuJl4KYrqG/tzAOUnvTy8IFa9YqYrJY= +github.com/gonvenience/term v1.0.5/go.mod h1:CYvcU7H3nE6eOP0gvGfYz4BjGJzM1GeNp+fx4IBWKLs= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 h1:BXxTozrOU8zgC5dkpn3J6NTRdoP+hjok/e+ACr4Hibk= +github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3/go.mod h1:x1uk6vxTiVuNt6S5R2UYgdhpj3oKojXvOXauHZ7dEnI= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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/hack/download.sh b/hack/download.sh new file mode 100755 index 0000000..5b94ddb --- /dev/null +++ b/hack/download.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# Copyright © 2024 The Homeport Team +# +# 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. + +set -euo pipefail + +ORG="${ORG:-homeport}" +REPO="${REPO:-termshot}" + +if ! hash curl 2>/dev/null; then + echo "Required tool curl is not installed." + exit 1 +fi + +if ! hash jq 2>/dev/null; then + echo -e 'Required tool \033[1mjq\033[0m is not installed.' + exit 1 +fi + +if [[ $# -eq 0 ]]; then + # Find the latest version using the GitHub API + SELECTED_TAG="$(curl --silent --location "https://api.github.com/repos/${ORG}/${REPO}/releases" | jq --raw-output 'map(select((.assets | length) > 0)) | .[0].tag_name')" +else + # Use provided argument as tag to download + SELECTED_TAG="$1" +fi + +# Find a suitable install location +for CANDIDATE in "$HOME/bin" "/usr/local/bin" "/usr/bin"; do + if [[ -w $CANDIDATE ]] && grep -q "$CANDIDATE" <<<"$PATH"; then + TARGET_DIR="$CANDIDATE" + break + fi +done + +# Bail out in case no suitable location could be found +if [[ -z ${TARGET_DIR:-} ]]; then + echo -e "Unable to determine a writable install location. Make sure that you have write access to either \\033[1m/usr/local/bin\\033[0m or \\033[1m${HOME}/bin\\033[0m and that is in your PATH." + exit 1 +fi + +SYSTEM_UNAME="$(uname | tr '[:upper:]' '[:lower:]')" +SYSTEM_ARCH="$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')" + +# Download and install +DOWNLOAD_URI="$(curl --silent --location "https://api.github.com/repos/${ORG}/${REPO}/releases/tags/${SELECTED_TAG}" | jq --raw-output "first(.assets[] | select( (.name | contains(\"${SYSTEM_UNAME}\")) and (.name | contains(\"${SYSTEM_ARCH}\")) ) | .browser_download_url)")" +if [[ -z ${DOWNLOAD_URI} ]]; then + echo -e "Unsupported operating system \\033[1m${SYSTEM_UNAME}\\033[0m or machine type \\033[1m${SYSTEM_ARCH}\\033[0m: Please check \\033[4;94mhttps://github.com/${ORG}/${REPO}/releases\\033[0m manually." + exit 1 +fi + +echo -e "Downloading \\033[4;94m${DOWNLOAD_URI}\\033[0m to install \\033[1m${TARGET_DIR}/${REPO}\\033[0m" +case "${DOWNLOAD_URI}" in + *tar.gz) + curl --progress-bar --location "${DOWNLOAD_URI}" | tar -xzf - -C "${TARGET_DIR}" "${REPO}" + ;; + + *) + if curl --progress-bar --location "${DOWNLOAD_URI}" --output "${TARGET_DIR}/${REPO}"; then + chmod a+rx "${TARGET_DIR}/${REPO}" + fi + ;; +esac + +echo -e "\\nSuccessfully installed \\033[1m${TARGET_DIR}/${REPO}\\033[0m\\n" diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 0000000..20868c6 --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,322 @@ +// Copyright © 2020 The Homeport Team +// +// 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. + +package cmd + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/gonvenience/bunt" + "github.com/gonvenience/neat" + + "github.com/homeport/termshot/internal/img" + "github.com/homeport/termshot/internal/ptexec" + + "github.com/spf13/cobra" +) + +// version string will be injected by automation +var version string + +// saveToClipboard function will be implemented by OS specific code +var saveToClipboard func(img.Scaffold) error + +var rootCmd = &cobra.Command{ + Use: fmt.Sprintf("%s [%s flags] [--] command [command flags] [command arguments] [...]", executableName(), executableName()), + Short: "Creates a screenshot of terminal command output", + Long: `Executes the provided command as-is with all flags and arguments in a pseudo +terminal and captures the generated output. The result is printed as it was +produced. Additionally, an image will be rendered in a lookalike terminal +window including all terminal colors and text decorations. +`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if showVersion, err := cmd.Flags().GetBool("version"); showVersion && err == nil { + if len(version) == 0 { + version = "(development)" + } + + // #nosec G104 + // nolint:all + bunt.Printf("Lime{*%s*} version DimGray{%s}\n", + executableName(), + version, + ) + + return nil + } + + rawRead, _ := cmd.Flags().GetString("raw-read") + rawWrite, _ := cmd.Flags().GetString("raw-write") + + if len(args) == 0 && rawRead == "" { + return cmd.Usage() + } + + var scaffold = img.NewImageCreator() + var buf bytes.Buffer + var pt = ptexec.New() + + // Initialise scaffold with a column sizing so that the + // content can be wrapped accordingly + // + if columns, err := cmd.Flags().GetInt("columns"); err == nil && columns > 0 { + scaffold.SetColumns(columns) + pt.Cols(uint16(columns)) + } + + // Change margin if requested + if margin, err := cmd.Flags().GetInt("margin"); err == nil { + if margin < 0 { + return fmt.Errorf("margin must be zero or greater: not %d", margin) + } + scaffold.SetMargin(float64(margin)) + } + + // Change padding if requested + if padding, err := cmd.Flags().GetInt("padding"); err == nil { + if padding < 0 { + return fmt.Errorf("padding must be zero or greater: not %d", padding) + } + scaffold.SetPadding(float64(padding)) + } + + // Disable window shadow if requested + // + if val, err := cmd.Flags().GetBool("no-shadow"); err == nil { + scaffold.DrawShadow(!val) + } + + // Disable window decorations (buttons) if requested + // + if val, err := cmd.Flags().GetBool("no-decoration"); err == nil { + scaffold.DrawDecorations(!val) + } + + // Configure that canvas is clipped at the end + // + if val, err := cmd.Flags().GetBool("clip-canvas"); err == nil { + scaffold.ClipCanvas(val) + } + + // Optional: Prepend command line arguments to output content + // + if includeCommand, err := cmd.Flags().GetBool("show-cmd"); err == nil && includeCommand && rawRead == "" { + if err := scaffold.AddCommand(args...); err != nil { + return err + } + } + + // Get the actual content for the screenshot + // + if rawRead == "" { + // Run the provided command in a pseudo terminal and capture + // the output to be later rendered into the screenshot + bytes, err := pt.Command(args[0], args[1:]...).Run() + if err != nil { + return fmt.Errorf("failed to run command in pseudo terminal: %w", err) + } + buf.Write(bytes) + + } else { + // Read the content from an existing file instead of + // executing a command to read its output + bytes, err := readFile(rawRead) + if err != nil { + return fmt.Errorf("failed to read contents: %w", err) + } + buf.Write(bytes) + } + + // Allow manual override of command output content + // + if edit, err := cmd.Flags().GetBool("edit"); err == nil && edit && rawRead == "" { + tmpFile, tmpErr := os.CreateTemp("", executableName()) + if tmpErr != nil { + return tmpErr + } + + defer func() { _ = os.Remove(tmpFile.Name()) }() + + if err := os.WriteFile(tmpFile.Name(), buf.Bytes(), os.FileMode(0644)); err != nil { + return err + } + + editor := os.Getenv("EDITOR") + if len(editor) == 0 { + editor = "vi" + } + + if _, err := ptexec.New().Command(editor, tmpFile.Name()).Run(); err != nil { + return err + } + + bytes, tmpErr := os.ReadFile(tmpFile.Name()) + if tmpErr != nil { + return tmpErr + } + + buf.Reset() + buf.Write(bytes) + } + + // Add the captured output to the scaffold + // + if err := scaffold.AddContent(&buf); err != nil { + return err + } + + // Optional: Save content as-is to a file + // + if rawWrite != "" { + var output *os.File + var err error + switch rawWrite { + case "-": + output = os.Stdout + + default: + output, err = os.Create(filepath.Clean(rawWrite)) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + defer func() { _ = output.Close() }() + } + + return scaffold.WriteRaw(output) + } + + // Optional: Save image to clipboard + // + if toClipboard, err := cmd.Flags().GetBool("clipboard"); err == nil && toClipboard { + return saveToClipboard(scaffold) + } + + // Save image to file + // + filename, err := cmd.Flags().GetString("filename") + if filename == "" || err != nil { + fmt.Fprintf(os.Stderr, "failed to read filename from command-line, defaulting to out.png") + filename = "out.png" + } + + if extension := filepath.Ext(filename); extension != ".png" { + return fmt.Errorf("file extension %q of filename %q is not supported, only png is supported", extension, filename) + } + + file, err := os.Create(filepath.Clean(filename)) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + defer func() { _ = file.Close() }() + return scaffold.WritePNG(file) + }, +} + +// Execute is the main entry point into the CLI code +func Execute() { + rootCmd.SetFlagErrorFunc(func(c *cobra.Command, e error) error { + return fmt.Errorf("unknown %s flag %w", + executableName(), + fmt.Errorf("issue with %v\n\nIn order to differentiate between program flags and command flags,\nuse '--' before the command so that all flags before the separator\nbelong to %s, while all others are used for the command.\n\n%s", e, executableName(), c.UsageString()), + ) + }) + + if err := rootCmd.Execute(); err != nil { + var headline, content string + + type wrappedError interface { + Error() string + Unwrap() error + } + + switch err := err.(type) { + case wrappedError: + headline = strings.SplitN(err.Error(), ":", 2)[0] + content = err.Unwrap().Error() + + default: + headline = "Error occurred" + content = err.Error() + } + + fmt.Fprint(os.Stderr, neat.ContentBox( + headline, + content, + neat.HeadlineColor(bunt.OrangeRed), + neat.ContentColor(bunt.LightCoral), + neat.NoLineWrap(), + )) + + os.Exit(1) + } +} + +func executableName() string { + if executable, err := os.Executable(); err == nil { + return filepath.Clean(filepath.Base(executable)) + } + + return "termshot" +} + +func readFile(name string) ([]byte, error) { + switch name { + case "-": + return io.ReadAll(os.Stdin) + + default: + return os.ReadFile(filepath.Clean(name)) + } +} + +func init() { + rootCmd.Flags().SortFlags = false + + // flags to control content + rootCmd.Flags().BoolP("edit", "e", false, "edit content before creating screenshot") + + // flags to control look + rootCmd.Flags().BoolP("show-cmd", "c", false, "include command in screenshot") + rootCmd.Flags().IntP("columns", "C", 0, "force fixed number of columns in screenshot") + rootCmd.Flags().IntP("margin", "m", 48, "set margin around the window") + rootCmd.Flags().IntP("padding", "p", 24, "set padding around the content inside window") + rootCmd.Flags().Bool("no-decoration", false, "do not draw window decorations") + rootCmd.Flags().Bool("no-shadow", false, "do not draw window shadow") + rootCmd.Flags().BoolP("clip-canvas", "s", false, "clip canvas to visible image area (no margin)") + + // flags for output related settings + rootCmd.Flags().StringP("filename", "f", "out.png", "filename of the screenshot") + + // flags for raw output processing + rootCmd.Flags().String("raw-write", "", "write raw output to file instead of creating a screenshot") + rootCmd.Flags().String("raw-read", "", "read raw input from file instead of executing a command") + + // internals + rootCmd.Flags().BoolP("version", "v", false, "show version") +} diff --git a/internal/cmd/root_darwin.go b/internal/cmd/root_darwin.go new file mode 100644 index 0000000..935c4f6 --- /dev/null +++ b/internal/cmd/root_darwin.go @@ -0,0 +1,75 @@ +// Copyright © 2023 The Homeport Team +// +// 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. + +package cmd + +import ( + "bytes" + "encoding/hex" + "fmt" + "os" + "os/exec" + + "github.com/homeport/termshot/internal/img" +) + +const osascript = "/usr/bin/osascript" + +// hasOsascript checks if /usr/bin/osascript exists and is executable +func hasOsascript() bool { + if fi, err := os.Stat(osascript); err == nil { + return fi.Mode()&0111 != 0 + } + + return false +} + +func init() { + if hasOsascript() { + // register tool flag to enable clipboard option + rootCmd.Flags().BoolP("clipboard", "b", false, "copy termshot to clipboard, overrules filename option") + + // register function to copy image into the clipboard + saveToClipboard = func(scaffold img.Scaffold) error { + var buf bytes.Buffer + + if _, err := buf.WriteString("set the clipboard to «data PICT"); err != nil { + return err + } + + if err := scaffold.WritePNG(hex.NewEncoder(&buf)); err != nil { + return err + } + + if _, err := buf.WriteString("»"); err != nil { + return err + } + + cmd := exec.Command(osascript, "-e", buf.String()) // #nosec G204 + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Fprint(os.Stderr, string(out)) + return err + } + + return nil + } + } +} diff --git a/internal/img/img_suite_test.go b/internal/img/img_suite_test.go new file mode 100644 index 0000000..d1bb0b7 --- /dev/null +++ b/internal/img/img_suite_test.go @@ -0,0 +1,77 @@ +// Copyright © 2020 The Homeport Team +// +// 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. + +package img_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + + . "github.com/homeport/termshot/internal/img" +) + +func TestImg(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Image Creation Suite") +} + +func testdata(path ...string) string { + return filepath.Join(append([]string{"..", "..", "test", "data"}, path...)...) +} + +func LookLike(path string) types.GomegaMatcher { + return &LookLikeMatcher{path} +} + +type LookLikeMatcher struct{ path string } + +func (m *LookLikeMatcher) Match(actual interface{}) (bool, error) { + scaffold, ok := actual.(Scaffold) + if !ok { + return false, fmt.Errorf("LookLike must be passed a Scaffold. Got\n%T", actual) + } + + var out bytes.Buffer + if err := scaffold.WritePNG(&out); err != nil { + return false, err + } + + reference, err := os.ReadFile(m.path) + if err != nil { + return false, err + } + + return bytes.Equal(out.Bytes(), reference), nil +} + +func (matcher *LookLikeMatcher) FailureMessage(actual interface{}) string { + return fmt.Sprintf("Expected scaffold to look like %s", matcher.path) +} + +func (matcher *LookLikeMatcher) NegatedFailureMessage(actual interface{}) string { + return fmt.Sprintf("Expected scaffold not to look like %s", matcher.path) +} diff --git a/internal/img/output.go b/internal/img/output.go new file mode 100644 index 0000000..2882994 --- /dev/null +++ b/internal/img/output.go @@ -0,0 +1,446 @@ +// Copyright © 2020 The Homeport Team +// +// 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. + +package img + +import ( + "fmt" + "image" + "image/color" + "image/png" + "io" + "math" + "os" + "strings" + + "github.com/esimov/stackblur-go" + "github.com/fogleman/gg" + "github.com/golang/freetype/truetype" + "github.com/gonvenience/bunt" + "github.com/gonvenience/font" + "github.com/gonvenience/term" + imgfont "golang.org/x/image/font" +) + +const ( + red = "#ED655A" + yellow = "#E1C04C" + green = "#71BD47" +) + +const ( + defaultFontSize = 12 + defaultFontDPI = 144 +) + +// commandIndicator is the string to be used to indicate the command in the screenshot +var commandIndicator = func() string { + if val, ok := os.LookupEnv("TS_COMMAND_INDICATOR"); ok { + return val + } + + return "➜" +}() + +type Scaffold struct { + content bunt.String + + factor float64 + + columns int + + defaultForegroundColor color.Color + + clipCanvas bool + + drawDecorations bool + drawShadow bool + + shadowBaseColor string + shadowRadius uint8 + shadowOffsetX float64 + shadowOffsetY float64 + + padding float64 + margin float64 + + regular imgfont.Face + bold imgfont.Face + italic imgfont.Face + boldItalic imgfont.Face + lineSpacing float64 + tabSpaces int +} + +func NewImageCreator() Scaffold { + f := 2.0 + + fontFaceOptions := &truetype.Options{ + Size: f * defaultFontSize, + DPI: defaultFontDPI, + } + + return Scaffold{ + defaultForegroundColor: bunt.LightGray, + + factor: f, + + margin: f * 48, + padding: f * 24, + + drawDecorations: true, + drawShadow: true, + + shadowBaseColor: "#10101066", + shadowRadius: uint8(math.Min(f*16, 255)), + shadowOffsetX: f * 16, + shadowOffsetY: f * 16, + + regular: font.Hack.Regular(fontFaceOptions), + bold: font.Hack.Bold(fontFaceOptions), + italic: font.Hack.Italic(fontFaceOptions), + boldItalic: font.Hack.BoldItalic(fontFaceOptions), + + lineSpacing: 1.2, + tabSpaces: 2, + } +} + +func (s *Scaffold) SetFontFaceRegular(face imgfont.Face) { s.regular = face } + +func (s *Scaffold) SetFontFaceBold(face imgfont.Face) { s.bold = face } + +func (s *Scaffold) SetFontFaceItalic(face imgfont.Face) { s.italic = face } + +func (s *Scaffold) SetFontFaceBoldItalic(face imgfont.Face) { s.boldItalic = face } + +func (s *Scaffold) SetColumns(columns int) { s.columns = columns } + +func (s *Scaffold) SetMargin(margin float64) { s.margin = margin * s.factor } + +func (s *Scaffold) SetPadding(padding float64) { s.padding = padding * s.factor } + +func (s *Scaffold) DrawDecorations(value bool) { s.drawDecorations = value } + +func (s *Scaffold) DrawShadow(value bool) { s.drawShadow = value } + +func (s *Scaffold) ClipCanvas(value bool) { s.clipCanvas = value } + +func (s *Scaffold) GetFixedColumns() int { + if s.columns != 0 { + return s.columns + } + + columns, _ := term.GetTerminalSize() + return columns +} + +func (s *Scaffold) AddCommand(args ...string) error { + return s.AddContent(strings.NewReader( + bunt.Sprintf("Lime{%s} DimGray{%s}\n", + commandIndicator, + strings.Join(args, " "), + ), + )) +} + +func (s *Scaffold) AddContent(in io.Reader) error { + parsed, err := bunt.ParseStream(in) + if err != nil { + return fmt.Errorf("failed to parse input stream: %w", err) + } + + var tmp bunt.String + var counter int + for _, cr := range *parsed { + counter++ + + if cr.Symbol == '\n' { + counter = 0 + } + + // Add an additional newline in case the column + // count is reached and line wrapping is needed + if counter > s.GetFixedColumns() { + counter = 0 + tmp = append(tmp, bunt.ColoredRune{ + Settings: cr.Settings, + Symbol: '\n', + }) + } + + tmp = append(tmp, cr) + } + + s.content = append(s.content, tmp...) + + return nil +} + +func (s *Scaffold) fontHeight() float64 { + return float64(s.regular.Metrics().Height >> 6) +} + +func (s *Scaffold) measureContent() (width float64, height float64) { + var tmp = make([]rune, len(s.content)) + for i, cr := range s.content { + tmp[i] = cr.Symbol + } + + lines := strings.Split( + strings.TrimSuffix( + string(tmp), + "\n", + ), + "\n", + ) + + // temporary drawer for reference calucation + tmpDrawer := &imgfont.Drawer{Face: s.regular} + + // width, either by using longest line, or by fixed column value + switch s.columns { + case 0: // unlimited: max width of all lines + for _, line := range lines { + advance := tmpDrawer.MeasureString(line) + if lineWidth := float64(advance >> 6); lineWidth > width { + width = lineWidth + } + } + + default: // fixed: max width based on column count + width = float64(tmpDrawer.MeasureString(strings.Repeat("a", s.GetFixedColumns())) >> 6) + } + + // height, lines times font height and line spacing + height = float64(len(lines)) * s.fontHeight() * s.lineSpacing + + return width, height +} + +func (s *Scaffold) image() (image.Image, error) { + var f = func(value float64) float64 { return s.factor * value } + + var ( + corner = f(6) + radius = f(9) + distance = f(25) + ) + + contentWidth, contentHeight := s.measureContent() + + // Make sure the output window is big enough in case no content or very few + // content will be rendered + contentWidth = math.Max(contentWidth, 3*distance+3*radius) + + marginX, marginY := s.margin, s.margin + paddingX, paddingY := s.padding, s.padding + + xOffset := marginX + yOffset := marginY + + var titleOffset float64 + if s.drawDecorations { + titleOffset = f(40) + } + + width := contentWidth + 2*marginX + 2*paddingX + height := contentHeight + 2*marginY + 2*paddingY + titleOffset + + dc := gg.NewContext(int(width), int(height)) + + // Optional: Apply blurred rounded rectangle to mimic the window shadow + // + if s.drawShadow { + xOffset -= s.shadowOffsetX / 2 + yOffset -= s.shadowOffsetY / 2 + + bc := gg.NewContext(int(width), int(height)) + bc.DrawRoundedRectangle(xOffset+s.shadowOffsetX, yOffset+s.shadowOffsetY, width-2*marginX, height-2*marginY, corner) + bc.SetHexColor(s.shadowBaseColor) + bc.Fill() + + src := bc.Image() + dst := image.NewNRGBA(src.Bounds()) + if err := stackblur.Process(dst, src, uint32(s.shadowRadius)); err != nil { + return nil, err + } + + dc.DrawImage(dst, 0, 0) + } + + // Draw rounded rectangle with outline to produce impression of a window + // + dc.DrawRoundedRectangle(xOffset, yOffset, width-2*marginX, height-2*marginY, corner) + dc.SetHexColor("#151515") + dc.Fill() + + dc.DrawRoundedRectangle(xOffset, yOffset, width-2*marginX, height-2*marginY, corner) + dc.SetHexColor("#404040") + dc.SetLineWidth(f(1)) + dc.Stroke() + + // Optional: Draw window decorations (i.e. three buttons) to produce the + // impression of an actional window + // + if s.drawDecorations { + for i, color := range []string{red, yellow, green} { + dc.DrawCircle(xOffset+paddingX+float64(i)*distance+f(4), yOffset+paddingY+f(4), radius) + dc.SetHexColor(color) + dc.Fill() + } + } + + // Apply the actual text into the prepared content area of the window + // + var x, y = xOffset + paddingX, yOffset + paddingY + titleOffset + s.fontHeight() + for _, cr := range s.content { + switch cr.Settings & 0x1C { + case 4: + dc.SetFontFace(s.bold) + + case 8: + dc.SetFontFace(s.italic) + + case 12: + dc.SetFontFace(s.boldItalic) + + default: + dc.SetFontFace(s.regular) + } + + str := string(cr.Symbol) + w, h := dc.MeasureString(str) + + // background color + switch cr.Settings & 0x02 { //nolint:gocritic + case 2: + dc.SetRGB255( + int((cr.Settings>>32)&0xFF), // #nosec G115 + int((cr.Settings>>40)&0xFF), // #nosec G115 + int((cr.Settings>>48)&0xFF), // #nosec G115 + ) + + dc.DrawRectangle(x, y-h+12, w, h) + dc.Fill() + } + + // foreground color + switch cr.Settings & 0x01 { + case 1: + dc.SetRGB255( + int((cr.Settings>>8)&0xFF), // #nosec G115 + int((cr.Settings>>16)&0xFF), // #nosec G115 + int((cr.Settings>>24)&0xFF), // #nosec G115 + ) + + default: + dc.SetColor(s.defaultForegroundColor) + } + + switch str { + case "\n": + x = xOffset + paddingX + y += h * s.lineSpacing + continue + + case "\t": + x += w * float64(s.tabSpaces) + continue + + case "✗", "ˣ": // mitigate issue #1 by replacing it with a similar character + str = "×" + } + + dc.DrawString(str, x, y) + + // There seems to be no font face based way to do an underlined + // string, therefore manually draw a line under each character + if cr.Settings&0x1C == 16 { + dc.DrawLine(x, y+f(4), x+w, y+f(4)) + dc.SetLineWidth(f(1)) + dc.Stroke() + } + + x += w + } + + return dc.Image(), nil +} + +// Write writes the scaffold content as PNG into the provided writer +// +// Deprecated: Use [Scaffold.WritePNG] instead. +func (s *Scaffold) Write(w io.Writer) error { + return s.WritePNG(w) +} + +// WritePNG writes the scaffold content as PNG into the provided writer +func (s *Scaffold) WritePNG(w io.Writer) error { + img, err := s.image() + if err != nil { + return err + } + + // Optional: Clip image to minimum size by removing all surrounding transparent pixels + // + if s.clipCanvas { + if imgRGBA, ok := img.(*image.RGBA); ok { + var minX, minY = math.MaxInt, math.MaxInt + var maxX, maxY = 0, 0 + + var bounds = imgRGBA.Bounds() + for x := bounds.Min.X; x < bounds.Max.X; x++ { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + r, g, b, a := imgRGBA.At(x, y).RGBA() + isTransparent := r == 0 && g == 0 && b == 0 && a == 0 + + if !isTransparent { + if x < minX { + minX = x + } + + if y < minY { + minY = y + } + + if x > maxX { + maxX = x + } + + if y > maxY { + maxY = y + } + } + } + } + + img = imgRGBA.SubImage(image.Rect(minX, minY, maxX, maxY)) + } + } + + return png.Encode(w, img) +} + +// WriteRaw writes the scaffold content as-is into the provided writer +func (s *Scaffold) WriteRaw(w io.Writer) error { + _, err := w.Write([]byte(s.content.String())) + return err +} diff --git a/internal/img/output_test.go b/internal/img/output_test.go new file mode 100644 index 0000000..9fe4dd7 --- /dev/null +++ b/internal/img/output_test.go @@ -0,0 +1,131 @@ +// Copyright © 2020 The Homeport Team +// +// 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. + +package img_test + +import ( + "bytes" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/gonvenience/bunt" + . "github.com/homeport/termshot/internal/img" +) + +var _ = Describe("Creating images", func() { + Context("Use scaffold to create PNG file", func() { + BeforeEach(func() { + SetColorSettings(ON, ON) + }) + + It("should write a PNG stream based on provided input", func() { + scaffold := NewImageCreator() + Expect(scaffold.AddContent(strings.NewReader("foobar"))).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-foobar.png"))) + }) + + It("should omit the window decorations when configured", func() { + scaffold := NewImageCreator() + scaffold.DrawDecorations(false) + + Expect(scaffold.AddContent(strings.NewReader("foobar"))).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-no-decoration.png"))) + }) + + It("should omit the window shadow when configured", func() { + scaffold := NewImageCreator() + scaffold.DrawShadow(false) + + Expect(scaffold.AddContent(strings.NewReader("foobar"))).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-no-shadow.png"))) + }) + + It("should clip the canvas when configured", func() { + scaffold := NewImageCreator() + scaffold.ClipCanvas(true) + + Expect(scaffold.AddContent(strings.NewReader("foobar"))).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-clip-canvas.png"))) + }) + + It("should wrap the content when configured", func() { + scaffold := NewImageCreator() + scaffold.SetColumns(4) + + Expect(scaffold.AddContent(strings.NewReader("foobar"))).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-wrapping.png"))) + }) + + It("should show the command when configured", func() { + scaffold := NewImageCreator() + Expect(scaffold.AddCommand("echo", "foobar")).To(Succeed()) + Expect(scaffold.AddContent(strings.NewReader("foobar"))).To(Succeed()) + Expect(scaffold).To(LookLike(testdata("expected-show-cmd.png"))) + }) + + It("should apply margin correctly", func() { + scaffold := NewImageCreator() + scaffold.SetMargin(24) + + Expect(scaffold.AddContent(strings.NewReader("foobar"))).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-margin.png"))) + }) + + It("should apply padding correctly", func() { + scaffold := NewImageCreator() + scaffold.SetPadding(60) + + Expect(scaffold.AddContent(strings.NewReader("foobar"))).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-padding.png"))) + }) + + It("should write a PNG stream based on provided input with ANSI sequences", func() { + var buf bytes.Buffer + _, _ = Fprintf(&buf, "Text with emphasis, like *bold*, _italic_, _*bold/italic*_ or ~underline~.\n\n") + _, _ = Fprintf(&buf, "Colors:\n") + _, _ = Fprintf(&buf, "\tRed{Red}\n") + _, _ = Fprintf(&buf, "\tGreen{Green}\n") + _, _ = Fprintf(&buf, "\tBlue{Blue}\n") + _, _ = Fprintf(&buf, "\tMintCream{MintCream}\n") + + scaffold := NewImageCreator() + Expect(scaffold.AddContent(&buf)).ToNot(HaveOccurred()) + Expect(scaffold).To(LookLike(testdata("expected-ansi.png"))) + }) + }) + + Context("Use scaffold to create raw output file", func() { + var buf bytes.Buffer + + BeforeEach(func() { + SetColorSettings(ON, ON) + buf.Reset() + }) + + It("should write an output file with the content as-is", func() { + scaffold := NewImageCreator() + Expect(scaffold.AddContent(strings.NewReader(Sprintf("MintCream{foobar}")))).To(Succeed()) + Expect(scaffold.WriteRaw(&buf)).To(Succeed()) + Expect(buf.String()).To(Equal("\x1b[38;2;245;255;250mfoobar\x1b[0m")) + }) + }) +}) diff --git a/internal/ptexec/exec.go b/internal/ptexec/exec.go new file mode 100644 index 0000000..85b5998 --- /dev/null +++ b/internal/ptexec/exec.go @@ -0,0 +1,231 @@ +// Copyright © 2020 The Homeport Team +// +// 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. + +package ptexec + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + + "github.com/creack/pty" + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +// PseudoTerminal defines the setup for a command to be run in a pseudo +// terminal, e.g. terminal size, or output settings +type PseudoTerminal struct { + name string + args []string + + shell string + + cols uint16 + rows uint16 + resize bool + + stdout io.Writer +} + +// New creates a new pseudo terminal builder +func New() *PseudoTerminal { + return &PseudoTerminal{ + shell: "/bin/sh", + resize: true, + stdout: os.Stdout, + } +} + +// Cols sets the width/columns for the pseudo terminal +func (c *PseudoTerminal) Cols(cols uint16) *PseudoTerminal { + c.cols = cols + return c +} + +// Rows sets the lines/rows for the pseudo terminal +func (c *PseudoTerminal) Rows(rows uint16) *PseudoTerminal { + c.rows = rows + return c +} + +// Stdout sets the writer to be used for the standard output +func (c *PseudoTerminal) Stdout(stdout io.Writer) *PseudoTerminal { + c.stdout = stdout + return c +} + +// Command sets the command and arguments to be used +func (c *PseudoTerminal) Command(name string, args ...string) *PseudoTerminal { + c.name = name + c.args = args + return c +} + +// Run runs the provided command/script with the given arguments in a pseudo +// terminal (PTY) so that the behavior is the same if it would be executed +// in a terminal +func (c *PseudoTerminal) Run() ([]byte, error) { + if c.name == "" { + return nil, fmt.Errorf("no command specified") + } + + // Convenience hack in case command contains a space, for example in case + // typical construct like "foo | grep" are used. + if strings.Contains(c.name, " ") { + c.args = []string{ + "-c", + strings.Join(append( + []string{c.name}, + c.args..., + ), " "), + } + c.name = c.shell + } + + // Set RAW mode for Stdin + if isTerminal(os.Stdin) { + oldState, rawErr := term.MakeRaw(int(os.Stdin.Fd())) + if rawErr != nil { + return nil, fmt.Errorf("failed to enable RAW mode for Stdin: %w", rawErr) + } + + // And make sure to restore the original mode eventually + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() + } + + // collect all errors along the way + var errors = []error{} + + // #nosec G204 -- since this is exactly what we want, arbitrary commands + pt, err := c.pseudoTerminal(exec.Command(c.name, c.args...)) + if err != nil { + return nil, err + } + + // Support terminal resizing + if c.resize && isTerminal(os.Stdin) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + go func() { + for range ch { + if ptyErr := pty.InheritSize(os.Stdin, pt); ptyErr != nil { + errors = append(errors, fmt.Errorf("error resizing PTY: %w", ptyErr)) + } + } + }() + + ch <- syscall.SIGWINCH + defer func() { + signal.Stop(ch) + close(ch) + }() + } + + go func() { + defer func() { _ = pt.Close() }() + _, copyErr := io.Copy(pt, os.Stdin) + if copyErr != nil { + errors = append(errors, copyErr) + } + }() + + var buf bytes.Buffer + if err = copy(io.MultiWriter(c.stdout, &buf), pt); err != nil { + return nil, err + } + + if len(errors) > 0 { + fmt.Fprintf(os.Stderr, "issues in background tasks:\n") + for _, err := range errors { + fmt.Fprintf(os.Stderr, "- %v\n", err.Error()) + } + } + + return buf.Bytes(), nil +} + +func (c *PseudoTerminal) pseudoTerminal(cmd *exec.Cmd) (*os.File, error) { + if c.cols == 0 && c.rows == 0 { + return pty.Start(cmd) + } + + size, err := pty.GetsizeFull(os.Stdout) + if err != nil { + // Obtaining terminal size is prone to error in CI systems, e.g. in + // GitHub Action setup or similar, so only fail if CI is not set + if !isCI() { + return nil, fmt.Errorf("failed to get size: %w", err) + } + + // For CI systems, assume a reasonable default even if the terminal + // size cannot be obtained through ioctl + size = &pty.Winsize{Rows: 25, Cols: 80} + } + + // Overwrite rows if fixed value is configured + if c.rows != 0 { + size.Rows = c.rows + } + + // Overwrite columns if fixed value is configured + if c.cols != 0 { + size.Cols = c.cols + } + + // With fixed rows/cols, terminal resizing support is not useful + c.resize = false + + return pty.StartWithSize(cmd, size) +} + +func copy(dst io.Writer, src io.Reader) error { + _, err := io.Copy(dst, src) + if err != nil { + switch terr := err.(type) { //nolint:gocritic + case *os.PathError: + // Workaround for issue https://github.com/creack/pty/issues/100 + // where on Linux systems it can happen that the pseudo terminal + // process finishes while termshot is trying to read. Assuming + // that the content is already read, this error is treated the + // same as if it would be an EOF. + if terr.Op == "read" && terr.Path == "/dev/ptmx" { + return nil + } + } + } + + return err +} + +func isTerminal(f *os.File) bool { + return isatty.IsTerminal(f.Fd()) || + isatty.IsCygwinTerminal(f.Fd()) +} + +func isCI() bool { + ci, ok := os.LookupEnv("CI") + return ok && ci == "true" +} diff --git a/internal/ptexec/exec_test.go b/internal/ptexec/exec_test.go new file mode 100644 index 0000000..1c385c6 --- /dev/null +++ b/internal/ptexec/exec_test.go @@ -0,0 +1,56 @@ +// Copyright © 2025 The Homeport Team +// +// 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. + +package ptexec_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/homeport/termshot/internal/ptexec" +) + +var _ = Describe("Pseudo Terminal Execute Suite", func() { + Context("running commands in pseudo terminal", func() { + It("should run a command just fine", func() { + out, err := New().Stdout(GinkgoWriter). + Command("echo", "hello"). + Run() + + Expect(err).ToNot(HaveOccurred()) + Expect(trimmed(out)).To(Equal("hello")) + }) + + It("should run a script just fine", func() { + out, err := New().Stdout(GinkgoWriter). + Command("echo hello"). + Run() + + Expect(err).ToNot(HaveOccurred()) + Expect(trimmed(out)).To(Equal("hello")) + }) + + It("should run with fixed terminal size", func() { + out, err := New().Stdout(GinkgoWriter).Cols(40).Rows(12).Command("stty", "size").Run() + Expect(err).ToNot(HaveOccurred()) + Expect(trimmed(out)).To(Equal("12 40")) + }) + }) +}) diff --git a/internal/ptexec/ptexec_suite_test.go b/internal/ptexec/ptexec_suite_test.go new file mode 100644 index 0000000..aaaee30 --- /dev/null +++ b/internal/ptexec/ptexec_suite_test.go @@ -0,0 +1,38 @@ +// Copyright © 2025 The Homeport Team +// +// 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. + +package ptexec_test + +import ( + "strings" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPtexec(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pseudo Terminal Exec Suite") +} + +func trimmed(in []byte) string { + return strings.TrimSpace(string(in)) +} diff --git a/test/data/expected-ansi.png b/test/data/expected-ansi.png new file mode 100644 index 0000000..0e511ef Binary files /dev/null and b/test/data/expected-ansi.png differ diff --git a/test/data/expected-clip-canvas.png b/test/data/expected-clip-canvas.png new file mode 100644 index 0000000..9975649 Binary files /dev/null and b/test/data/expected-clip-canvas.png differ diff --git a/test/data/expected-foobar.png b/test/data/expected-foobar.png new file mode 100644 index 0000000..8d16fde Binary files /dev/null and b/test/data/expected-foobar.png differ diff --git a/test/data/expected-margin.png b/test/data/expected-margin.png new file mode 100644 index 0000000..4f15d8c Binary files /dev/null and b/test/data/expected-margin.png differ diff --git a/test/data/expected-no-decoration.png b/test/data/expected-no-decoration.png new file mode 100644 index 0000000..a05da1b Binary files /dev/null and b/test/data/expected-no-decoration.png differ diff --git a/test/data/expected-no-shadow.png b/test/data/expected-no-shadow.png new file mode 100644 index 0000000..9ea70b4 Binary files /dev/null and b/test/data/expected-no-shadow.png differ diff --git a/test/data/expected-padding.png b/test/data/expected-padding.png new file mode 100644 index 0000000..b7605b9 Binary files /dev/null and b/test/data/expected-padding.png differ diff --git a/test/data/expected-show-cmd.png b/test/data/expected-show-cmd.png new file mode 100644 index 0000000..ab05300 Binary files /dev/null and b/test/data/expected-show-cmd.png differ diff --git a/test/data/expected-wrapping.png b/test/data/expected-wrapping.png new file mode 100644 index 0000000..465a7ff Binary files /dev/null and b/test/data/expected-wrapping.png differ