chore: import upstream snapshot with attribution
spellcheck / spellcheck (push) Successful in 17m28s
golangci-lint / golangci-lint (push) Has been cancelled
Tests / Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:05 +08:00
commit 607e84665c
39 changed files with 2093 additions and 0 deletions
+10
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+15
View File
@@ -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
+27
View File
@@ -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"
+31
View File
@@ -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
+30
View File
@@ -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
+30
View File
@@ -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 }}
+38
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
dist
**/*.coverprofile
+6
View File
@@ -0,0 +1,6 @@
---
version: "2"
linters:
enable:
- gocritic
- gosec
+53
View File
@@ -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"
+21
View File
@@ -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.
+42
View File
@@ -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 \
./...
+140
View File
@@ -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 <cwd>/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 <file>`
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 <file>`
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.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`homeport/termshot`
- 原始仓库:https://github.com/homeport/termshot
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+27
View File
@@ -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()
}
+42
View File
@@ -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
)
+113
View File
@@ -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=
+83
View File
@@ -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"
+322
View File
@@ -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")
}
+75
View File
@@ -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
}
}
}
+77
View File
@@ -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)
}
+446
View File
@@ -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
}
+131
View File
@@ -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"))
})
})
})
+231
View File
@@ -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"
}
+56
View File
@@ -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"))
})
})
})
+38
View File
@@ -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))
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB