mirror of
https://github.com/cheat/cheat.git
synced 2026-03-07 11:13:33 +01:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2aa2d3611 | ||
|
|
80e0e0d3ae | ||
|
|
417b5b4e42 | ||
|
|
9b92261604 | ||
|
|
ca1ec0e38d | ||
|
|
52403dbe4a | ||
|
|
5ad1a3c39f | ||
|
|
d4a8a79628 | ||
|
|
007c9f9efe | ||
|
|
f61203ac1b | ||
|
|
f1db4ee378 | ||
|
|
366d63afdc | ||
|
|
c1551683a3 | ||
|
|
09aad6f8ea | ||
|
|
adb5a43810 | ||
|
|
cab039a9d8 | ||
|
|
97e80beceb | ||
|
|
1969423b5c | ||
|
|
4497ce1b84 | ||
|
|
5eee02bc40 | ||
|
|
2d50c6a6eb | ||
|
|
6f919fd675 | ||
|
|
fd1465ee38 | ||
|
|
00ec2c130d | ||
|
|
8eafa5adfe | ||
|
|
b604027205 | ||
|
|
2a19755804 | ||
|
|
cc85a4bdb1 | ||
|
|
7908a678df | ||
|
|
7c0eacb53d | ||
|
|
4bf804ac60 | ||
|
|
33c5918087 | ||
|
|
d34177729d | ||
|
|
7fa50328d7 | ||
|
|
1790aec85d | ||
|
|
6bf51e758f | ||
|
|
242da8c89a | ||
|
|
2294f40ee0 | ||
|
|
fe25019b14 | ||
|
|
bfb071c0b2 | ||
|
|
95a4e31b6c | ||
|
|
0d9c92c8c0 | ||
|
|
16c50bb659 | ||
|
|
1a85c9e9c8 | ||
|
|
c9ccefa607 | ||
|
|
3a6b6e58f0 | ||
|
|
2edc0ee299 | ||
|
|
bd9fa1ba70 | ||
|
|
bb85e611f4 | ||
|
|
a2f538f114 | ||
|
|
80c91cbdee | ||
|
|
ede2d2dbaa | ||
|
|
db3d7e53a4 | ||
|
|
06c4ff52fc | ||
|
|
cbc2638d96 | ||
|
|
fd93da799d | ||
|
|
5c5ed7344f | ||
|
|
d773383f70 | ||
|
|
2717044b62 | ||
|
|
2d635293c5 | ||
|
|
f0bfeda47a | ||
|
|
f1540290a7 | ||
|
|
0b80a608c3 | ||
|
|
3c1e24a0e8 | ||
|
|
2a6586b41b | ||
|
|
6421953183 | ||
|
|
0c47f44ff9 | ||
|
|
77f9c3fdd0 | ||
|
|
b53a14b1a7 | ||
|
|
f1e8602369 | ||
|
|
ddbe710881 | ||
|
|
d598d96fce | ||
|
|
4fdec50487 | ||
|
|
9de866dfb6 | ||
|
|
eb99a070ce | ||
|
|
73f80bde48 | ||
|
|
8130b2f3bd | ||
|
|
f4e6c76e58 | ||
|
|
85f5ae8ec7 | ||
|
|
484b447391 | ||
|
|
cfd1702bc6 | ||
|
|
7406ebfb5e | ||
|
|
0737af2fec | ||
|
|
a23d372d1f | ||
|
|
fe66ff3768 | ||
|
|
7fed1f63a6 | ||
|
|
a297d1619c | ||
|
|
ef1da90a77 | ||
|
|
d8f405c112 | ||
|
|
f8403ff241 | ||
|
|
65f6be3fd8 | ||
|
|
1cb53697d2 | ||
|
|
14f321b0e6 | ||
|
|
d3250fda79 | ||
|
|
c482488c41 | ||
|
|
fe8f39013e | ||
|
|
1016b20ef2 | ||
|
|
def8985dcd | ||
|
|
e6f12147df | ||
|
|
a8c2c396ed | ||
|
|
35262df4f2 |
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Force LF line endings for mock/test data files to ensure consistent
|
||||||
|
# behavior across platforms (Windows git autocrlf converts to CRLF otherwise)
|
||||||
|
mocks/** text eol=lf
|
||||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -3,9 +3,5 @@ updates:
|
|||||||
- package-ecosystem: gomod
|
- package-ecosystem: gomod
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: weekly
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
ignore:
|
|
||||||
- dependency-name: github.com/alecthomas/chroma
|
|
||||||
versions:
|
|
||||||
- 0.9.1
|
|
||||||
|
|||||||
79
.github/workflows/build.yml
vendored
79
.github/workflows/build.yml
vendored
@@ -1,57 +1,38 @@
|
|||||||
name: Go
|
---
|
||||||
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ master ]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# TODO: is it possible to DRY out these jobs? Aside from `runs-on`, they are
|
lint:
|
||||||
# identical.
|
runs-on: ubuntu-latest
|
||||||
build-linux:
|
|
||||||
runs-on: [ ubuntu-latest ]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
- name: Set up Go
|
with:
|
||||||
uses: actions/setup-go@v2
|
go-version: stable
|
||||||
with:
|
- name: Install revive
|
||||||
go-version: 1.16
|
run: go install github.com/mgechev/revive@latest
|
||||||
|
- name: Lint
|
||||||
- name: Set up Revive (linter)
|
run: revive -exclude vendor/... ./...
|
||||||
run: go get -u github.com/boyter/scc github.com/mgechev/revive
|
- name: Vet
|
||||||
env:
|
run: go vet ./...
|
||||||
GO111MODULE: off
|
- name: Check formatting
|
||||||
|
run: test -z "$(gofmt -l . | grep -v vendor/)"
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: make build
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: make test
|
|
||||||
|
|
||||||
build-osx:
|
|
||||||
runs-on: [ macos-latest ]
|
|
||||||
|
|
||||||
|
test:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
- name: Set up Go
|
with:
|
||||||
uses: actions/setup-go@v2
|
go-version: stable
|
||||||
with:
|
- name: Build
|
||||||
go-version: 1.16
|
run: go build -mod vendor ./cmd/cheat
|
||||||
|
- name: Test
|
||||||
- name: Set up Revive (linter)
|
run: go test ./...
|
||||||
run: go get -u github.com/boyter/scc github.com/mgechev/revive
|
|
||||||
env:
|
|
||||||
GO111MODULE: off
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: make build
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: make test
|
|
||||||
|
|
||||||
# TODO: windows
|
|
||||||
|
|||||||
34
.github/workflows/codeql-analysis.yml
vendored
34
.github/workflows/codeql-analysis.yml
vendored
@@ -1,12 +1,11 @@
|
|||||||
|
---
|
||||||
name: CodeQL
|
name: CodeQL
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [master]
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master ]
|
branches: [master]
|
||||||
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '45 23 * * 0'
|
- cron: '45 23 * * 0'
|
||||||
|
|
||||||
@@ -14,23 +13,18 @@ jobs:
|
|||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: [ 'go' ]
|
language: [go]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
- name: Initialize CodeQL
|
||||||
- name: Initialize CodeQL
|
uses: github/codeql-action/init@v3
|
||||||
uses: github/codeql-action/init@v1
|
with:
|
||||||
with:
|
languages: ${{ matrix.language }}
|
||||||
languages: ${{ matrix.language }}
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v3
|
||||||
- name: Autobuild
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/autobuild@v1
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v1
|
|
||||||
|
|||||||
17
.github/workflows/homebrew.yml
vendored
17
.github/workflows/homebrew.yml
vendored
@@ -1,17 +0,0 @@
|
|||||||
name: homebrew
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
homebrew:
|
|
||||||
name: Bump Homebrew formula
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: mislav/bump-homebrew-formula-action@v1
|
|
||||||
with:
|
|
||||||
# A PR will be sent to github.com/Homebrew/homebrew-core to update this formula:
|
|
||||||
formula-name: cheat
|
|
||||||
env:
|
|
||||||
COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }}
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
|||||||
dist
|
dist
|
||||||
tags
|
tags
|
||||||
|
.tmp
|
||||||
|
*.test
|
||||||
|
.claude
|
||||||
|
|||||||
105
.test-mutations.json
Normal file
105
.test-mutations.json
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"test_command": "go test ./...",
|
||||||
|
"last_updated": "2026-02-15T00:00:00Z",
|
||||||
|
"modules": {
|
||||||
|
"internal/sheet/parse.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/sheet/parse_test.go", "internal/sheet/parse_extended_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 8,
|
||||||
|
"mutations_killed": 8,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Originally 7/8 (87.5%). Added TestHasMalformedYAML to kill YAML unmarshal error survivor."
|
||||||
|
},
|
||||||
|
"internal/config/validate.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/config/validate_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 8,
|
||||||
|
"mutations_killed": 8,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Originally 7/8 (87.5%). Added TestInvalidateInvalidCheatpath to kill cheatpath.Validate() delegation survivor."
|
||||||
|
},
|
||||||
|
"internal/sheets/filter.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/sheets/filter_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 7,
|
||||||
|
"mutations_killed": 5,
|
||||||
|
"mutation_score": 71.4,
|
||||||
|
"notes": "Survivors relate to UTF-8 condition ordering and OR→AND on dead code path. Not actionable — logically equivalent mutations."
|
||||||
|
},
|
||||||
|
"internal/config/paths.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/config/paths_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 8,
|
||||||
|
"mutations_killed": 8,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Perfect score. Excellent existing coverage."
|
||||||
|
},
|
||||||
|
"internal/sheet/colorize.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/sheet/colorize_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 5,
|
||||||
|
"mutations_killed": 5,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Originally 2/5 (40%). Added TestColorizeDefaultSyntax and TestColorizeExplicitSyntax. All 5 mutations now killed."
|
||||||
|
},
|
||||||
|
"internal/sheets/consolidate.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/sheets/consolidate_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 2,
|
||||||
|
"mutations_killed": 2,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Override semantics well-tested."
|
||||||
|
},
|
||||||
|
"internal/display/indent.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/display/indent_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 3,
|
||||||
|
"mutations_killed": 3,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Originally 2/3 (66.7%). Added TestIndentTrimsWhitespace to kill TrimSpace survivor."
|
||||||
|
},
|
||||||
|
"internal/display/faint.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/display/faint_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 3,
|
||||||
|
"mutations_killed": 3,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Perfect score."
|
||||||
|
},
|
||||||
|
"internal/sheets/tags.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/sheets/tags_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 2,
|
||||||
|
"mutations_killed": 2,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "UTF-8 validation and sort order both tested."
|
||||||
|
},
|
||||||
|
"internal/sheet/validate.go": {
|
||||||
|
"status": "completed",
|
||||||
|
"covering_tests": ["internal/sheet/validate_test.go"],
|
||||||
|
"last_tested": "2026-02-15T00:00:00Z",
|
||||||
|
"mutations_applied": 10,
|
||||||
|
"mutations_killed": 10,
|
||||||
|
"mutation_score": 100.0,
|
||||||
|
"notes": "Perfect score. All security checks well-tested."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"global_statistics": {
|
||||||
|
"total_modules": 10,
|
||||||
|
"completed_modules": 10,
|
||||||
|
"total_mutations": 56,
|
||||||
|
"total_killed": 54,
|
||||||
|
"total_survived": 2,
|
||||||
|
"overall_score": 96.4
|
||||||
|
}
|
||||||
|
}
|
||||||
126
CLAUDE.md
Normal file
126
CLAUDE.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Common Development Commands
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
# Build for your architecture
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Build release binaries for all platforms
|
||||||
|
make build-release
|
||||||
|
|
||||||
|
# Install cheat to your PATH
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing and Quality Checks
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
make test
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
go test -run TestFunctionName ./internal/package_name
|
||||||
|
|
||||||
|
# Generate test coverage report
|
||||||
|
make coverage
|
||||||
|
|
||||||
|
# Run linter (revive)
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Run go vet
|
||||||
|
make vet
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
make fmt
|
||||||
|
|
||||||
|
# Run all checks (vendor, fmt, lint, vet, test)
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
```bash
|
||||||
|
# Install development dependencies (revive linter, scc)
|
||||||
|
make setup
|
||||||
|
|
||||||
|
# Update and verify vendored dependencies
|
||||||
|
make vendor-update
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The `cheat` command-line tool is organized into several key packages:
|
||||||
|
|
||||||
|
### Command Layer (`cmd/cheat/`)
|
||||||
|
- `main.go`: Entry point, cobra command definition, flag registration, command routing
|
||||||
|
- `cmd_*.go`: Individual command implementations (view, edit, list, search, etc.)
|
||||||
|
- Commands are routed via a `switch` block in the cobra `RunE` handler
|
||||||
|
|
||||||
|
### Completions (`internal/completions/`)
|
||||||
|
- Dynamic shell completion functions for cheatsheet names, tags, and cheatpath names
|
||||||
|
- `generate.go`: Generates completion scripts for bash, zsh, fish, and powershell
|
||||||
|
|
||||||
|
### Core Internal Packages
|
||||||
|
|
||||||
|
1. **`internal/config`**: Configuration management
|
||||||
|
- Loads YAML config from platform-specific paths
|
||||||
|
- Manages editor, pager, colorization settings
|
||||||
|
- Validates and expands cheatpath configurations
|
||||||
|
|
||||||
|
2. **`internal/cheatpath`**: Cheatsheet path management
|
||||||
|
- Represents collections of cheatsheets on filesystem
|
||||||
|
- Handles read-only vs writable paths
|
||||||
|
- Supports filtering and validation
|
||||||
|
|
||||||
|
3. **`internal/sheet`**: Individual cheatsheet handling
|
||||||
|
- Parses YAML frontmatter for tags and syntax
|
||||||
|
- Implements syntax highlighting via Chroma
|
||||||
|
- Provides search functionality within sheets
|
||||||
|
|
||||||
|
4. **`internal/sheets`**: Collection operations
|
||||||
|
- Loads sheets from multiple cheatpaths
|
||||||
|
- Consolidates duplicates (local overrides global)
|
||||||
|
- Filters by tags and sorts results
|
||||||
|
|
||||||
|
5. **`internal/display`**: Output formatting
|
||||||
|
- Writes to stdout or pager
|
||||||
|
- Handles text formatting and indentation
|
||||||
|
|
||||||
|
6. **`internal/installer`**: First-run installer
|
||||||
|
- Prompts user for initial configuration choices
|
||||||
|
- Generates default `conf.yml` and downloads community cheatsheets
|
||||||
|
|
||||||
|
7. **`internal/repo`**: Git repository management
|
||||||
|
- Clones community cheatsheet repositories
|
||||||
|
- Updates existing repositories
|
||||||
|
|
||||||
|
### Key Design Patterns
|
||||||
|
|
||||||
|
- **Filesystem-based storage**: Cheatsheets are plain text files
|
||||||
|
- **Override mechanism**: Local sheets override community sheets with same name
|
||||||
|
- **Tag system**: Sheets can be categorized with tags in frontmatter
|
||||||
|
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
|
||||||
|
- **Directory-scoped discovery**: Walks up from cwd to find the nearest `.cheat` directory (like `.git` discovery)
|
||||||
|
|
||||||
|
### Sheet Format
|
||||||
|
|
||||||
|
Cheatsheets are plain text files optionally prefixed with YAML frontmatter:
|
||||||
|
```
|
||||||
|
---
|
||||||
|
syntax: bash
|
||||||
|
tags: [ networking, ssh ]
|
||||||
|
---
|
||||||
|
# SSH tunneling example
|
||||||
|
ssh -L 8080:localhost:80 user@remote
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with the Codebase
|
||||||
|
|
||||||
|
- Always check for `.git` directories and skip them during filesystem walks
|
||||||
|
- Use `go-git` for repository operations, not exec'ing git commands
|
||||||
|
- Platform-specific paths are handled in `internal/config/paths.go`
|
||||||
|
- Color output uses ANSI codes via the Chroma library
|
||||||
|
- Test files use the `mocks` package for test data
|
||||||
@@ -1,46 +1,17 @@
|
|||||||
CONTRIBUTING
|
# Contributing
|
||||||
============
|
|
||||||
Do you want to contribute to `cheat`? There are a few ways to help:
|
|
||||||
|
|
||||||
#### Submit a cheatsheet ####
|
Thank you for your interest in `cheat`.
|
||||||
Do you have a witty bash one-liner to share? [Open a pull-request][pr] against
|
|
||||||
the [cheatsheets][] repository. (The `cheat` executable source code lives in
|
|
||||||
[cheat/cheat][cheat]. Cheatsheet content lives in
|
|
||||||
[cheat/cheatsheets][cheatsheets].)
|
|
||||||
|
|
||||||
#### Report a bug ####
|
Pull requests are no longer being accepted, and have been disabled on this
|
||||||
Did you find a bug? Report it in the [issue tracker][issues]. (But before you
|
repository. The maintainer is not currently reviewing or merging external code
|
||||||
do, please look through the open issues to make sure that it hasn't already
|
contributions.
|
||||||
been reported.)
|
|
||||||
|
|
||||||
#### Add a feature ####
|
Bug reports are still welcome. If you've found a bug, please open an issue in
|
||||||
Do you have a feature that you'd like to contribute? Propose it in the [issue
|
the [issue tracker][issues]. Before doing so, please search through the
|
||||||
tracker][issues] to discuss with the maintainer whether it would be considered
|
existing open issues to make sure it hasn't already been reported.
|
||||||
for merging.
|
|
||||||
|
|
||||||
`cheat` is mostly mature and feature-complete, but may still have some room for
|
Feature requests may be filed, but are unlikely to be implemented. The project
|
||||||
new features.
|
is now mature and the maintainer considers its feature set to be essentially
|
||||||
|
complete.
|
||||||
|
|
||||||
#### Add documentation ####
|
|
||||||
Did you encounter features, bugs, edge-cases, use-cases, or environment
|
|
||||||
considerations that were undocumented or under-documented? Add them to the
|
|
||||||
[wiki][]. (You may also open a pull-request against the `README`, if
|
|
||||||
appropriate.)
|
|
||||||
|
|
||||||
Do you enjoy technical writing or proofreading? Help keep the documentation
|
|
||||||
error-free and well-organized.
|
|
||||||
|
|
||||||
#### Spread the word ####
|
|
||||||
Are you unable to do the above, but still want to contribute? You can help
|
|
||||||
`cheat` simply by telling others about it. Share it with friends and coworkers
|
|
||||||
that might benefit from using it.
|
|
||||||
|
|
||||||
#### Pull Requests ####
|
|
||||||
Please open all pull-requests against the `develop` branch.
|
|
||||||
|
|
||||||
|
|
||||||
[cheat]: https://github.com/cheat/cheat
|
|
||||||
[cheatsheets]: https://github.com/cheat/cheatsheets
|
|
||||||
[issues]: https://github.com/cheat/cheat/issues
|
[issues]: https://github.com/cheat/cheat/issues
|
||||||
[pr]: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork
|
|
||||||
[wiki]: https://github.com/cheat/cheat/wiki
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# NB: this image isn't used anywhere in the build pipeline. It exists to
|
# NB: this image isn't used anywhere in the build pipeline. It exists to
|
||||||
# conveniently facilitate ad-hoc experimentation in a sandboxed environment
|
# conveniently facilitate ad-hoc experimentation in a sandboxed environment
|
||||||
# during development.
|
# during development.
|
||||||
FROM golang:1.15-alpine
|
FROM golang:1.26-alpine
|
||||||
|
|
||||||
RUN apk add git less make
|
RUN apk add git less make
|
||||||
|
|
||||||
|
|||||||
241
HACKING.md
Normal file
241
HACKING.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Hacking Guide
|
||||||
|
|
||||||
|
This document provides a comprehensive guide for developing `cheat`, including setup, architecture overview, and code patterns.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install system dependencies
|
||||||
|
|
||||||
|
The following are required and must be available on your `PATH`:
|
||||||
|
- `git`
|
||||||
|
- `go` (>= 1.19 is recommended)
|
||||||
|
- `make`
|
||||||
|
|
||||||
|
Optional dependencies:
|
||||||
|
- `docker`
|
||||||
|
- `pandoc` (necessary to generate a `man` page)
|
||||||
|
|
||||||
|
### 2. Install utility applications
|
||||||
|
Run `make setup` to install `scc` and `revive`, which are used by various `make` targets.
|
||||||
|
|
||||||
|
### 3. Development workflow
|
||||||
|
|
||||||
|
1. Make changes to the `cheat` source code
|
||||||
|
2. Run `make test` to run unit-tests
|
||||||
|
3. Fix compiler errors and failing tests as necessary
|
||||||
|
4. Run `make build`. A `cheat` executable will be written to the `dist` directory
|
||||||
|
5. Use the new executable by running `dist/cheat <command>`
|
||||||
|
6. Run `make install` to install `cheat` to your `PATH`
|
||||||
|
7. Run `make build-release` to build cross-platform binaries in `dist`
|
||||||
|
8. Run `make clean` to clean the `dist` directory when desired
|
||||||
|
|
||||||
|
You may run `make help` to see a list of available `make` commands.
|
||||||
|
|
||||||
|
### 4. Testing
|
||||||
|
|
||||||
|
#### Unit Tests
|
||||||
|
Run unit tests with:
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Integration Tests
|
||||||
|
Integration tests that require network access are separated using build tags. Run them with:
|
||||||
|
```bash
|
||||||
|
make test-integration
|
||||||
|
```
|
||||||
|
|
||||||
|
To run all tests (unit and integration):
|
||||||
|
```bash
|
||||||
|
make test-all
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test Coverage
|
||||||
|
Generate a coverage report with:
|
||||||
|
```bash
|
||||||
|
make coverage # HTML report
|
||||||
|
make coverage-text # Terminal output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Package Structure
|
||||||
|
|
||||||
|
The `cheat` application follows a clean architecture with well-separated concerns:
|
||||||
|
|
||||||
|
- **`cmd/cheat/`**: Command layer (cobra-based CLI, flag registration, command routing, shell completions)
|
||||||
|
- **`internal/config`**: Configuration management (YAML loading, validation, paths)
|
||||||
|
- **`internal/cheatpath`**: Cheatsheet path management (collections, filtering)
|
||||||
|
- **`internal/sheet`**: Individual cheatsheet handling (parsing, search, highlighting)
|
||||||
|
- **`internal/sheets`**: Collection operations (loading, consolidation, filtering)
|
||||||
|
- **`internal/display`**: Output formatting (pager integration, colorization)
|
||||||
|
- **`internal/repo`**: Git repository management for community sheets
|
||||||
|
|
||||||
|
### Key Design Patterns
|
||||||
|
|
||||||
|
- **Filesystem-based storage**: Cheatsheets are plain text files
|
||||||
|
- **Override mechanism**: Local sheets override community sheets with same name
|
||||||
|
- **Tag system**: Sheets can be categorized with tags in frontmatter
|
||||||
|
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
|
||||||
|
|
||||||
|
## Core Types and Functions
|
||||||
|
|
||||||
|
### Config (`internal/config`)
|
||||||
|
|
||||||
|
The main configuration structure:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Colorize bool `yaml:"colorize"`
|
||||||
|
Editor string `yaml:"editor"`
|
||||||
|
Cheatpaths []cp.Path `yaml:"cheatpaths"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
|
Formatter string `yaml:"formatter"`
|
||||||
|
Pager string `yaml:"pager"`
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key functions:
|
||||||
|
- `New(confPath, resolve)` - Load config from file
|
||||||
|
- `Validate()` - Validate configuration values
|
||||||
|
- `Editor()` - Get editor from environment or defaults (package-level function)
|
||||||
|
- `Pager()` - Get pager from environment or defaults (package-level function)
|
||||||
|
|
||||||
|
### Cheatpath (`internal/cheatpath`)
|
||||||
|
|
||||||
|
Represents a directory containing cheatsheets:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Path struct {
|
||||||
|
Name string // Friendly name (e.g., "personal")
|
||||||
|
Path string // Filesystem path
|
||||||
|
Tags []string // Tags applied to all sheets in this path
|
||||||
|
ReadOnly bool // Whether sheets can be modified
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sheet (`internal/sheet`)
|
||||||
|
|
||||||
|
Represents an individual cheatsheet:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Sheet struct {
|
||||||
|
Title string // Sheet name (from filename)
|
||||||
|
CheatPath string // Name of the cheatpath this sheet belongs to
|
||||||
|
Path string // Full filesystem path
|
||||||
|
Text string // Content (without frontmatter)
|
||||||
|
Tags []string // Combined tags (from frontmatter + cheatpath)
|
||||||
|
Syntax string // Syntax for highlighting
|
||||||
|
ReadOnly bool // Whether sheet can be edited
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key methods:
|
||||||
|
- `New(title, cheatpath, path, tags, readOnly)` - Load from file
|
||||||
|
- `Search(reg)` - Search content with a compiled regexp
|
||||||
|
- `Colorize(conf)` - Apply syntax highlighting (modifies sheet in place)
|
||||||
|
- `Tagged(needle)` - Check if sheet has the given tag
|
||||||
|
|
||||||
|
## Common Operations
|
||||||
|
|
||||||
|
### Loading and Displaying a Sheet
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Load sheet
|
||||||
|
s, err := sheet.New("tar", "personal", "/path/to/tar", []string{"personal"}, false)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply syntax highlighting (modifies sheet in place)
|
||||||
|
s.Colorize(conf)
|
||||||
|
|
||||||
|
// Display with pager
|
||||||
|
display.Write(s.Text, conf)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with Sheet Collections
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Load all sheets from cheatpaths (returns a slice of maps, one per cheatpath)
|
||||||
|
allSheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidate to handle duplicates (later cheatpaths take precedence)
|
||||||
|
consolidated := sheets.Consolidate(allSheets)
|
||||||
|
|
||||||
|
// Filter by tag (operates on the slice of maps)
|
||||||
|
filtered := sheets.Filter(allSheets, []string{"networking"})
|
||||||
|
|
||||||
|
// Sort alphabetically (returns a sorted slice)
|
||||||
|
sorted := sheets.Sort(consolidated)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sheet Format
|
||||||
|
|
||||||
|
Cheatsheets are plain text files that may begin with YAML frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
syntax: bash
|
||||||
|
tags: [networking, linux, ssh]
|
||||||
|
---
|
||||||
|
# Connect to remote server
|
||||||
|
ssh user@hostname
|
||||||
|
|
||||||
|
# Copy files over SSH
|
||||||
|
scp local_file user@hostname:/remote/path
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests with:
|
||||||
|
```bash
|
||||||
|
make test # Run all tests
|
||||||
|
make coverage # Generate coverage report
|
||||||
|
go test ./... # Go test directly
|
||||||
|
```
|
||||||
|
|
||||||
|
Test files follow Go conventions:
|
||||||
|
- `*_test.go` files in same package
|
||||||
|
- Table-driven tests for multiple scenarios
|
||||||
|
- Mock data in `mocks` package
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The codebase follows consistent error handling patterns:
|
||||||
|
- Functions return explicit errors
|
||||||
|
- Errors are wrapped with context using `fmt.Errorf`
|
||||||
|
- User-facing errors are written to stderr
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```go
|
||||||
|
s, err := sheet.New(title, cheatpath, path, tags, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load sheet: %w", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing with Docker
|
||||||
|
|
||||||
|
It may be useful to test your changes within a pristine environment. An Alpine-based docker container has been provided for that purpose.
|
||||||
|
|
||||||
|
Build the docker container:
|
||||||
|
```bash
|
||||||
|
make docker-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Shell into the container:
|
||||||
|
```bash
|
||||||
|
make docker-sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cheat` source code will be mounted at `/app` within the container.
|
||||||
|
|
||||||
|
To destroy the container:
|
||||||
|
```bash
|
||||||
|
make distclean
|
||||||
|
```
|
||||||
76
INSTALLING.md
Normal file
76
INSTALLING.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Installing
|
||||||
|
`cheat` has no runtime dependencies. As such, installing it is generally
|
||||||
|
straightforward. There are a few methods available:
|
||||||
|
|
||||||
|
## Install manually
|
||||||
|
### Unix-like
|
||||||
|
On Unix-like systems, you may simply paste the following snippet into your terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /tmp \
|
||||||
|
&& wget https://github.com/cheat/cheat/releases/download/5.0.0/cheat-linux-amd64.gz \
|
||||||
|
&& gunzip cheat-linux-amd64.gz \
|
||||||
|
&& chmod +x cheat-linux-amd64 \
|
||||||
|
&& sudo mv cheat-linux-amd64 /usr/local/bin/cheat
|
||||||
|
```
|
||||||
|
|
||||||
|
You may need to need to change the version number (`5.0.0`) and the archive
|
||||||
|
(`cheat-linux-amd64.gz`) depending on your platform.
|
||||||
|
|
||||||
|
See the [releases page][releases] for a list of supported platforms.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
On Windows, download the appropriate binary from the [releases page][releases],
|
||||||
|
unzip the archive, and place the `cheat.exe` executable on your `PATH`.
|
||||||
|
|
||||||
|
## Install via `go install`
|
||||||
|
If you have `go` version `>=1.26` available on your `PATH`, you can install
|
||||||
|
`cheat` via `go install`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install github.com/cheat/cheat/cmd/cheat@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install via package manager
|
||||||
|
Several community-maintained packages are also available:
|
||||||
|
|
||||||
|
Package manager | Package(s)
|
||||||
|
---------------- | -----------
|
||||||
|
aur | [cheat][pkg-aur-cheat], [cheat-bin][pkg-aur-cheat-bin]
|
||||||
|
brew | [cheat][pkg-brew]
|
||||||
|
docker | [docker-cheat][pkg-docker]
|
||||||
|
nix | [nixos.cheat][pkg-nix]
|
||||||
|
snap | [cheat][pkg-snap]
|
||||||
|
|
||||||
|
## Configuring
|
||||||
|
Three things must be done before you can use `cheat`:
|
||||||
|
1. A config file must be generated
|
||||||
|
2. [`cheatpaths`][cheatpaths] must be configured
|
||||||
|
3. [Community cheatsheets][community] must be downloaded
|
||||||
|
|
||||||
|
On first run, `cheat` will run an installer that will do all of the above
|
||||||
|
automatically. After the installer is complete, it is strongly advised that you
|
||||||
|
view the configuration file that was generated, as you may want to change some
|
||||||
|
of its default values (to enable colorization, change the paginator, etc).
|
||||||
|
|
||||||
|
### conf.yml
|
||||||
|
`cheat` is configured by a YAML file that will be auto-generated on first run.
|
||||||
|
|
||||||
|
By default, the config file is assumed to exist on an XDG-compliant
|
||||||
|
configuration path like `~/.config/cheat/conf.yml`. If you would like to store
|
||||||
|
it elsewhere, you may export a `CHEAT_CONFIG_PATH` environment variable that
|
||||||
|
specifies its path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export CHEAT_CONFIG_PATH="~/.dotfiles/cheat/conf.yml"
|
||||||
|
```
|
||||||
|
|
||||||
|
[cheatpaths]: README.md#cheatpaths
|
||||||
|
[community]: https://github.com/cheat/cheatsheets/
|
||||||
|
[pkg-aur-cheat-bin]: https://aur.archlinux.org/packages/cheat-bin
|
||||||
|
[pkg-aur-cheat]: https://aur.archlinux.org/packages/cheat
|
||||||
|
[pkg-brew]: https://formulae.brew.sh/formula/cheat
|
||||||
|
[pkg-docker]: https://github.com/bannmann/docker-cheat
|
||||||
|
[pkg-nix]: https://search.nixos.org/packages?channel=unstable&show=cheat&from=0&size=50&sort=relevance&type=packages&query=cheat
|
||||||
|
[pkg-snap]: https://snapcraft.io/cheat
|
||||||
|
[releases]: https://github.com/cheat/cheat/releases
|
||||||
130
Makefile
130
Makefile
@@ -3,6 +3,9 @@ makefile := $(realpath $(lastword $(MAKEFILE_LIST)))
|
|||||||
cmd_dir := ./cmd/cheat
|
cmd_dir := ./cmd/cheat
|
||||||
dist_dir := ./dist
|
dist_dir := ./dist
|
||||||
|
|
||||||
|
# parallel jobs for build-release (can be overridden)
|
||||||
|
JOBS ?= 8
|
||||||
|
|
||||||
# executables
|
# executables
|
||||||
CAT := cat
|
CAT := cat
|
||||||
COLUMN := column
|
COLUMN := column
|
||||||
@@ -24,6 +27,7 @@ ZIP := zip -m
|
|||||||
docker_image := cheat-devel:latest
|
docker_image := cheat-devel:latest
|
||||||
|
|
||||||
# build flags
|
# build flags
|
||||||
|
export CGO_ENABLED := 0
|
||||||
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
|
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
|
||||||
GOBIN :=
|
GOBIN :=
|
||||||
TMPDIR := /tmp
|
TMPDIR := /tmp
|
||||||
@@ -31,70 +35,97 @@ TMPDIR := /tmp
|
|||||||
# release binaries
|
# release binaries
|
||||||
releases := \
|
releases := \
|
||||||
$(dist_dir)/cheat-darwin-amd64 \
|
$(dist_dir)/cheat-darwin-amd64 \
|
||||||
|
$(dist_dir)/cheat-darwin-arm64 \
|
||||||
$(dist_dir)/cheat-linux-386 \
|
$(dist_dir)/cheat-linux-386 \
|
||||||
$(dist_dir)/cheat-linux-amd64 \
|
$(dist_dir)/cheat-linux-amd64 \
|
||||||
$(dist_dir)/cheat-linux-arm5 \
|
$(dist_dir)/cheat-linux-arm5 \
|
||||||
$(dist_dir)/cheat-linux-arm6 \
|
$(dist_dir)/cheat-linux-arm6 \
|
||||||
$(dist_dir)/cheat-linux-arm7 \
|
|
||||||
$(dist_dir)/cheat-linux-arm64 \
|
$(dist_dir)/cheat-linux-arm64 \
|
||||||
|
$(dist_dir)/cheat-linux-arm7 \
|
||||||
|
$(dist_dir)/cheat-netbsd-amd64 \
|
||||||
|
$(dist_dir)/cheat-openbsd-amd64 \
|
||||||
|
$(dist_dir)/cheat-solaris-amd64 \
|
||||||
$(dist_dir)/cheat-windows-amd64.exe
|
$(dist_dir)/cheat-windows-amd64.exe
|
||||||
|
|
||||||
## build: build an executable for your architecture
|
## build: build an executable for your architecture
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: $(dist_dir) clean fmt lint vet vendor generate man
|
build: | clean $(dist_dir) fmt lint vet vendor man
|
||||||
$(GO) build $(BUILD_FLAGS) -o $(dist_dir)/cheat $(cmd_dir)
|
$(GO) build $(BUILD_FLAGS) -o $(dist_dir)/cheat $(cmd_dir)
|
||||||
|
|
||||||
## build-release: build release executables
|
## build-release: build release executables
|
||||||
|
# Runs prepare once, then builds all binaries in parallel
|
||||||
|
# Override jobs with: make build-release JOBS=16
|
||||||
.PHONY: build-release
|
.PHONY: build-release
|
||||||
build-release: $(releases)
|
build-release: prepare
|
||||||
|
$(MAKE) -j$(JOBS) $(releases)
|
||||||
|
|
||||||
# cheat-darwin-amd64
|
# cheat-darwin-amd64
|
||||||
$(dist_dir)/cheat-darwin-amd64: prepare
|
$(dist_dir)/cheat-darwin-amd64:
|
||||||
GOARCH=amd64 GOOS=darwin \
|
GOARCH=amd64 GOOS=darwin \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
|
# cheat-darwin-arm64
|
||||||
|
$(dist_dir)/cheat-darwin-arm64:
|
||||||
|
GOARCH=arm64 GOOS=darwin \
|
||||||
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
# cheat-linux-386
|
# cheat-linux-386
|
||||||
$(dist_dir)/cheat-linux-386: prepare
|
$(dist_dir)/cheat-linux-386:
|
||||||
GOARCH=386 GOOS=linux \
|
GOARCH=386 GOOS=linux \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
# cheat-linux-amd64
|
# cheat-linux-amd64
|
||||||
$(dist_dir)/cheat-linux-amd64: prepare
|
$(dist_dir)/cheat-linux-amd64:
|
||||||
GOARCH=amd64 GOOS=linux \
|
GOARCH=amd64 GOOS=linux \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
# cheat-linux-arm5
|
# cheat-linux-arm5
|
||||||
$(dist_dir)/cheat-linux-arm5: prepare
|
$(dist_dir)/cheat-linux-arm5:
|
||||||
GOARCH=arm GOOS=linux GOARM=5 \
|
GOARCH=arm GOOS=linux GOARM=5 \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
# cheat-linux-arm6
|
# cheat-linux-arm6
|
||||||
$(dist_dir)/cheat-linux-arm6: prepare
|
$(dist_dir)/cheat-linux-arm6:
|
||||||
GOARCH=arm GOOS=linux GOARM=6 \
|
GOARCH=arm GOOS=linux GOARM=6 \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
# cheat-linux-arm7
|
# cheat-linux-arm7
|
||||||
$(dist_dir)/cheat-linux-arm7: prepare
|
$(dist_dir)/cheat-linux-arm7:
|
||||||
GOARCH=arm GOOS=linux GOARM=7 \
|
GOARCH=arm GOOS=linux GOARM=7 \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
# cheat-linux-arm64
|
# cheat-linux-arm64
|
||||||
$(dist_dir)/cheat-linux-arm64: prepare
|
$(dist_dir)/cheat-linux-arm64:
|
||||||
GOARCH=arm64 GOOS=linux \
|
GOARCH=arm64 GOOS=linux \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
|
# cheat-netbsd-amd64
|
||||||
|
$(dist_dir)/cheat-netbsd-amd64:
|
||||||
|
GOARCH=amd64 GOOS=netbsd \
|
||||||
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
|
# cheat-openbsd-amd64
|
||||||
|
$(dist_dir)/cheat-openbsd-amd64:
|
||||||
|
GOARCH=amd64 GOOS=openbsd \
|
||||||
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
|
# cheat-solaris-amd64
|
||||||
|
$(dist_dir)/cheat-solaris-amd64:
|
||||||
|
GOARCH=amd64 GOOS=solaris \
|
||||||
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
|
||||||
|
|
||||||
# cheat-windows-amd64
|
# cheat-windows-amd64
|
||||||
$(dist_dir)/cheat-windows-amd64.exe: prepare
|
$(dist_dir)/cheat-windows-amd64.exe:
|
||||||
GOARCH=amd64 GOOS=windows \
|
GOARCH=amd64 GOOS=windows \
|
||||||
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(ZIP) $@.zip $@
|
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(ZIP) $@.zip $@ -j
|
||||||
|
|
||||||
# ./dist
|
# ./dist
|
||||||
$(dist_dir):
|
$(dist_dir):
|
||||||
$(MKDIR) $(dist_dir)
|
$(MKDIR) $(dist_dir)
|
||||||
|
|
||||||
.PHONY: generate
|
# .tmp
|
||||||
generate:
|
.tmp:
|
||||||
$(GO) generate $(cmd_dir)
|
$(MKDIR) .tmp
|
||||||
|
|
||||||
## install: build and install cheat on your PATH
|
## install: build and install cheat on your PATH
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
@@ -103,8 +134,9 @@ install: build
|
|||||||
|
|
||||||
## clean: remove compiled executables
|
## clean: remove compiled executables
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: $(dist_dir)
|
clean:
|
||||||
$(RM) -f $(dist_dir)/*
|
$(RM) -f $(dist_dir)/*
|
||||||
|
$(RM) -rf .tmp
|
||||||
|
|
||||||
## distclean: remove the tags file
|
## distclean: remove the tags file
|
||||||
.PHONY: distclean
|
.PHONY: distclean
|
||||||
@@ -115,7 +147,8 @@ distclean:
|
|||||||
## setup: install revive (linter) and scc (sloc tool)
|
## setup: install revive (linter) and scc (sloc tool)
|
||||||
.PHONY: setup
|
.PHONY: setup
|
||||||
setup:
|
setup:
|
||||||
GO111MODULE=off $(GO) get -u github.com/boyter/scc github.com/mgechev/revive
|
$(GO) install github.com/boyter/scc@latest
|
||||||
|
$(GO) install github.com/mgechev/revive@latest
|
||||||
|
|
||||||
## sloc: count "semantic lines of code"
|
## sloc: count "semantic lines of code"
|
||||||
.PHONY: sloc
|
.PHONY: sloc
|
||||||
@@ -139,8 +172,9 @@ vendor:
|
|||||||
$(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
|
$(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
|
||||||
|
|
||||||
## vendor-update: update vendored dependencies
|
## vendor-update: update vendored dependencies
|
||||||
|
.PHONY: vendor-update
|
||||||
vendor-update:
|
vendor-update:
|
||||||
$(GO) get -t -u ./... && $(GO) mod vendor
|
$(GO) get -t -u ./... && $(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
|
||||||
|
|
||||||
## fmt: run go fmt
|
## fmt: run go fmt
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
@@ -162,18 +196,70 @@ vet:
|
|||||||
test:
|
test:
|
||||||
$(GO) test ./...
|
$(GO) test ./...
|
||||||
|
|
||||||
|
## test-integration: run integration tests (requires network)
|
||||||
|
.PHONY: test-integration
|
||||||
|
test-integration:
|
||||||
|
$(GO) test -tags=integration -count=1 ./...
|
||||||
|
|
||||||
|
## test-all: run all tests (unit and integration)
|
||||||
|
.PHONY: test-all
|
||||||
|
test-all: test test-integration
|
||||||
|
|
||||||
|
## test-fuzz: run quick fuzz tests for security-critical functions
|
||||||
|
.PHONY: test-fuzz
|
||||||
|
test-fuzz:
|
||||||
|
@./test/fuzz.sh 15s
|
||||||
|
|
||||||
|
## test-fuzz-long: run extended fuzz tests (10 minutes each)
|
||||||
|
.PHONY: test-fuzz-long
|
||||||
|
test-fuzz-long:
|
||||||
|
@./test/fuzz.sh 10m
|
||||||
|
|
||||||
## coverage: generate a test coverage report
|
## coverage: generate a test coverage report
|
||||||
.PHONY: coverage
|
.PHONY: coverage
|
||||||
coverage:
|
coverage: .tmp
|
||||||
$(GO) test ./... -coverprofile=$(TMPDIR)/cheat-coverage.out && \
|
$(GO) test ./... -coverprofile=.tmp/cheat-coverage.out && \
|
||||||
$(GO) tool cover -html=$(TMPDIR)/cheat-coverage.out
|
$(GO) tool cover -html=.tmp/cheat-coverage.out -o .tmp/cheat-coverage.html && \
|
||||||
|
echo "Coverage report generated: .tmp/cheat-coverage.html" && \
|
||||||
|
(sensible-browser .tmp/cheat-coverage.html 2>/dev/null || \
|
||||||
|
xdg-open .tmp/cheat-coverage.html 2>/dev/null || \
|
||||||
|
open .tmp/cheat-coverage.html 2>/dev/null || \
|
||||||
|
echo "Please open .tmp/cheat-coverage.html in your browser")
|
||||||
|
|
||||||
|
## coverage-text: show test coverage by function in terminal
|
||||||
|
.PHONY: coverage-text
|
||||||
|
coverage-text: .tmp
|
||||||
|
$(GO) test ./... -coverprofile=.tmp/cheat-coverage.out && \
|
||||||
|
$(GO) tool cover -func=.tmp/cheat-coverage.out | $(SORT) -k3 -n
|
||||||
|
|
||||||
|
## benchmark: run performance benchmarks
|
||||||
|
.PHONY: benchmark
|
||||||
|
benchmark: .tmp
|
||||||
|
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./test/integration | tee .tmp/benchmark-latest.txt && \
|
||||||
|
$(RM) -f integration.test
|
||||||
|
|
||||||
|
## benchmark-cpu: run benchmarks with CPU profiling
|
||||||
|
.PHONY: benchmark-cpu
|
||||||
|
benchmark-cpu: .tmp
|
||||||
|
$(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./test/integration && \
|
||||||
|
$(RM) -f integration.test && \
|
||||||
|
echo "CPU profile saved to .tmp/cpu.prof" && \
|
||||||
|
echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof"
|
||||||
|
|
||||||
|
## benchmark-mem: run benchmarks with memory profiling
|
||||||
|
.PHONY: benchmark-mem
|
||||||
|
benchmark-mem: .tmp
|
||||||
|
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./test/integration && \
|
||||||
|
$(RM) -f integration.test && \
|
||||||
|
echo "Memory profile saved to .tmp/mem.prof" && \
|
||||||
|
echo "View with: go tool pprof -http=:8080 .tmp/mem.prof"
|
||||||
|
|
||||||
## check: format, lint, vet, vendor, and run unit-tests
|
## check: format, lint, vet, vendor, and run unit-tests
|
||||||
.PHONY: check
|
.PHONY: check
|
||||||
check: | vendor fmt lint vet test
|
check: | vendor fmt lint vet test
|
||||||
|
|
||||||
.PHONY: prepare
|
.PHONY: prepare
|
||||||
prepare: | $(dist_dir) clean generate vendor fmt lint vet test
|
prepare: | clean $(dist_dir) vendor fmt lint vet test
|
||||||
|
|
||||||
## docker-setup: create a docker image for use during development
|
## docker-setup: create a docker image for use during development
|
||||||
.PHONY: docker-setup
|
.PHONY: docker-setup
|
||||||
|
|||||||
242
README.md
242
README.md
@@ -1,8 +1,6 @@
|
|||||||

|

|
||||||
|
|
||||||
|
# cheat
|
||||||
cheat
|
|
||||||
=====
|
|
||||||
|
|
||||||
`cheat` allows you to create and view interactive cheatsheets on the
|
`cheat` allows you to create and view interactive cheatsheets on the
|
||||||
command-line. It was designed to help remind \*nix system administrators of
|
command-line. It was designed to help remind \*nix system administrators of
|
||||||
@@ -13,9 +11,7 @@ remember.
|
|||||||
|
|
||||||
Use `cheat` with [cheatsheets][].
|
Use `cheat` with [cheatsheets][].
|
||||||
|
|
||||||
|
## Example
|
||||||
Example
|
|
||||||
-------
|
|
||||||
The next time you're forced to disarm a nuclear weapon without consulting
|
The next time you're forced to disarm a nuclear weapon without consulting
|
||||||
Google, you may run:
|
Google, you may run:
|
||||||
|
|
||||||
@@ -42,116 +38,10 @@ tar -xjvf '/path/to/foo.tgz'
|
|||||||
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
|
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
For installation and configuration instructions, see [INSTALLING.md][].
|
||||||
|
|
||||||
Installing
|
## Usage
|
||||||
----------
|
|
||||||
`cheat` has no dependencies. To install it, download the executable from the
|
|
||||||
[releases][] page and place it on your `PATH`.
|
|
||||||
|
|
||||||
Alternatively, if you have [go][] installed, you may install `cheat` using `go
|
|
||||||
get`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go get -u github.com/cheat/cheat/cmd/cheat
|
|
||||||
```
|
|
||||||
|
|
||||||
Configuring
|
|
||||||
-----------
|
|
||||||
### conf.yml ###
|
|
||||||
`cheat` is configured by a YAML file that will be auto-generated on first run.
|
|
||||||
|
|
||||||
By default, the config file is assumed to exist on an XDG-compliant
|
|
||||||
configuration path like `~/.config/cheat/conf.yml`. If you would like to store
|
|
||||||
it elsewhere, you may export a `CHEAT_CONFIG_PATH` environment variable that
|
|
||||||
specifies its path:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export CHEAT_CONFIG_PATH="~/.dotfiles/cheat/conf.yml"
|
|
||||||
```
|
|
||||||
|
|
||||||
Cheatsheets
|
|
||||||
-----------
|
|
||||||
Cheatsheets are plain-text files with no file extension, and are named
|
|
||||||
according to the command used to view them:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cheat tar # file is named "tar"
|
|
||||||
cheat foo/bar # file is named "bar", in a "foo" subdirectory
|
|
||||||
```
|
|
||||||
|
|
||||||
Cheatsheet text may optionally be preceeded by a YAML frontmatter header that
|
|
||||||
assigns tags and specifies syntax:
|
|
||||||
|
|
||||||
```
|
|
||||||
---
|
|
||||||
syntax: javascript
|
|
||||||
tags: [ array, map ]
|
|
||||||
---
|
|
||||||
// To map over an array:
|
|
||||||
const squares = [1, 2, 3, 4].map(x => x * x);
|
|
||||||
```
|
|
||||||
|
|
||||||
The `cheat` executable includes no cheatsheets, but [community-sourced
|
|
||||||
cheatsheets are available][cheatsheets]. You will be asked if you would like to
|
|
||||||
install the community-sourced cheatsheets the first time you run `cheat`.
|
|
||||||
|
|
||||||
### Script ###
|
|
||||||
You can manage the cheatsheets via a script `cheatsheets`.
|
|
||||||
|
|
||||||
#### Download and install ####
|
|
||||||
```sh
|
|
||||||
mkdir -p ~/.local/bin
|
|
||||||
wget -O ~/.local/bin/cheatsheets https://raw.githubusercontent.com/cheat/cheat/master/scripts/git/cheatsheets
|
|
||||||
chmod +x ~/.local/bin/cheatsheets
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Pull changes ####
|
|
||||||
To pull the community and personal cheatsheets call `cheatsheets pull`
|
|
||||||
|
|
||||||
#### Push changes ####
|
|
||||||
To push your personal cheatsheets call `cheatsheets push`
|
|
||||||
|
|
||||||
Cheatpaths
|
|
||||||
----------
|
|
||||||
Cheatsheets are stored on "cheatpaths", which are directories that contain
|
|
||||||
cheatsheets. Cheatpaths are specified in the `conf.yml` file.
|
|
||||||
|
|
||||||
It can be useful to configure `cheat` against multiple cheatpaths. A common
|
|
||||||
pattern is to store cheatsheets from multiple repositories on individual
|
|
||||||
cheatpaths:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# conf.yml:
|
|
||||||
# ...
|
|
||||||
cheatpaths:
|
|
||||||
- name: community # a name for the cheatpath
|
|
||||||
path: ~/documents/cheat/community # the path's location on the filesystem
|
|
||||||
tags: [ community ] # these tags will be applied to all sheets on the path
|
|
||||||
readonly: true # if true, `cheat` will not create new cheatsheets here
|
|
||||||
|
|
||||||
- name: personal
|
|
||||||
path: ~/documents/cheat/personal # this is a separate directory and repository than above
|
|
||||||
tags: [ personal ]
|
|
||||||
readonly: false # new sheets may be written here
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
The `readonly` option instructs `cheat` not to edit (or create) any cheatsheets
|
|
||||||
on the path. This is useful to prevent merge-conflicts from arising on upstream
|
|
||||||
cheatsheet repositories.
|
|
||||||
|
|
||||||
If a user attempts to edit a cheatsheet on a read-only cheatpath, `cheat` will
|
|
||||||
transparently copy that sheet to a writeable directory before opening it for
|
|
||||||
editing.
|
|
||||||
|
|
||||||
### Directory-scoped Cheatpaths ###
|
|
||||||
At times, it can be useful to closely associate cheatsheets with a directory on
|
|
||||||
your filesystem. `cheat` facilitates this by searching for a `.cheat` folder in
|
|
||||||
the current working directory. If found, the `.cheat` directory will
|
|
||||||
(temporarily) be added to the cheatpaths.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
To view a cheatsheet:
|
To view a cheatsheet:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -178,6 +68,12 @@ To list all available cheatsheets:
|
|||||||
cheat -l
|
cheat -l
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To briefly list all cheatsheets (names and tags only):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cheat -b
|
||||||
|
```
|
||||||
|
|
||||||
To list all cheatsheets that are tagged with "networking":
|
To list all cheatsheets that are tagged with "networking":
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -209,22 +105,108 @@ Flags may be combined in intuitive ways. Example: to search sheets on the
|
|||||||
cheat -p personal -t networking --regex -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
|
cheat -p personal -t networking --regex -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Cheatsheets
|
||||||
|
Cheatsheets are plain-text files with no file extension, and are named
|
||||||
|
according to the command used to view them:
|
||||||
|
|
||||||
Advanced Usage
|
```sh
|
||||||
--------------
|
cheat tar # file is named "tar"
|
||||||
Shell autocompletion is currently available for `bash`, `fish`, and `zsh`. Copy
|
cheat foo/bar # file is named "bar", in a "foo" subdirectory
|
||||||
the relevant [completion script][completions] into the appropriate directory on
|
```
|
||||||
your filesystem to enable autocompletion. (This directory will vary depending
|
|
||||||
on operating system and shell specifics.)
|
|
||||||
|
|
||||||
Additionally, `cheat` supports enhanced autocompletion via integration with
|
Cheatsheet text may optionally be preceded by a YAML frontmatter header that
|
||||||
[fzf][]. To enable `fzf` integration:
|
assigns tags and specifies syntax:
|
||||||
|
|
||||||
1. Ensure that `fzf` is available on your `$PATH`
|
```
|
||||||
2. Set an envvar: `export CHEAT_USE_FZF=true`
|
---
|
||||||
|
syntax: javascript
|
||||||
|
tags: [ array, map ]
|
||||||
|
---
|
||||||
|
// To map over an array:
|
||||||
|
const squares = [1, 2, 3, 4].map(x => x * x);
|
||||||
|
```
|
||||||
|
|
||||||
[Releases]: https://github.com/cheat/cheat/releases
|
Syntax highlighting is provided by [Chroma][], and the `syntax` value may be
|
||||||
[cheatsheets]: https://github.com/cheat/cheatsheets
|
set to any lexer name that Chroma supports. See Chroma's [supported
|
||||||
[completions]: https://github.com/cheat/cheat/tree/master/scripts
|
languages][] for a complete list.
|
||||||
[fzf]: https://github.com/junegunn/fzf
|
|
||||||
[go]: https://golang.org
|
The `cheat` executable includes no cheatsheets, but [community-sourced
|
||||||
|
cheatsheets are available][cheatsheets]. You will be asked if you would like to
|
||||||
|
install the community-sourced cheatsheets the first time you run `cheat`.
|
||||||
|
|
||||||
|
## Cheatpaths
|
||||||
|
Cheatsheets are stored on "cheatpaths", which are directories that contain
|
||||||
|
cheatsheets. Cheatpaths are specified in the `conf.yml` file.
|
||||||
|
|
||||||
|
It can be useful to configure `cheat` against multiple cheatpaths. A common
|
||||||
|
pattern is to store cheatsheets from multiple repositories on individual
|
||||||
|
cheatpaths:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# conf.yml:
|
||||||
|
# ...
|
||||||
|
cheatpaths:
|
||||||
|
- name: community # a name for the cheatpath
|
||||||
|
path: ~/documents/cheat/community # the path's location on the filesystem
|
||||||
|
tags: [ community ] # these tags will be applied to all sheets on the path
|
||||||
|
readonly: true # if true, `cheat` will not create new cheatsheets here
|
||||||
|
|
||||||
|
- name: personal
|
||||||
|
path: ~/documents/cheat/personal # this is a separate directory and repository than above
|
||||||
|
tags: [ personal ]
|
||||||
|
readonly: false # new sheets may be written here
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `readonly` option instructs `cheat` not to edit (or create) any cheatsheets
|
||||||
|
on the path. This is useful to prevent merge-conflicts from arising on upstream
|
||||||
|
cheatsheet repositories.
|
||||||
|
|
||||||
|
If a user attempts to edit a cheatsheet on a read-only cheatpath, `cheat` will
|
||||||
|
transparently copy that sheet to a writeable directory before opening it for
|
||||||
|
editing.
|
||||||
|
|
||||||
|
### Directory-scoped Cheatpaths
|
||||||
|
At times, it can be useful to closely associate cheatsheets with a directory on
|
||||||
|
your filesystem. `cheat` facilitates this by searching for a `.cheat` directory
|
||||||
|
in the current working directory and its ancestors (similar to how `git` locates
|
||||||
|
`.git` directories). The nearest `.cheat` directory found will (temporarily) be
|
||||||
|
added to the cheatpaths. This means you can place a `.cheat` directory at your
|
||||||
|
project root and it will be available from any subdirectory within that project.
|
||||||
|
|
||||||
|
## Autocompletion
|
||||||
|
`cheat` can generate shell completion scripts for `bash`, `zsh`, `fish`, and
|
||||||
|
`powershell` via the `--completion` flag:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cheat --completion bash
|
||||||
|
cheat --completion zsh
|
||||||
|
cheat --completion fish
|
||||||
|
cheat --completion powershell
|
||||||
|
```
|
||||||
|
|
||||||
|
Pipe the output to the appropriate location for your shell. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# bash (user-local)
|
||||||
|
mkdir -p ~/.local/share/bash-completion/completions
|
||||||
|
cheat --completion bash > ~/.local/share/bash-completion/completions/cheat
|
||||||
|
|
||||||
|
# bash (system-wide)
|
||||||
|
cheat --completion bash > /etc/bash_completion.d/cheat
|
||||||
|
|
||||||
|
# zsh (ensure the directory is on your fpath)
|
||||||
|
cheat --completion zsh > "${fpath[1]}/_cheat"
|
||||||
|
|
||||||
|
# fish
|
||||||
|
cheat --completion fish > ~/.config/fish/completions/cheat.fish
|
||||||
|
```
|
||||||
|
|
||||||
|
Completions are dynamically generated and include cheatsheet names, tags, and
|
||||||
|
cheatpath names.
|
||||||
|
|
||||||
|
[INSTALLING.md]: INSTALLING.md
|
||||||
|
[Releases]: https://github.com/cheat/cheat/releases
|
||||||
|
[cheatsheets]: https://github.com/cheat/cheatsheets
|
||||||
|
[Chroma]: https://github.com/alecthomas/chroma
|
||||||
|
[supported languages]: https://github.com/alecthomas/chroma#supported-languages
|
||||||
|
|||||||
169
adr/001-path-traversal-protection.md
Normal file
169
adr/001-path-traversal-protection.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# ADR-001: Path Traversal Protection for Cheatsheet Names
|
||||||
|
|
||||||
|
Date: 2025-01-21
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The `cheat` tool allows users to create, edit, and remove cheatsheets using commands like:
|
||||||
|
- `cheat --edit <name>`
|
||||||
|
- `cheat --rm <name>`
|
||||||
|
|
||||||
|
Without validation, a user could potentially provide malicious names like:
|
||||||
|
- `../../../etc/passwd` (directory traversal)
|
||||||
|
- `/etc/passwd` (absolute path)
|
||||||
|
- `~/.ssh/authorized_keys` (home directory expansion)
|
||||||
|
|
||||||
|
While `cheat` is a local tool run by the user themselves (not a network service), path traversal could still lead to:
|
||||||
|
1. Accidental file overwrites outside cheatsheet directories
|
||||||
|
2. Confusion about where files are being created
|
||||||
|
3. Potential security issues in shared environments
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We implemented input validation for cheatsheet names to prevent directory traversal attacks. The validation rejects names that:
|
||||||
|
|
||||||
|
1. Contain `..` (parent directory references)
|
||||||
|
2. Are absolute paths (start with `/` on Unix)
|
||||||
|
3. Start with `~` (home directory expansion)
|
||||||
|
4. Are empty
|
||||||
|
5. Start with `.` (hidden files - these are not displayed by cheat)
|
||||||
|
|
||||||
|
The validation is performed at the application layer before any file operations occur.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Validation Function
|
||||||
|
|
||||||
|
The validation is implemented in `internal/sheet/validate.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Validate(name string) error {
|
||||||
|
// Reject empty names
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("cheatsheet name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject names containing directory traversal
|
||||||
|
if strings.Contains(name, "..") {
|
||||||
|
return fmt.Errorf("cheatsheet name cannot contain '..'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject absolute paths
|
||||||
|
if filepath.IsAbs(name) {
|
||||||
|
return fmt.Errorf("cheatsheet name cannot be an absolute path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject names that start with ~ (home directory expansion)
|
||||||
|
if strings.HasPrefix(name, "~") {
|
||||||
|
return fmt.Errorf("cheatsheet name cannot start with '~'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject hidden files (files that start with a dot)
|
||||||
|
filename := filepath.Base(name)
|
||||||
|
if strings.HasPrefix(filename, ".") {
|
||||||
|
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
The validation is called in:
|
||||||
|
- `cmd/cheat/cmd_edit.go` - before creating or editing a cheatsheet
|
||||||
|
- `cmd/cheat/cmd_remove.go` - before removing a cheatsheet
|
||||||
|
|
||||||
|
### Allowed Patterns
|
||||||
|
|
||||||
|
The following patterns are explicitly allowed:
|
||||||
|
- Simple names: `docker`, `git`
|
||||||
|
- Nested paths: `docker/compose`, `lang/go/slice`
|
||||||
|
- Current directory references: `./mysheet`
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
1. **Safety**: Prevents accidental or intentional file operations outside cheatsheet directories
|
||||||
|
2. **Simplicity**: Validation happens early, before any file operations
|
||||||
|
3. **User-friendly**: Clear error messages explain why a name was rejected
|
||||||
|
4. **Performance**: Minimal overhead - simple string checks
|
||||||
|
5. **Compatibility**: Doesn't break existing valid cheatsheet names
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
1. **Limitation**: Users cannot use `..` in cheatsheet names even if legitimate
|
||||||
|
2. **No symlink support**: Cannot create cheatsheets through symlinks outside the cheatpath
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
1. Uses Go's `filepath.IsAbs()` which handles platform differences (Windows vs Unix)
|
||||||
|
2. No attempt to resolve or canonicalize paths - validation is purely syntactic
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Threat Model
|
||||||
|
|
||||||
|
`cheat` is a local command-line tool, not a network service. The primary threats are:
|
||||||
|
- User error (accidentally overwriting important files)
|
||||||
|
- Malicious scripts that invoke `cheat` with crafted arguments
|
||||||
|
- Shared system scenarios where cheatsheets might be shared
|
||||||
|
|
||||||
|
### What This Protects Against
|
||||||
|
|
||||||
|
- Directory traversal using `../`
|
||||||
|
- Absolute path access to system files
|
||||||
|
- Shell expansion of `~` to home directory
|
||||||
|
- Empty names that might cause unexpected behavior
|
||||||
|
- Hidden files that wouldn't be displayed anyway
|
||||||
|
|
||||||
|
### What This Does NOT Protect Against
|
||||||
|
|
||||||
|
- Users with filesystem permissions can still directly edit any file
|
||||||
|
- Symbolic links within the cheatpath pointing outside
|
||||||
|
- Race conditions (TOCTOU) - though minimal risk for a local tool
|
||||||
|
- Malicious content within cheatsheets themselves
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Comprehensive tests ensure the validation works correctly:
|
||||||
|
|
||||||
|
1. **Unit tests** (`internal/sheet/validate_test.go`) verify the validation logic
|
||||||
|
2. **Integration tests** verify the actual binary blocks malicious inputs
|
||||||
|
3. **No system files are accessed** during testing - all tests use isolated directories
|
||||||
|
|
||||||
|
Example test cases:
|
||||||
|
```bash
|
||||||
|
# These are blocked:
|
||||||
|
cheat --edit "../../../etc/passwd"
|
||||||
|
cheat --edit "/etc/passwd"
|
||||||
|
cheat --edit "~/.ssh/config"
|
||||||
|
cheat --rm ".."
|
||||||
|
|
||||||
|
# These are allowed:
|
||||||
|
cheat --edit "docker"
|
||||||
|
cheat --edit "docker/compose"
|
||||||
|
cheat --edit "./local"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative Approaches Considered
|
||||||
|
|
||||||
|
1. **Path resolution and verification**: Resolve the final path and check if it's within the cheatpath
|
||||||
|
- Rejected: More complex, potential race conditions, platform-specific edge cases
|
||||||
|
|
||||||
|
2. **Chroot/sandbox**: Run file operations in a restricted environment
|
||||||
|
- Rejected: Overkill for a local tool, platform compatibility issues
|
||||||
|
|
||||||
|
3. **Filename allowlist**: Only allow alphanumeric characters and specific symbols
|
||||||
|
- Rejected: Too restrictive, would break existing cheatsheets with valid special characters
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
|
||||||
|
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
|
||||||
|
- Go filepath package documentation: https://pkg.go.dev/path/filepath
|
||||||
100
adr/002-environment-variable-parsing.md
Normal file
100
adr/002-environment-variable-parsing.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# ADR-002: No Defensive Checks for Environment Variable Parsing
|
||||||
|
|
||||||
|
Date: 2025-01-21
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
In the `EnvVars()` function in `internal/config/env.go`, the code parses environment variables assuming they all contain an equals sign:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
pair := strings.SplitN(e, "=", 2)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
pair[0] = strings.ToUpper(pair[0])
|
||||||
|
}
|
||||||
|
envvars[pair[0]] = pair[1] // Could panic if pair has < 2 elements
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `os.Environ()` returned a string without an equals sign, `strings.SplitN` would return a slice with only one element, causing a panic when accessing `pair[1]`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will **not** add defensive checks for this condition. The current code that assumes all environment strings contain "=" will remain unchanged.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### Go Runtime Guarantees
|
||||||
|
|
||||||
|
Go's official documentation guarantees that `os.Environ()` returns environment variables in the form "key=value". This is a documented contract of the Go runtime that has been stable since Go 1.0.
|
||||||
|
|
||||||
|
### Empirical Evidence
|
||||||
|
|
||||||
|
Testing across platforms confirms:
|
||||||
|
- All environment variables returned by `os.Environ()` contain at least one "="
|
||||||
|
- Empty environment variables appear as "KEY=" (with an empty value)
|
||||||
|
- Even Windows special variables like "=C:=C:\path" maintain the format
|
||||||
|
|
||||||
|
### Cost-Benefit Analysis
|
||||||
|
|
||||||
|
Adding defensive code would:
|
||||||
|
- **Cost**: Add complexity and cognitive overhead
|
||||||
|
- **Cost**: Suggest uncertainty about Go's documented behavior
|
||||||
|
- **Cost**: Create dead code that can never execute under normal conditions
|
||||||
|
- **Benefit**: Protect against a theoretical scenario that violates Go's guarantees
|
||||||
|
|
||||||
|
The only scenarios where this could panic are:
|
||||||
|
1. A bug in Go's runtime (extremely unlikely, would affect all Go programs)
|
||||||
|
2. Corrupted OS-level environment (would cause broader system issues)
|
||||||
|
3. Breaking change in future Go version (would break many programs, unlikely)
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Simpler, more readable code
|
||||||
|
- Trust in platform guarantees reduces unnecessary defensive programming
|
||||||
|
- No performance overhead from unnecessary checks
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Theoretical panic if Go's guarantees are violated
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- Follows Go community standards of trusting standard library contracts
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### 1. Add Defensive Check
|
||||||
|
```go
|
||||||
|
if len(pair) < 2 {
|
||||||
|
continue // or pair[1] = ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Rejected**: Adds complexity for a condition that should never occur.
|
||||||
|
|
||||||
|
### 2. Add Panic with Clear Message
|
||||||
|
```go
|
||||||
|
if len(pair) < 2 {
|
||||||
|
panic("os.Environ() contract violation: " + e)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Rejected**: Would crash the program for the same theoretical issue.
|
||||||
|
|
||||||
|
### 3. Add Comment Documenting Assumption
|
||||||
|
```go
|
||||||
|
// os.Environ() guarantees "key=value" format, so pair[1] is safe
|
||||||
|
envvars[pair[0]] = pair[1]
|
||||||
|
```
|
||||||
|
**Rejected**: While documentation is good, this particular guarantee is fundamental to Go.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
If Go ever changes this behavior (extremely unlikely as it would break compatibility), it would be caught immediately in testing as the program would panic on startup. This would be a clear signal to revisit this decision.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Go os.Environ() documentation: https://pkg.go.dev/os#Environ
|
||||||
|
- Go os.Environ() source code and tests
|
||||||
104
adr/003-search-parallelization.md
Normal file
104
adr/003-search-parallelization.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# ADR-003: No Parallelization for Search Operations
|
||||||
|
|
||||||
|
Date: 2025-01-22
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We investigated optimizing cheat's search performance through parallelization. Initial assumptions suggested that I/O operations (reading multiple cheatsheet files) would be the primary bottleneck, making parallel processing beneficial.
|
||||||
|
|
||||||
|
Performance benchmarks were implemented to measure search operations, and a parallel search implementation using goroutines was created and tested.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will **not** implement parallel search. The sequential implementation will remain unchanged.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
### Performance Profile Analysis
|
||||||
|
|
||||||
|
CPU profiling revealed that search performance is dominated by:
|
||||||
|
- **Process creation overhead** (~30% in `os/exec.(*Cmd).Run`)
|
||||||
|
- **System calls** (~30% in `syscall.Syscall6`)
|
||||||
|
- **Process management** (fork, exec, pipe setup)
|
||||||
|
|
||||||
|
The actual search logic (regex matching, file reading) was negligible in the profile, indicating our optimization efforts were targeting the wrong bottleneck.
|
||||||
|
|
||||||
|
### Benchmark Results
|
||||||
|
|
||||||
|
Parallel implementation showed minimal improvements:
|
||||||
|
- Simple search: 17ms → 15.3ms (10% improvement)
|
||||||
|
- Regex search: 15ms → 14.9ms (minimal improvement)
|
||||||
|
- Colorized search: 19.5ms → 16.8ms (14% improvement)
|
||||||
|
- Complex regex: 20ms → 15.3ms (24% improvement)
|
||||||
|
|
||||||
|
The best case saved only ~5ms in absolute terms.
|
||||||
|
|
||||||
|
### Cost-Benefit Analysis
|
||||||
|
|
||||||
|
**Costs of parallelization:**
|
||||||
|
- Added complexity with goroutines, channels, and synchronization
|
||||||
|
- Increased maintenance burden
|
||||||
|
- More difficult debugging and testing
|
||||||
|
- Potential race conditions
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- 5-15% performance improvement (5ms in real terms)
|
||||||
|
- Imperceptible to users in interactive use
|
||||||
|
|
||||||
|
### User Experience Perspective
|
||||||
|
|
||||||
|
For a command-line tool:
|
||||||
|
- Current 15-20ms response time is excellent
|
||||||
|
- Users cannot perceive 5ms differences
|
||||||
|
- Sub-50ms is considered "instant" in HCI research
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Simpler, more maintainable codebase
|
||||||
|
- Easier to debug and reason about
|
||||||
|
- No synchronization bugs or race conditions
|
||||||
|
- Focus remains on code clarity
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Missed opportunity for ~5ms performance gain
|
||||||
|
- Search remains single-threaded
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- Performance remains excellent for intended use case
|
||||||
|
- Follows Go philosophy of preferring simplicity
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### 1. Keep Parallel Implementation
|
||||||
|
**Rejected**: Complexity outweighs negligible performance gains.
|
||||||
|
|
||||||
|
### 2. Optimize Process Startup
|
||||||
|
**Rejected**: Process creation overhead is inherent to CLI tools and cannot be avoided without fundamental architecture changes.
|
||||||
|
|
||||||
|
### 3. Future Optimizations
|
||||||
|
If performance becomes critical, consider:
|
||||||
|
- **Long-running daemon**: Eliminate process startup overhead entirely
|
||||||
|
- **Shell function**: Reduce fork/exec overhead
|
||||||
|
- **Compiled-in cheatsheets**: Eliminate file I/O
|
||||||
|
|
||||||
|
However, these would fundamentally change the tool's architecture and usage model.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This decision reinforces important principles:
|
||||||
|
1. Always profile before optimizing
|
||||||
|
2. Consider the full execution context
|
||||||
|
3. Measure what matters to users
|
||||||
|
4. Complexity has a real cost
|
||||||
|
|
||||||
|
The parallelization attempt was valuable as a learning exercise and definitively answered whether this optimization path was worthwhile.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Benchmark implementation: test/integration/search_bench_test.go
|
||||||
|
- Reverted parallel implementation: see git history (commit 82eb918)
|
||||||
80
adr/004-recursive-cheat-directory-search.md
Normal file
80
adr/004-recursive-cheat-directory-search.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# ADR-004: Recursive `.cheat` Directory Search
|
||||||
|
|
||||||
|
Date: 2026-02-15
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Previously, `cheat` only checked the current working directory for a `.cheat`
|
||||||
|
subdirectory to use as a directory-scoped cheatpath. If a user was in
|
||||||
|
`~/projects/myapp/src/handlers/` but the `.cheat` directory lived at
|
||||||
|
`~/projects/myapp/.cheat`, it would not be found. Users requested (#602) that
|
||||||
|
`cheat` walk up the directory hierarchy to find the nearest `.cheat`
|
||||||
|
directory, mirroring the discovery pattern used by `git` for `.git`
|
||||||
|
directories.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Walk upward from the current working directory to the filesystem root, and
|
||||||
|
stop at the first `.cheat` directory found. Only directories are matched (a
|
||||||
|
file named `.cheat` is ignored).
|
||||||
|
|
||||||
|
### Stop at first `.cheat` found
|
||||||
|
|
||||||
|
Rather than collecting multiple `.cheat` directories from ancestor directories:
|
||||||
|
|
||||||
|
- Matches `.git` discovery semantics, which users already understand
|
||||||
|
- Fits the existing single-cheatpath-named-`"cwd"` code without structural
|
||||||
|
changes
|
||||||
|
- Avoids precedence and naming complexity when multiple `.cheat` directories
|
||||||
|
exist in the ancestor chain
|
||||||
|
- `cheat` already supports multiple cheatpaths via `conf.yml` for users who
|
||||||
|
want that; directory-scoped `.cheat` serves the project-context use case
|
||||||
|
|
||||||
|
### Walk to filesystem root (not `$HOME`)
|
||||||
|
|
||||||
|
Rather than stopping the search at `$HOME`:
|
||||||
|
|
||||||
|
- Simpler implementation with no platform-specific home-directory detection
|
||||||
|
- Supports sysadmins working in `/etc`, `/srv`, `/var`, or other paths
|
||||||
|
outside `$HOME`
|
||||||
|
- The boundary only matters on the failure path (no `.cheat` found anywhere),
|
||||||
|
where the cost is a few extra `stat` calls
|
||||||
|
- Security is not a concern since cheatsheets are display-only text, not
|
||||||
|
executable code
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Users can place `.cheat` at their project root and it works from any
|
||||||
|
subdirectory, matching their mental model
|
||||||
|
- No configuration changes needed; existing `.cheat` directories continue to
|
||||||
|
work identically
|
||||||
|
- Minimal code change (one small helper function)
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- A `.cheat` directory in an unexpected ancestor could be picked up
|
||||||
|
unintentionally, though this is unlikely in practice and matches how `.git`
|
||||||
|
works
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- The cheatpath name remains `"cwd"` regardless of which ancestor the `.cheat`
|
||||||
|
was found in
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### 1. Stop at `$HOME`
|
||||||
|
**Rejected**: Adds platform-specific complexity for minimal benefit. The only
|
||||||
|
downside of walking to root is a few extra `stat` calls on the failure path.
|
||||||
|
|
||||||
|
### 2. Collect multiple `.cheat` directories
|
||||||
|
**Rejected**: Introduces precedence and naming complexity. Users who want
|
||||||
|
multiple cheatpaths can configure them in `conf.yml`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- GitHub issue: #602
|
||||||
|
- Implementation: `findLocalCheatpath()` in `internal/config/new.go`
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
//go:build ignore
|
|
||||||
// +build ignore
|
|
||||||
|
|
||||||
// This script embeds `docopt.txt and `conf.yml` into the binary during at
|
|
||||||
// build time.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
|
|
||||||
// get the cwd
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the project root
|
|
||||||
root, err := filepath.Abs(cwd + "../../../")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// specify template file information
|
|
||||||
type file struct {
|
|
||||||
In string
|
|
||||||
Out string
|
|
||||||
Method string
|
|
||||||
}
|
|
||||||
|
|
||||||
// enumerate the template files to process
|
|
||||||
files := []file{
|
|
||||||
file{
|
|
||||||
In: "cmd/cheat/docopt.txt",
|
|
||||||
Out: "cmd/cheat/str_usage.go",
|
|
||||||
Method: "usage"},
|
|
||||||
file{
|
|
||||||
In: "configs/conf.yml",
|
|
||||||
Out: "cmd/cheat/str_config.go",
|
|
||||||
Method: "configs"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// iterate over each static file
|
|
||||||
for _, file := range files {
|
|
||||||
|
|
||||||
// delete the outfile
|
|
||||||
os.Remove(filepath.Join(root, file.Out))
|
|
||||||
|
|
||||||
// read the static template
|
|
||||||
bytes, err := ioutil.ReadFile(filepath.Join(root, file.In))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// render the template
|
|
||||||
data := template(file.Method, string(bytes))
|
|
||||||
|
|
||||||
// write the file to the specified outpath
|
|
||||||
spath := filepath.Join(root, file.Out)
|
|
||||||
err = ioutil.WriteFile(spath, []byte(data), 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// template packages the
|
|
||||||
func template(method string, body string) string {
|
|
||||||
|
|
||||||
// specify the template string
|
|
||||||
t := `package main
|
|
||||||
|
|
||||||
// Code generated .* DO NOT EDIT.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func %s() string {
|
|
||||||
return strings.TrimSpace(%s)
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
return fmt.Sprintf(t, method, "`"+body+"`")
|
|
||||||
}
|
|
||||||
13
cmd/cheat/cmd_conf.go
Normal file
13
cmd/cheat/cmd_conf.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cmdConf(_ *cobra.Command, _ []string, conf config.Config) {
|
||||||
|
fmt.Println(conf.Path)
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
"github.com/cheat/cheat/internal/display"
|
"github.com/cheat/cheat/internal/display"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdDirectories lists the configured cheatpaths.
|
// cmdDirectories lists the configured cheatpaths.
|
||||||
func cmdDirectories(opts map[string]interface{}, conf config.Config) {
|
func cmdDirectories(_ *cobra.Command, _ []string, conf config.Config) {
|
||||||
|
|
||||||
// initialize a tabwriter to produce cleanly columnized output
|
// initialize a tabwriter to produce cleanly columnized output
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
@@ -18,11 +20,7 @@ func cmdDirectories(opts map[string]interface{}, conf config.Config) {
|
|||||||
|
|
||||||
// generate sorted, columnized output
|
// generate sorted, columnized output
|
||||||
for _, path := range conf.Cheatpaths {
|
for _, path := range conf.Cheatpaths {
|
||||||
fmt.Fprintln(w, fmt.Sprintf(
|
fmt.Fprintf(w, "%s:\t%s\n", path.Name, path.Path)
|
||||||
"%s:\t%s",
|
|
||||||
path.Name,
|
|
||||||
path.Path,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// write columnized output to stdout
|
// write columnized output to stdout
|
||||||
|
|||||||
@@ -4,31 +4,39 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/cheatpath"
|
"github.com/cheat/cheat/internal/cheatpath"
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
"github.com/cheat/cheat/internal/sheet"
|
||||||
"github.com/cheat/cheat/internal/sheets"
|
"github.com/cheat/cheat/internal/sheets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdEdit opens a cheatsheet for editing (or creates it if it doesn't exist).
|
// cmdEdit opens a cheatsheet for editing (or creates it if it doesn't exist).
|
||||||
func cmdEdit(opts map[string]interface{}, conf config.Config) {
|
func cmdEdit(cmd *cobra.Command, _ []string, conf config.Config) {
|
||||||
|
|
||||||
cheatsheet := opts["--edit"].(string)
|
cheatsheet, _ := cmd.Flags().GetString("edit")
|
||||||
|
|
||||||
|
// validate the cheatsheet name
|
||||||
|
if err := sheet.Validate(cheatsheet); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// load the cheatsheets
|
// load the cheatsheets
|
||||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to list cheatsheets: %v", err))
|
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("tag") {
|
||||||
// filter cheatcheats by tag if --tag was provided
|
tagVal, _ := cmd.Flags().GetString("tag")
|
||||||
if opts["--tag"] != nil {
|
|
||||||
cheatsheets = sheets.Filter(
|
cheatsheets = sheets.Filter(
|
||||||
cheatsheets,
|
cheatsheets,
|
||||||
strings.Split(opts["--tag"].(string), ","),
|
strings.Split(tagVal, ","),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,55 +54,36 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
|
|||||||
// if the sheet exists and is not read-only, edit it in place
|
// if the sheet exists and is not read-only, edit it in place
|
||||||
if ok && !sheet.ReadOnly {
|
if ok && !sheet.ReadOnly {
|
||||||
editpath = sheet.Path
|
editpath = sheet.Path
|
||||||
|
} else {
|
||||||
// if the sheet exists but is read-only, copy it before editing
|
// for read-only or non-existent sheets, resolve a writeable path
|
||||||
} else if ok && sheet.ReadOnly {
|
|
||||||
// compute the new edit path
|
|
||||||
// begin by getting a writeable cheatpath
|
|
||||||
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
|
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
|
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// compute the new edit path
|
// use the existing title for read-only copies, the requested name otherwise
|
||||||
editpath = path.Join(writepath.Path, sheet.Title)
|
title := cheatsheet
|
||||||
|
if ok {
|
||||||
|
title = sheet.Title
|
||||||
|
}
|
||||||
|
editpath = filepath.Join(writepath.Path, title)
|
||||||
|
|
||||||
// create any necessary subdirectories
|
if ok {
|
||||||
dirs := path.Dir(editpath)
|
// copy the read-only sheet to the writeable path
|
||||||
if dirs != "." {
|
// (Copy handles MkdirAll internally)
|
||||||
if err := os.MkdirAll(dirs, 0755); err != nil {
|
if err := sheet.Copy(editpath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
|
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
// create any necessary subdirectories for the new sheet
|
||||||
// copy the sheet to the new edit path
|
dirs := filepath.Dir(editpath)
|
||||||
err = sheet.Copy(editpath)
|
if dirs != "." {
|
||||||
if err != nil {
|
if err := os.MkdirAll(dirs, 0755); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
|
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the sheet does not exist, create it
|
|
||||||
} else {
|
|
||||||
// compute the new edit path
|
|
||||||
// begin by getting a writeable cheatpath
|
|
||||||
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// compute the new edit path
|
|
||||||
editpath = path.Join(writepath.Path, cheatsheet)
|
|
||||||
|
|
||||||
// create any necessary subdirectories
|
|
||||||
dirs := path.Dir(editpath)
|
|
||||||
if dirs != "." {
|
|
||||||
if err := os.MkdirAll(dirs, 0755); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,14 +93,14 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
|
|||||||
// call to `exec.Command` will fail.
|
// call to `exec.Command` will fail.
|
||||||
parts := strings.Fields(conf.Editor)
|
parts := strings.Fields(conf.Editor)
|
||||||
editor := parts[0]
|
editor := parts[0]
|
||||||
args := append(parts[1:], editpath)
|
editorArgs := append(parts[1:], editpath)
|
||||||
|
|
||||||
// edit the cheatsheet
|
// edit the cheatsheet
|
||||||
cmd := exec.Command(editor, args...)
|
editorCmd := exec.Command(editor, editorArgs...)
|
||||||
cmd.Stdout = os.Stdout
|
editorCmd.Stdout = os.Stdout
|
||||||
cmd.Stdin = os.Stdin
|
editorCmd.Stdin = os.Stdin
|
||||||
cmd.Stderr = os.Stderr
|
editorCmd.Stderr = os.Stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := editorCmd.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to edit cheatsheet: %v\n", err)
|
fmt.Fprintf(os.Stderr, "failed to edit cheatsheet: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,54 +3,27 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mitchellh/go-homedir"
|
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
"github.com/cheat/cheat/internal/installer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdInit displays an example config file.
|
// cmdInit displays an example config file.
|
||||||
func cmdInit() {
|
func cmdInit(home string, envvars map[string]string) {
|
||||||
|
|
||||||
// get the user's home directory
|
// identify the os-specific paths at which configs may be located
|
||||||
home, err := homedir.Dir()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// read the envvars into a map of strings
|
|
||||||
envvars := map[string]string{}
|
|
||||||
for _, e := range os.Environ() {
|
|
||||||
pair := strings.SplitN(e, "=", 2)
|
|
||||||
envvars[pair[0]] = pair[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// load the config template
|
|
||||||
configs := configs()
|
|
||||||
|
|
||||||
// identify the os-specifc paths at which configs may be located
|
|
||||||
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
|
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
|
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// determine the appropriate paths for config data and (optional) community
|
|
||||||
// cheatsheets based on the user's platform
|
|
||||||
confpath := confpaths[0]
|
confpath := confpaths[0]
|
||||||
confdir := path.Dir(confpath)
|
|
||||||
|
|
||||||
// create paths for community and personal cheatsheets
|
// expand template placeholders and comment out community cheatpath
|
||||||
community := path.Join(confdir, "/cheatsheets/community")
|
configs := installer.ExpandTemplate(configs(), confpath)
|
||||||
personal := path.Join(confdir, "/cheatsheets/personal")
|
configs = installer.CommentCommunity(configs, confpath)
|
||||||
|
|
||||||
// template the above paths into the default configs
|
|
||||||
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
|
|
||||||
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
|
|
||||||
|
|
||||||
// output the templated configs
|
// output the templated configs
|
||||||
fmt.Println(configs)
|
fmt.Println(configs)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
"github.com/cheat/cheat/internal/display"
|
"github.com/cheat/cheat/internal/display"
|
||||||
"github.com/cheat/cheat/internal/sheet"
|
"github.com/cheat/cheat/internal/sheet"
|
||||||
@@ -16,20 +18,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// cmdList lists all available cheatsheets.
|
// cmdList lists all available cheatsheets.
|
||||||
func cmdList(opts map[string]interface{}, conf config.Config) {
|
func cmdList(cmd *cobra.Command, args []string, conf config.Config) {
|
||||||
|
|
||||||
// load the cheatsheets
|
// load the cheatsheets
|
||||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to list cheatsheets: %v", err))
|
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("tag") {
|
||||||
// filter cheatsheets by tag if --tag was provided
|
tagVal, _ := cmd.Flags().GetString("tag")
|
||||||
if opts["--tag"] != nil {
|
|
||||||
cheatsheets = sheets.Filter(
|
cheatsheets = sheets.Filter(
|
||||||
cheatsheets,
|
cheatsheets,
|
||||||
strings.Split(opts["--tag"].(string), ","),
|
strings.Split(tagVal, ","),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,24 +50,18 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// filter if <cheatsheet> was specified
|
// filter if <cheatsheet> was specified
|
||||||
// NB: our docopt specification is misleading here. When used in conjunction
|
if len(args) > 0 {
|
||||||
// with `-l`, `<cheatsheet>` is really a pattern against which to filter
|
|
||||||
// sheet titles.
|
|
||||||
if opts["<cheatsheet>"] != nil {
|
|
||||||
|
|
||||||
// initialize a slice of filtered sheets
|
// initialize a slice of filtered sheets
|
||||||
filtered := []sheet.Sheet{}
|
filtered := []sheet.Sheet{}
|
||||||
|
|
||||||
// initialize our filter pattern
|
// initialize our filter pattern
|
||||||
pattern := "(?i)" + opts["<cheatsheet>"].(string)
|
pattern := "(?i)" + args[0]
|
||||||
|
|
||||||
// compile the regex
|
// compile the regex
|
||||||
reg, err := regexp.Compile(pattern)
|
reg, err := regexp.Compile(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(
|
fmt.Fprintf(os.Stderr, "failed to compile regexp: %s, %v\n", pattern, err)
|
||||||
os.Stderr,
|
|
||||||
fmt.Sprintf("failed to compile regexp: %s, %v", pattern, err),
|
|
||||||
)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,17 +85,18 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
|
|||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0)
|
w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0)
|
||||||
|
|
||||||
// write a header row
|
|
||||||
fmt.Fprintln(w, "title:\tfile:\ttags:")
|
|
||||||
|
|
||||||
// generate sorted, columnized output
|
// generate sorted, columnized output
|
||||||
for _, sheet := range flattened {
|
briefFlag, _ := cmd.Flags().GetBool("brief")
|
||||||
fmt.Fprintln(w, fmt.Sprintf(
|
if briefFlag {
|
||||||
"%s\t%s\t%s",
|
fmt.Fprintln(w, "title:\ttags:")
|
||||||
sheet.Title,
|
for _, sheet := range flattened {
|
||||||
sheet.Path,
|
fmt.Fprintf(w, "%s\t%s\n", sheet.Title, strings.Join(sheet.Tags, ","))
|
||||||
strings.Join(sheet.Tags, ","),
|
}
|
||||||
))
|
} else {
|
||||||
|
fmt.Fprintln(w, "title:\tfile:\ttags:")
|
||||||
|
for _, sheet := range flattened {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\n", sheet.Title, sheet.Path, strings.Join(sheet.Tags, ","))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// write columnized output to stdout
|
// write columnized output to stdout
|
||||||
|
|||||||
@@ -5,27 +5,35 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
"github.com/cheat/cheat/internal/sheet"
|
||||||
"github.com/cheat/cheat/internal/sheets"
|
"github.com/cheat/cheat/internal/sheets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdRemove opens a cheatsheet for editing (or creates it if it doesn't exist).
|
// cmdRemove removes (deletes) a cheatsheet.
|
||||||
func cmdRemove(opts map[string]interface{}, conf config.Config) {
|
func cmdRemove(cmd *cobra.Command, _ []string, conf config.Config) {
|
||||||
|
|
||||||
cheatsheet := opts["--rm"].(string)
|
cheatsheet, _ := cmd.Flags().GetString("rm")
|
||||||
|
|
||||||
|
// validate the cheatsheet name
|
||||||
|
if err := sheet.Validate(cheatsheet); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// load the cheatsheets
|
// load the cheatsheets
|
||||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to list cheatsheets: %v", err))
|
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("tag") {
|
||||||
// filter cheatcheats by tag if --tag was provided
|
tagVal, _ := cmd.Flags().GetString("tag")
|
||||||
if opts["--tag"] != nil {
|
|
||||||
cheatsheets = sheets.Filter(
|
cheatsheets = sheets.Filter(
|
||||||
cheatsheets,
|
cheatsheets,
|
||||||
strings.Split(opts["--tag"].(string), ","),
|
strings.Split(tagVal, ","),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,19 +45,19 @@ func cmdRemove(opts map[string]interface{}, conf config.Config) {
|
|||||||
// fail early if the requested cheatsheet does not exist
|
// fail early if the requested cheatsheet does not exist
|
||||||
sheet, ok := consolidated[cheatsheet]
|
sheet, ok := consolidated[cheatsheet]
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("No cheatsheet found for '%s'.\n", cheatsheet))
|
fmt.Fprintf(os.Stderr, "No cheatsheet found for '%s'.\n", cheatsheet)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fail early if the sheet is read-only
|
// fail early if the sheet is read-only
|
||||||
if sheet.ReadOnly {
|
if sheet.ReadOnly {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("cheatsheet '%s' is read-only.", cheatsheet))
|
fmt.Fprintf(os.Stderr, "cheatsheet '%s' is read-only.\n", cheatsheet)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, attempt to delete the sheet
|
// otherwise, attempt to delete the sheet
|
||||||
if err := os.Remove(sheet.Path); err != nil {
|
if err := os.Remove(sheet.Path); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to delete sheet: %s, %v", sheet.Title, err))
|
fmt.Fprintf(os.Stderr, "failed to delete sheet: %s, %v\n", sheet.Title, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,31 +6,49 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
"github.com/cheat/cheat/internal/display"
|
"github.com/cheat/cheat/internal/display"
|
||||||
"github.com/cheat/cheat/internal/sheets"
|
"github.com/cheat/cheat/internal/sheets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdSearch searches for strings in cheatsheets.
|
// cmdSearch searches for strings in cheatsheets.
|
||||||
func cmdSearch(opts map[string]interface{}, conf config.Config) {
|
func cmdSearch(cmd *cobra.Command, args []string, conf config.Config) {
|
||||||
|
|
||||||
phrase := opts["--search"].(string)
|
phrase, _ := cmd.Flags().GetString("search")
|
||||||
|
colorize, _ := cmd.Flags().GetBool("colorize")
|
||||||
|
useRegex, _ := cmd.Flags().GetBool("regex")
|
||||||
|
|
||||||
// load the cheatsheets
|
// load the cheatsheets
|
||||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to list cheatsheets: %v", err))
|
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("tag") {
|
||||||
// filter cheatcheats by tag if --tag was provided
|
tagVal, _ := cmd.Flags().GetString("tag")
|
||||||
if opts["--tag"] != nil {
|
|
||||||
cheatsheets = sheets.Filter(
|
cheatsheets = sheets.Filter(
|
||||||
cheatsheets,
|
cheatsheets,
|
||||||
strings.Split(opts["--tag"].(string), ","),
|
strings.Split(tagVal, ","),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepare the search pattern
|
||||||
|
pattern := "(?i)" + phrase
|
||||||
|
|
||||||
|
// unless --regex is provided, in which case we pass the regex unaltered
|
||||||
|
if useRegex {
|
||||||
|
pattern = phrase
|
||||||
|
}
|
||||||
|
|
||||||
|
// compile the regex once, outside the loop
|
||||||
|
reg, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "failed to compile regexp: %s, %v\n", pattern, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// iterate over each cheatpath
|
// iterate over each cheatpath
|
||||||
out := ""
|
out := ""
|
||||||
for _, pathcheats := range cheatsheets {
|
for _, pathcheats := range cheatsheets {
|
||||||
@@ -40,28 +58,13 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
|
|||||||
|
|
||||||
// if <cheatsheet> was provided, constrain the search only to
|
// if <cheatsheet> was provided, constrain the search only to
|
||||||
// matching cheatsheets
|
// matching cheatsheets
|
||||||
if opts["<cheatsheet>"] != nil && sheet.Title != opts["<cheatsheet>"] {
|
if len(args) > 0 && sheet.Title != args[0] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// assume that we want to perform a case-insensitive search for <phrase>
|
// `Search` will return text entries that match the search terms.
|
||||||
pattern := "(?i)" + phrase
|
// We're using it here to overwrite the prior cheatsheet Text,
|
||||||
|
// filtering it to only what is relevant.
|
||||||
// unless --regex is provided, in which case we pass the regex unaltered
|
|
||||||
if opts["--regex"] == true {
|
|
||||||
pattern = phrase
|
|
||||||
}
|
|
||||||
|
|
||||||
// compile the regex
|
|
||||||
reg, err := regexp.Compile(pattern)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to compile regexp: %s, %v", pattern, err))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// `Search` will return text entries that match the search terms. We're
|
|
||||||
// using it here to overwrite the prior cheatsheet Text, filtering it to
|
|
||||||
// only what is relevant
|
|
||||||
sheet.Text = sheet.Search(reg)
|
sheet.Text = sheet.Search(reg)
|
||||||
|
|
||||||
// if the sheet did not match the search, ignore it and move on
|
// if the sheet did not match the search, ignore it and move on
|
||||||
@@ -70,18 +73,20 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if colorization was requested, apply it here
|
// if colorization was requested, apply it here
|
||||||
if conf.Color(opts) {
|
if conf.Color(colorize) {
|
||||||
sheet.Colorize(conf)
|
sheet.Colorize(conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// display the cheatsheet title and path
|
// display the cheatsheet body
|
||||||
out += fmt.Sprintf("%s %s\n",
|
out += fmt.Sprintf(
|
||||||
display.Underline(sheet.Title),
|
"%s %s\n%s\n",
|
||||||
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
|
// append the cheatsheet title
|
||||||
|
sheet.Title,
|
||||||
|
// append the cheatsheet path
|
||||||
|
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(colorize)),
|
||||||
|
// indent each line of content
|
||||||
|
display.Indent(sheet.Text),
|
||||||
)
|
)
|
||||||
|
|
||||||
// indent each line of content
|
|
||||||
out += display.Indent(sheet.Text) + "\n"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +94,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
|
|||||||
out = strings.TrimSpace(out)
|
out = strings.TrimSpace(out)
|
||||||
|
|
||||||
// display the output
|
// display the output
|
||||||
// NB: resist the temptation to call `display.Display` multiple times in
|
// NB: resist the temptation to call `display.Write` multiple times in the
|
||||||
// the loop above. That will not play nicely with the paginator.
|
// loop above. That will not play nicely with the paginator.
|
||||||
display.Write(out, conf)
|
display.Write(out, conf)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
"github.com/cheat/cheat/internal/display"
|
"github.com/cheat/cheat/internal/display"
|
||||||
"github.com/cheat/cheat/internal/sheets"
|
"github.com/cheat/cheat/internal/sheets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdTags lists all tags in use.
|
// cmdTags lists all tags in use.
|
||||||
func cmdTags(opts map[string]interface{}, conf config.Config) {
|
func cmdTags(_ *cobra.Command, _ []string, conf config.Config) {
|
||||||
|
|
||||||
// load the cheatsheets
|
// load the cheatsheets
|
||||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to list cheatsheets: %v", err))
|
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
cmd/cheat/cmd_update.go
Normal file
42
cmd/cheat/cmd_update.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
"github.com/cheat/cheat/internal/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdUpdate updates git-backed cheatpaths.
|
||||||
|
func cmdUpdate(_ *cobra.Command, _ []string, conf config.Config) {
|
||||||
|
|
||||||
|
hasError := false
|
||||||
|
|
||||||
|
for _, path := range conf.Cheatpaths {
|
||||||
|
err := repo.Pull(path.Path)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
fmt.Printf("%s: ok\n", path.Name)
|
||||||
|
|
||||||
|
case errors.Is(err, git.ErrRepositoryNotExists):
|
||||||
|
fmt.Printf("%s: skipped (not a git repository)\n", path.Name)
|
||||||
|
|
||||||
|
case errors.Is(err, repo.ErrDirtyWorktree):
|
||||||
|
fmt.Printf("%s: skipped (dirty worktree)\n", path.Name)
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: error (%v)\n", path.Name, err)
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasError {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,33 +5,37 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
"github.com/cheat/cheat/internal/display"
|
"github.com/cheat/cheat/internal/display"
|
||||||
"github.com/cheat/cheat/internal/sheets"
|
"github.com/cheat/cheat/internal/sheets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdView displays a cheatsheet for viewing.
|
// cmdView displays a cheatsheet for viewing.
|
||||||
func cmdView(opts map[string]interface{}, conf config.Config) {
|
func cmdView(cmd *cobra.Command, args []string, conf config.Config) {
|
||||||
|
|
||||||
cheatsheet := opts["<cheatsheet>"].(string)
|
cheatsheet := args[0]
|
||||||
|
|
||||||
|
colorize, _ := cmd.Flags().GetBool("colorize")
|
||||||
|
|
||||||
// load the cheatsheets
|
// load the cheatsheets
|
||||||
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to list cheatsheets: %v", err))
|
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("tag") {
|
||||||
// filter cheatcheats by tag if --tag was provided
|
tagVal, _ := cmd.Flags().GetString("tag")
|
||||||
if opts["--tag"] != nil {
|
|
||||||
cheatsheets = sheets.Filter(
|
cheatsheets = sheets.Filter(
|
||||||
cheatsheets,
|
cheatsheets,
|
||||||
strings.Split(opts["--tag"].(string), ","),
|
strings.Split(tagVal, ","),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if --all was passed, display cheatsheets from all cheatpaths
|
// if --all was passed, display cheatsheets from all cheatpaths
|
||||||
if opts["--all"].(bool) {
|
allFlag, _ := cmd.Flags().GetBool("all")
|
||||||
|
if allFlag {
|
||||||
// iterate over the cheatpaths
|
// iterate over the cheatpaths
|
||||||
out := ""
|
out := ""
|
||||||
for _, cheatpath := range cheatsheets {
|
for _, cheatpath := range cheatsheets {
|
||||||
@@ -41,12 +45,12 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
|
|||||||
|
|
||||||
// identify the matching cheatsheet
|
// identify the matching cheatsheet
|
||||||
out += fmt.Sprintf("%s %s\n",
|
out += fmt.Sprintf("%s %s\n",
|
||||||
display.Underline(sheet.Title),
|
sheet.Title,
|
||||||
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
|
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(colorize)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// apply colorization if requested
|
// apply colorization if requested
|
||||||
if conf.Color(opts) {
|
if conf.Color(colorize) {
|
||||||
sheet.Colorize(conf)
|
sheet.Colorize(conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +77,7 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apply colorization if requested
|
// apply colorization if requested
|
||||||
if conf.Color(opts) {
|
if conf.Color(colorize) {
|
||||||
sheet.Colorize(conf)
|
sheet.Colorize(conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
cmd/cheat/config.go
Normal file
74
cmd/cheat/config.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// configs returns the default configuration template
|
||||||
|
func configs() string {
|
||||||
|
return `---
|
||||||
|
# The editor to use with 'cheat -e <sheet>'. Overridden by $VISUAL or $EDITOR.
|
||||||
|
editor: EDITOR_PATH
|
||||||
|
|
||||||
|
# Should 'cheat' always colorize output?
|
||||||
|
colorize: false
|
||||||
|
|
||||||
|
# Which 'chroma' colorscheme should be applied to the output?
|
||||||
|
# Options are available here:
|
||||||
|
# https://github.com/alecthomas/chroma/tree/master/styles
|
||||||
|
style: monokai
|
||||||
|
|
||||||
|
# Which 'chroma' "formatter" should be applied?
|
||||||
|
# One of: "terminal", "terminal256", "terminal16m"
|
||||||
|
formatter: terminal256
|
||||||
|
|
||||||
|
# Through which pager should output be piped?
|
||||||
|
# 'less -FRX' is recommended on Unix systems
|
||||||
|
# 'more' is recommended on Windows
|
||||||
|
pager: PAGER_PATH
|
||||||
|
|
||||||
|
# The paths at which cheatsheets are available. Tags associated with a cheatpath
|
||||||
|
# are automatically attached to all cheatsheets residing on that path.
|
||||||
|
#
|
||||||
|
# Whenever cheatsheets share the same title (like 'tar'), the most local
|
||||||
|
# cheatsheets (those which come later in this file) take precedence over the
|
||||||
|
# less local sheets. This allows you to create your own "overides" for
|
||||||
|
# "upstream" cheatsheets.
|
||||||
|
#
|
||||||
|
# But what if you want to view the "upstream" cheatsheets instead of your own?
|
||||||
|
# Cheatsheets may be filtered by 'tags' in combination with the '--tag' flag.
|
||||||
|
#
|
||||||
|
# Example: 'cheat tar --tag=community' will display the 'tar' cheatsheet that
|
||||||
|
# is tagged as 'community' rather than your own.
|
||||||
|
#
|
||||||
|
# Paths that come earlier are considered to be the most "global", and paths
|
||||||
|
# that come later are considered to be the most "local". The most "local" paths
|
||||||
|
# take precedence.
|
||||||
|
#
|
||||||
|
# See: https://github.com/cheat/cheat/blob/master/doc/cheat.1.md#cheatpaths
|
||||||
|
cheatpaths:
|
||||||
|
|
||||||
|
# Cheatsheets that are tagged "personal" are stored here by default:
|
||||||
|
- name: personal
|
||||||
|
path: PERSONAL_PATH
|
||||||
|
tags: [ personal ]
|
||||||
|
readonly: false
|
||||||
|
|
||||||
|
# Cheatsheets that are tagged "work" are stored here by default:
|
||||||
|
- name: work
|
||||||
|
path: WORK_PATH
|
||||||
|
tags: [ work ]
|
||||||
|
readonly: false
|
||||||
|
|
||||||
|
# Community cheatsheets (https://github.com/cheat/cheatsheets):
|
||||||
|
# To install: git clone https://github.com/cheat/cheatsheets COMMUNITY_PATH
|
||||||
|
- name: community
|
||||||
|
path: COMMUNITY_PATH
|
||||||
|
tags: [ community ]
|
||||||
|
readonly: true
|
||||||
|
|
||||||
|
# You can also use glob patterns to automatically load cheatsheets from all
|
||||||
|
# directories that match.
|
||||||
|
#
|
||||||
|
# Example: overload cheatsheets for projects under ~/src/github.com/example/*/
|
||||||
|
#- name: example-projects
|
||||||
|
# path: ~/src/github.com/example/**/.cheat
|
||||||
|
# tags: [ example ]
|
||||||
|
# readonly: true`
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
Usage:
|
|
||||||
cheat [options] [<cheatsheet>]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--init Write a default config file to stdout
|
|
||||||
-a --all Search among all cheatpaths
|
|
||||||
-c --colorize Colorize output
|
|
||||||
-d --directories List cheatsheet directories
|
|
||||||
-e --edit=<cheatsheet> Edit <cheatsheet>
|
|
||||||
-l --list List cheatsheets
|
|
||||||
-p --path=<name> Return only sheets found on cheatpath <name>
|
|
||||||
-r --regex Treat search <phrase> as a regex
|
|
||||||
-s --search=<phrase> Search cheatsheets for <phrase>
|
|
||||||
-t --tag=<tag> Return only sheets matching <tag>
|
|
||||||
-T --tags List all tags in use
|
|
||||||
-v --version Print the version number
|
|
||||||
--rm=<cheatsheet> Remove (delete) <cheatsheet>
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
To initialize a config file:
|
|
||||||
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
|
|
||||||
|
|
||||||
To view the tar cheatsheet:
|
|
||||||
cheat tar
|
|
||||||
|
|
||||||
To edit (or create) the foo cheatsheet:
|
|
||||||
cheat -e foo
|
|
||||||
|
|
||||||
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
|
|
||||||
cheat -p work -e foo/bar
|
|
||||||
|
|
||||||
To view all cheatsheet directories:
|
|
||||||
cheat -d
|
|
||||||
|
|
||||||
To list all available cheatsheets:
|
|
||||||
cheat -l
|
|
||||||
|
|
||||||
To list all cheatsheets whose titles match "apt":
|
|
||||||
cheat -l apt
|
|
||||||
|
|
||||||
To list all tags in use:
|
|
||||||
cheat -T
|
|
||||||
|
|
||||||
To list available cheatsheets that are tagged as "personal":
|
|
||||||
cheat -l -t personal
|
|
||||||
|
|
||||||
To search for "ssh" among all cheatsheets, and colorize matches:
|
|
||||||
cheat -c -s ssh
|
|
||||||
|
|
||||||
To search (by regex) for cheatsheets that contain an IP address:
|
|
||||||
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
|
|
||||||
|
|
||||||
To remove (delete) the foo/bar cheatsheet:
|
|
||||||
cheat --rm foo/bar
|
|
||||||
@@ -1,39 +1,148 @@
|
|||||||
|
// Package main serves as the executable entrypoint.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
//go:generate go run ../../build/embed.go
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/docopt/docopt-go"
|
|
||||||
"github.com/mitchellh/go-homedir"
|
"github.com/mitchellh/go-homedir"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/cheatpath"
|
"github.com/cheat/cheat/internal/cheatpath"
|
||||||
|
"github.com/cheat/cheat/internal/completions"
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
"github.com/cheat/cheat/internal/installer"
|
"github.com/cheat/cheat/internal/installer"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "4.2.3"
|
const version = "5.1.0"
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "cheat [cheatsheet]",
|
||||||
|
Short: "Create and view interactive cheatsheets on the command-line",
|
||||||
|
Long: `cheat allows you to create and view interactive cheatsheets on the
|
||||||
|
command-line. It was designed to help remind *nix system administrators of
|
||||||
|
options for commands that they use frequently, but not frequently enough to
|
||||||
|
remember.`,
|
||||||
|
Example: ` To initialize a config file:
|
||||||
|
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
|
||||||
|
|
||||||
|
To view the tar cheatsheet:
|
||||||
|
cheat tar
|
||||||
|
|
||||||
|
To edit (or create) the foo cheatsheet:
|
||||||
|
cheat -e foo
|
||||||
|
|
||||||
|
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
|
||||||
|
cheat -p work -e foo/bar
|
||||||
|
|
||||||
|
To view all cheatsheet directories:
|
||||||
|
cheat -d
|
||||||
|
|
||||||
|
To list all available cheatsheets:
|
||||||
|
cheat -l
|
||||||
|
|
||||||
|
To briefly list all cheatsheets whose titles match "apt":
|
||||||
|
cheat -b apt
|
||||||
|
|
||||||
|
To list all tags in use:
|
||||||
|
cheat -T
|
||||||
|
|
||||||
|
To list available cheatsheets that are tagged as "personal":
|
||||||
|
cheat -l -t personal
|
||||||
|
|
||||||
|
To search for "ssh" among all cheatsheets, and colorize matches:
|
||||||
|
cheat -c -s ssh
|
||||||
|
|
||||||
|
To search (by regex) for cheatsheets that contain an IP address:
|
||||||
|
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
|
||||||
|
|
||||||
|
To remove (delete) the foo/bar cheatsheet:
|
||||||
|
cheat --rm foo/bar
|
||||||
|
|
||||||
|
To update all git-backed cheatpaths:
|
||||||
|
cheat --update
|
||||||
|
|
||||||
|
To view the configuration file path:
|
||||||
|
cheat --conf
|
||||||
|
|
||||||
|
To generate shell completions (bash, zsh, fish, powershell):
|
||||||
|
cheat --completion bash`,
|
||||||
|
RunE: run,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
ValidArgsFunction: completions.Cheatsheets,
|
||||||
|
CompletionOptions: cobra.CompletionOptions{
|
||||||
|
DisableDefaultCmd: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
f := rootCmd.Flags()
|
||||||
|
|
||||||
|
// bool flags
|
||||||
|
f.BoolP("all", "a", false, "Search among all cheatpaths")
|
||||||
|
f.BoolP("brief", "b", false, "List cheatsheets without file paths")
|
||||||
|
f.BoolP("colorize", "c", false, "Colorize output")
|
||||||
|
f.BoolP("directories", "d", false, "List cheatsheet directories")
|
||||||
|
f.Bool("init", false, "Write a default config file to stdout")
|
||||||
|
f.BoolP("list", "l", false, "List cheatsheets")
|
||||||
|
f.BoolP("regex", "r", false, "Treat search <phrase> as a regex")
|
||||||
|
f.BoolP("tags", "T", false, "List all tags in use")
|
||||||
|
f.BoolP("update", "u", false, "Update git-backed cheatpaths")
|
||||||
|
f.BoolP("version", "v", false, "Print the version number")
|
||||||
|
f.Bool("conf", false, "Display the config file path")
|
||||||
|
|
||||||
|
// string flags
|
||||||
|
f.StringP("edit", "e", "", "Edit `cheatsheet`")
|
||||||
|
f.StringP("path", "p", "", "Return only sheets found on cheatpath `name`")
|
||||||
|
f.StringP("search", "s", "", "Search cheatsheets for `phrase`")
|
||||||
|
f.StringP("tag", "t", "", "Return only sheets matching `tag`")
|
||||||
|
f.String("rm", "", "Remove (delete) `cheatsheet`")
|
||||||
|
f.String("completion", "", "Generate shell completion script (`shell`: bash, zsh, fish, powershell)")
|
||||||
|
|
||||||
|
// register flag completion functions
|
||||||
|
rootCmd.RegisterFlagCompletionFunc("tag", completions.Tags)
|
||||||
|
rootCmd.RegisterFlagCompletionFunc("path", completions.Paths)
|
||||||
|
rootCmd.RegisterFlagCompletionFunc("edit", completions.Cheatsheets)
|
||||||
|
rootCmd.RegisterFlagCompletionFunc("rm", completions.Cheatsheets)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// initialize options
|
func run(cmd *cobra.Command, args []string) error {
|
||||||
opts, err := docopt.Parse(usage(), nil, true, version, false)
|
f := cmd.Flags()
|
||||||
if err != nil {
|
|
||||||
// panic here, because this should never happen
|
// handle --init early (no config needed)
|
||||||
panic(fmt.Errorf("docopt failed to parse: %v", err))
|
if initFlag, _ := f.GetBool("init"); initFlag {
|
||||||
|
home, err := homedir.Dir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
envvars := config.EnvVars()
|
||||||
|
cmdInit(home, envvars)
|
||||||
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if --init was passed, we don't want to attempt to load a config file.
|
// handle --version early
|
||||||
// Instead, just execute cmd_init and exit
|
if versionFlag, _ := f.GetBool("version"); versionFlag {
|
||||||
if opts["--init"] != nil && opts["--init"] == true {
|
fmt.Println(version)
|
||||||
cmdInit()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle --completion early (no config needed)
|
||||||
|
if f.Changed("completion") {
|
||||||
|
shell, _ := f.GetString("completion")
|
||||||
|
return completions.Generate(cmd, shell, os.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
// get the user's home directory
|
// get the user's home directory
|
||||||
home, err := homedir.Dir()
|
home, err := homedir.Dir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -42,16 +151,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// read the envvars into a map of strings
|
// read the envvars into a map of strings
|
||||||
envvars := map[string]string{}
|
envvars := config.EnvVars()
|
||||||
for _, e := range os.Environ() {
|
|
||||||
pair := strings.SplitN(e, "=", 2)
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
pair[0] = strings.ToUpper(pair[0])
|
|
||||||
}
|
|
||||||
envvars[pair[0]] = pair[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// identify the os-specifc paths at which configs may be located
|
// identify the os-specific paths at which configs may be located
|
||||||
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
|
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||||
@@ -92,7 +194,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initialize the configs
|
// initialize the configs
|
||||||
conf, err := config.New(opts, confpath, true)
|
conf, err := config.New(confpath, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -105,10 +207,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// filter the cheatpaths if --path was passed
|
// filter the cheatpaths if --path was passed
|
||||||
if opts["--path"] != nil {
|
if f.Changed("path") {
|
||||||
|
pathVal, _ := f.GetString("path")
|
||||||
conf.Cheatpaths, err = cheatpath.Filter(
|
conf.Cheatpaths, err = cheatpath.Filter(
|
||||||
conf.Cheatpaths,
|
conf.Cheatpaths,
|
||||||
opts["--path"].(string),
|
pathVal,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "invalid option --path: %v\n", err)
|
fmt.Fprintf(os.Stderr, "invalid option --path: %v\n", err)
|
||||||
@@ -117,38 +220,48 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// determine which command to execute
|
// determine which command to execute
|
||||||
var cmd func(map[string]interface{}, config.Config)
|
confFlag, _ := f.GetBool("conf")
|
||||||
|
dirFlag, _ := f.GetBool("directories")
|
||||||
|
listFlag, _ := f.GetBool("list")
|
||||||
|
briefFlag, _ := f.GetBool("brief")
|
||||||
|
tagsFlag, _ := f.GetBool("tags")
|
||||||
|
updateFlag, _ := f.GetBool("update")
|
||||||
|
tagVal, _ := f.GetString("tag")
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case opts["--directories"].(bool):
|
case confFlag:
|
||||||
cmd = cmdDirectories
|
cmdConf(cmd, args, conf)
|
||||||
|
|
||||||
case opts["--edit"] != nil:
|
case dirFlag:
|
||||||
cmd = cmdEdit
|
cmdDirectories(cmd, args, conf)
|
||||||
|
|
||||||
case opts["--list"].(bool):
|
case f.Changed("edit"):
|
||||||
cmd = cmdList
|
cmdEdit(cmd, args, conf)
|
||||||
|
|
||||||
case opts["--tags"].(bool):
|
case listFlag, briefFlag:
|
||||||
cmd = cmdTags
|
cmdList(cmd, args, conf)
|
||||||
|
|
||||||
case opts["--search"] != nil:
|
case tagsFlag:
|
||||||
cmd = cmdSearch
|
cmdTags(cmd, args, conf)
|
||||||
|
|
||||||
case opts["--rm"] != nil:
|
case updateFlag:
|
||||||
cmd = cmdRemove
|
cmdUpdate(cmd, args, conf)
|
||||||
|
|
||||||
case opts["<cheatsheet>"] != nil:
|
case f.Changed("search"):
|
||||||
cmd = cmdView
|
cmdSearch(cmd, args, conf)
|
||||||
|
|
||||||
case opts["--tag"] != nil && opts["--tag"].(string) != "":
|
case f.Changed("rm"):
|
||||||
cmd = cmdList
|
cmdRemove(cmd, args, conf)
|
||||||
|
|
||||||
|
case len(args) > 0:
|
||||||
|
cmdView(cmd, args, conf)
|
||||||
|
|
||||||
|
case tagVal != "":
|
||||||
|
cmdList(cmd, args, conf)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
fmt.Println(usage())
|
return cmd.Help()
|
||||||
os.Exit(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// execute the command
|
return nil
|
||||||
cmd(opts, conf)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// Code generated .* DO NOT EDIT.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func configs() string {
|
|
||||||
return strings.TrimSpace(`---
|
|
||||||
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
|
|
||||||
editor: vim
|
|
||||||
|
|
||||||
# Should 'cheat' always colorize output?
|
|
||||||
colorize: true
|
|
||||||
|
|
||||||
# Which 'chroma' colorscheme should be applied to the output?
|
|
||||||
# Options are available here:
|
|
||||||
# https://github.com/alecthomas/chroma/tree/master/styles
|
|
||||||
style: monokai
|
|
||||||
|
|
||||||
# Which 'chroma' "formatter" should be applied?
|
|
||||||
# One of: "terminal", "terminal256", "terminal16m"
|
|
||||||
formatter: terminal16m
|
|
||||||
|
|
||||||
# Through which pager should output be piped? (Unset this key for no pager.)
|
|
||||||
pager: less -FRX
|
|
||||||
|
|
||||||
# The paths at which cheatsheets are available. Tags associated with a cheatpath
|
|
||||||
# are automatically attached to all cheatsheets residing on that path.
|
|
||||||
#
|
|
||||||
# Whenever cheatsheets share the same title (like 'tar'), the most local
|
|
||||||
# cheatsheets (those which come later in this file) take precedent over the
|
|
||||||
# less local sheets. This allows you to create your own "overides" for
|
|
||||||
# "upstream" cheatsheets.
|
|
||||||
#
|
|
||||||
# But what if you want to view the "upstream" cheatsheets instead of your own?
|
|
||||||
# Cheatsheets may be filtered via 'cheat -t <tag>' in combination with other
|
|
||||||
# commands. So, if you want to view the 'tar' cheatsheet that is tagged as
|
|
||||||
# 'community' rather than your own, you can use: cheat tar -t community
|
|
||||||
cheatpaths:
|
|
||||||
|
|
||||||
# Paths that come earlier are considered to be the most "global", and will
|
|
||||||
# thus be overridden by more local cheatsheets. That being the case, you
|
|
||||||
# should probably list community cheatsheets first.
|
|
||||||
#
|
|
||||||
# Note that the paths and tags listed below are placeholders. You may freely
|
|
||||||
# change them to suit your needs.
|
|
||||||
#
|
|
||||||
# Community cheatsheets must be installed separately, though you may have
|
|
||||||
# downloaded them automatically when installing 'cheat'. If not, you may
|
|
||||||
# download them here:
|
|
||||||
#
|
|
||||||
# https://github.com/cheat/cheatsheets
|
|
||||||
#
|
|
||||||
# Once downloaded, ensure that 'path' below points to the location at which
|
|
||||||
# you downloaded the community cheatsheets.
|
|
||||||
- name: community
|
|
||||||
path: COMMUNITY_PATH
|
|
||||||
tags: [ community ]
|
|
||||||
readonly: true
|
|
||||||
|
|
||||||
# If you have personalized cheatsheets, list them last. They will take
|
|
||||||
# precedence over the more global cheatsheets.
|
|
||||||
- name: personal
|
|
||||||
path: PERSONAL_PATH
|
|
||||||
tags: [ personal ]
|
|
||||||
readonly: false
|
|
||||||
|
|
||||||
# While it requires no configuration here, it's also worth noting that
|
|
||||||
# 'cheat' will automatically append directories named '.cheat' within the
|
|
||||||
# current working directory to the 'cheatpath'. This can be very useful if
|
|
||||||
# you'd like to closely associate cheatsheets with, for example, a directory
|
|
||||||
# containing source code.
|
|
||||||
#
|
|
||||||
# Such "directory-scoped" cheatsheets will be treated as the most "local"
|
|
||||||
# cheatsheets, and will override less "local" cheatsheets. Likewise,
|
|
||||||
# directory-scoped cheatsheets will always be editable ('readonly: false').
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// Code generated .* DO NOT EDIT.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func usage() string {
|
|
||||||
return strings.TrimSpace(`Usage:
|
|
||||||
cheat [options] [<cheatsheet>]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--init Write a default config file to stdout
|
|
||||||
-a --all Search among all cheatpaths
|
|
||||||
-c --colorize Colorize output
|
|
||||||
-d --directories List cheatsheet directories
|
|
||||||
-e --edit=<cheatsheet> Edit <cheatsheet>
|
|
||||||
-l --list List cheatsheets
|
|
||||||
-p --path=<name> Return only sheets found on cheatpath <name>
|
|
||||||
-r --regex Treat search <phrase> as a regex
|
|
||||||
-s --search=<phrase> Search cheatsheets for <phrase>
|
|
||||||
-t --tag=<tag> Return only sheets matching <tag>
|
|
||||||
-T --tags List all tags in use
|
|
||||||
-v --version Print the version number
|
|
||||||
--rm=<cheatsheet> Remove (delete) <cheatsheet>
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
To initialize a config file:
|
|
||||||
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
|
|
||||||
|
|
||||||
To view the tar cheatsheet:
|
|
||||||
cheat tar
|
|
||||||
|
|
||||||
To edit (or create) the foo cheatsheet:
|
|
||||||
cheat -e foo
|
|
||||||
|
|
||||||
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
|
|
||||||
cheat -p work -e foo/bar
|
|
||||||
|
|
||||||
To view all cheatsheet directories:
|
|
||||||
cheat -d
|
|
||||||
|
|
||||||
To list all available cheatsheets:
|
|
||||||
cheat -l
|
|
||||||
|
|
||||||
To list all cheatsheets whose titles match "apt":
|
|
||||||
cheat -l apt
|
|
||||||
|
|
||||||
To list all tags in use:
|
|
||||||
cheat -T
|
|
||||||
|
|
||||||
To list available cheatsheets that are tagged as "personal":
|
|
||||||
cheat -l -t personal
|
|
||||||
|
|
||||||
To search for "ssh" among all cheatsheets, and colorize matches:
|
|
||||||
cheat -c -s ssh
|
|
||||||
|
|
||||||
To search (by regex) for cheatsheets that contain an IP address:
|
|
||||||
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
|
|
||||||
|
|
||||||
To remove (delete) the foo/bar cheatsheet:
|
|
||||||
cheat --rm foo/bar
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
---
|
|
||||||
# The editor to use with 'cheat -e <sheet>'. Defaults to $EDITOR or $VISUAL.
|
|
||||||
editor: vim
|
|
||||||
|
|
||||||
# Should 'cheat' always colorize output?
|
|
||||||
colorize: true
|
|
||||||
|
|
||||||
# Which 'chroma' colorscheme should be applied to the output?
|
|
||||||
# Options are available here:
|
|
||||||
# https://github.com/alecthomas/chroma/tree/master/styles
|
|
||||||
style: monokai
|
|
||||||
|
|
||||||
# Which 'chroma' "formatter" should be applied?
|
|
||||||
# One of: "terminal", "terminal256", "terminal16m"
|
|
||||||
formatter: terminal16m
|
|
||||||
|
|
||||||
# Through which pager should output be piped? (Unset this key for no pager.)
|
|
||||||
pager: less -FRX
|
|
||||||
|
|
||||||
# The paths at which cheatsheets are available. Tags associated with a cheatpath
|
|
||||||
# are automatically attached to all cheatsheets residing on that path.
|
|
||||||
#
|
|
||||||
# Whenever cheatsheets share the same title (like 'tar'), the most local
|
|
||||||
# cheatsheets (those which come later in this file) take precedent over the
|
|
||||||
# less local sheets. This allows you to create your own "overides" for
|
|
||||||
# "upstream" cheatsheets.
|
|
||||||
#
|
|
||||||
# But what if you want to view the "upstream" cheatsheets instead of your own?
|
|
||||||
# Cheatsheets may be filtered via 'cheat -t <tag>' in combination with other
|
|
||||||
# commands. So, if you want to view the 'tar' cheatsheet that is tagged as
|
|
||||||
# 'community' rather than your own, you can use: cheat tar -t community
|
|
||||||
cheatpaths:
|
|
||||||
|
|
||||||
# Paths that come earlier are considered to be the most "global", and will
|
|
||||||
# thus be overridden by more local cheatsheets. That being the case, you
|
|
||||||
# should probably list community cheatsheets first.
|
|
||||||
#
|
|
||||||
# Note that the paths and tags listed below are placeholders. You may freely
|
|
||||||
# change them to suit your needs.
|
|
||||||
#
|
|
||||||
# Community cheatsheets must be installed separately, though you may have
|
|
||||||
# downloaded them automatically when installing 'cheat'. If not, you may
|
|
||||||
# download them here:
|
|
||||||
#
|
|
||||||
# https://github.com/cheat/cheatsheets
|
|
||||||
#
|
|
||||||
# Once downloaded, ensure that 'path' below points to the location at which
|
|
||||||
# you downloaded the community cheatsheets.
|
|
||||||
- name: community
|
|
||||||
path: COMMUNITY_PATH
|
|
||||||
tags: [ community ]
|
|
||||||
readonly: true
|
|
||||||
|
|
||||||
# If you have personalized cheatsheets, list them last. They will take
|
|
||||||
# precedence over the more global cheatsheets.
|
|
||||||
- name: personal
|
|
||||||
path: PERSONAL_PATH
|
|
||||||
tags: [ personal ]
|
|
||||||
readonly: false
|
|
||||||
|
|
||||||
# While it requires no configuration here, it's also worth noting that
|
|
||||||
# 'cheat' will automatically append directories named '.cheat' within the
|
|
||||||
# current working directory to the 'cheatpath'. This can be very useful if
|
|
||||||
# you'd like to closely associate cheatsheets with, for example, a directory
|
|
||||||
# containing source code.
|
|
||||||
#
|
|
||||||
# Such "directory-scoped" cheatsheets will be treated as the most "local"
|
|
||||||
# cheatsheets, and will override less "local" cheatsheets. Likewise,
|
|
||||||
# directory-scoped cheatsheets will always be editable ('readonly: false').
|
|
||||||
271
doc/cheat.1
271
doc/cheat.1
@@ -1,208 +1,202 @@
|
|||||||
.\" Automatically generated by Pandoc 2.2.1
|
.\" Automatically generated by Pandoc 3.1.11.1
|
||||||
.\"
|
.\"
|
||||||
.TH "CHEAT" "1" "" "" "General Commands Manual"
|
.TH "CHEAT" "1" "" "" "General Commands Manual"
|
||||||
.hy
|
|
||||||
.SH NAME
|
.SH NAME
|
||||||
.PP
|
\f[B]cheat\f[R] \[em] create and view command\-line cheatsheets
|
||||||
\f[B]cheat\f[] \[em] create and view command\-line cheatsheets
|
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
.PP
|
.PP
|
||||||
\f[B]cheat\f[] [options] [\f[I]CHEATSHEET\f[]]
|
\f[B]cheat\f[R] [options] [\f[I]CHEATSHEET\f[R]]
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
.PP
|
\f[B]cheat\f[R] allows you to create and view interactive cheatsheets on
|
||||||
\f[B]cheat\f[] allows you to create and view interactive cheatsheets on
|
|
||||||
the command\-line.
|
the command\-line.
|
||||||
It was designed to help remind *nix system administrators of options for
|
It was designed to help remind *nix system administrators of options for
|
||||||
commands that they use frequently, but not frequently enough to
|
commands that they use frequently, but not frequently enough to
|
||||||
remember.
|
remember.
|
||||||
.SH OPTIONS
|
.SH OPTIONS
|
||||||
.TP
|
.TP
|
||||||
.B \[en]init
|
\[en]init
|
||||||
Print a config file to stdout.
|
Print a config file to stdout.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-c, \[en]colorize
|
\[en]conf
|
||||||
|
Display the config file path.
|
||||||
|
.TP
|
||||||
|
\-a, \[en]all
|
||||||
|
Search among all cheatpaths.
|
||||||
|
.TP
|
||||||
|
\-b, \[en]brief
|
||||||
|
List cheatsheets without file paths.
|
||||||
|
.TP
|
||||||
|
\-c, \[en]colorize
|
||||||
Colorize output.
|
Colorize output.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-d, \[en]directories
|
\-d, \[en]directories
|
||||||
List cheatsheet directories.
|
List cheatsheet directories.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-e, \[en]edit=\f[I]CHEATSHEET\f[]
|
\-e, \[en]edit=\f[I]CHEATSHEET\f[R]
|
||||||
Open \f[I]CHEATSHEET\f[] for editing.
|
Open \f[I]CHEATSHEET\f[R] for editing.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-l, \[en]list
|
\-l, \[en]list
|
||||||
List available cheatsheets.
|
List available cheatsheets.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-p, \[en]path=\f[I]PATH\f[]
|
\-p, \[en]path=\f[I]PATH\f[R]
|
||||||
Filter only to sheets found on path \f[I]PATH\f[].
|
Filter only to sheets found on path \f[I]PATH\f[R].
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-r, \[en]regex
|
\-r, \[en]regex
|
||||||
Treat search \f[I]PHRASE\f[] as a regular expression.
|
Treat search \f[I]PHRASE\f[R] as a regular expression.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-s, \[en]search=\f[I]PHRASE\f[]
|
\-s, \[en]search=\f[I]PHRASE\f[R]
|
||||||
Search cheatsheets for \f[I]PHRASE\f[].
|
Search cheatsheets for \f[I]PHRASE\f[R].
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-t, \[en]tag=\f[I]TAG\f[]
|
\-t, \[en]tag=\f[I]TAG\f[R]
|
||||||
Filter only to sheets tagged with \f[I]TAG\f[].
|
Filter only to sheets tagged with \f[I]TAG\f[R].
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-T, \[en]tags
|
\-T, \[en]tags
|
||||||
List all tags in use.
|
List all tags in use.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \-v, \[en]version
|
\-u, \[en]update
|
||||||
|
Update git\-backed cheatpaths by pulling the latest changes.
|
||||||
|
.TP
|
||||||
|
\-v, \[en]version
|
||||||
Print the version number.
|
Print the version number.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B \[en]rm=\f[I]CHEATSHEET\f[]
|
\[en]rm=\f[I]CHEATSHEET\f[R]
|
||||||
Remove (deletes) \f[I]CHEATSHEET\f[].
|
Remove (deletes) \f[I]CHEATSHEET\f[R].
|
||||||
.RS
|
.TP
|
||||||
.RE
|
\[en]completion=\f[I]SHELL\f[R]
|
||||||
|
Generate a shell completion script.
|
||||||
|
\f[I]SHELL\f[R] must be one of: \f[B]bash\f[R], \f[B]zsh\f[R],
|
||||||
|
\f[B]fish\f[R], \f[B]powershell\f[R].
|
||||||
.SH EXAMPLES
|
.SH EXAMPLES
|
||||||
.TP
|
.TP
|
||||||
.B To view the foo cheatsheet:
|
To view the foo cheatsheet:
|
||||||
cheat \f[I]foo\f[]
|
cheat \f[I]foo\f[R]
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To edit (or create) the foo cheatsheet:
|
To edit (or create) the foo cheatsheet:
|
||||||
cheat \-e \f[I]foo\f[]
|
cheat \-e \f[I]foo\f[R]
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To edit (or create) the foo/bar cheatsheet on the `work' cheatpath:
|
To edit (or create) the foo/bar cheatsheet on the `work' cheatpath:
|
||||||
cheat \-p \f[I]work\f[] \-e \f[I]foo/bar\f[]
|
cheat \-p \f[I]work\f[R] \-e \f[I]foo/bar\f[R]
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To view all cheatsheet directories:
|
To view all cheatsheet directories:
|
||||||
cheat \-d
|
cheat \-d
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To list all available cheatsheets:
|
To list all available cheatsheets:
|
||||||
cheat \-l
|
cheat \-l
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To list all cheatsheets whose titles match `apt':
|
To briefly list all cheatsheets whose titles match `apt':
|
||||||
cheat \-l \f[I]apt\f[]
|
cheat \-b \f[I]apt\f[R]
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To list all tags in use:
|
To list all tags in use:
|
||||||
cheat \-T
|
cheat \-T
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To list available cheatsheets that are tagged as `personal':
|
To list available cheatsheets that are tagged as `personal':
|
||||||
cheat \-l \-t \f[I]personal\f[]
|
cheat \-l \-t \f[I]personal\f[R]
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To search for `ssh' among all cheatsheets, and colorize matches:
|
To search for `ssh' among all cheatsheets, and colorize matches:
|
||||||
cheat \-c \-s \f[I]ssh\f[]
|
cheat \-c \-s \f[I]ssh\f[R]
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To search (by regex) for cheatsheets that contain an IP address:
|
To search (by regex) for cheatsheets that contain an IP address:
|
||||||
cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[]
|
cheat \-c \-r \-s \f[I]`(?:[0\-9]{1,3}.){3}[0\-9]{1,3}'\f[R]
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
.TP
|
||||||
.B To remove (delete) the foo/bar cheatsheet:
|
To remove (delete) the foo/bar cheatsheet:
|
||||||
cheat \[en]rm \f[I]foo/bar\f[]
|
cheat \[en]rm \f[I]foo/bar\f[R]
|
||||||
.RS
|
.TP
|
||||||
.RE
|
To update all git\-backed cheatpaths:
|
||||||
|
cheat \[en]update
|
||||||
|
.TP
|
||||||
|
To update only the `community' cheatpath:
|
||||||
|
cheat \-u \-p \f[I]community\f[R]
|
||||||
|
.TP
|
||||||
|
To view the configuration file path:
|
||||||
|
cheat \[en]conf
|
||||||
.SH FILES
|
.SH FILES
|
||||||
.SS Configuration
|
.SS Configuration
|
||||||
.PP
|
\f[B]cheat\f[R] is configured via a YAML file that is conventionally
|
||||||
\f[B]cheat\f[] is configured via a YAML file that is conventionally
|
named \f[I]conf.yml\f[R].
|
||||||
named \f[I]conf.yaml\f[].
|
\f[B]cheat\f[R] will search for \f[I]conf.yml\f[R] in varying locations,
|
||||||
\f[B]cheat\f[] will search for \f[I]conf.yaml\f[] in varying locations,
|
|
||||||
depending upon your platform:
|
depending upon your platform:
|
||||||
.SS Linux, OSX, and other Unixes
|
.SS Linux, OSX, and other Unixes
|
||||||
.IP "1." 3
|
.IP "1." 3
|
||||||
\f[B]CHEAT_CONFIG_PATH\f[]
|
\f[B]CHEAT_CONFIG_PATH\f[R]
|
||||||
.IP "2." 3
|
.IP "2." 3
|
||||||
\f[B]XDG_CONFIG_HOME\f[]/cheat/conf.yaml
|
\f[B]XDG_CONFIG_HOME\f[R]/cheat/conf.yml
|
||||||
.IP "3." 3
|
.IP "3." 3
|
||||||
\f[B]$HOME\f[]/.config/cheat/conf.yml
|
\f[B]$HOME\f[R]/.config/cheat/conf.yml
|
||||||
.IP "4." 3
|
.IP "4." 3
|
||||||
\f[B]$HOME\f[]/.cheat/conf.yml
|
\f[B]$HOME\f[R]/.cheat/conf.yml
|
||||||
|
.IP "5." 3
|
||||||
|
/etc/cheat/conf.yml
|
||||||
.SS Windows
|
.SS Windows
|
||||||
.IP "1." 3
|
.IP "1." 3
|
||||||
\f[B]CHEAT_CONFIG_PATH\f[]
|
\f[B]CHEAT_CONFIG_PATH\f[R]
|
||||||
.IP "2." 3
|
.IP "2." 3
|
||||||
\f[B]APPDATA\f[]/cheat/conf.yml
|
\f[B]APPDATA\f[R]/cheat/conf.yml
|
||||||
.IP "3." 3
|
.IP "3." 3
|
||||||
\f[B]PROGRAMDATA\f[]/cheat/conf.yml
|
\f[B]PROGRAMDATA\f[R]/cheat/conf.yml
|
||||||
.PP
|
.PP
|
||||||
\f[B]cheat\f[] will search in the order specified above.
|
\f[B]cheat\f[R] will search in the order specified above.
|
||||||
The first \f[I]conf.yaml\f[] encountered will be respected.
|
The first \f[I]conf.yml\f[R] encountered will be respected.
|
||||||
.PP
|
.PP
|
||||||
If \f[B]cheat\f[] cannot locate a config file, it will ask if you'd like
|
If \f[B]cheat\f[R] cannot locate a config file, it will ask if you\[cq]d
|
||||||
to generate one automatically.
|
like to generate one automatically.
|
||||||
Alternatively, you may also generate a config file manually by running
|
Alternatively, you may also generate a config file manually by running
|
||||||
\f[B]cheat \[en]init\f[] and saving its output to the appropriate
|
\f[B]cheat \[en]init\f[R] and saving its output to the appropriate
|
||||||
location for your platform.
|
location for your platform.
|
||||||
.SS Cheatpaths
|
.SS Cheatpaths
|
||||||
.PP
|
\f[B]cheat\f[R] reads its cheatsheets from \[lq]cheatpaths\[rq], which
|
||||||
\f[B]cheat\f[] reads its cheatsheets from \[lq]cheatpaths\[rq], which
|
|
||||||
are the directories in which cheatsheets are stored.
|
are the directories in which cheatsheets are stored.
|
||||||
Cheatpaths may be configured in \f[I]conf.yaml\f[], and viewed via
|
Cheatpaths may be configured in \f[I]conf.yml\f[R], and viewed via
|
||||||
\f[B]cheat \-d\f[].
|
\f[B]cheat \-d\f[R].
|
||||||
.PP
|
.PP
|
||||||
For detailed instructions on how to configure cheatpaths, please refer
|
For detailed instructions on how to configure cheatpaths, please refer
|
||||||
to the comments in conf.yml.
|
to the comments in conf.yml.
|
||||||
.SS Autocompletion
|
.SS Autocompletion
|
||||||
|
\f[B]cheat\f[R] can generate shell completion scripts for
|
||||||
|
\f[B]bash\f[R], \f[B]zsh\f[R], \f[B]fish\f[R], and \f[B]powershell\f[R]
|
||||||
|
via the \f[B]\[en]completion\f[R] flag:
|
||||||
|
.IP
|
||||||
|
.EX
|
||||||
|
cheat \-\-completion bash
|
||||||
|
cheat \-\-completion zsh
|
||||||
|
cheat \-\-completion fish
|
||||||
|
cheat \-\-completion powershell
|
||||||
|
.EE
|
||||||
.PP
|
.PP
|
||||||
Autocompletion scripts for \f[B]bash\f[], \f[B]zsh\f[], and
|
Completions are dynamically generated and include cheatsheet names,
|
||||||
\f[B]fish\f[] are available for download:
|
tags, and cheatpath names.
|
||||||
.IP \[bu] 2
|
|
||||||
<https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
|
|
||||||
.IP \[bu] 2
|
|
||||||
<https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
|
|
||||||
.IP \[bu] 2
|
|
||||||
<https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
|
|
||||||
.PP
|
.PP
|
||||||
The \f[B]bash\f[] and \f[B]zsh\f[] scripts provide optional integration
|
To install completions, pipe the output to the appropriate location for
|
||||||
with \f[B]fzf\f[], if the latter is available on your \f[B]PATH\f[].
|
your shell.
|
||||||
|
For example, on \f[B]bash\f[R]:
|
||||||
|
.IP
|
||||||
|
.EX
|
||||||
|
cheat \-\-completion bash > /etc/bash_completion.d/cheat
|
||||||
|
.EE
|
||||||
.PP
|
.PP
|
||||||
The installation process will vary per system and shell configuration,
|
Or for the current user only:
|
||||||
and thus will not be discussed here.
|
.IP
|
||||||
|
.EX
|
||||||
|
cheat \-\-completion bash > \[ti]/.local/share/bash\-completion/completions/cheat
|
||||||
|
.EE
|
||||||
|
.PP
|
||||||
|
For \f[B]zsh\f[R], you may need to add the completions directory to your
|
||||||
|
\f[B]fpath\f[R]:
|
||||||
|
.IP
|
||||||
|
.EX
|
||||||
|
cheat \-\-completion zsh > \[dq]${fpath[1]}/_cheat\[dq]
|
||||||
|
.EE
|
||||||
|
.PP
|
||||||
|
For \f[B]fish\f[R]:
|
||||||
|
.IP
|
||||||
|
.EX
|
||||||
|
cheat \-\-completion fish > \[ti]/.config/fish/completions/cheat.fish
|
||||||
|
.EE
|
||||||
.SH ENVIRONMENT
|
.SH ENVIRONMENT
|
||||||
.TP
|
.TP
|
||||||
.B \f[B]CHEAT_CONFIG_PATH\f[]
|
\f[B]CHEAT_CONFIG_PATH\f[R]
|
||||||
The path at which the config file is available.
|
The path at which the config file is available.
|
||||||
If \f[B]CHEAT_CONFIG_PATH\f[] is set, all other config paths will be
|
If \f[B]CHEAT_CONFIG_PATH\f[R] is set, all other config paths will be
|
||||||
ignored.
|
ignored.
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.TP
|
|
||||||
.B \f[B]CHEAT_USE_FZF\f[]
|
|
||||||
If set, autocompletion scripts will attempt to integrate with
|
|
||||||
\f[B]fzf\f[].
|
|
||||||
.RS
|
|
||||||
.RE
|
|
||||||
.SH RETURN VALUES
|
.SH RETURN VALUES
|
||||||
.IP "0." 3
|
.IP "0." 3
|
||||||
Successful termination
|
Successful termination
|
||||||
@@ -211,11 +205,12 @@ Application error
|
|||||||
.IP "2." 3
|
.IP "2." 3
|
||||||
Cheatsheet(s) not found
|
Cheatsheet(s) not found
|
||||||
.SH BUGS
|
.SH BUGS
|
||||||
.PP
|
See GitHub issues: \c
|
||||||
See GitHub issues: <https://github.com/cheat/cheat/issues>
|
.UR https://github.com/cheat/cheat/issues
|
||||||
|
.UE \c
|
||||||
.SH AUTHOR
|
.SH AUTHOR
|
||||||
.PP
|
Christopher Allen Lane \c
|
||||||
Christopher Allen Lane <chris@chris-allen-lane.com>
|
.MT chris@chris-allen-lane.com
|
||||||
|
.ME \c
|
||||||
.SH SEE ALSO
|
.SH SEE ALSO
|
||||||
.PP
|
\f[B]fzf(1)\f[R]
|
||||||
\f[B]fzf(1)\f[]
|
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ OPTIONS
|
|||||||
--init
|
--init
|
||||||
: Print a config file to stdout.
|
: Print a config file to stdout.
|
||||||
|
|
||||||
|
--conf
|
||||||
|
: Display the config file path.
|
||||||
|
|
||||||
|
-a, --all
|
||||||
|
: Search among all cheatpaths.
|
||||||
|
|
||||||
|
-b, --brief
|
||||||
|
: List cheatsheets without file paths.
|
||||||
|
|
||||||
-c, --colorize
|
-c, --colorize
|
||||||
: Colorize output.
|
: Colorize output.
|
||||||
|
|
||||||
@@ -50,12 +59,19 @@ OPTIONS
|
|||||||
-T, --tags
|
-T, --tags
|
||||||
: List all tags in use.
|
: List all tags in use.
|
||||||
|
|
||||||
|
-u, --update
|
||||||
|
: Update git-backed cheatpaths by pulling the latest changes.
|
||||||
|
|
||||||
-v, --version
|
-v, --version
|
||||||
: Print the version number.
|
: Print the version number.
|
||||||
|
|
||||||
--rm=_CHEATSHEET_
|
--rm=_CHEATSHEET_
|
||||||
: Remove (deletes) _CHEATSHEET_.
|
: Remove (deletes) _CHEATSHEET_.
|
||||||
|
|
||||||
|
--completion=_SHELL_
|
||||||
|
: Generate a shell completion script. _SHELL_ must be one of: **bash**,
|
||||||
|
**zsh**, **fish**, **powershell**.
|
||||||
|
|
||||||
|
|
||||||
EXAMPLES
|
EXAMPLES
|
||||||
========
|
========
|
||||||
@@ -75,8 +91,8 @@ To view all cheatsheet directories:
|
|||||||
To list all available cheatsheets:
|
To list all available cheatsheets:
|
||||||
: cheat -l
|
: cheat -l
|
||||||
|
|
||||||
To list all cheatsheets whose titles match 'apt':
|
To briefly list all cheatsheets whose titles match 'apt':
|
||||||
: cheat -l _apt_
|
: cheat -b _apt_
|
||||||
|
|
||||||
To list all tags in use:
|
To list all tags in use:
|
||||||
: cheat -T
|
: cheat -T
|
||||||
@@ -93,6 +109,15 @@ To search (by regex) for cheatsheets that contain an IP address:
|
|||||||
To remove (delete) the foo/bar cheatsheet:
|
To remove (delete) the foo/bar cheatsheet:
|
||||||
: cheat --rm _foo/bar_
|
: cheat --rm _foo/bar_
|
||||||
|
|
||||||
|
To update all git-backed cheatpaths:
|
||||||
|
: cheat --update
|
||||||
|
|
||||||
|
To update only the 'community' cheatpath:
|
||||||
|
: cheat -u -p _community_
|
||||||
|
|
||||||
|
To view the configuration file path:
|
||||||
|
: cheat --conf
|
||||||
|
|
||||||
|
|
||||||
FILES
|
FILES
|
||||||
=====
|
=====
|
||||||
@@ -100,15 +125,16 @@ FILES
|
|||||||
Configuration
|
Configuration
|
||||||
-------------
|
-------------
|
||||||
**cheat** is configured via a YAML file that is conventionally named
|
**cheat** is configured via a YAML file that is conventionally named
|
||||||
_conf.yaml_. **cheat** will search for _conf.yaml_ in varying locations,
|
_conf.yml_. **cheat** will search for _conf.yml_ in varying locations,
|
||||||
depending upon your platform:
|
depending upon your platform:
|
||||||
|
|
||||||
### Linux, OSX, and other Unixes ###
|
### Linux, OSX, and other Unixes ###
|
||||||
|
|
||||||
1. **CHEAT_CONFIG_PATH**
|
1. **CHEAT_CONFIG_PATH**
|
||||||
2. **XDG_CONFIG_HOME**/cheat/conf.yaml
|
2. **XDG_CONFIG_HOME**/cheat/conf.yml
|
||||||
3. **$HOME**/.config/cheat/conf.yml
|
3. **$HOME**/.config/cheat/conf.yml
|
||||||
4. **$HOME**/.cheat/conf.yml
|
4. **$HOME**/.cheat/conf.yml
|
||||||
|
5. /etc/cheat/conf.yml
|
||||||
|
|
||||||
### Windows ###
|
### Windows ###
|
||||||
|
|
||||||
@@ -116,7 +142,7 @@ depending upon your platform:
|
|||||||
2. **APPDATA**/cheat/conf.yml
|
2. **APPDATA**/cheat/conf.yml
|
||||||
3. **PROGRAMDATA**/cheat/conf.yml
|
3. **PROGRAMDATA**/cheat/conf.yml
|
||||||
|
|
||||||
**cheat** will search in the order specified above. The first _conf.yaml_
|
**cheat** will search in the order specified above. The first _conf.yml_
|
||||||
encountered will be respected.
|
encountered will be respected.
|
||||||
|
|
||||||
If **cheat** cannot locate a config file, it will ask if you'd like to generate
|
If **cheat** cannot locate a config file, it will ask if you'd like to generate
|
||||||
@@ -128,7 +154,7 @@ for your platform.
|
|||||||
Cheatpaths
|
Cheatpaths
|
||||||
----------
|
----------
|
||||||
**cheat** reads its cheatsheets from "cheatpaths", which are the directories in
|
**cheat** reads its cheatsheets from "cheatpaths", which are the directories in
|
||||||
which cheatsheets are stored. Cheatpaths may be configured in _conf.yaml_, and
|
which cheatsheets are stored. Cheatpaths may be configured in _conf.yml_, and
|
||||||
viewed via **cheat -d**.
|
viewed via **cheat -d**.
|
||||||
|
|
||||||
For detailed instructions on how to configure cheatpaths, please refer to the
|
For detailed instructions on how to configure cheatpaths, please refer to the
|
||||||
@@ -137,18 +163,33 @@ comments in conf.yml.
|
|||||||
|
|
||||||
Autocompletion
|
Autocompletion
|
||||||
--------------
|
--------------
|
||||||
Autocompletion scripts for **bash**, **zsh**, and **fish** are available for
|
**cheat** can generate shell completion scripts for **bash**, **zsh**,
|
||||||
download:
|
**fish**, and **powershell** via the **--completion** flag:
|
||||||
|
|
||||||
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
|
cheat --completion bash
|
||||||
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
|
cheat --completion zsh
|
||||||
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
|
cheat --completion fish
|
||||||
|
cheat --completion powershell
|
||||||
|
|
||||||
The **bash** and **zsh** scripts provide optional integration with **fzf**, if
|
Completions are dynamically generated and include cheatsheet names, tags, and
|
||||||
the latter is available on your **PATH**.
|
cheatpath names.
|
||||||
|
|
||||||
The installation process will vary per system and shell configuration, and thus
|
To install completions, pipe the output to the appropriate location for your
|
||||||
will not be discussed here.
|
shell. For example, on **bash**:
|
||||||
|
|
||||||
|
cheat --completion bash > /etc/bash_completion.d/cheat
|
||||||
|
|
||||||
|
Or for the current user only:
|
||||||
|
|
||||||
|
cheat --completion bash > ~/.local/share/bash-completion/completions/cheat
|
||||||
|
|
||||||
|
For **zsh**, you may need to add the completions directory to your **fpath**:
|
||||||
|
|
||||||
|
cheat --completion zsh > "${fpath[1]}/_cheat"
|
||||||
|
|
||||||
|
For **fish**:
|
||||||
|
|
||||||
|
cheat --completion fish > ~/.config/fish/completions/cheat.fish
|
||||||
|
|
||||||
|
|
||||||
ENVIRONMENT
|
ENVIRONMENT
|
||||||
@@ -159,10 +200,6 @@ ENVIRONMENT
|
|||||||
: The path at which the config file is available. If **CHEAT_CONFIG_PATH** is
|
: The path at which the config file is available. If **CHEAT_CONFIG_PATH** is
|
||||||
set, all other config paths will be ignored.
|
set, all other config paths will be ignored.
|
||||||
|
|
||||||
**CHEAT_USE_FZF**
|
|
||||||
|
|
||||||
: If set, autocompletion scripts will attempt to integrate with **fzf**.
|
|
||||||
|
|
||||||
RETURN VALUES
|
RETURN VALUES
|
||||||
=============
|
=============
|
||||||
|
|
||||||
|
|||||||
42
go.mod
42
go.mod
@@ -1,17 +1,39 @@
|
|||||||
module github.com/cheat/cheat
|
module github.com/cheat/cheat
|
||||||
|
|
||||||
go 1.14
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma v0.9.1
|
github.com/alecthomas/chroma/v2 v2.23.1
|
||||||
github.com/davecgh/go-spew v1.1.1
|
github.com/davecgh/go-spew v1.1.1
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
|
github.com/go-git/go-git/v5 v5.16.5
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/mattn/go-isatty v0.0.20
|
||||||
github.com/mattn/go-isatty v0.0.14
|
|
||||||
github.com/mitchellh/go-homedir v1.1.0
|
github.com/mitchellh/go-homedir v1.1.0
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/sergi/go-diff v1.1.0 // indirect
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
)
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0
|
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
require (
|
||||||
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
|
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
|
github.com/kevinburke/ssh_config v1.5.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||||
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
147
go.sum
147
go.sum
@@ -1,59 +1,128 @@
|
|||||||
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
github.com/alecthomas/chroma v0.9.1 h1:cBmvQqRImzR5aWqdMxYZByND4S7BCS/g0svZb28h0Dc=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
github.com/alecthomas/chroma v0.9.1/go.mod h1:eMuEnpA18XbG/WhOWtCzJHS7WqEtDAI+HxdwoW0nVSk=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
|
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||||
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
|
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||||
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
|
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||||
|
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
|
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA=
|
||||||
|
github.com/kevinburke/ssh_config v1.5.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
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/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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
|
||||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
|
||||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||||
|
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||||
|
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||||
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
// Package cheatpath implements functions pertaining to cheatsheet file path
|
||||||
|
// management.
|
||||||
package cheatpath
|
package cheatpath
|
||||||
|
|
||||||
// Cheatpath encapsulates cheatsheet path information
|
// Path encapsulates cheatsheet path information
|
||||||
type Cheatpath struct {
|
type Path struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
ReadOnly bool `yaml:"readonly"`
|
ReadOnly bool `yaml:"readonly"`
|
||||||
|
|||||||
90
internal/cheatpath/cheatpath_test.go
Normal file
90
internal/cheatpath/cheatpath_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package cheatpath
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheatpathValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cheatpath Path
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid cheatpath",
|
||||||
|
cheatpath: Path{
|
||||||
|
Name: "personal",
|
||||||
|
Path: "/home/user/.config/cheat/personal",
|
||||||
|
ReadOnly: false,
|
||||||
|
Tags: []string{"personal"},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty name",
|
||||||
|
cheatpath: Path{
|
||||||
|
Name: "",
|
||||||
|
Path: "/home/user/.config/cheat/personal",
|
||||||
|
ReadOnly: false,
|
||||||
|
Tags: []string{"personal"},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "cheatpath name cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
cheatpath: Path{
|
||||||
|
Name: "personal",
|
||||||
|
Path: "",
|
||||||
|
ReadOnly: false,
|
||||||
|
Tags: []string{"personal"},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "cheatpath path cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both empty",
|
||||||
|
cheatpath: Path{
|
||||||
|
Name: "",
|
||||||
|
Path: "",
|
||||||
|
ReadOnly: true,
|
||||||
|
Tags: nil,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "cheatpath name cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "minimal valid",
|
||||||
|
cheatpath: Path{
|
||||||
|
Name: "x",
|
||||||
|
Path: "/",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with readonly and tags",
|
||||||
|
cheatpath: Path{
|
||||||
|
Name: "community",
|
||||||
|
Path: "/usr/share/cheat",
|
||||||
|
ReadOnly: true,
|
||||||
|
Tags: []string{"community", "shared", "readonly"},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.cheatpath.Validate()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Errorf("Validate() error = %v, want error containing %q", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Filter filters all cheatpaths that are not named `name`
|
// Filter filters all cheatpaths that are not named `name`
|
||||||
func Filter(paths []Cheatpath, name string) ([]Cheatpath, error) {
|
func Filter(paths []Path, name string) ([]Path, error) {
|
||||||
|
|
||||||
// if a path of the given name exists, return it
|
// if a path of the given name exists, return it
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
if path.Name == name {
|
if path.Name == name {
|
||||||
return []Cheatpath{path}, nil
|
return []Path{path}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, return an error
|
// otherwise, return an error
|
||||||
return []Cheatpath{}, fmt.Errorf("cheatpath does not exist: %s", name)
|
return []Path{}, fmt.Errorf("cheatpath does not exist: %s", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
func TestFilterSuccess(t *testing.T) {
|
func TestFilterSuccess(t *testing.T) {
|
||||||
|
|
||||||
// init cheatpaths
|
// init cheatpaths
|
||||||
paths := []Cheatpath{
|
paths := []Path{
|
||||||
Cheatpath{Name: "foo"},
|
Path{Name: "foo"},
|
||||||
Cheatpath{Name: "bar"},
|
Path{Name: "bar"},
|
||||||
Cheatpath{Name: "baz"},
|
Path{Name: "baz"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter the paths
|
// filter the paths
|
||||||
@@ -39,14 +39,14 @@ func TestFilterSuccess(t *testing.T) {
|
|||||||
func TestFilterFailure(t *testing.T) {
|
func TestFilterFailure(t *testing.T) {
|
||||||
|
|
||||||
// init cheatpaths
|
// init cheatpaths
|
||||||
paths := []Cheatpath{
|
paths := []Path{
|
||||||
Cheatpath{Name: "foo"},
|
Path{Name: "foo"},
|
||||||
Cheatpath{Name: "bar"},
|
Path{Name: "bar"},
|
||||||
Cheatpath{Name: "baz"},
|
Path{Name: "baz"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// filter the paths
|
// filter the paths
|
||||||
paths, err := Filter(paths, "qux")
|
_, err := Filter(paths, "qux")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("failed to return an error on non-existent cheatpath")
|
t.Errorf("failed to return an error on non-existent cheatpath")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate returns an error if the cheatpath is invalid
|
// Validate ensures that the Path is valid
|
||||||
func (c *Cheatpath) Validate() error {
|
func (c Path) Validate() error {
|
||||||
|
|
||||||
if c.Name == "" {
|
if c.Name == "" {
|
||||||
return fmt.Errorf("invalid cheatpath: name must be specified")
|
return fmt.Errorf("cheatpath name cannot be empty")
|
||||||
}
|
}
|
||||||
if c.Path == "" {
|
if c.Path == "" {
|
||||||
return fmt.Errorf("invalid cheatpath: path must be specified")
|
return fmt.Errorf("cheatpath path cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package cheatpath
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestValidateValid asserts that valid cheatpaths validate successfully
|
|
||||||
func TestValidateValid(t *testing.T) {
|
|
||||||
|
|
||||||
// initialize a valid cheatpath
|
|
||||||
cheatpath := Cheatpath{
|
|
||||||
Name: "foo",
|
|
||||||
Path: "/foo",
|
|
||||||
ReadOnly: false,
|
|
||||||
Tags: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// assert that no errors are returned
|
|
||||||
if err := cheatpath.Validate(); err != nil {
|
|
||||||
t.Errorf("failed to validate valid cheatpath: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateMissingName asserts that paths that are missing a name fail to
|
|
||||||
// validate
|
|
||||||
func TestValidateMissingName(t *testing.T) {
|
|
||||||
|
|
||||||
// initialize a valid cheatpath
|
|
||||||
cheatpath := Cheatpath{
|
|
||||||
Path: "/foo",
|
|
||||||
ReadOnly: false,
|
|
||||||
Tags: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// assert that no errors are returned
|
|
||||||
if err := cheatpath.Validate(); err == nil {
|
|
||||||
t.Errorf("failed to invalidate cheatpath without name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestValidateMissingPath asserts that paths that are missing a path fail to
|
|
||||||
// validate
|
|
||||||
func TestValidateMissingPath(t *testing.T) {
|
|
||||||
|
|
||||||
// initialize a valid cheatpath
|
|
||||||
cheatpath := Cheatpath{
|
|
||||||
Name: "foo",
|
|
||||||
ReadOnly: false,
|
|
||||||
Tags: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// assert that no errors are returned
|
|
||||||
if err := cheatpath.Validate(); err == nil {
|
|
||||||
t.Errorf("failed to invalidate cheatpath without path")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,21 +4,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Writeable returns a writeable Cheatpath
|
// Writeable returns a writeable Path
|
||||||
func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
|
func Writeable(cheatpaths []Path) (Path, error) {
|
||||||
|
|
||||||
// iterate backwards over the cheatpaths
|
// iterate backwards over the cheatpaths
|
||||||
// NB: we're going backwards because we assume that the most "local"
|
// NB: we're going backwards because we assume that the most "local"
|
||||||
// cheatpath will be specified last in the configs
|
// cheatpath will be specified last in the configs
|
||||||
for i := len(cheatpaths) - 1; i >= 0; i-- {
|
for i := len(cheatpaths) - 1; i >= 0; i-- {
|
||||||
|
|
||||||
// if the cheatpath is not read-only, it is writeable, and thus returned
|
// if the cheatpath is not read-only, it is writeable, and thus returned
|
||||||
if cheatpaths[i].ReadOnly == false {
|
if !cheatpaths[i].ReadOnly {
|
||||||
return cheatpaths[i], nil
|
return cheatpaths[i], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, return an error
|
// otherwise, return an error
|
||||||
return Cheatpath{}, fmt.Errorf("no writeable cheatpaths found")
|
return Path{}, fmt.Errorf("no writeable cheatpaths found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
func TestWriteableOK(t *testing.T) {
|
func TestWriteableOK(t *testing.T) {
|
||||||
|
|
||||||
// initialize some cheatpaths
|
// initialize some cheatpaths
|
||||||
cheatpaths := []Cheatpath{
|
cheatpaths := []Path{
|
||||||
Cheatpath{Path: "/foo", ReadOnly: true},
|
Path{Path: "/foo", ReadOnly: true},
|
||||||
Cheatpath{Path: "/bar", ReadOnly: false},
|
Path{Path: "/bar", ReadOnly: false},
|
||||||
Cheatpath{Path: "/baz", ReadOnly: true},
|
Path{Path: "/baz", ReadOnly: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the writeable cheatpath
|
// get the writeable cheatpath
|
||||||
@@ -34,10 +34,10 @@ func TestWriteableOK(t *testing.T) {
|
|||||||
func TestWriteableNotOK(t *testing.T) {
|
func TestWriteableNotOK(t *testing.T) {
|
||||||
|
|
||||||
// initialize some cheatpaths
|
// initialize some cheatpaths
|
||||||
cheatpaths := []Cheatpath{
|
cheatpaths := []Path{
|
||||||
Cheatpath{Path: "/foo", ReadOnly: true},
|
Path{Path: "/foo", ReadOnly: true},
|
||||||
Cheatpath{Path: "/bar", ReadOnly: true},
|
Path{Path: "/bar", ReadOnly: true},
|
||||||
Cheatpath{Path: "/baz", ReadOnly: true},
|
Path{Path: "/baz", ReadOnly: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the writeable cheatpath
|
// get the writeable cheatpath
|
||||||
|
|||||||
43
internal/completions/cheatsheets.go
Normal file
43
internal/completions/cheatsheets.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Package completions provides dynamic shell completion functions and
|
||||||
|
// completion script generation for the cheat CLI.
|
||||||
|
package completions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/internal/sheets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cheatsheets provides completion for cheatsheet names.
|
||||||
|
func Cheatsheets(
|
||||||
|
_ *cobra.Command,
|
||||||
|
args []string,
|
||||||
|
_ string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
conf, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
consolidated := sheets.Consolidate(cheatsheets)
|
||||||
|
|
||||||
|
names := make([]string, 0, len(consolidated))
|
||||||
|
for name := range consolidated {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
return names, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
38
internal/completions/config.go
Normal file
38
internal/completions/config.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package completions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/mitchellh/go-homedir"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadConfig loads the cheat configuration for use in completion functions.
|
||||||
|
// It returns an error rather than exiting, since completions should degrade
|
||||||
|
// gracefully.
|
||||||
|
func loadConfig() (config.Config, error) {
|
||||||
|
home, err := homedir.Dir()
|
||||||
|
if err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
envvars := config.EnvVars()
|
||||||
|
|
||||||
|
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
|
||||||
|
if err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
confpath, err := config.Path(confpaths)
|
||||||
|
if err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conf, err := config.New(confpath, true)
|
||||||
|
if err != nil {
|
||||||
|
return config.Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return conf, nil
|
||||||
|
}
|
||||||
24
internal/completions/generate.go
Normal file
24
internal/completions/generate.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package completions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate writes a shell completion script to the given writer.
|
||||||
|
func Generate(cmd *cobra.Command, shell string, w io.Writer) error {
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
return cmd.Root().GenBashCompletionV2(w, true)
|
||||||
|
case "zsh":
|
||||||
|
return cmd.Root().GenZshCompletion(w)
|
||||||
|
case "fish":
|
||||||
|
return cmd.Root().GenFishCompletion(w, true)
|
||||||
|
case "powershell":
|
||||||
|
return cmd.Root().GenPowerShellCompletionWithDesc(w)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/completions/paths.go
Normal file
25
internal/completions/paths.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package completions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paths provides completion for the --path flag.
|
||||||
|
func Paths(
|
||||||
|
_ *cobra.Command,
|
||||||
|
_ []string,
|
||||||
|
_ string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
|
||||||
|
conf, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(conf.Cheatpaths))
|
||||||
|
for _, cp := range conf.Cheatpaths {
|
||||||
|
names = append(names, cp.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return names, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
27
internal/completions/tags.go
Normal file
27
internal/completions/tags.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package completions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/internal/sheets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tags provides completion for the --tag flag.
|
||||||
|
func Tags(
|
||||||
|
_ *cobra.Command,
|
||||||
|
_ []string,
|
||||||
|
_ string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
|
|
||||||
|
conf, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
cheatsheets, err := sheets.Load(conf.Cheatpaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
return sheets.Tags(cheatsheets), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Color indicates whether colorization should be applied to the output
|
// Color indicates whether colorization should be applied to the output
|
||||||
func (c *Config) Color(opts map[string]interface{}) bool {
|
func (c *Config) Color(forceColorize bool) bool {
|
||||||
|
|
||||||
// default to the colorization specified in the configs...
|
// default to the colorization specified in the configs...
|
||||||
colorize := c.Colorize
|
colorize := c.Colorize
|
||||||
@@ -18,7 +18,7 @@ func (c *Config) Color(opts map[string]interface{}) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ... *unless* the --colorize flag was passed
|
// ... *unless* the --colorize flag was passed
|
||||||
if opts["--colorize"] == true {
|
if forceColorize {
|
||||||
colorize = true
|
colorize = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ func TestColor(t *testing.T) {
|
|||||||
// mock a config
|
// mock a config
|
||||||
conf := Config{}
|
conf := Config{}
|
||||||
|
|
||||||
opts := map[string]interface{}{"--colorize": false}
|
if conf.Color(false) {
|
||||||
if conf.Color(opts) {
|
t.Errorf("failed to respect forceColorize (false)")
|
||||||
t.Errorf("failed to respect --colorize (false)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
opts = map[string]interface{}{"--colorize": true}
|
if !conf.Color(true) {
|
||||||
if !conf.Color(opts) {
|
t.Errorf("failed to respect forceColorize (true)")
|
||||||
t.Errorf("failed to respect --colorize (true)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,17 @@
|
|||||||
|
// Package config implements functions pertaining to configuration management.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
cp "github.com/cheat/cheat/internal/cheatpath"
|
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||||
|
|
||||||
"github.com/mitchellh/go-homedir"
|
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config encapsulates configuration parameters
|
// Config encapsulates configuration parameters
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Colorize bool `yaml:"colorize"`
|
Colorize bool `yaml:"colorize"`
|
||||||
Editor string `yaml:"editor"`
|
Editor string `yaml:"editor"`
|
||||||
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
|
Cheatpaths []cp.Path `yaml:"cheatpaths"`
|
||||||
Style string `yaml:"style"`
|
Style string `yaml:"style"`
|
||||||
Formatter string `yaml:"formatter"`
|
Formatter string `yaml:"formatter"`
|
||||||
Pager string `yaml:"pager"`
|
Pager string `yaml:"pager"`
|
||||||
}
|
Path string
|
||||||
|
|
||||||
// New returns a new Config struct
|
|
||||||
func New(opts map[string]interface{}, confPath string, resolve bool) (Config, error) {
|
|
||||||
|
|
||||||
// read the config file
|
|
||||||
buf, err := ioutil.ReadFile(confPath)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("could not read config file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize a config object
|
|
||||||
conf := Config{}
|
|
||||||
|
|
||||||
// unmarshal the yaml
|
|
||||||
err = yaml.UnmarshalStrict(buf, &conf)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if a .cheat directory exists locally, append it to the cheatpaths
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
local := filepath.Join(cwd, ".cheat")
|
|
||||||
if _, err := os.Stat(local); err == nil {
|
|
||||||
path := cp.Cheatpath{
|
|
||||||
Name: "cwd",
|
|
||||||
Path: local,
|
|
||||||
ReadOnly: false,
|
|
||||||
Tags: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
conf.Cheatpaths = append(conf.Cheatpaths, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// process cheatpaths
|
|
||||||
for i, cheatpath := range conf.Cheatpaths {
|
|
||||||
|
|
||||||
// expand ~ in config paths
|
|
||||||
expanded, err := homedir.Expand(cheatpath.Path)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// follow symlinks
|
|
||||||
//
|
|
||||||
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
|
|
||||||
// It's necessary because `EvalSymlinks` will error if the symlink points
|
|
||||||
// to a non-existent location on the filesystem. When unit-testing,
|
|
||||||
// however, we don't want to have dependencies on the filesystem. As such,
|
|
||||||
// `resolve` is a switch that allows us to turn off symlink resolution when
|
|
||||||
// running the config tests.
|
|
||||||
if resolve {
|
|
||||||
evaled, err := filepath.EvalSymlinks(expanded)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, fmt.Errorf(
|
|
||||||
"failed to resolve symlink: %s: %v",
|
|
||||||
expanded,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
expanded = evaled
|
|
||||||
}
|
|
||||||
|
|
||||||
conf.Cheatpaths[i].Path = expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
// if an editor was not provided in the configs, look to envvars
|
|
||||||
if conf.Editor == "" {
|
|
||||||
if os.Getenv("VISUAL") != "" {
|
|
||||||
conf.Editor = os.Getenv("VISUAL")
|
|
||||||
} else if os.Getenv("EDITOR") != "" {
|
|
||||||
conf.Editor = os.Getenv("EDITOR")
|
|
||||||
} else {
|
|
||||||
return Config{}, fmt.Errorf("no editor set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if a chroma style was not provided, set a default
|
|
||||||
if conf.Style == "" {
|
|
||||||
conf.Style = "bw"
|
|
||||||
}
|
|
||||||
|
|
||||||
// if a chroma formatter was not provided, set a default
|
|
||||||
if conf.Formatter == "" {
|
|
||||||
conf.Formatter = "terminal16m"
|
|
||||||
}
|
|
||||||
|
|
||||||
// if a pager was not provided, set a default
|
|
||||||
if strings.TrimSpace(conf.Pager) == "" {
|
|
||||||
conf.Pager = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return conf, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
148
internal/config/config_extended_test.go
Normal file
148
internal/config/config_extended_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/mocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConfigYAMLErrors tests YAML parsing errors
|
||||||
|
func TestConfigYAMLErrors(t *testing.T) {
|
||||||
|
// Create a temporary file with invalid YAML
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
invalidYAML := filepath.Join(tempDir, "invalid.yml")
|
||||||
|
err = os.WriteFile(invalidYAML, []byte("cheatpaths: [{unclosed\n"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write invalid yaml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to load invalid YAML
|
||||||
|
_, err = New(invalidYAML, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid YAML, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigDefaults tests default values
|
||||||
|
func TestConfigDefaults(t *testing.T) {
|
||||||
|
// Load empty config
|
||||||
|
conf, err := New(mocks.Path("conf/empty.yml"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check defaults
|
||||||
|
if conf.Style != "bw" {
|
||||||
|
t.Errorf("expected default style 'bw', got %s", conf.Style)
|
||||||
|
}
|
||||||
|
|
||||||
|
if conf.Formatter != "terminal" {
|
||||||
|
t.Errorf("expected default formatter 'terminal', got %s", conf.Formatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigSymlinkResolution tests symlink resolution
|
||||||
|
func TestConfigSymlinkResolution(t *testing.T) {
|
||||||
|
// Create temp directory structure
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
|
||||||
|
tempDir, err = filepath.EvalSymlinks(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create target directory
|
||||||
|
targetDir := filepath.Join(tempDir, "target")
|
||||||
|
err = os.Mkdir(targetDir, 0755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create target dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create symlink
|
||||||
|
linkPath := filepath.Join(tempDir, "link")
|
||||||
|
err = os.Symlink(targetDir, linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with symlink path
|
||||||
|
configContent := `---
|
||||||
|
editor: vim
|
||||||
|
cheatpaths:
|
||||||
|
- name: test
|
||||||
|
path: ` + linkPath + `
|
||||||
|
readonly: true
|
||||||
|
`
|
||||||
|
configFile := filepath.Join(tempDir, "config.yml")
|
||||||
|
err = os.WriteFile(configFile, []byte(configContent), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config with symlink resolution
|
||||||
|
conf, err := New(configFile, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify symlink was resolved
|
||||||
|
if len(conf.Cheatpaths) == 0 {
|
||||||
|
t.Fatal("expected at least one cheatpath, got none")
|
||||||
|
}
|
||||||
|
if conf.Cheatpaths[0].Path != targetDir {
|
||||||
|
t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigBrokenSymlink tests broken symlink handling
|
||||||
|
func TestConfigBrokenSymlink(t *testing.T) {
|
||||||
|
// Create temp directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create broken symlink
|
||||||
|
linkPath := filepath.Join(tempDir, "broken-link")
|
||||||
|
err = os.Symlink("/nonexistent/path", linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with broken symlink
|
||||||
|
configContent := `---
|
||||||
|
editor: vim
|
||||||
|
cheatpaths:
|
||||||
|
- name: test
|
||||||
|
path: ` + linkPath + `
|
||||||
|
readonly: true
|
||||||
|
`
|
||||||
|
configFile := filepath.Join(tempDir, "config.yml")
|
||||||
|
err = os.WriteFile(configFile, []byte(configContent), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config with symlink resolution should skip the broken cheatpath
|
||||||
|
// (warn to stderr) rather than hard-error
|
||||||
|
conf, err := New(configFile, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
|
||||||
|
}
|
||||||
|
if len(conf.Cheatpaths) != 0 {
|
||||||
|
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/config/config_fuzz_test.go
Normal file
67
internal/config/config_fuzz_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzFindLocalCheatpath exercises findLocalCheatpath with randomised
|
||||||
|
// directory depths and .cheat placements. For each fuzz input it builds a
|
||||||
|
// temporary directory hierarchy, places a single .cheat directory at a
|
||||||
|
// computed level, and asserts that the function always returns it.
|
||||||
|
func FuzzFindLocalCheatpath(f *testing.F) {
|
||||||
|
// Seed corpus: (totalDepth, cheatPlacement)
|
||||||
|
f.Add(uint8(1), uint8(0)) // depth 1, .cheat at root
|
||||||
|
f.Add(uint8(3), uint8(0)) // depth 3, .cheat at root
|
||||||
|
f.Add(uint8(5), uint8(3)) // depth 5, .cheat at level 3
|
||||||
|
f.Add(uint8(1), uint8(1)) // depth 1, .cheat at same level as search dir
|
||||||
|
f.Add(uint8(10), uint8(5)) // deep hierarchy
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, totalDepth uint8, cheatPlacement uint8) {
|
||||||
|
// Clamp to reasonable values to keep I/O bounded
|
||||||
|
depth := int(totalDepth%15) + 1 // 1..15
|
||||||
|
cheatAt := int(cheatPlacement) % (depth + 1) // 0..depth (0 = tempDir itself)
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Build chain: tempDir/d0/d1/…/d{depth-1}
|
||||||
|
dirs := make([]string, 0, depth+1)
|
||||||
|
dirs = append(dirs, tempDir)
|
||||||
|
current := tempDir
|
||||||
|
for i := 0; i < depth; i++ {
|
||||||
|
current = filepath.Join(current, fmt.Sprintf("d%d", i))
|
||||||
|
if err := os.Mkdir(current, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
dirs = append(dirs, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place .cheat at dirs[cheatAt]
|
||||||
|
cheatDir := filepath.Join(dirs[cheatAt], ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .cheat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search from the deepest directory
|
||||||
|
result := findLocalCheatpath(current)
|
||||||
|
|
||||||
|
// Invariant 1: must find the .cheat we placed
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("depth=%d cheatAt=%d: expected %s, got %s",
|
||||||
|
depth, cheatAt, cheatDir, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant 2: result must end with /.cheat
|
||||||
|
if !strings.HasSuffix(result, string(filepath.Separator)+".cheat") {
|
||||||
|
t.Errorf("result %q does not end with /.cheat", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant 3: result must be under tempDir
|
||||||
|
if !strings.HasPrefix(result, tempDir) {
|
||||||
|
t.Errorf("result %q is not under tempDir %s", result, tempDir)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,20 +4,289 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
"github.com/mitchellh/go-homedir"
|
"github.com/mitchellh/go-homedir"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/cheatpath"
|
"github.com/cheat/cheat/internal/cheatpath"
|
||||||
"github.com/cheat/cheat/internal/mock"
|
"github.com/cheat/cheat/mocks"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found
|
||||||
|
func TestFindLocalCheatpathInCurrentDir(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s, got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathInParent tests walking up to a parent directory
|
||||||
|
func TestFindLocalCheatpathInParent(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subDir := filepath.Join(tempDir, "sub")
|
||||||
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(subDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s, got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathInGrandparent tests walking up multiple levels
|
||||||
|
func TestFindLocalCheatpathInGrandparent(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deepDir := filepath.Join(tempDir, "a", "b", "c")
|
||||||
|
if err := os.MkdirAll(deepDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create deep dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(deepDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s, got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathNearestWins tests that the closest .cheat is returned
|
||||||
|
func TestFindLocalCheatpathNearestWins(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create .cheat at root level
|
||||||
|
if err := os.Mkdir(filepath.Join(tempDir, ".cheat"), 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create root .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sub/.cheat (the nearer one)
|
||||||
|
subDir := filepath.Join(tempDir, "sub")
|
||||||
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub dir: %v", err)
|
||||||
|
}
|
||||||
|
nearCheatDir := filepath.Join(subDir, ".cheat")
|
||||||
|
if err := os.Mkdir(nearCheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search from sub/deep/
|
||||||
|
deepDir := filepath.Join(subDir, "deep")
|
||||||
|
if err := os.Mkdir(deepDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create deep dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(deepDir)
|
||||||
|
if result != nearCheatDir {
|
||||||
|
t.Errorf("expected nearest %s, got %s", nearCheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathNotFound tests that empty string is returned when no .cheat exists
|
||||||
|
func TestFindLocalCheatpathNotFound(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("expected empty string, got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathSkipsFile tests that a file named .cheat is not matched
|
||||||
|
func TestFindLocalCheatpathSkipsFile(t *testing.T) {
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create .cheat as a file, not a directory
|
||||||
|
cheatFile := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.WriteFile(cheatFile, []byte("not a directory"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("expected empty string for .cheat file, got %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathSymlink tests that a .cheat symlink to a directory is found
|
||||||
|
func TestFindLocalCheatpathSymlink(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create the real directory
|
||||||
|
realDir := filepath.Join(tempDir, "real-cheat")
|
||||||
|
if err := os.Mkdir(realDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create real dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symlink .cheat -> real-cheat
|
||||||
|
cheatLink := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Symlink(realDir, cheatLink); err != nil {
|
||||||
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := findLocalCheatpath(tempDir)
|
||||||
|
if result != cheatLink {
|
||||||
|
t.Errorf("expected %s, got %s", cheatLink, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathSymlinkInAncestor tests discovery through a symlinked
|
||||||
|
// ancestor directory. When the cwd is reached via a symlink, filepath.Dir
|
||||||
|
// walks the symlinked path (not the real path), so .cheat must be findable
|
||||||
|
// through that chain.
|
||||||
|
func TestFindLocalCheatpathSymlinkInAncestor(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create real/project/.cheat
|
||||||
|
realProject := filepath.Join(tempDir, "real", "project")
|
||||||
|
if err := os.MkdirAll(realProject, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create real project dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(realProject, ".cheat"), 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create symlink: linked -> real/project
|
||||||
|
linkedProject := filepath.Join(tempDir, "linked")
|
||||||
|
if err := os.Symlink(realProject, linkedProject); err != nil {
|
||||||
|
t.Fatalf("failed to create symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sub inside the symlinked path
|
||||||
|
subDir := filepath.Join(linkedProject, "sub")
|
||||||
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search from linked/sub — should find linked/.cheat
|
||||||
|
// (os.Stat follows symlinks, so linked/.cheat resolves to real/project/.cheat)
|
||||||
|
result := findLocalCheatpath(subDir)
|
||||||
|
expected := filepath.Join(linkedProject, ".cheat")
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindLocalCheatpathPermissionDenied tests that unreadable ancestor
|
||||||
|
// directories are skipped and the walk continues upward.
|
||||||
|
func TestFindLocalCheatpathPermissionDenied(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Unix permissions do not apply on Windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("test requires non-root user")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Resolve symlinks (macOS /var -> /private/var)
|
||||||
|
tempDir, err := filepath.EvalSymlinks(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to resolve symlinks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tempDir/.cheat (the target we want found)
|
||||||
|
cheatDir := filepath.Join(tempDir, ".cheat")
|
||||||
|
if err := os.Mkdir(cheatDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tempDir/restricted/ with its own .cheat and sub/
|
||||||
|
restricted := filepath.Join(tempDir, "restricted")
|
||||||
|
if err := os.Mkdir(restricted, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create restricted dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(restricted, ".cheat"), 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create restricted .cheat dir: %v", err)
|
||||||
|
}
|
||||||
|
subDir := filepath.Join(restricted, "sub")
|
||||||
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create sub dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make restricted/ unreadable — blocks stat of children
|
||||||
|
if err := os.Chmod(restricted, 0000); err != nil {
|
||||||
|
t.Fatalf("failed to chmod: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chmod(restricted, 0755) })
|
||||||
|
|
||||||
|
// Walk from restricted/sub: stat("restricted/sub/.cheat") fails (EACCES),
|
||||||
|
// stat("restricted/.cheat") fails (EACCES), walk continues to tempDir/.cheat
|
||||||
|
result := findLocalCheatpath(subDir)
|
||||||
|
if result != cheatDir {
|
||||||
|
t.Errorf("expected %s (walked past restricted dir), got %s", cheatDir, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfig asserts that the configs are loaded correctly
|
// TestConfig asserts that the configs are loaded correctly
|
||||||
func TestConfigSuccessful(t *testing.T) {
|
func TestConfigSuccessful(t *testing.T) {
|
||||||
|
|
||||||
|
// Chdir into a temp directory so no ancestor .cheat directory can
|
||||||
|
// leak into the cheatpaths (findLocalCheatpath walks the full
|
||||||
|
// ancestor chain).
|
||||||
|
oldCwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get cwd: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Chdir(oldCwd)
|
||||||
|
if err := os.Chdir(t.TempDir()); err != nil {
|
||||||
|
t.Fatalf("failed to chdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear env vars so they don't override the config file value
|
||||||
|
oldVisual := os.Getenv("VISUAL")
|
||||||
|
oldEditor := os.Getenv("EDITOR")
|
||||||
|
os.Unsetenv("VISUAL")
|
||||||
|
os.Unsetenv("EDITOR")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("VISUAL", oldVisual)
|
||||||
|
os.Setenv("EDITOR", oldEditor)
|
||||||
|
}()
|
||||||
|
|
||||||
// initialize a config
|
// initialize a config
|
||||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
|
conf, err := New(mocks.Path("conf/conf.yml"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to parse config file: %v", err)
|
t.Errorf("failed to parse config file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -37,19 +306,19 @@ func TestConfigSuccessful(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assert that the cheatpaths are correct
|
// assert that the cheatpaths are correct
|
||||||
want := []cheatpath.Cheatpath{
|
want := []cheatpath.Path{
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Path: filepath.Join(home, ".dotfiles/cheat/community"),
|
Path: filepath.Join(home, ".dotfiles", "cheat", "community"),
|
||||||
ReadOnly: true,
|
ReadOnly: true,
|
||||||
Tags: []string{"community"},
|
Tags: []string{"community"},
|
||||||
},
|
},
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Path: filepath.Join(home, ".dotfiles/cheat/work"),
|
Path: filepath.Join(home, ".dotfiles", "cheat", "work"),
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
Tags: []string{"work"},
|
Tags: []string{"work"},
|
||||||
},
|
},
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Path: filepath.Join(home, ".dotfiles/cheat/personal"),
|
Path: filepath.Join(home, ".dotfiles", "cheat", "personal"),
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
Tags: []string{"personal"},
|
Tags: []string{"personal"},
|
||||||
},
|
},
|
||||||
@@ -69,43 +338,84 @@ func TestConfigSuccessful(t *testing.T) {
|
|||||||
func TestConfigFailure(t *testing.T) {
|
func TestConfigFailure(t *testing.T) {
|
||||||
|
|
||||||
// attempt to read a non-existent config file
|
// attempt to read a non-existent config file
|
||||||
_, err := New(map[string]interface{}{}, "/does-not-exit", false)
|
_, err := New("/does-not-exit", false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("failed to error on unreadable config")
|
t.Errorf("failed to error on unreadable config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEmptyEditor asserts that envvars are respected if an editor is not
|
// TestEditorEnvOverride asserts that $VISUAL and $EDITOR override the
|
||||||
// specified in the configs
|
// config file value at runtime (regression test for #589)
|
||||||
func TestEmptyEditor(t *testing.T) {
|
func TestEditorEnvOverride(t *testing.T) {
|
||||||
|
// save and clear the environment variables
|
||||||
|
oldVisual := os.Getenv("VISUAL")
|
||||||
|
oldEditor := os.Getenv("EDITOR")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("VISUAL", oldVisual)
|
||||||
|
os.Setenv("EDITOR", oldEditor)
|
||||||
|
}()
|
||||||
|
|
||||||
// clear the environment variables
|
// with no env vars, the config file value should be used
|
||||||
os.Setenv("VISUAL", "")
|
os.Unsetenv("VISUAL")
|
||||||
os.Setenv("EDITOR", "")
|
os.Unsetenv("EDITOR")
|
||||||
|
conf, err := New(mocks.Path("conf/conf.yml"), false)
|
||||||
// initialize a config
|
|
||||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("failed to return an error on empty editor")
|
|
||||||
}
|
|
||||||
|
|
||||||
// set editor, and assert that it is respected
|
|
||||||
os.Setenv("EDITOR", "foo")
|
|
||||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to init configs: %v", err)
|
t.Fatalf("failed to init configs: %v", err)
|
||||||
}
|
}
|
||||||
if conf.Editor != "foo" {
|
if conf.Editor != "vim" {
|
||||||
t.Errorf("failed to respect editor: want: foo, got: %s", conf.Editor)
|
t.Errorf("expected config file editor: want: vim, got: %s", conf.Editor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// set visual, and assert that it overrides editor
|
// $EDITOR should override the config file value
|
||||||
os.Setenv("VISUAL", "bar")
|
os.Setenv("EDITOR", "nano")
|
||||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
|
conf, err = New(mocks.Path("conf/conf.yml"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to init configs: %v", err)
|
t.Fatalf("failed to init configs: %v", err)
|
||||||
}
|
}
|
||||||
if conf.Editor != "bar" {
|
if conf.Editor != "nano" {
|
||||||
t.Errorf("failed to respect editor: want: bar, got: %s", conf.Editor)
|
t.Errorf("$EDITOR should override config: want: nano, got: %s", conf.Editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $VISUAL should override both $EDITOR and the config file value
|
||||||
|
os.Setenv("VISUAL", "emacs")
|
||||||
|
conf, err = New(mocks.Path("conf/conf.yml"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init configs: %v", err)
|
||||||
|
}
|
||||||
|
if conf.Editor != "emacs" {
|
||||||
|
t.Errorf("$VISUAL should override all: want: emacs, got: %s", conf.Editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorEnvFallback asserts that env vars are used as fallback when
|
||||||
|
// no editor is specified in the config file
|
||||||
|
func TestEditorEnvFallback(t *testing.T) {
|
||||||
|
// save and clear the environment variables
|
||||||
|
oldVisual := os.Getenv("VISUAL")
|
||||||
|
oldEditor := os.Getenv("EDITOR")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("VISUAL", oldVisual)
|
||||||
|
os.Setenv("EDITOR", oldEditor)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// set $EDITOR and assert it's used when config has no editor
|
||||||
|
os.Unsetenv("VISUAL")
|
||||||
|
os.Setenv("EDITOR", "foo")
|
||||||
|
conf, err := New(mocks.Path("conf/empty.yml"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init configs: %v", err)
|
||||||
|
}
|
||||||
|
if conf.Editor != "foo" {
|
||||||
|
t.Errorf("failed to respect $EDITOR: want: foo, got: %s", conf.Editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set $VISUAL and assert it takes precedence over $EDITOR
|
||||||
|
os.Setenv("VISUAL", "bar")
|
||||||
|
conf, err = New(mocks.Path("conf/empty.yml"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init configs: %v", err)
|
||||||
|
}
|
||||||
|
if conf.Editor != "bar" {
|
||||||
|
t.Errorf("failed to respect $VISUAL: want: bar, got: %s", conf.Editor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
internal/config/editor.go
Normal file
41
internal/config/editor.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Editor attempts to locate an editor that's appropriate for the environment.
|
||||||
|
func Editor() (string, error) {
|
||||||
|
|
||||||
|
// default to `notepad.exe` on Windows
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "notepad", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// look for `nano` and `vim` on the `PATH`
|
||||||
|
def, _ := exec.LookPath("editor") // default `editor` wrapper
|
||||||
|
nano, _ := exec.LookPath("nano")
|
||||||
|
vim, _ := exec.LookPath("vim")
|
||||||
|
|
||||||
|
// set editor priority
|
||||||
|
editors := []string{
|
||||||
|
os.Getenv("VISUAL"),
|
||||||
|
os.Getenv("EDITOR"),
|
||||||
|
def,
|
||||||
|
nano,
|
||||||
|
vim,
|
||||||
|
}
|
||||||
|
|
||||||
|
// return the first editor that was found per the priority above
|
||||||
|
for _, editor := range editors {
|
||||||
|
if editor != "" {
|
||||||
|
return editor, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return an error if no path is found
|
||||||
|
return "", fmt.Errorf("no editor set")
|
||||||
|
}
|
||||||
95
internal/config/editor_test.go
Normal file
95
internal/config/editor_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEditor tests the Editor function
|
||||||
|
func TestEditor(t *testing.T) {
|
||||||
|
// Save original env vars
|
||||||
|
oldVisual := os.Getenv("VISUAL")
|
||||||
|
oldEditor := os.Getenv("EDITOR")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("VISUAL", oldVisual)
|
||||||
|
os.Setenv("EDITOR", oldEditor)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Run("windows default", func(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("skipping windows test on non-windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear env vars
|
||||||
|
os.Setenv("VISUAL", "")
|
||||||
|
os.Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
editor, err := Editor()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if editor != "notepad" {
|
||||||
|
t.Errorf("expected 'notepad' on windows, got %s", editor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("VISUAL takes precedence", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("skipping non-windows test on windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("VISUAL", "emacs")
|
||||||
|
os.Setenv("EDITOR", "nano")
|
||||||
|
|
||||||
|
editor, err := Editor()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if editor != "emacs" {
|
||||||
|
t.Errorf("expected VISUAL to take precedence, got %s", editor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EDITOR when no VISUAL", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("skipping non-windows test on windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("VISUAL", "")
|
||||||
|
os.Setenv("EDITOR", "vim")
|
||||||
|
|
||||||
|
editor, err := Editor()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if editor != "vim" {
|
||||||
|
t.Errorf("expected EDITOR value, got %s", editor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no editor found error", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("skipping non-windows test on windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all environment variables
|
||||||
|
os.Setenv("VISUAL", "")
|
||||||
|
os.Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
// Create a custom PATH that doesn't include common editors
|
||||||
|
oldPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", oldPath)
|
||||||
|
|
||||||
|
// Set a very limited PATH that won't have editors
|
||||||
|
os.Setenv("PATH", "/nonexistent")
|
||||||
|
|
||||||
|
editor, err := Editor()
|
||||||
|
|
||||||
|
// If we found an editor, it's likely in the system
|
||||||
|
// This test might not always produce an error on systems with editors
|
||||||
|
if editor == "" && err == nil {
|
||||||
|
t.Error("expected error when no editor found")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
20
internal/config/env.go
Normal file
20
internal/config/env.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvVars reads environment variables into a map of strings.
|
||||||
|
func EnvVars() map[string]string {
|
||||||
|
envvars := map[string]string{}
|
||||||
|
for _, e := range os.Environ() {
|
||||||
|
pair := strings.SplitN(e, "=", 2)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
pair[0] = strings.ToUpper(pair[0])
|
||||||
|
}
|
||||||
|
envvars[pair[0]] = pair[1]
|
||||||
|
}
|
||||||
|
return envvars
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
@@ -16,7 +15,7 @@ func Init(confpath string, configs string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// write the config file
|
// write the config file
|
||||||
if err := ioutil.WriteFile(confpath, []byte(configs), 0644); err != nil {
|
if err := os.WriteFile(confpath, []byte(configs), 0644); err != nil {
|
||||||
return fmt.Errorf("failed to create file: %v", err)
|
return fmt.Errorf("failed to create file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ import (
|
|||||||
func TestInit(t *testing.T) {
|
func TestInit(t *testing.T) {
|
||||||
|
|
||||||
// initialize a temporary config file
|
// initialize a temporary config file
|
||||||
confFile, err := ioutil.TempFile("", "cheat-test")
|
confFile, err := os.CreateTemp("", "cheat-test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to create temp file: %v", err)
|
t.Errorf("failed to create temp file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,7 @@ func TestInit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// read back the config file contents
|
// read back the config file contents
|
||||||
bytes, err := ioutil.ReadFile(confFile.Name())
|
bytes, err := os.ReadFile(confFile.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to read config file: %v", err)
|
t.Errorf("failed to read config file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -36,3 +37,87 @@ func TestInit(t *testing.T) {
|
|||||||
t.Errorf("failed to write configs: want: %s, got: %s", conf, got)
|
t.Errorf("failed to write configs: want: %s, got: %s", conf, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInitCreateDirectory tests that Init creates the directory if it doesn't exist
|
||||||
|
func TestInitCreateDirectory(t *testing.T) {
|
||||||
|
// Create a temp directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-init-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Path to a config file in a non-existent subdirectory
|
||||||
|
confPath := filepath.Join(tempDir, "subdir", "conf.yml")
|
||||||
|
|
||||||
|
// Initialize the config file
|
||||||
|
conf := "test config"
|
||||||
|
if err = Init(confPath, conf); err != nil {
|
||||||
|
t.Errorf("failed to init config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the directory was created
|
||||||
|
if _, err := os.Stat(filepath.Dir(confPath)); os.IsNotExist(err) {
|
||||||
|
t.Error("Init did not create the directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file was created with correct content
|
||||||
|
bytes, err := os.ReadFile(confPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to read config file: %v", err)
|
||||||
|
}
|
||||||
|
if string(bytes) != conf {
|
||||||
|
t.Errorf("config content mismatch: got %q, want %q", string(bytes), conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitWriteError tests error handling when file write fails
|
||||||
|
func TestInitWriteError(t *testing.T) {
|
||||||
|
// Skip this test if running as root (can write anywhere)
|
||||||
|
if runtime.GOOS != "windows" && os.Getuid() == 0 {
|
||||||
|
t.Skip("Cannot test write errors as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a platform-appropriate invalid path
|
||||||
|
invalidPath := "/dev/null/impossible/path/conf.yml"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
invalidPath = `NUL\impossible\path\conf.yml`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to write to a read-only directory
|
||||||
|
err := Init(invalidPath, "test")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when writing to invalid path, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitExistingFile tests that Init overwrites existing files
|
||||||
|
func TestInitExistingFile(t *testing.T) {
|
||||||
|
// Create a temp file
|
||||||
|
tempFile, err := os.CreateTemp("", "cheat-init-existing-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
// Write initial content
|
||||||
|
initialContent := "initial content"
|
||||||
|
if err := os.WriteFile(tempFile.Name(), []byte(initialContent), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write initial content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with new content
|
||||||
|
newContent := "new config content"
|
||||||
|
if err = Init(tempFile.Name(), newContent); err != nil {
|
||||||
|
t.Errorf("failed to init over existing file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file was overwritten
|
||||||
|
bytes, err := os.ReadFile(tempFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to read config file: %v", err)
|
||||||
|
}
|
||||||
|
if string(bytes) != newContent {
|
||||||
|
t.Errorf("config not overwritten: got %q, want %q", string(bytes), newContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
147
internal/config/new.go
Normal file
147
internal/config/new.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||||
|
|
||||||
|
"github.com/mitchellh/go-homedir"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New returns a new Config struct
|
||||||
|
func New(confPath string, resolve bool) (Config, error) {
|
||||||
|
|
||||||
|
// read the config file
|
||||||
|
buf, err := os.ReadFile(confPath)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("could not read config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize a config object
|
||||||
|
conf := Config{}
|
||||||
|
|
||||||
|
// store the config path
|
||||||
|
conf.Path = confPath
|
||||||
|
|
||||||
|
// unmarshal the yaml
|
||||||
|
err = yaml.Unmarshal(buf, &conf)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if a .cheat directory exists in the current directory or any ancestor,
|
||||||
|
// append it to the cheatpaths
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if local := findLocalCheatpath(cwd); local != "" {
|
||||||
|
path := cp.Path{
|
||||||
|
Name: "cwd",
|
||||||
|
Path: local,
|
||||||
|
ReadOnly: false,
|
||||||
|
Tags: []string{},
|
||||||
|
}
|
||||||
|
conf.Cheatpaths = append(conf.Cheatpaths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// process cheatpaths
|
||||||
|
var validPaths []cp.Path
|
||||||
|
for _, cheatpath := range conf.Cheatpaths {
|
||||||
|
|
||||||
|
// expand ~ in config paths
|
||||||
|
expanded, err := homedir.Expand(cheatpath.Path)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow symlinks
|
||||||
|
//
|
||||||
|
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
|
||||||
|
// It's necessary because `EvalSymlinks` will error if the symlink points
|
||||||
|
// to a non-existent location on the filesystem. When unit-testing,
|
||||||
|
// however, we don't want to have dependencies on the filesystem. As such,
|
||||||
|
// `resolve` is a switch that allows us to turn off symlink resolution when
|
||||||
|
// running the config tests.
|
||||||
|
if resolve {
|
||||||
|
evaled, err := filepath.EvalSymlinks(expanded)
|
||||||
|
if err != nil {
|
||||||
|
// if the path simply doesn't exist, warn and skip it
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(os.Stderr,
|
||||||
|
"WARNING: cheatpath '%s' does not exist, skipping\n",
|
||||||
|
expanded,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return Config{}, fmt.Errorf(
|
||||||
|
"failed to resolve symlink: %s: %v",
|
||||||
|
expanded,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded = evaled
|
||||||
|
}
|
||||||
|
|
||||||
|
cheatpath.Path = expanded
|
||||||
|
validPaths = append(validPaths, cheatpath)
|
||||||
|
}
|
||||||
|
conf.Cheatpaths = validPaths
|
||||||
|
|
||||||
|
// determine the editor: env vars override the config file value,
|
||||||
|
// following standard Unix convention (see #589)
|
||||||
|
if v := os.Getenv("VISUAL"); v != "" {
|
||||||
|
conf.Editor = v
|
||||||
|
} else if v := os.Getenv("EDITOR"); v != "" {
|
||||||
|
conf.Editor = v
|
||||||
|
} else {
|
||||||
|
conf.Editor = strings.TrimSpace(conf.Editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if an editor was still not determined, attempt to choose one
|
||||||
|
// that's appropriate for the environment
|
||||||
|
if conf.Editor == "" {
|
||||||
|
if conf.Editor, err = Editor(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if a chroma style was not provided, set a default
|
||||||
|
if conf.Style == "" {
|
||||||
|
conf.Style = "bw"
|
||||||
|
}
|
||||||
|
|
||||||
|
// if a chroma formatter was not provided, set a default
|
||||||
|
if conf.Formatter == "" {
|
||||||
|
conf.Formatter = "terminal"
|
||||||
|
}
|
||||||
|
|
||||||
|
// load the pager
|
||||||
|
conf.Pager = strings.TrimSpace(conf.Pager)
|
||||||
|
|
||||||
|
return conf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
|
||||||
|
// It returns the path to the first .cheat directory found, or an empty string
|
||||||
|
// if none exists. This mirrors the discovery pattern used by git for .git
|
||||||
|
// directories.
|
||||||
|
func findLocalCheatpath(dir string) string {
|
||||||
|
for {
|
||||||
|
candidate := filepath.Join(dir, ".cheat")
|
||||||
|
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
135
internal/config/new_test.go
Normal file
135
internal/config/new_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewTrimsWhitespace(t *testing.T) {
|
||||||
|
// clear env vars so they don't override the config file value
|
||||||
|
oldVisual := os.Getenv("VISUAL")
|
||||||
|
oldEditor := os.Getenv("EDITOR")
|
||||||
|
os.Unsetenv("VISUAL")
|
||||||
|
os.Unsetenv("EDITOR")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("VISUAL", oldVisual)
|
||||||
|
os.Setenv("EDITOR", oldEditor)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create a temporary config file with whitespace in editor and pager
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
configContent := `---
|
||||||
|
editor: " vim -c 'set number' "
|
||||||
|
pager: " less -R "
|
||||||
|
style: monokai
|
||||||
|
formatter: terminal
|
||||||
|
cheatpaths:
|
||||||
|
- name: personal
|
||||||
|
path: ~/cheat
|
||||||
|
tags: []
|
||||||
|
readonly: false
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the config
|
||||||
|
conf, err := New(configPath, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify editor is trimmed
|
||||||
|
expectedEditor := "vim -c 'set number'"
|
||||||
|
if conf.Editor != expectedEditor {
|
||||||
|
t.Errorf("editor not properly trimmed: got %q, want %q", conf.Editor, expectedEditor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify pager is trimmed
|
||||||
|
expectedPager := "less -R"
|
||||||
|
if conf.Pager != expectedPager {
|
||||||
|
t.Errorf("pager not properly trimmed: got %q, want %q", conf.Pager, expectedPager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEmptyEditorFallback(t *testing.T) {
|
||||||
|
// Skip if required environment variables would interfere
|
||||||
|
oldVisual := os.Getenv("VISUAL")
|
||||||
|
oldEditor := os.Getenv("EDITOR")
|
||||||
|
os.Unsetenv("VISUAL")
|
||||||
|
os.Unsetenv("EDITOR")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("VISUAL", oldVisual)
|
||||||
|
os.Setenv("EDITOR", oldEditor)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create a config with whitespace-only editor
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
configContent := `---
|
||||||
|
editor: " "
|
||||||
|
pager: less
|
||||||
|
style: monokai
|
||||||
|
formatter: terminal
|
||||||
|
cheatpaths:
|
||||||
|
- name: personal
|
||||||
|
path: ~/cheat
|
||||||
|
tags: []
|
||||||
|
readonly: false
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the config
|
||||||
|
conf, err := New(configPath, false)
|
||||||
|
if err != nil {
|
||||||
|
// It's OK if this fails due to no editor being found
|
||||||
|
// The important thing is it doesn't panic
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it succeeded, editor should not be empty (fallback was used)
|
||||||
|
if conf.Editor == "" {
|
||||||
|
t.Error("editor should not be empty after fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWhitespaceOnlyPager(t *testing.T) {
|
||||||
|
// Create a config with whitespace-only pager
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
configContent := `---
|
||||||
|
editor: vim
|
||||||
|
pager: " "
|
||||||
|
style: monokai
|
||||||
|
formatter: terminal
|
||||||
|
cheatpaths:
|
||||||
|
- name: personal
|
||||||
|
path: ~/cheat
|
||||||
|
tags: []
|
||||||
|
readonly: false
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the config
|
||||||
|
conf, err := New(configPath, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pager should be empty after trimming
|
||||||
|
if conf.Pager != "" {
|
||||||
|
t.Errorf("pager should be empty after trimming whitespace: got %q", conf.Pager)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/config/pager.go
Normal file
32
internal/config/pager.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pager attempts to locate a pager that's appropriate for the environment.
|
||||||
|
func Pager() string {
|
||||||
|
|
||||||
|
// default to `more` on Windows
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "more"
|
||||||
|
}
|
||||||
|
|
||||||
|
// if $PAGER is set, return the corresponding pager
|
||||||
|
if os.Getenv("PAGER") != "" {
|
||||||
|
return os.Getenv("PAGER")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, search for `pager`, `less`, and `more` on the `$PATH`. If
|
||||||
|
// none are found, return an empty pager.
|
||||||
|
for _, pager := range []string{"pager", "less", "more"} {
|
||||||
|
if path, err := exec.LookPath(pager); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// default to no pager
|
||||||
|
return ""
|
||||||
|
}
|
||||||
82
internal/config/pager_test.go
Normal file
82
internal/config/pager_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPager tests the Pager function
|
||||||
|
func TestPager(t *testing.T) {
|
||||||
|
// Save original env var
|
||||||
|
oldPager := os.Getenv("PAGER")
|
||||||
|
defer os.Setenv("PAGER", oldPager)
|
||||||
|
|
||||||
|
t.Run("windows default", func(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("skipping windows test on non-windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("PAGER", "")
|
||||||
|
pager := Pager()
|
||||||
|
if pager != "more" {
|
||||||
|
t.Errorf("expected 'more' on windows, got %s", pager)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PAGER env var", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("skipping non-windows test on windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("PAGER", "bat")
|
||||||
|
pager := Pager()
|
||||||
|
if pager != "bat" {
|
||||||
|
t.Errorf("expected PAGER env var value, got %s", pager)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fallback to system pager", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("skipping non-windows test on windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("PAGER", "")
|
||||||
|
pager := Pager()
|
||||||
|
|
||||||
|
if pager == "" {
|
||||||
|
return // no pager found is acceptable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should find one of the known fallback pagers
|
||||||
|
validPagers := map[string]bool{
|
||||||
|
"pager": true,
|
||||||
|
"less": true,
|
||||||
|
"more": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(pager)
|
||||||
|
if !validPagers[base] {
|
||||||
|
t.Errorf("unexpected pager value: %s (base: %s)", pager, base)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no pager available", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("skipping non-windows test on windows platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("PAGER", "")
|
||||||
|
|
||||||
|
// Save and modify PATH to ensure no pagers are found
|
||||||
|
oldPath := os.Getenv("PATH")
|
||||||
|
defer os.Setenv("PATH", oldPath)
|
||||||
|
os.Setenv("PATH", "/nonexistent")
|
||||||
|
|
||||||
|
pager := Pager()
|
||||||
|
if pager != "" {
|
||||||
|
t.Errorf("expected empty string when no pager found, got %s", pager)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -24,7 +23,7 @@ func TestPathConfigNotExists(t *testing.T) {
|
|||||||
func TestPathConfigExists(t *testing.T) {
|
func TestPathConfigExists(t *testing.T) {
|
||||||
|
|
||||||
// initialize a temporary config file
|
// initialize a temporary config file
|
||||||
confFile, err := ioutil.TempFile("", "cheat-test")
|
confFile, err := os.CreateTemp("", "cheat-test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to create temp file: %v", err)
|
t.Errorf("failed to create temp file: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,25 +28,30 @@ func Paths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch sys {
|
switch sys {
|
||||||
case "android", "darwin", "linux", "freebsd":
|
|
||||||
|
// darwin/linux/unix
|
||||||
|
case "aix", "android", "darwin", "dragonfly", "freebsd", "illumos", "ios",
|
||||||
|
"linux", "netbsd", "openbsd", "plan9", "solaris":
|
||||||
paths := []string{}
|
paths := []string{}
|
||||||
|
|
||||||
// don't include the `XDG_CONFIG_HOME` path if that envvar is not set
|
// don't include the `XDG_CONFIG_HOME` path if that envvar is not set
|
||||||
if xdgpath, ok := envvars["XDG_CONFIG_HOME"]; ok {
|
if xdgpath, ok := envvars["XDG_CONFIG_HOME"]; ok {
|
||||||
paths = append(paths, filepath.Join(xdgpath, "/cheat/conf.yml"))
|
paths = append(paths, filepath.Join(xdgpath, "cheat", "conf.yml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
paths = append(paths, []string{
|
paths = append(paths, []string{
|
||||||
filepath.Join(home, ".config/cheat/conf.yml"),
|
filepath.Join(home, ".config", "cheat", "conf.yml"),
|
||||||
filepath.Join(home, ".cheat/conf.yml"),
|
filepath.Join(home, ".cheat", "conf.yml"),
|
||||||
"/etc/cheat/conf.yml",
|
"/etc/cheat/conf.yml",
|
||||||
}...)
|
}...)
|
||||||
|
|
||||||
return paths, nil
|
return paths, nil
|
||||||
|
|
||||||
|
// windows
|
||||||
case "windows":
|
case "windows":
|
||||||
return []string{
|
return []string{
|
||||||
filepath.Join(envvars["APPDATA"], "/cheat/conf.yml"),
|
filepath.Join(envvars["APPDATA"], "cheat", "conf.yml"),
|
||||||
filepath.Join(envvars["PROGRAMDATA"], "/cheat/conf.yml"),
|
filepath.Join(envvars["PROGRAMDATA"], "cheat", "conf.yml"),
|
||||||
}, nil
|
}, nil
|
||||||
default:
|
default:
|
||||||
return []string{}, fmt.Errorf("unsupported os: %s", sys)
|
return []string{}, fmt.Errorf("unsupported os: %s", sys)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
@@ -10,6 +12,9 @@ import (
|
|||||||
// TestValidatePathsNix asserts that the proper config paths are returned on
|
// TestValidatePathsNix asserts that the proper config paths are returned on
|
||||||
// *nix platforms
|
// *nix platforms
|
||||||
func TestValidatePathsNix(t *testing.T) {
|
func TestValidatePathsNix(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("filepath.Join uses backslashes on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
// mock the user's home directory
|
// mock the user's home directory
|
||||||
home := "/home/foo"
|
home := "/home/foo"
|
||||||
@@ -57,6 +62,9 @@ func TestValidatePathsNix(t *testing.T) {
|
|||||||
// TestValidatePathsNixNoXDG asserts that the proper config paths are returned
|
// TestValidatePathsNixNoXDG asserts that the proper config paths are returned
|
||||||
// on *nix platforms when `XDG_CONFIG_HOME is not set
|
// on *nix platforms when `XDG_CONFIG_HOME is not set
|
||||||
func TestValidatePathsNixNoXDG(t *testing.T) {
|
func TestValidatePathsNixNoXDG(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("filepath.Join uses backslashes on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
// mock the user's home directory
|
// mock the user's home directory
|
||||||
home := "/home/foo"
|
home := "/home/foo"
|
||||||
@@ -106,8 +114,8 @@ func TestValidatePathsWindows(t *testing.T) {
|
|||||||
|
|
||||||
// mock some envvars
|
// mock some envvars
|
||||||
envvars := map[string]string{
|
envvars := map[string]string{
|
||||||
"APPDATA": "/apps",
|
"APPDATA": filepath.Join("C:", "apps"),
|
||||||
"PROGRAMDATA": "/programs",
|
"PROGRAMDATA": filepath.Join("C:", "programs"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the paths for the platform
|
// get the paths for the platform
|
||||||
@@ -118,8 +126,8 @@ func TestValidatePathsWindows(t *testing.T) {
|
|||||||
|
|
||||||
// specify the expected output
|
// specify the expected output
|
||||||
want := []string{
|
want := []string{
|
||||||
"/apps/cheat/conf.yml",
|
filepath.Join("C:", "apps", "cheat", "conf.yml"),
|
||||||
"/programs/cheat/conf.yml",
|
filepath.Join("C:", "programs", "cheat", "conf.yml"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// assert that output matches expectations
|
// assert that output matches expectations
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ func TestValidateCorrect(t *testing.T) {
|
|||||||
Colorize: true,
|
Colorize: true,
|
||||||
Editor: "vim",
|
Editor: "vim",
|
||||||
Formatter: "terminal16m",
|
Formatter: "terminal16m",
|
||||||
Cheatpaths: []cheatpath.Cheatpath{
|
Cheatpaths: []cheatpath.Path{
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Name: "foo",
|
Name: "foo",
|
||||||
Path: "/foo",
|
Path: "/foo",
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
@@ -38,8 +38,8 @@ func TestInvalidateMissingEditor(t *testing.T) {
|
|||||||
conf := Config{
|
conf := Config{
|
||||||
Colorize: true,
|
Colorize: true,
|
||||||
Formatter: "terminal16m",
|
Formatter: "terminal16m",
|
||||||
Cheatpaths: []cheatpath.Cheatpath{
|
Cheatpaths: []cheatpath.Path{
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Name: "foo",
|
Name: "foo",
|
||||||
Path: "/foo",
|
Path: "/foo",
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
@@ -71,19 +71,28 @@ func TestInvalidateMissingCheatpaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMissingInvalidFormatters asserts that configs which contain invalid
|
// TestInvalidateInvalidFormatter asserts that configs which contain invalid
|
||||||
// formatters are invalidated
|
// formatters are invalidated
|
||||||
func TestMissingInvalidFormatters(t *testing.T) {
|
func TestInvalidateInvalidFormatter(t *testing.T) {
|
||||||
|
|
||||||
// mock a config
|
// mock a config with a valid editor and cheatpaths but invalid formatter
|
||||||
conf := Config{
|
conf := Config{
|
||||||
Colorize: true,
|
Colorize: true,
|
||||||
Editor: "vim",
|
Editor: "vim",
|
||||||
|
Formatter: "html",
|
||||||
|
Cheatpaths: []cheatpath.Path{
|
||||||
|
cheatpath.Path{
|
||||||
|
Name: "foo",
|
||||||
|
Path: "/foo",
|
||||||
|
ReadOnly: false,
|
||||||
|
Tags: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// assert that no errors are returned
|
// assert that the config is invalidated due to the formatter
|
||||||
if err := conf.Validate(); err == nil {
|
if err := conf.Validate(); err == nil {
|
||||||
t.Errorf("failed to invalidate config without formatter")
|
t.Errorf("failed to invalidate config with invalid formatter")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,14 +105,14 @@ func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
|
|||||||
Colorize: true,
|
Colorize: true,
|
||||||
Editor: "vim",
|
Editor: "vim",
|
||||||
Formatter: "terminal16m",
|
Formatter: "terminal16m",
|
||||||
Cheatpaths: []cheatpath.Cheatpath{
|
Cheatpaths: []cheatpath.Path{
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Name: "foo",
|
Name: "foo",
|
||||||
Path: "/foo",
|
Path: "/foo",
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
Tags: []string{},
|
Tags: []string{},
|
||||||
},
|
},
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Name: "foo",
|
Name: "foo",
|
||||||
Path: "/bar",
|
Path: "/bar",
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
@@ -127,14 +136,14 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
|
|||||||
Colorize: true,
|
Colorize: true,
|
||||||
Editor: "vim",
|
Editor: "vim",
|
||||||
Formatter: "terminal16m",
|
Formatter: "terminal16m",
|
||||||
Cheatpaths: []cheatpath.Cheatpath{
|
Cheatpaths: []cheatpath.Path{
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Name: "foo",
|
Name: "foo",
|
||||||
Path: "/foo",
|
Path: "/foo",
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
Tags: []string{},
|
Tags: []string{},
|
||||||
},
|
},
|
||||||
cheatpath.Cheatpath{
|
cheatpath.Path{
|
||||||
Name: "bar",
|
Name: "bar",
|
||||||
Path: "/foo",
|
Path: "/foo",
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
@@ -148,3 +157,28 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
|
|||||||
t.Errorf("failed to invalidate config with cheatpaths with duplicate paths")
|
t.Errorf("failed to invalidate config with cheatpaths with duplicate paths")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInvalidateInvalidCheatpath asserts that configs containing a cheatpath
|
||||||
|
// with an empty name are invalidated
|
||||||
|
func TestInvalidateInvalidCheatpath(t *testing.T) {
|
||||||
|
|
||||||
|
// mock a config with a cheatpath that has an empty name
|
||||||
|
conf := Config{
|
||||||
|
Colorize: true,
|
||||||
|
Editor: "vim",
|
||||||
|
Formatter: "terminal16m",
|
||||||
|
Cheatpaths: []cheatpath.Path{
|
||||||
|
cheatpath.Path{
|
||||||
|
Name: "",
|
||||||
|
Path: "/foo",
|
||||||
|
ReadOnly: false,
|
||||||
|
Tags: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// assert that an error is returned
|
||||||
|
if err := conf.Validate(); err == nil {
|
||||||
|
t.Errorf("failed to invalidate config with invalid cheatpath (empty name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
// Package display implement functions pertaining to writing formatted
|
||||||
|
// cheatsheet content to stdout, or alternatively the system pager.
|
||||||
package display
|
package display
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
// Faint returns a faintly-colored string that's used to de-prioritize text
|
||||||
)
|
// written to stdout
|
||||||
|
func Faint(str string, colorize bool) string {
|
||||||
// Faint returns an faint string
|
|
||||||
func Faint(str string, conf config.Config) string {
|
|
||||||
// make `str` faint only if colorization has been requested
|
// make `str` faint only if colorization has been requested
|
||||||
if conf.Colorize {
|
if colorize {
|
||||||
return fmt.Sprintf(fmt.Sprintf("\033[2m%s\033[0m", str))
|
return fmt.Sprintf("\033[2m%s\033[0m", str)
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, return the string unmodified
|
// otherwise, return the string unmodified
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
package display
|
package display
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestFaint asserts that Faint applies faint formatting
|
// TestFaint asserts that Faint applies faint formatting
|
||||||
func TestFaint(t *testing.T) {
|
func TestFaint(t *testing.T) {
|
||||||
|
|
||||||
// case: apply colorization
|
// case: apply colorization
|
||||||
conf := config.Config{Colorize: true}
|
|
||||||
want := "\033[2mfoo\033[0m"
|
want := "\033[2mfoo\033[0m"
|
||||||
got := Faint("foo", conf)
|
got := Faint("foo", true)
|
||||||
if want != got {
|
if want != got {
|
||||||
t.Errorf("failed to faint: want: %s, got: %s", want, got)
|
t.Errorf("failed to faint: want: %s, got: %s", want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// case: do not apply colorization
|
// case: do not apply colorization
|
||||||
conf.Colorize = false
|
|
||||||
want = "foo"
|
want = "foo"
|
||||||
got = Faint("foo", conf)
|
got = Faint("foo", false)
|
||||||
if want != got {
|
if want != got {
|
||||||
t.Errorf("failed to faint: want: %s, got: %s", want, got)
|
t.Errorf("failed to faint: want: %s, got: %s", want, got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,13 @@ func TestIndent(t *testing.T) {
|
|||||||
t.Errorf("failed to indent: want: %s, got: %s", want, got)
|
t.Errorf("failed to indent: want: %s, got: %s", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIndentTrimsWhitespace asserts that Indent trims leading and trailing
|
||||||
|
// whitespace before indenting
|
||||||
|
func TestIndentTrimsWhitespace(t *testing.T) {
|
||||||
|
got := Indent(" foo\nbar\nbaz \n")
|
||||||
|
want := "\tfoo\n\tbar\n\tbaz\n"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("failed to trim and indent: want: %q, got: %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Underline returns an underlined string
|
|
||||||
func Underline(str string) string {
|
|
||||||
return fmt.Sprintf(fmt.Sprintf("\033[4m%s\033[0m", str))
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestUnderline asserts that Underline applies underline formatting
|
|
||||||
func TestUnderline(t *testing.T) {
|
|
||||||
want := "\033[4mfoo\033[0m"
|
|
||||||
got := Underline("foo")
|
|
||||||
if want != got {
|
|
||||||
t.Errorf("failed to underline: want: %s, got: %s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,19 +19,23 @@ func Write(out string, conf config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, pipe output through the pager
|
// otherwise, pipe output through the pager
|
||||||
parts := strings.Split(conf.Pager, " ")
|
writeToPager(out, conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeToPager writes output through a pager command
|
||||||
|
func writeToPager(out string, conf config.Config) {
|
||||||
|
parts := strings.Fields(conf.Pager)
|
||||||
pager := parts[0]
|
pager := parts[0]
|
||||||
args := parts[1:]
|
args := parts[1:]
|
||||||
|
|
||||||
// run the pager
|
// configure the pager
|
||||||
cmd := exec.Command(pager, args...)
|
cmd := exec.Command(pager, args...)
|
||||||
cmd.Stdin = strings.NewReader(out)
|
cmd.Stdin = strings.NewReader(out)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
|
|
||||||
// handle errors
|
// run the pager and handle errors
|
||||||
err := cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
fmt.Fprintf(os.Stderr, "failed to write to pager: %v\n", err)
|
||||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("failed to write to pager: %v", err))
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
internal/display/write_test.go
Normal file
136
internal/display/write_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package display
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWriteToPager tests the writeToPager function
|
||||||
|
func TestWriteToPager(t *testing.T) {
|
||||||
|
// Skip these tests in CI/CD environments where interactive commands might not work
|
||||||
|
if os.Getenv("CI") != "" {
|
||||||
|
t.Skip("Skipping pager tests in CI environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We can't easily test os.Exit calls, so we focus on testing writeToPager
|
||||||
|
// which contains the core logic
|
||||||
|
|
||||||
|
t.Run("successful pager execution", func(t *testing.T) {
|
||||||
|
// Save original stdout
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
defer func() {
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create pipe for capturing output
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
// Use 'cat' as a simple pager that just outputs input
|
||||||
|
conf := config.Config{
|
||||||
|
Pager: "cat",
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will call os.Exit on error, so we need to be careful
|
||||||
|
// We're using 'cat' which should always succeed
|
||||||
|
input := "Test output\n"
|
||||||
|
|
||||||
|
// Run in a goroutine to avoid blocking
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
writeToPager(input, conf)
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for completion or timeout
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close write end and read output
|
||||||
|
w.Close()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, r)
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
if buf.String() != input {
|
||||||
|
t.Errorf("expected output %q, got %q", input, buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pager with arguments", func(t *testing.T) {
|
||||||
|
// Save original stdout
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
defer func() {
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create pipe for capturing output
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
// Use 'cat' with '-A' flag (shows non-printing characters)
|
||||||
|
conf := config.Config{
|
||||||
|
Pager: "cat -A",
|
||||||
|
}
|
||||||
|
|
||||||
|
input := "Test\toutput\n"
|
||||||
|
|
||||||
|
// Run in a goroutine
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
writeToPager(input, conf)
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close write end and read output
|
||||||
|
w.Close()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, r)
|
||||||
|
|
||||||
|
// cat -A shows tabs as ^I and line endings as $
|
||||||
|
expected := "Test^Ioutput$\n"
|
||||||
|
if buf.String() != expected {
|
||||||
|
t.Errorf("expected output %q, got %q", expected, buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteToPagerError tests error handling in writeToPager
|
||||||
|
func TestWriteToPagerError(t *testing.T) {
|
||||||
|
if os.Getenv("TEST_PAGER_ERROR_SUBPROCESS") == "1" {
|
||||||
|
// This is the subprocess - run the actual test
|
||||||
|
conf := config.Config{Pager: "/nonexistent/command"}
|
||||||
|
writeToPager("test", conf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run test in subprocess to handle os.Exit
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestWriteToPagerError$")
|
||||||
|
cmd.Env = append(os.Environ(), "TEST_PAGER_ERROR_SUBPROCESS=1")
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
// Should exit with error
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected process to exit with error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain error message
|
||||||
|
if !strings.Contains(string(output), "failed to write to pager") {
|
||||||
|
t.Errorf("expected error message about pager failure, got %q", string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package installer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
const cloneURL = "https://github.com/cheat/cheatsheets.git"
|
|
||||||
|
|
||||||
// clone clones the community cheatsheets
|
|
||||||
func clone(path string) error {
|
|
||||||
|
|
||||||
// perform the clone in a shell
|
|
||||||
cmd := exec.Command("git", "clone", cloneURL, path)
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
err := cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to clone cheatsheets: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
// Package installer implements functions that provide a first-time
|
||||||
|
// installation wizard.
|
||||||
package installer
|
package installer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,20 +11,34 @@ import (
|
|||||||
// Prompt prompts the user for a answer
|
// Prompt prompts the user for a answer
|
||||||
func Prompt(prompt string, def bool) (bool, error) {
|
func Prompt(prompt string, def bool) (bool, error) {
|
||||||
|
|
||||||
// initialize a line reader
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
|
|
||||||
// display the prompt
|
// display the prompt
|
||||||
fmt.Print(fmt.Sprintf("%s: ", prompt))
|
fmt.Printf("%s: ", prompt)
|
||||||
|
|
||||||
// read the answer
|
// read one byte at a time until newline to avoid buffering past the
|
||||||
ans, err := reader.ReadString('\n')
|
// end of the current line, which would consume input intended for
|
||||||
if err != nil {
|
// subsequent Prompt calls on the same stdin
|
||||||
return false, fmt.Errorf("failed to parse input: %v", err)
|
var line []byte
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if buf[0] == '\n' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if buf[0] != '\r' {
|
||||||
|
line = append(line, buf[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if len(line) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to prompt: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize the answer
|
// normalize the answer
|
||||||
ans = strings.ToLower(strings.TrimSpace(ans))
|
ans := strings.ToLower(strings.TrimSpace(string(line)))
|
||||||
|
|
||||||
// return the appropriate response
|
// return the appropriate response
|
||||||
switch ans {
|
switch ans {
|
||||||
|
|||||||
159
internal/installer/prompt_test.go
Normal file
159
internal/installer/prompt_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrompt(t *testing.T) {
|
||||||
|
// Save original stdin/stdout
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
defer func() {
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prompt string
|
||||||
|
input string
|
||||||
|
defaultVal bool
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
wantPrompt string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "answer yes",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: "y\n",
|
||||||
|
defaultVal: false,
|
||||||
|
want: true,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "answer yes with uppercase",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: "Y\n",
|
||||||
|
defaultVal: false,
|
||||||
|
want: true,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "answer yes with spaces",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: " y \n",
|
||||||
|
defaultVal: false,
|
||||||
|
want: true,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "answer no",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: "n\n",
|
||||||
|
defaultVal: true,
|
||||||
|
want: false,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "answer no with any text",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: "anything\n",
|
||||||
|
defaultVal: true,
|
||||||
|
want: false,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty answer uses default true",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: "\n",
|
||||||
|
defaultVal: true,
|
||||||
|
want: true,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty answer uses default false",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: "\n",
|
||||||
|
defaultVal: false,
|
||||||
|
want: false,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace answer uses default",
|
||||||
|
prompt: "Continue?",
|
||||||
|
input: " \n",
|
||||||
|
defaultVal: true,
|
||||||
|
want: true,
|
||||||
|
wantPrompt: "Continue?: ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create a pipe for stdin
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdin = r
|
||||||
|
|
||||||
|
// Create a pipe for stdout to capture the prompt
|
||||||
|
rOut, wOut, _ := os.Pipe()
|
||||||
|
os.Stdout = wOut
|
||||||
|
|
||||||
|
// Write input to stdin
|
||||||
|
go func() {
|
||||||
|
defer w.Close()
|
||||||
|
io.WriteString(w, tt.input)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Call the function
|
||||||
|
got, err := Prompt(tt.prompt, tt.defaultVal)
|
||||||
|
|
||||||
|
// Close stdout write end and read the prompt
|
||||||
|
wOut.Close()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, rOut)
|
||||||
|
|
||||||
|
// Check error
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Prompt() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check result
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Prompt() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that prompt was displayed correctly
|
||||||
|
if buf.String() != tt.wantPrompt {
|
||||||
|
t.Errorf("Prompt display = %q, want %q", buf.String(), tt.wantPrompt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptError(t *testing.T) {
|
||||||
|
// Save original stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
defer func() {
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create a pipe and close it immediately to simulate read error
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdin = r
|
||||||
|
r.Close()
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
// This should cause a read error
|
||||||
|
_, err := Prompt("Test?", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when reading from closed stdin, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to prompt") {
|
||||||
|
t.Errorf("expected 'failed to prompt' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,26 +3,19 @@ package installer
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
"github.com/cheat/cheat/internal/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run runs the installer
|
// Run runs the installer
|
||||||
func Run(configs string, confpath string) error {
|
func Run(configs string, confpath string) error {
|
||||||
|
|
||||||
// determine the appropriate paths for config data and (optional) community
|
// expand template placeholders with platform-appropriate paths
|
||||||
// cheatsheets based on the user's platform
|
configs = ExpandTemplate(configs, confpath)
|
||||||
confdir := path.Dir(confpath)
|
|
||||||
|
|
||||||
// create paths for community and personal cheatsheets
|
// determine cheatsheet directory paths
|
||||||
community := path.Join(confdir, "/cheatsheets/community")
|
community, personal, work := cheatsheetDirs(confpath)
|
||||||
personal := path.Join(confdir, "/cheatsheets/personal")
|
|
||||||
|
|
||||||
// template the above paths into the default configs
|
|
||||||
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
|
|
||||||
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
|
|
||||||
|
|
||||||
// prompt the user to download the community cheatsheets
|
// prompt the user to download the community cheatsheets
|
||||||
yes, err := Prompt(
|
yes, err := Prompt(
|
||||||
@@ -35,13 +28,17 @@ func Run(configs string, confpath string) error {
|
|||||||
|
|
||||||
// clone the community cheatsheets if so instructed
|
// clone the community cheatsheets if so instructed
|
||||||
if yes {
|
if yes {
|
||||||
// clone the community cheatsheets
|
fmt.Printf("Cloning community cheatsheets to %s.\n", community)
|
||||||
if err := clone(community); err != nil {
|
if err := repo.Clone(community); err != nil {
|
||||||
return fmt.Errorf("failed to clone cheatsheets: %v", err)
|
return fmt.Errorf("failed to clone cheatsheets: %v", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
configs = CommentCommunity(configs, confpath)
|
||||||
|
}
|
||||||
|
|
||||||
// also create a directory for personal cheatsheets
|
// always create personal and work directories
|
||||||
if err := os.MkdirAll(personal, os.ModePerm); err != nil {
|
for _, dir := range []string{personal, work} {
|
||||||
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %v", err)
|
return fmt.Errorf("failed to create directory: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
267
internal/installer/run_test.go
Normal file
267
internal/installer/run_test.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRun(t *testing.T) {
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-installer-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Save original stdin/stdout
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
defer func() {
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configs string
|
||||||
|
confpath string
|
||||||
|
userInput string
|
||||||
|
wantErr bool
|
||||||
|
wantInErr string
|
||||||
|
checkFiles []string
|
||||||
|
dontWantFiles []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "user declines community cheatsheets",
|
||||||
|
configs: `---
|
||||||
|
editor: EDITOR_PATH
|
||||||
|
pager: PAGER_PATH
|
||||||
|
cheatpaths:
|
||||||
|
- name: community
|
||||||
|
path: COMMUNITY_PATH
|
||||||
|
tags: [ community ]
|
||||||
|
readonly: true
|
||||||
|
- name: personal
|
||||||
|
path: PERSONAL_PATH
|
||||||
|
tags: [ personal ]
|
||||||
|
readonly: false
|
||||||
|
`,
|
||||||
|
confpath: filepath.Join(tempDir, "conf1", "conf.yml"),
|
||||||
|
userInput: "n\n",
|
||||||
|
wantErr: false,
|
||||||
|
checkFiles: []string{"conf1/conf.yml", "conf1/cheatsheets/personal", "conf1/cheatsheets/work"},
|
||||||
|
dontWantFiles: []string{"conf1/cheatsheets/community"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user accepts but clone fails",
|
||||||
|
configs: `---
|
||||||
|
cheatpaths:
|
||||||
|
- name: community
|
||||||
|
path: COMMUNITY_PATH
|
||||||
|
`,
|
||||||
|
confpath: filepath.Join(tempDir, "conf2", "conf.yml"),
|
||||||
|
userInput: "y\n",
|
||||||
|
wantErr: true,
|
||||||
|
wantInErr: "failed to clone cheatsheets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid config path",
|
||||||
|
configs: "test",
|
||||||
|
// /dev/null/... is truly uncreatable on Unix;
|
||||||
|
// NUL\... is uncreatable on Windows
|
||||||
|
confpath: func() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return `NUL\impossible\conf.yml`
|
||||||
|
}
|
||||||
|
return "/dev/null/impossible/conf.yml"
|
||||||
|
}(),
|
||||||
|
userInput: "n\n",
|
||||||
|
wantErr: true,
|
||||||
|
wantInErr: "failed to create",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-create a .git dir inside the community path so go-git's PlainClone
|
||||||
|
// returns ErrRepositoryAlreadyExists (otherwise, on CI runners with
|
||||||
|
// network access, the real clone succeeds and the test fails)
|
||||||
|
fakeGitDir := filepath.Join(tempDir, "conf2", "cheatsheets", "community", ".git")
|
||||||
|
if err := os.MkdirAll(fakeGitDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create fake .git dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(fakeGitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write fake HEAD: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create stdin pipe
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdin = r
|
||||||
|
|
||||||
|
// Create stdout pipe to suppress output
|
||||||
|
_, wOut, _ := os.Pipe()
|
||||||
|
os.Stdout = wOut
|
||||||
|
|
||||||
|
// Write user input
|
||||||
|
go func() {
|
||||||
|
defer w.Close()
|
||||||
|
io.WriteString(w, tt.userInput)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Run the installer
|
||||||
|
err := Run(tt.configs, tt.confpath)
|
||||||
|
|
||||||
|
// Close pipes
|
||||||
|
wOut.Close()
|
||||||
|
|
||||||
|
// Check error
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil && tt.wantInErr != "" && !strings.Contains(err.Error(), tt.wantInErr) {
|
||||||
|
t.Errorf("Run() error = %v, want error containing %q", err, tt.wantInErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check created files
|
||||||
|
for _, file := range tt.checkFiles {
|
||||||
|
path := filepath.Join(tempDir, file)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected file %s to exist, but it doesn't", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check files that shouldn't exist
|
||||||
|
for _, file := range tt.dontWantFiles {
|
||||||
|
path := filepath.Join(tempDir, file)
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
t.Errorf("expected file %s to not exist, but it does", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPromptError(t *testing.T) {
|
||||||
|
// Save original stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
defer func() {
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Close stdin to cause prompt error
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdin = r
|
||||||
|
r.Close()
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
tempDir, _ := os.MkdirTemp("", "cheat-installer-prompt-test-*")
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
err := Run("test", filepath.Join(tempDir, "conf.yml"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when prompt fails, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to prompt") {
|
||||||
|
t.Errorf("expected 'failed to prompt' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStringReplacements(t *testing.T) {
|
||||||
|
// Test that path replacements work correctly
|
||||||
|
configs := `---
|
||||||
|
editor: EDITOR_PATH
|
||||||
|
pager: PAGER_PATH
|
||||||
|
cheatpaths:
|
||||||
|
- name: personal
|
||||||
|
path: PERSONAL_PATH
|
||||||
|
tags: [ personal ]
|
||||||
|
readonly: false
|
||||||
|
- name: work
|
||||||
|
path: WORK_PATH
|
||||||
|
tags: [ work ]
|
||||||
|
readonly: false
|
||||||
|
- name: community
|
||||||
|
path: COMMUNITY_PATH
|
||||||
|
tags: [ community ]
|
||||||
|
readonly: true
|
||||||
|
`
|
||||||
|
|
||||||
|
// Create temp directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-installer-replace-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
confpath := filepath.Join(tempDir, "conf.yml")
|
||||||
|
confdir := filepath.Dir(confpath)
|
||||||
|
|
||||||
|
// Expected paths
|
||||||
|
expectedPersonal := filepath.Join(confdir, "cheatsheets", "personal")
|
||||||
|
|
||||||
|
// Save original stdin/stdout
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
defer func() {
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create stdin pipe with "n" answer
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdin = r
|
||||||
|
go func() {
|
||||||
|
defer w.Close()
|
||||||
|
io.WriteString(w, "n\n")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Suppress stdout
|
||||||
|
_, wOut, _ := os.Pipe()
|
||||||
|
os.Stdout = wOut
|
||||||
|
defer wOut.Close()
|
||||||
|
|
||||||
|
// Run installer
|
||||||
|
err = Run(configs, confpath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the created config file
|
||||||
|
content, err := os.ReadFile(confpath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check replacements
|
||||||
|
contentStr := string(content)
|
||||||
|
if strings.Contains(contentStr, "COMMUNITY_PATH") {
|
||||||
|
t.Error("COMMUNITY_PATH was not replaced")
|
||||||
|
}
|
||||||
|
if strings.Contains(contentStr, "PERSONAL_PATH") {
|
||||||
|
t.Error("PERSONAL_PATH was not replaced")
|
||||||
|
}
|
||||||
|
if strings.Contains(contentStr, "EDITOR_PATH") {
|
||||||
|
t.Error("EDITOR_PATH was not replaced")
|
||||||
|
}
|
||||||
|
if strings.Contains(contentStr, "PAGER_PATH") {
|
||||||
|
t.Error("PAGER_PATH was not replaced")
|
||||||
|
}
|
||||||
|
if strings.Contains(contentStr, "WORK_PATH") {
|
||||||
|
t.Error("WORK_PATH was not replaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify community path is commented out (user declined)
|
||||||
|
if strings.Contains(contentStr, " - name: community") {
|
||||||
|
t.Error("expected community cheatpath to be commented out when declined")
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentStr, " #- name: community") {
|
||||||
|
t.Error("expected commented-out community cheatpath")
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentStr, expectedPersonal) {
|
||||||
|
t.Errorf("expected personal path %q in config", expectedPersonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/installer/template.go
Normal file
58
internal/installer/template.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cheatsheetDirs returns the community, personal, and work cheatsheet
|
||||||
|
// directory paths derived from a config file path.
|
||||||
|
func cheatsheetDirs(confpath string) (community, personal, work string) {
|
||||||
|
confdir := filepath.Dir(confpath)
|
||||||
|
community = filepath.Join(confdir, "cheatsheets", "community")
|
||||||
|
personal = filepath.Join(confdir, "cheatsheets", "personal")
|
||||||
|
work = filepath.Join(confdir, "cheatsheets", "work")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandTemplate replaces placeholder tokens in the config template with
|
||||||
|
// platform-appropriate paths derived from confpath.
|
||||||
|
func ExpandTemplate(configs string, confpath string) string {
|
||||||
|
community, personal, work := cheatsheetDirs(confpath)
|
||||||
|
|
||||||
|
// substitute paths
|
||||||
|
configs = strings.ReplaceAll(configs, "COMMUNITY_PATH", community)
|
||||||
|
configs = strings.ReplaceAll(configs, "PERSONAL_PATH", personal)
|
||||||
|
configs = strings.ReplaceAll(configs, "WORK_PATH", work)
|
||||||
|
|
||||||
|
// locate and set a default pager
|
||||||
|
configs = strings.ReplaceAll(configs, "PAGER_PATH", config.Pager())
|
||||||
|
|
||||||
|
// locate and set a default editor
|
||||||
|
if editor, err := config.Editor(); err == nil {
|
||||||
|
configs = strings.ReplaceAll(configs, "EDITOR_PATH", editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentCommunity comments out the community cheatpath block in the config
|
||||||
|
// template. This is used when the community cheatsheets directory won't exist
|
||||||
|
// (either because the user declined to download them, or because the config
|
||||||
|
// is being output as an example).
|
||||||
|
func CommentCommunity(configs string, confpath string) string {
|
||||||
|
community, _, _ := cheatsheetDirs(confpath)
|
||||||
|
|
||||||
|
return strings.ReplaceAll(configs,
|
||||||
|
" - name: community\n"+
|
||||||
|
" path: "+community+"\n"+
|
||||||
|
" tags: [ community ]\n"+
|
||||||
|
" readonly: true",
|
||||||
|
" #- name: community\n"+
|
||||||
|
" # path: "+community+"\n"+
|
||||||
|
" # tags: [ community ]\n"+
|
||||||
|
" # readonly: true",
|
||||||
|
)
|
||||||
|
}
|
||||||
26
internal/repo/clone.go
Normal file
26
internal/repo/clone.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Package repo implements functions pertaining to the management of git repos.
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clone clones the community cheatsheets repository to the specified directory
|
||||||
|
func Clone(dir string) error {
|
||||||
|
|
||||||
|
// clone the community cheatsheets
|
||||||
|
_, err := git.PlainClone(dir, false, &git.CloneOptions{
|
||||||
|
URL: "https://github.com/cheat/cheatsheets.git",
|
||||||
|
Depth: 1,
|
||||||
|
Progress: os.Stdout,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to clone cheatsheets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
80
internal/repo/clone_integration_test.go
Normal file
80
internal/repo/clone_integration_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCloneIntegration performs a real clone operation to verify functionality
|
||||||
|
// Run with: go test -tags=integration ./internal/repo -v -run TestCloneIntegration
|
||||||
|
func TestCloneIntegration(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary directory
|
||||||
|
tmpDir, err := os.MkdirTemp("", "cheat-clone-integration-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
destDir := filepath.Join(tmpDir, "cheatsheets")
|
||||||
|
|
||||||
|
t.Logf("Cloning to: %s", destDir)
|
||||||
|
|
||||||
|
// Perform the actual clone
|
||||||
|
err = Clone(destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Clone() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the clone succeeded
|
||||||
|
info, err := os.Stat(destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("destination directory not created: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Fatal("destination is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for .git directory
|
||||||
|
gitDir := filepath.Join(destDir, ".git")
|
||||||
|
if _, err := os.Stat(gitDir); err != nil {
|
||||||
|
t.Error(".git directory not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for some expected cheatsheets
|
||||||
|
expectedFiles := []string{
|
||||||
|
"bash", // bash cheatsheet should exist
|
||||||
|
"git", // git cheatsheet should exist
|
||||||
|
"ls", // ls cheatsheet should exist
|
||||||
|
}
|
||||||
|
|
||||||
|
foundCount := 0
|
||||||
|
for _, file := range expectedFiles {
|
||||||
|
path := filepath.Join(destDir, file)
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
foundCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundCount < 2 {
|
||||||
|
t.Errorf("expected at least 2 common cheatsheets, found %d", foundCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Clone integration test passed!")
|
||||||
|
|
||||||
|
// Test cloning to existing directory (should fail)
|
||||||
|
err = Clone(destDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when cloning to existing repository, got nil")
|
||||||
|
} else {
|
||||||
|
t.Logf("Expected error when cloning to existing dir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/repo/clone_test.go
Normal file
53
internal/repo/clone_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestClone tests the Clone function
|
||||||
|
func TestClone(t *testing.T) {
|
||||||
|
// This test requires network access, so we'll only test error cases
|
||||||
|
// that don't require actual cloning
|
||||||
|
|
||||||
|
t.Run("clone to read-only directory", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("chmod does not restrict writes on Windows")
|
||||||
|
}
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("Cannot test read-only directory as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary directory
|
||||||
|
tempDir, err := os.MkdirTemp("", "cheat-clone-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create a read-only subdirectory
|
||||||
|
readOnlyDir := filepath.Join(tempDir, "readonly")
|
||||||
|
if err := os.Mkdir(readOnlyDir, 0555); err != nil {
|
||||||
|
t.Fatalf("failed to create read-only dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to clone to read-only directory
|
||||||
|
targetDir := filepath.Join(readOnlyDir, "cheatsheets")
|
||||||
|
err = Clone(targetDir)
|
||||||
|
|
||||||
|
// Should fail because we can't write to read-only directory
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when cloning to read-only directory, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("clone to invalid path", func(t *testing.T) {
|
||||||
|
// Try to clone to a path with null bytes (invalid on most filesystems)
|
||||||
|
err := Clone("/tmp/invalid\x00path")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error with invalid path, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
125
internal/repo/gitdir.go
Normal file
125
internal/repo/gitdir.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitSep is the `.git` path component surrounded by path separators.
|
||||||
|
// Used to match `.git` as a complete path component, not as a suffix
|
||||||
|
// of a directory name (e.g., `personal.git`).
|
||||||
|
var gitSep = string(os.PathSeparator) + ".git" + string(os.PathSeparator)
|
||||||
|
|
||||||
|
// GitDir returns `true` if we are iterating over a directory contained within
|
||||||
|
// a repositories `.git` directory.
|
||||||
|
func GitDir(path string) (bool, error) {
|
||||||
|
|
||||||
|
/*
|
||||||
|
A bit of context is called for here, because this functionality has
|
||||||
|
previously caused a number of tricky, subtle bugs.
|
||||||
|
|
||||||
|
Fundamentally, here we are simply trying to avoid walking over the
|
||||||
|
contents of the `.git` directory. Doing so potentially makes
|
||||||
|
hundreds/thousands of needless syscalls, and can noticeably harm
|
||||||
|
performance on machines with slow disks.
|
||||||
|
|
||||||
|
The earliest effort to solve this problem involved simply returning
|
||||||
|
`fs.SkipDir` when the cheatsheet file path began with `.`, signifying a
|
||||||
|
hidden directory. This, however, caused two problems:
|
||||||
|
|
||||||
|
1. The `.cheat` directory was ignored
|
||||||
|
2. Cheatsheets installed by `brew` (which were by default installed to
|
||||||
|
`~/.config/cheat`) were ignored
|
||||||
|
|
||||||
|
See: https://github.com/cheat/cheat/issues/690
|
||||||
|
|
||||||
|
To remedy this, the exclusion criteria were narrowed, and the search
|
||||||
|
for a literal `.` was replaced with a search for a literal `.git`.
|
||||||
|
This, however, broke user installations that stored cheatsheets in
|
||||||
|
`git` submodules, because such an installation would contain a `.git`
|
||||||
|
file that pointed to the upstream repository.
|
||||||
|
|
||||||
|
See: https://github.com/cheat/cheat/issues/694
|
||||||
|
|
||||||
|
The next attempt at solving this was to search for a `.git` literal
|
||||||
|
string in the cheatsheet file path. If a match was not found, we would
|
||||||
|
continue to walk the directory, as before.
|
||||||
|
|
||||||
|
If a match *was* found, we determined whether `.git` referred to a file
|
||||||
|
or directory, and would only stop walking the path in the latter case.
|
||||||
|
|
||||||
|
This, however, caused crashes if a cheatpath contained a `.gitignore`
|
||||||
|
file. (Presumably, a crash would likewise occur on the presence of
|
||||||
|
`.gitattributes`, `.gitmodules`, etc.)
|
||||||
|
|
||||||
|
See: https://github.com/cheat/cheat/issues/699
|
||||||
|
|
||||||
|
Accounting for all of the above, the next solution was to search not
|
||||||
|
for `.git`, but `.git/` (including the directory separator), and then
|
||||||
|
only ceasing to walk the directory on a match.
|
||||||
|
|
||||||
|
This, however, also had a bug: searching for `.git/` also matched
|
||||||
|
directory names that *ended with* `.git`, like `personal.git/`. This
|
||||||
|
caused cheatsheets stored under such paths to be silently skipped.
|
||||||
|
|
||||||
|
See: https://github.com/cheat/cheat/issues/711
|
||||||
|
|
||||||
|
The current (and hopefully final) solution requires the path separator
|
||||||
|
on *both* sides of `.git`, i.e., searching for `/.git/`. This ensures
|
||||||
|
that `.git` is matched only as a complete path component, not as a
|
||||||
|
suffix of a directory name.
|
||||||
|
|
||||||
|
To summarize, this code must account for the following possibilities:
|
||||||
|
|
||||||
|
1. A cheatpath is not a repository
|
||||||
|
2. A cheatpath is a repository
|
||||||
|
3. A cheatpath is a repository, and contains a `.git*` file
|
||||||
|
4. A cheatpath is a submodule
|
||||||
|
5. A cheatpath is a hidden directory
|
||||||
|
6. A cheatpath is inside a directory whose name ends with `.git`
|
||||||
|
|
||||||
|
Care must be taken to support the above on both Unix and Windows
|
||||||
|
systems, which have different directory separators and line-endings.
|
||||||
|
|
||||||
|
NB: `filepath.Walk` always passes absolute paths to the walk function,
|
||||||
|
so `.git` will never appear as the first path component. This is what
|
||||||
|
makes the "separator on both sides" approach safe.
|
||||||
|
|
||||||
|
A reasonable smoke-test for ensuring that skipping is being applied
|
||||||
|
correctly is to run the following command:
|
||||||
|
|
||||||
|
make && strace ./dist/cheat -l | wc -l
|
||||||
|
|
||||||
|
That check should be run twice: once normally, and once after
|
||||||
|
commenting out the "skip" check in `sheets.Load`.
|
||||||
|
|
||||||
|
The specific line counts don't matter; what matters is that the number
|
||||||
|
of syscalls should be significantly lower with the skip check enabled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// determine if `.git` appears as a complete path component
|
||||||
|
pos := strings.Index(path, gitSep)
|
||||||
|
|
||||||
|
// if it does not, we know for certain that we are not within a `.git`
|
||||||
|
// directory.
|
||||||
|
if pos == -1 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If `path` does contain the string `.git`, we need to determine if we're
|
||||||
|
// inside of a `.git` directory, or if `path` points to a cheatsheet that's
|
||||||
|
// stored within a `git` submodule.
|
||||||
|
//
|
||||||
|
// See: https://github.com/cheat/cheat/issues/694
|
||||||
|
|
||||||
|
// truncate `path` to the occurrence of `.git`
|
||||||
|
f, err := os.Stat(path[:pos+5])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to stat path %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// return true or false depending on whether the truncated path is a
|
||||||
|
// directory
|
||||||
|
return f.Mode().IsDir(), nil
|
||||||
|
}
|
||||||
355
internal/repo/gitdir_test.go
Normal file
355
internal/repo/gitdir_test.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupGitDirTestTree creates a temporary directory structure that exercises
|
||||||
|
// every case documented in GitDir's comment block. The caller must defer
|
||||||
|
// os.RemoveAll on the returned root.
|
||||||
|
//
|
||||||
|
// Layout:
|
||||||
|
//
|
||||||
|
// root/
|
||||||
|
// ├── plain/ # not a repository
|
||||||
|
// │ └── sheet
|
||||||
|
// ├── repo/ # a repository (.git is a directory)
|
||||||
|
// │ ├── .git/
|
||||||
|
// │ │ ├── HEAD
|
||||||
|
// │ │ ├── objects/
|
||||||
|
// │ │ │ └── pack/
|
||||||
|
// │ │ └── refs/
|
||||||
|
// │ │ └── heads/
|
||||||
|
// │ ├── .gitignore
|
||||||
|
// │ ├── .gitattributes
|
||||||
|
// │ └── sheet
|
||||||
|
// ├── submodule/ # a submodule (.git is a file)
|
||||||
|
// │ ├── .git # file, not directory
|
||||||
|
// │ └── sheet
|
||||||
|
// ├── dotgit-suffix.git/ # directory name ends in .git (#711)
|
||||||
|
// │ └── cheat/
|
||||||
|
// │ └── sheet
|
||||||
|
// ├── dotgit-mid.git/ # .git suffix mid-path (#711)
|
||||||
|
// │ └── nested/
|
||||||
|
// │ └── sheet
|
||||||
|
// ├── .github/ # .github directory (not .git)
|
||||||
|
// │ └── workflows/
|
||||||
|
// │ └── ci.yml
|
||||||
|
// └── .hidden/ # generic hidden directory
|
||||||
|
// └── sheet
|
||||||
|
func setupGitDirTestTree(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
dirs := []string{
|
||||||
|
// case 1: not a repository
|
||||||
|
filepath.Join(root, "plain"),
|
||||||
|
|
||||||
|
// case 2: a repository (.git directory with contents)
|
||||||
|
filepath.Join(root, "repo", ".git", "objects", "pack"),
|
||||||
|
filepath.Join(root, "repo", ".git", "refs", "heads"),
|
||||||
|
|
||||||
|
// case 4: a submodule (.git is a file)
|
||||||
|
filepath.Join(root, "submodule"),
|
||||||
|
|
||||||
|
// case 6: directory name ending in .git (#711)
|
||||||
|
filepath.Join(root, "dotgit-suffix.git", "cheat"),
|
||||||
|
filepath.Join(root, "dotgit-mid.git", "nested"),
|
||||||
|
|
||||||
|
// .github (should not be confused with .git)
|
||||||
|
filepath.Join(root, ".github", "workflows"),
|
||||||
|
|
||||||
|
// generic hidden directory
|
||||||
|
filepath.Join(root, ".hidden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create dir %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
// sheets
|
||||||
|
filepath.Join(root, "plain", "sheet"): "plain sheet",
|
||||||
|
filepath.Join(root, "repo", "sheet"): "repo sheet",
|
||||||
|
filepath.Join(root, "submodule", "sheet"): "submod sheet",
|
||||||
|
filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"): "dotgit sheet",
|
||||||
|
filepath.Join(root, "dotgit-mid.git", "nested", "sheet"): "dotgit nested",
|
||||||
|
filepath.Join(root, ".hidden", "sheet"): "hidden sheet",
|
||||||
|
|
||||||
|
// git metadata
|
||||||
|
filepath.Join(root, "repo", ".git", "HEAD"): "ref: refs/heads/main\n",
|
||||||
|
filepath.Join(root, "repo", ".gitignore"): "*.tmp\n",
|
||||||
|
filepath.Join(root, "repo", ".gitattributes"): "* text=auto\n",
|
||||||
|
filepath.Join(root, "submodule", ".git"): "gitdir: ../.git/modules/sub\n",
|
||||||
|
filepath.Join(root, ".github", "workflows", "ci.yml"): "name: CI\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, content := range files {
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitDir(t *testing.T) {
|
||||||
|
root := setupGitDirTestTree(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Case 1: not a repository — no .git anywhere in path
|
||||||
|
{
|
||||||
|
name: "plain directory, no repo",
|
||||||
|
path: filepath.Join(root, "plain", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 2: a repository — paths *inside* .git/ should be detected
|
||||||
|
{
|
||||||
|
name: "inside .git directory",
|
||||||
|
path: filepath.Join(root, "repo", ".git", "HEAD"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inside .git/objects",
|
||||||
|
path: filepath.Join(root, "repo", ".git", "objects", "pack", "somefile"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inside .git/refs",
|
||||||
|
path: filepath.Join(root, "repo", ".git", "refs", "heads", "main"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 2 (cont.): files *alongside* .git should NOT be detected
|
||||||
|
{
|
||||||
|
name: "sheet in repo root (beside .git dir)",
|
||||||
|
path: filepath.Join(root, "repo", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 3: .git* files (like .gitignore) should NOT trigger
|
||||||
|
{
|
||||||
|
name: ".gitignore file",
|
||||||
|
path: filepath.Join(root, "repo", ".gitignore"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ".gitattributes file",
|
||||||
|
path: filepath.Join(root, "repo", ".gitattributes"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 4: submodule — .git is a file, not a directory
|
||||||
|
{
|
||||||
|
name: "sheet in submodule (where .git is a file)",
|
||||||
|
path: filepath.Join(root, "submodule", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Case 6: directory name ends with .git (#711)
|
||||||
|
{
|
||||||
|
name: "sheet under directory ending in .git",
|
||||||
|
path: filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sheet under .git-suffixed dir, nested deeper",
|
||||||
|
path: filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// .github directory — must not be confused with .git
|
||||||
|
{
|
||||||
|
name: "file inside .github directory",
|
||||||
|
path: filepath.Join(root, ".github", "workflows", "ci.yml"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden directory that is not .git
|
||||||
|
{
|
||||||
|
name: "file inside generic hidden directory",
|
||||||
|
path: filepath.Join(root, ".hidden", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Path with no .git at all
|
||||||
|
{
|
||||||
|
name: "path with no .git component whatsoever",
|
||||||
|
path: filepath.Join(root, "nonexistent", "file"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := GitDir(tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGitDirWithNestedGitDir tests a repo inside a .git-suffixed parent
|
||||||
|
// directory. This is the nastiest combination: a real .git directory that
|
||||||
|
// appears *after* a .git suffix in the path.
|
||||||
|
func TestGitDirWithNestedGitDir(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
// Create: root/cheats.git/repo/.git/HEAD
|
||||||
|
// root/cheats.git/repo/sheet
|
||||||
|
gitDir := filepath.Join(root, "cheats.git", "repo", ".git")
|
||||||
|
if err := os.MkdirAll(gitDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "cheats.git", "repo", "sheet"), []byte("content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sheet beside .git in .git-suffixed parent",
|
||||||
|
path: filepath.Join(root, "cheats.git", "repo", "sheet"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file inside .git inside .git-suffixed parent",
|
||||||
|
path: filepath.Join(root, "cheats.git", "repo", ".git", "HEAD"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := GitDir(tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GitDir(%q) returned unexpected error: %v", tt.path, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("GitDir(%q) = %v, want %v", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGitDirSubmoduleInsideDotGitSuffix tests a submodule (.git file)
|
||||||
|
// inside a .git-suffixed parent directory.
|
||||||
|
func TestGitDirSubmoduleInsideDotGitSuffix(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
// Create: root/personal.git/submod/.git (file)
|
||||||
|
// root/personal.git/submod/sheet
|
||||||
|
subDir := filepath.Join(root, "personal.git", "submod")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// .git as a file (submodule pointer)
|
||||||
|
if err := os.WriteFile(filepath.Join(subDir, ".git"), []byte("gitdir: ../../.git/modules/sub\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(subDir, "sheet"), []byte("content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := GitDir(filepath.Join(subDir, "sheet"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
t.Error("GitDir should return false for sheet in submodule under .git-suffixed parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGitDirIntegrationWalk simulates what sheets.Load does: walking a
|
||||||
|
// directory tree and checking each path with GitDir. This verifies that
|
||||||
|
// the function works correctly in the context of filepath.Walk, which is
|
||||||
|
// how it is actually called.
|
||||||
|
func TestGitDirIntegrationWalk(t *testing.T) {
|
||||||
|
root := setupGitDirTestTree(t)
|
||||||
|
|
||||||
|
// Walk the tree and collect which paths GitDir says to skip
|
||||||
|
var skipped []string
|
||||||
|
var visited []string
|
||||||
|
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isGit, err := GitDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isGit {
|
||||||
|
skipped = append(skipped, path)
|
||||||
|
} else {
|
||||||
|
visited = append(visited, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Walk failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files inside .git/ should be skipped
|
||||||
|
expectSkipped := []string{
|
||||||
|
filepath.Join(root, "repo", ".git", "HEAD"),
|
||||||
|
}
|
||||||
|
for _, want := range expectSkipped {
|
||||||
|
found := false
|
||||||
|
for _, got := range skipped {
|
||||||
|
if got == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected %q to be skipped, but it was not", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sheets should NOT be skipped — including the #711 case
|
||||||
|
expectVisited := []string{
|
||||||
|
filepath.Join(root, "plain", "sheet"),
|
||||||
|
filepath.Join(root, "repo", "sheet"),
|
||||||
|
filepath.Join(root, "submodule", "sheet"),
|
||||||
|
filepath.Join(root, "dotgit-suffix.git", "cheat", "sheet"),
|
||||||
|
filepath.Join(root, "dotgit-mid.git", "nested", "sheet"),
|
||||||
|
filepath.Join(root, ".hidden", "sheet"),
|
||||||
|
}
|
||||||
|
for _, want := range expectVisited {
|
||||||
|
found := false
|
||||||
|
for _, got := range visited {
|
||||||
|
if got == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected %q to be visited (not skipped), but it was not found in visited paths", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/repo/pull.go
Normal file
130
internal/repo/pull.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
|
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||||
|
"github.com/mitchellh/go-homedir"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDirtyWorktree indicates that the worktree has uncommitted changes.
|
||||||
|
var ErrDirtyWorktree = errors.New("dirty worktree")
|
||||||
|
|
||||||
|
// Pull performs a git pull on the repository at path. It returns
|
||||||
|
// ErrDirtyWorktree if the worktree has uncommitted changes, and
|
||||||
|
// git.ErrRepositoryNotExists if path is not a git repository.
|
||||||
|
func Pull(path string) error {
|
||||||
|
|
||||||
|
// open the repository
|
||||||
|
r, err := git.PlainOpen(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the worktree
|
||||||
|
wt, err := r.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the worktree is clean
|
||||||
|
status, err := wt.Status()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !status.IsClean() {
|
||||||
|
return ErrDirtyWorktree
|
||||||
|
}
|
||||||
|
|
||||||
|
// build pull options, using SSH auth when the remote is SSH
|
||||||
|
opts := &git.PullOptions{}
|
||||||
|
if auth, err := sshAuth(r); err == nil && auth != nil {
|
||||||
|
opts.Auth = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull
|
||||||
|
err = wt.Pull(opts)
|
||||||
|
if errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultKeyFiles are the SSH key filenames tried in order, matching the
|
||||||
|
// default behavior of OpenSSH.
|
||||||
|
var defaultKeyFiles = []string{
|
||||||
|
"id_rsa",
|
||||||
|
"id_ecdsa",
|
||||||
|
"id_ecdsa_sk",
|
||||||
|
"id_ed25519",
|
||||||
|
"id_ed25519_sk",
|
||||||
|
"id_dsa",
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshAuth returns an appropriate SSH auth method if the origin remote uses
|
||||||
|
// the SSH protocol, or nil if it does not. It tries the SSH agent first, then
|
||||||
|
// falls back to default key files in ~/.ssh/.
|
||||||
|
func sshAuth(r *git.Repository) (transport.AuthMethod, error) {
|
||||||
|
remote, err := r.Remote("origin")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := remote.Config().URLs
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ep, err := transport.NewEndpoint(urls[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ep.Protocol != "ssh" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user := ep.User
|
||||||
|
if user == "" {
|
||||||
|
user = "git"
|
||||||
|
}
|
||||||
|
|
||||||
|
// try default key files first — this is more reliable than the SSH
|
||||||
|
// agent, which may report success even when no keys are loaded
|
||||||
|
home, err := homedir.Dir()
|
||||||
|
if err == nil {
|
||||||
|
if auth := findKeyFile(filepath.Join(home, ".ssh"), user); auth != nil {
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fall back to SSH agent
|
||||||
|
if auth, err := gitssh.NewSSHAgentAuth(user); err == nil {
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findKeyFile looks for a usable SSH private key in sshDir, trying the
|
||||||
|
// standard OpenSSH default filenames in order. Returns nil if no usable key
|
||||||
|
// is found.
|
||||||
|
func findKeyFile(sshDir, user string) transport.AuthMethod {
|
||||||
|
for _, name := range defaultKeyFiles {
|
||||||
|
keyPath := filepath.Join(sshDir, name)
|
||||||
|
if _, err := os.Stat(keyPath); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
auth, err := gitssh.NewPublicKeysFromFile(user, keyPath, "")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
315
internal/repo/pull_test.go
Normal file
315
internal/repo/pull_test.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
gitconfig "github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testCommitOpts returns a CommitOptions suitable for test commits.
|
||||||
|
func testCommitOpts() *git.CommitOptions {
|
||||||
|
return &git.CommitOptions{
|
||||||
|
Author: &object.Signature{
|
||||||
|
Name: "test",
|
||||||
|
Email: "test@test",
|
||||||
|
When: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initBareRepoWithCommit creates a bare git repository at dir with an initial
|
||||||
|
// commit, suitable for use as a remote.
|
||||||
|
func initBareRepoWithCommit(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// init a non-bare repo to make the commit, then we'll clone it as bare
|
||||||
|
tmpWork := t.TempDir()
|
||||||
|
r, err := git.PlainInit(tmpWork, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := filepath.Join(tmpWork, "README")
|
||||||
|
if err := os.WriteFile(f, []byte("hello\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wt, err := r.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get worktree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wt.Add("README"); err != nil {
|
||||||
|
t.Fatalf("failed to stage file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = wt.Commit("initial commit", testCommitOpts()); err != nil {
|
||||||
|
t.Fatalf("failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone as bare into the target dir
|
||||||
|
if _, err = git.PlainClone(dir, true, &git.CloneOptions{URL: tmpWork}); err != nil {
|
||||||
|
t.Fatalf("failed to create bare clone: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneLocal clones the bare repo at bareDir into a new working directory and
|
||||||
|
// returns the path.
|
||||||
|
func cloneLocal(t *testing.T, bareDir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := git.PlainClone(dir, false, &git.CloneOptions{
|
||||||
|
URL: bareDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to clone: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushNewCommit clones bareDir into a temporary working copy, commits a new
|
||||||
|
// file, and pushes back to the bare repo.
|
||||||
|
func pushNewCommit(t *testing.T, bareDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpWork := t.TempDir()
|
||||||
|
r, err := git.PlainClone(tmpWork, false, &git.CloneOptions{URL: bareDir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to clone for push: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpWork, "new.txt"), []byte("new\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wt, err := r.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get worktree: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := wt.Add("new.txt"); err != nil {
|
||||||
|
t.Fatalf("failed to stage file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := wt.Commit("add new file", testCommitOpts()); err != nil {
|
||||||
|
t.Fatalf("failed to commit: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Push(&git.PushOptions{}); err != nil {
|
||||||
|
t.Fatalf("failed to push: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestKey creates an unencrypted ed25519 PEM private key file at path.
|
||||||
|
func generateTestKey(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, priv, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||||
|
if err := os.WriteFile(path, pemBytes, 0600); err != nil {
|
||||||
|
t.Fatalf("failed to write key file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pull tests ---
|
||||||
|
|
||||||
|
func TestPull_NotARepo(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := Pull(dir)
|
||||||
|
if err != git.ErrRepositoryNotExists {
|
||||||
|
t.Fatalf("expected ErrRepositoryNotExists, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPull_CleanAlreadyUpToDate(t *testing.T) {
|
||||||
|
bare := t.TempDir()
|
||||||
|
initBareRepoWithCommit(t, bare)
|
||||||
|
clone := cloneLocal(t, bare)
|
||||||
|
|
||||||
|
err := Pull(clone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil (already up-to-date), got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPull_NewUpstreamChanges(t *testing.T) {
|
||||||
|
bare := t.TempDir()
|
||||||
|
initBareRepoWithCommit(t, bare)
|
||||||
|
clone := cloneLocal(t, bare)
|
||||||
|
|
||||||
|
// push a new commit to the bare repo after the clone
|
||||||
|
pushNewCommit(t, bare)
|
||||||
|
|
||||||
|
err := Pull(clone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil (successful pull), got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify the new file was pulled
|
||||||
|
if _, err := os.Stat(filepath.Join(clone, "new.txt")); err != nil {
|
||||||
|
t.Fatalf("expected new.txt to exist after pull: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPull_DirtyWorktree(t *testing.T) {
|
||||||
|
bare := t.TempDir()
|
||||||
|
initBareRepoWithCommit(t, bare)
|
||||||
|
clone := cloneLocal(t, bare)
|
||||||
|
|
||||||
|
// make the worktree dirty with a modified tracked file
|
||||||
|
if err := os.WriteFile(filepath.Join(clone, "README"), []byte("changed\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to modify file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Pull(clone)
|
||||||
|
if err != ErrDirtyWorktree {
|
||||||
|
t.Fatalf("expected ErrDirtyWorktree, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPull_DirtyWorktreeUntracked(t *testing.T) {
|
||||||
|
bare := t.TempDir()
|
||||||
|
initBareRepoWithCommit(t, bare)
|
||||||
|
clone := cloneLocal(t, bare)
|
||||||
|
|
||||||
|
// make the worktree dirty with an untracked file
|
||||||
|
if err := os.WriteFile(filepath.Join(clone, "untracked.txt"), []byte("new\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Pull(clone)
|
||||||
|
if err != ErrDirtyWorktree {
|
||||||
|
t.Fatalf("expected ErrDirtyWorktree, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sshAuth tests ---
|
||||||
|
|
||||||
|
func TestSshAuth_NonSSHRemote(t *testing.T) {
|
||||||
|
bare := t.TempDir()
|
||||||
|
initBareRepoWithCommit(t, bare)
|
||||||
|
clone := cloneLocal(t, bare)
|
||||||
|
|
||||||
|
r, err := git.PlainOpen(clone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the clone's origin is a local file:// path, not SSH
|
||||||
|
auth, err := sshAuth(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got: %v", err)
|
||||||
|
}
|
||||||
|
if auth != nil {
|
||||||
|
t.Fatalf("expected nil auth for non-SSH remote, got: %v", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSshAuth_NoRemote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
r, err := git.PlainInit(dir, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repo has no remotes
|
||||||
|
auth, err := sshAuth(r)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for missing remote, got auth: %v", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSshAuth_SSHRemote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
r, err := git.PlainInit(dir, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add an SSH remote
|
||||||
|
_, err = r.CreateRemote(&gitconfig.RemoteConfig{
|
||||||
|
Name: "origin",
|
||||||
|
URLs: []string{"git@github.com:example/repo.git"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create remote: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshAuth should not return an error — even if no key is found, it
|
||||||
|
// returns (nil, nil) rather than an error
|
||||||
|
auth, err := sshAuth(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// we can't predict whether auth is nil or non-nil here because it
|
||||||
|
// depends on whether the test runner has SSH keys or an agent; just
|
||||||
|
// verify it didn't error
|
||||||
|
_ = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- findKeyFile tests ---
|
||||||
|
|
||||||
|
func TestFindKeyFile_ValidKey(t *testing.T) {
|
||||||
|
sshDir := t.TempDir()
|
||||||
|
generateTestKey(t, filepath.Join(sshDir, "id_ed25519"))
|
||||||
|
|
||||||
|
auth := findKeyFile(sshDir, "git")
|
||||||
|
if auth == nil {
|
||||||
|
t.Fatal("expected non-nil auth for valid key file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindKeyFile_NoKeys(t *testing.T) {
|
||||||
|
sshDir := t.TempDir()
|
||||||
|
|
||||||
|
auth := findKeyFile(sshDir, "git")
|
||||||
|
if auth != nil {
|
||||||
|
t.Fatalf("expected nil auth for empty directory, got: %v", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindKeyFile_InvalidKey(t *testing.T) {
|
||||||
|
sshDir := t.TempDir()
|
||||||
|
// write garbage into a file named like a key
|
||||||
|
if err := os.WriteFile(filepath.Join(sshDir, "id_ed25519"), []byte("not a key"), 0600); err != nil {
|
||||||
|
t.Fatalf("failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := findKeyFile(sshDir, "git")
|
||||||
|
if auth != nil {
|
||||||
|
t.Fatalf("expected nil auth for invalid key file, got: %v", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindKeyFile_SkipsInvalidFindsValid(t *testing.T) {
|
||||||
|
sshDir := t.TempDir()
|
||||||
|
|
||||||
|
// put garbage in id_rsa (tried first), valid key in id_ed25519 (tried later)
|
||||||
|
if err := os.WriteFile(filepath.Join(sshDir, "id_rsa"), []byte("not a key"), 0600); err != nil {
|
||||||
|
t.Fatalf("failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
generateTestKey(t, filepath.Join(sshDir, "id_ed25519"))
|
||||||
|
|
||||||
|
auth := findKeyFile(sshDir, "git")
|
||||||
|
if auth == nil {
|
||||||
|
t.Fatal("expected non-nil auth; should skip invalid id_rsa and find id_ed25519")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/quick"
|
"github.com/alecthomas/chroma/v2/quick"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Colorize applies syntax-highlighting to a cheatsheet's Text.
|
// Colorize applies syntax-highlighting to a cheatsheet's Text.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sheet
|
package sheet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cheat/cheat/internal/config"
|
"github.com/cheat/cheat/internal/config"
|
||||||
@@ -16,19 +17,78 @@ func TestColorize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mock a sheet
|
// mock a sheet
|
||||||
|
original := "echo 'foo'"
|
||||||
s := Sheet{
|
s := Sheet{
|
||||||
Text: "echo 'foo'",
|
Text: original,
|
||||||
}
|
}
|
||||||
|
|
||||||
// colorize the sheet text
|
// colorize the sheet text
|
||||||
s.Colorize(conf)
|
s.Colorize(conf)
|
||||||
|
|
||||||
// initialize expectations
|
// assert that the text was modified (colorization applied)
|
||||||
want := "[38;2;181;137;0mecho[0m[38;2;147;161;161m"
|
if s.Text == original {
|
||||||
want += " [0m[38;2;42;161;152m'foo'[0m"
|
t.Error("Colorize did not modify sheet text")
|
||||||
|
}
|
||||||
|
|
||||||
// assert
|
// assert that ANSI escape codes are present
|
||||||
if s.Text != want {
|
if !strings.Contains(s.Text, "\x1b[") && !strings.Contains(s.Text, "[0m") {
|
||||||
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text)
|
t.Errorf("colorized text does not contain ANSI escape codes: %q", s.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assert that the original content is still present within the colorized output
|
||||||
|
if !strings.Contains(s.Text, "echo") || !strings.Contains(s.Text, "foo") {
|
||||||
|
t.Errorf("colorized text lost original content: %q", s.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestColorizeDefaultSyntax asserts that when no syntax is specified, the
|
||||||
|
// default ("bash") is used and produces the same output as an explicit "bash"
|
||||||
|
func TestColorizeDefaultSyntax(t *testing.T) {
|
||||||
|
|
||||||
|
conf := config.Config{
|
||||||
|
Formatter: "terminal16m",
|
||||||
|
Style: "monokai",
|
||||||
|
}
|
||||||
|
|
||||||
|
// use bash-specific content that tokenizes differently across lexers
|
||||||
|
code := "if [[ -f /etc/passwd ]]; then\n echo \"found\" | grep -o found\nfi"
|
||||||
|
|
||||||
|
// colorize with empty syntax (should default to "bash")
|
||||||
|
noSyntax := Sheet{Text: code}
|
||||||
|
noSyntax.Colorize(conf)
|
||||||
|
|
||||||
|
// colorize with explicit "bash" syntax
|
||||||
|
bashSyntax := Sheet{Text: code, Syntax: "bash"}
|
||||||
|
bashSyntax.Colorize(conf)
|
||||||
|
|
||||||
|
// both should produce the same output
|
||||||
|
if noSyntax.Text != bashSyntax.Text {
|
||||||
|
t.Errorf(
|
||||||
|
"default syntax does not match explicit bash:\ndefault: %q\nexplicit: %q",
|
||||||
|
noSyntax.Text,
|
||||||
|
bashSyntax.Text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestColorizeExplicitSyntax asserts that a specified syntax is used
|
||||||
|
func TestColorizeExplicitSyntax(t *testing.T) {
|
||||||
|
|
||||||
|
conf := config.Config{
|
||||||
|
Formatter: "terminal16m",
|
||||||
|
Style: "monokai",
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorize as bash
|
||||||
|
bashSheet := Sheet{Text: "def hello():\n pass", Syntax: "bash"}
|
||||||
|
bashSheet.Colorize(conf)
|
||||||
|
|
||||||
|
// colorize as python
|
||||||
|
pySheet := Sheet{Text: "def hello():\n pass", Syntax: "python"}
|
||||||
|
pySheet.Colorize(conf)
|
||||||
|
|
||||||
|
// different lexers should produce different output for Python code
|
||||||
|
if bashSheet.Text == pySheet.Text {
|
||||||
|
t.Error("bash and python syntax produced identical output")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Copy copies a cheatsheet to a new location
|
// Copy copies a cheatsheet to a new location
|
||||||
@@ -22,7 +22,7 @@ func (s *Sheet) Copy(dest string) error {
|
|||||||
defer infile.Close()
|
defer infile.Close()
|
||||||
|
|
||||||
// create any necessary subdirectories
|
// create any necessary subdirectories
|
||||||
dirs := path.Dir(dest)
|
dirs := filepath.Dir(dest)
|
||||||
if dirs != "." {
|
if dirs != "." {
|
||||||
if err := os.MkdirAll(dirs, 0755); err != nil {
|
if err := os.MkdirAll(dirs, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %s, %v", dirs, err)
|
return fmt.Errorf("failed to create directory: %s, %v", dirs, err)
|
||||||
@@ -39,6 +39,8 @@ func (s *Sheet) Copy(dest string) error {
|
|||||||
// copy file contents
|
// copy file contents
|
||||||
_, err = io.Copy(outfile, infile)
|
_, err = io.Copy(outfile, infile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Clean up the partially written file on error
|
||||||
|
os.Remove(dest)
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"failed to copy file: infile: %s, outfile: %s, err: %v",
|
"failed to copy file: infile: %s, outfile: %s, err: %v",
|
||||||
s.Path,
|
s.Path,
|
||||||
|
|||||||
145
internal/sheet/copy_error_test.go
Normal file
145
internal/sheet/copy_error_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package sheet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCopyErrors tests error cases for the Copy method
|
||||||
|
func TestCopyErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func() (*Sheet, string, func())
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "source file does not exist",
|
||||||
|
setup: func() (*Sheet, string, func()) {
|
||||||
|
sheet := &Sheet{
|
||||||
|
Title: "test",
|
||||||
|
Path: "/non/existent/file.txt",
|
||||||
|
CheatPath: "test",
|
||||||
|
}
|
||||||
|
dest := filepath.Join(os.TempDir(), "copy-test-dest.txt")
|
||||||
|
cleanup := func() {
|
||||||
|
os.Remove(dest)
|
||||||
|
}
|
||||||
|
return sheet, dest, cleanup
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "destination directory creation fails",
|
||||||
|
setup: func() (*Sheet, string, func()) {
|
||||||
|
src, err := os.CreateTemp("", "copy-test-src-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
src.WriteString("test content")
|
||||||
|
src.Close()
|
||||||
|
|
||||||
|
sheet := &Sheet{
|
||||||
|
Title: "test",
|
||||||
|
Path: src.Name(),
|
||||||
|
CheatPath: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
blockerFile := filepath.Join(os.TempDir(), "copy-blocker-file")
|
||||||
|
if err := os.WriteFile(blockerFile, []byte("blocker"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create blocker file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(blockerFile, "subdir", "dest.txt")
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
os.Remove(src.Name())
|
||||||
|
os.Remove(blockerFile)
|
||||||
|
}
|
||||||
|
return sheet, dest, cleanup
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "destination file creation fails",
|
||||||
|
setup: func() (*Sheet, string, func()) {
|
||||||
|
src, err := os.CreateTemp("", "copy-test-src-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
src.WriteString("test content")
|
||||||
|
src.Close()
|
||||||
|
|
||||||
|
sheet := &Sheet{
|
||||||
|
Title: "test",
|
||||||
|
Path: src.Name(),
|
||||||
|
CheatPath: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
destDir := filepath.Join(os.TempDir(), "copy-test-dir")
|
||||||
|
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
|
||||||
|
t.Fatalf("failed to create dest dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
os.Remove(src.Name())
|
||||||
|
os.RemoveAll(destDir)
|
||||||
|
}
|
||||||
|
return sheet, destDir, cleanup
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
sheet, dest, cleanup := tt.setup()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := sheet.Copy(dest)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Copy() expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCopyUnreadableSource verifies that Copy returns an error when the source
|
||||||
|
// file cannot be opened (e.g., permission denied).
|
||||||
|
func TestCopyUnreadableSource(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("chmod does not restrict reads on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.CreateTemp("", "copy-test-unreadable-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(src.Name())
|
||||||
|
|
||||||
|
if _, err := src.WriteString("test content"); err != nil {
|
||||||
|
t.Fatalf("failed to write content: %v", err)
|
||||||
|
}
|
||||||
|
src.Close()
|
||||||
|
|
||||||
|
sheet := &Sheet{
|
||||||
|
Title: "test",
|
||||||
|
Path: src.Name(),
|
||||||
|
CheatPath: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(os.TempDir(), "copy-unreadable-test.txt")
|
||||||
|
defer os.Remove(dest)
|
||||||
|
|
||||||
|
if err := os.Chmod(src.Name(), 0000); err != nil {
|
||||||
|
t.Skip("Cannot change file permissions on this platform")
|
||||||
|
}
|
||||||
|
defer os.Chmod(src.Name(), 0644)
|
||||||
|
|
||||||
|
err = sheet.Copy(dest)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected Copy to fail with permission error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination should not exist since the error occurs before it is created
|
||||||
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||||
|
t.Error("destination file should not exist after open failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package sheet
|
package sheet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -13,7 +12,7 @@ func TestCopyFlat(t *testing.T) {
|
|||||||
|
|
||||||
// mock a cheatsheet file
|
// mock a cheatsheet file
|
||||||
text := "this is the cheatsheet text"
|
text := "this is the cheatsheet text"
|
||||||
src, err := ioutil.TempFile("", "foo-src")
|
src, err := os.CreateTemp("", "foo-src")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to mock cheatsheet: %v", err)
|
t.Errorf("failed to mock cheatsheet: %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,7 +40,7 @@ func TestCopyFlat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assert that the destination file contains the correct text
|
// assert that the destination file contains the correct text
|
||||||
got, err := ioutil.ReadFile(outpath)
|
got, err := os.ReadFile(outpath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to read destination file: %v", err)
|
t.Errorf("failed to read destination file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -60,7 +59,7 @@ func TestCopyDeep(t *testing.T) {
|
|||||||
|
|
||||||
// mock a cheatsheet file
|
// mock a cheatsheet file
|
||||||
text := "this is the cheatsheet text"
|
text := "this is the cheatsheet text"
|
||||||
src, err := ioutil.TempFile("", "foo-src")
|
src, err := os.CreateTemp("", "foo-src")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to mock cheatsheet: %v", err)
|
t.Errorf("failed to mock cheatsheet: %v", err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +93,7 @@ func TestCopyDeep(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assert that the destination file contains the correct text
|
// assert that the destination file contains the correct text
|
||||||
got, err := ioutil.ReadFile(outpath)
|
got, err := os.ReadFile(outpath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to read destination file: %v", err)
|
t.Errorf("failed to read destination file: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
package frontmatter
|
package sheet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v1"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Frontmatter encapsulates cheatsheet frontmatter data
|
|
||||||
type Frontmatter struct {
|
|
||||||
Tags []string
|
|
||||||
Syntax string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse parses cheatsheet frontmatter
|
// Parse parses cheatsheet frontmatter
|
||||||
func Parse(markdown string) (string, Frontmatter, error) {
|
func parse(markdown string) (frontmatter, string, error) {
|
||||||
|
|
||||||
|
// detect the line-break style used in the content
|
||||||
|
linebreak := "\n"
|
||||||
|
if strings.Contains(markdown, "\r\n") {
|
||||||
|
linebreak = "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
// specify the frontmatter delimiter
|
// specify the frontmatter delimiter
|
||||||
delim := "---"
|
delim := fmt.Sprintf("---%s", linebreak)
|
||||||
|
|
||||||
// initialize a frontmatter struct
|
// initialize a frontmatter struct
|
||||||
var fm Frontmatter
|
var fm frontmatter
|
||||||
|
|
||||||
// if the markdown does not contain frontmatter, pass it through unmodified
|
// if the markdown does not contain frontmatter, pass it through unmodified
|
||||||
if !strings.HasPrefix(markdown, delim) {
|
if !strings.HasPrefix(markdown, delim) {
|
||||||
return strings.TrimSpace(markdown), fm, nil
|
return fm, markdown, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, split the frontmatter and cheatsheet text
|
// otherwise, split the frontmatter and cheatsheet text
|
||||||
@@ -32,13 +32,13 @@ func Parse(markdown string) (string, Frontmatter, error) {
|
|||||||
|
|
||||||
// return an error if the frontmatter parses into the wrong number of parts
|
// return an error if the frontmatter parses into the wrong number of parts
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return markdown, fm, fmt.Errorf("failed to delimit frontmatter")
|
return fm, markdown, fmt.Errorf("failed to delimit frontmatter")
|
||||||
}
|
}
|
||||||
|
|
||||||
// return an error if the YAML cannot be unmarshalled
|
// return an error if the YAML cannot be unmarshalled
|
||||||
if err := yaml.Unmarshal([]byte(parts[1]), &fm); err != nil {
|
if err := yaml.Unmarshal([]byte(parts[1]), &fm); err != nil {
|
||||||
return markdown, fm, fmt.Errorf("failed to unmarshal frontmatter: %v", err)
|
return fm, markdown, fmt.Errorf("failed to unmarshal frontmatter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(parts[2]), fm, nil
|
return fm, parts[2], nil
|
||||||
}
|
}
|
||||||
29
internal/sheet/parse_extended_test.go
Normal file
29
internal/sheet/parse_extended_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package sheet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseWindowsLineEndings tests parsing with Windows line endings
|
||||||
|
func TestParseWindowsLineEndings(t *testing.T) {
|
||||||
|
// stub our cheatsheet content with Windows line endings
|
||||||
|
markdown := "---\r\nsyntax: go\r\ntags: [ test ]\r\n---\r\nTo foo the bar: baz"
|
||||||
|
|
||||||
|
// parse the frontmatter
|
||||||
|
fm, text, err := parse(markdown)
|
||||||
|
|
||||||
|
// assert expectations
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to parse markdown: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "To foo the bar: baz"
|
||||||
|
if text != want {
|
||||||
|
t.Errorf("failed to parse text: want: %s, got: %s", want, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = "go"
|
||||||
|
if fm.Syntax != want {
|
||||||
|
t.Errorf("failed to parse syntax: want: %s, got: %s", want, fm.Syntax)
|
||||||
|
}
|
||||||
|
}
|
||||||
132
internal/sheet/parse_fuzz_test.go
Normal file
132
internal/sheet/parse_fuzz_test.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package sheet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzParse tests the parse function with fuzzing to uncover edge cases
|
||||||
|
// and potential panics in YAML frontmatter parsing
|
||||||
|
func FuzzParse(f *testing.F) {
|
||||||
|
// Add seed corpus with various valid and edge case inputs
|
||||||
|
// Valid frontmatter
|
||||||
|
f.Add("---\nsyntax: go\n---\nContent")
|
||||||
|
f.Add("---\ntags: [a, b]\n---\n")
|
||||||
|
f.Add("---\nsyntax: bash\ntags: [linux, shell]\n---\n#!/bin/bash\necho hello")
|
||||||
|
|
||||||
|
// No frontmatter
|
||||||
|
f.Add("No frontmatter here")
|
||||||
|
f.Add("")
|
||||||
|
f.Add("Just plain text\nwith multiple lines")
|
||||||
|
|
||||||
|
// Edge cases with delimiters
|
||||||
|
f.Add("---")
|
||||||
|
f.Add("---\n")
|
||||||
|
f.Add("---\n---")
|
||||||
|
f.Add("---\n---\n")
|
||||||
|
f.Add("---\n---\n---")
|
||||||
|
f.Add("---\n---\n---\n---")
|
||||||
|
f.Add("------\n------")
|
||||||
|
|
||||||
|
// Invalid YAML
|
||||||
|
f.Add("---\n{invalid yaml\n---\n")
|
||||||
|
f.Add("---\nsyntax: \"unclosed quote\n---\n")
|
||||||
|
f.Add("---\ntags: [a, b,\n---\n")
|
||||||
|
|
||||||
|
// Windows line endings
|
||||||
|
f.Add("---\r\nsyntax: go\r\n---\r\nContent")
|
||||||
|
f.Add("---\r\n---\r\n")
|
||||||
|
|
||||||
|
// Mixed line endings
|
||||||
|
f.Add("---\nsyntax: go\r\n---\nContent")
|
||||||
|
f.Add("---\r\nsyntax: go\n---\r\nContent")
|
||||||
|
|
||||||
|
// Unicode and special characters
|
||||||
|
f.Add("---\ntags: [emoji, 🎉]\n---\n")
|
||||||
|
f.Add("---\nsyntax: 中文\n---\n")
|
||||||
|
f.Add("---\ntags: [\x00, \x01]\n---\n")
|
||||||
|
|
||||||
|
// Very long inputs
|
||||||
|
f.Add("---\ntags: [" + strings.Repeat("a,", 1000) + "a]\n---\n")
|
||||||
|
f.Add("---\n" + strings.Repeat("field: value\n", 1000) + "---\n")
|
||||||
|
|
||||||
|
// Nested structures
|
||||||
|
f.Add("---\ntags:\n - nested\n - list\n---\n")
|
||||||
|
f.Add("---\nmeta:\n author: test\n version: 1.0\n---\n")
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, input string) {
|
||||||
|
// The parse function should never panic, regardless of input
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("parse panicked with input %q: %v", input, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fm, text, err := parse(input)
|
||||||
|
|
||||||
|
// Verify invariants
|
||||||
|
if err == nil {
|
||||||
|
// If parsing succeeded, validate the result
|
||||||
|
|
||||||
|
// The returned text should be a suffix of the input
|
||||||
|
// (either the whole input if no frontmatter, or the part after frontmatter)
|
||||||
|
if !strings.HasSuffix(input, text) && text != input {
|
||||||
|
t.Errorf("returned text %q is not a valid suffix of input %q", text, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If input starts with delimiter and has valid frontmatter,
|
||||||
|
// text should be shorter than input
|
||||||
|
if strings.HasPrefix(input, "---\n") || strings.HasPrefix(input, "---\r\n") {
|
||||||
|
if len(fm.Tags) > 0 || fm.Syntax != "" {
|
||||||
|
// We successfully parsed frontmatter, so text should be shorter
|
||||||
|
if len(text) >= len(input) {
|
||||||
|
t.Errorf("text length %d should be less than input length %d when frontmatter is parsed",
|
||||||
|
len(text), len(input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Tags can be nil when frontmatter is not present or empty
|
||||||
|
// This is expected behavior in Go for uninitialized slices
|
||||||
|
} else {
|
||||||
|
// If parsing failed, the original input should be returned as text
|
||||||
|
if text != input {
|
||||||
|
t.Errorf("on error, text should equal input: got %q, want %q", text, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzParseDelimiterHandling specifically tests delimiter edge cases
|
||||||
|
func FuzzParseDelimiterHandling(f *testing.F) {
|
||||||
|
// Seed corpus focusing on delimiter variations
|
||||||
|
f.Add("---", "content")
|
||||||
|
f.Add("", "---")
|
||||||
|
f.Add("---", "---")
|
||||||
|
f.Add("", "")
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, prefix string, suffix string) {
|
||||||
|
// Build input with controllable parts around delimiters
|
||||||
|
inputs := []string{
|
||||||
|
prefix + "---\n" + suffix,
|
||||||
|
prefix + "---\r\n" + suffix,
|
||||||
|
prefix + "---\n---\n" + suffix,
|
||||||
|
prefix + "---\r\n---\r\n" + suffix,
|
||||||
|
prefix + "---\n" + "yaml: data\n" + "---\n" + suffix,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range inputs {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("parse panicked with constructed input: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _, _ = parse(input)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package frontmatter
|
package sheet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -16,7 +16,7 @@ tags: [ test ]
|
|||||||
To foo the bar: baz`
|
To foo the bar: baz`
|
||||||
|
|
||||||
// parse the frontmatter
|
// parse the frontmatter
|
||||||
text, fm, err := Parse(markdown)
|
fm, text, err := parse(markdown)
|
||||||
|
|
||||||
// assert expectations
|
// assert expectations
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,7 +38,7 @@ To foo the bar: baz`
|
|||||||
t.Errorf("failed to parse tags: want: %s, got: %s", want, fm.Tags[0])
|
t.Errorf("failed to parse tags: want: %s, got: %s", want, fm.Tags[0])
|
||||||
}
|
}
|
||||||
if len(fm.Tags) != 1 {
|
if len(fm.Tags) != 1 {
|
||||||
t.Errorf("failed to parse tags: want: len 0, got: len %d", len(fm.Tags))
|
t.Errorf("failed to parse tags: want: len 1, got: len %d", len(fm.Tags))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func TestHasNoFrontmatter(t *testing.T) {
|
|||||||
markdown := "To foo the bar: baz"
|
markdown := "To foo the bar: baz"
|
||||||
|
|
||||||
// parse the frontmatter
|
// parse the frontmatter
|
||||||
text, fm, err := Parse(markdown)
|
fm, text, err := parse(markdown)
|
||||||
|
|
||||||
// assert expectations
|
// assert expectations
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,7 +81,7 @@ tags: [ test ]
|
|||||||
To foo the bar: baz`
|
To foo the bar: baz`
|
||||||
|
|
||||||
// parse the frontmatter
|
// parse the frontmatter
|
||||||
text, _, err := Parse(markdown)
|
_, text, err := parse(markdown)
|
||||||
|
|
||||||
// assert that an error was returned
|
// assert that an error was returned
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -93,3 +93,20 @@ To foo the bar: baz`
|
|||||||
t.Errorf("failed to parse text: want: %s, got: %s", markdown, text)
|
t.Errorf("failed to parse text: want: %s, got: %s", markdown, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHasMalformedYAML asserts that an error is returned when the frontmatter
|
||||||
|
// contains invalid YAML that cannot be unmarshalled
|
||||||
|
func TestHasMalformedYAML(t *testing.T) {
|
||||||
|
|
||||||
|
// stub cheatsheet content with syntactically invalid YAML between the
|
||||||
|
// delimiters (a bare tab character followed by unquoted colon)
|
||||||
|
markdown := "---\n\t:\t:\n---\nBody text here"
|
||||||
|
|
||||||
|
// parse the frontmatter
|
||||||
|
_, _, err := parse(markdown)
|
||||||
|
|
||||||
|
// assert that an error was returned due to YAML unmarshal failure
|
||||||
|
if err == nil {
|
||||||
|
t.Error("failed to error on malformed YAML frontmatter")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user