Compare commits

..

292 Commits

Author SHA1 Message Date
Christopher Allen Lane
ecfb83a3b0 docs: clean up project documentation
- Convert all headings from underline style to ATX style
- Fix heading hierarchy in INSTALLING.md (H3→H2, H4→H3)
- Move Installing section before Usage in README
- Bump install snippet version from 4.5.1 to 4.7.0
- Add feature request guidance to CONTRIBUTING.md
- Add installer package and .cheat discovery to CLAUDE.md
- Remove unused reference links and dead HTML comments
- Trim trailing whitespace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:56:58 -05:00
Christopher Allen Lane
9440b4f816 test: remove redundant tests
Remove integration tests in config_extended_test.go that duplicate
unit tests in config_test.go for findLocalCheatpath. Remove redundant
fuzz functions (FuzzFindLocalCheatpathNearestWins, FuzzTagsStress),
a duplicate parse test (TestParseInvalidYAML), and a permanently-skipped
interactive test (TestPromptIntegration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:46:17 -05:00
Christopher Allen Lane
971be88150 test: audit and clean up test suite
Delete tautological, no-assertion, and permanently-skipped tests.
Rewrite false-confidence tests that couldn't detect the bugs they
claimed to test. Decouple brittle assertions from error message
strings and third-party library output. Fix misleading test names
and error messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:42:11 -05:00
Christopher Allen Lane
d4a8a79628 chore: bump version to 4.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:24:54 -05:00
Christopher Allen Lane
007c9f9efe Merge branch 'brief-list-flag' 2026-02-15 12:24:42 -05:00
Christopher Allen Lane
f61203ac1b feat: add -b/--brief flag for compact list output (#505)
Add a --brief flag that lists cheatsheets with only title and tags,
omitting the file path column. Can be used standalone (-b) or combined
with --list (-lb), and composes with existing filters (-t, -p).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:21:35 -05:00
Christopher Allen Lane
f1db4ee378 Merge branch 'recursive-cheat-directory-search'
feat: walk up directory tree to find .cheat directory (#602)
2026-02-15 11:23:37 -05:00
Christopher Allen Lane
366d63afdc chore: bump version to 4.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:23:31 -05:00
Christopher Allen Lane
c1551683a3 feat: walk up directory tree to find .cheat directory (#602)
Previously cheat only checked the current working directory for a .cheat
subdirectory. Now it walks upward through ancestor directories, stopping
at the first .cheat directory found. This mirrors how git discovers .git
directories, so users can place .cheat at their project root and have it
work from any subdirectory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:30:29 -05:00
Christopher Allen Lane
09aad6f8ea docs: document supported syntax values in README
Adds a note explaining that syntax highlighting is powered by Chroma,
with a link to Chroma's supported languages list.

Closes #766

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:55:37 -05:00
Christopher Allen Lane
adb5a43810 chore: bump version to 4.5.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:35:59 -05:00
Christopher Allen Lane
cab039a9d8 docs: move ADRs to project root, remove boilerplate README
Move `doc/adr/` to `adr/` for discoverability. Remove the generic
ADR README — `ls adr/` serves the same purpose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:32:40 -05:00
Christopher Allen Lane
97e80beceb fix: match .git as complete path component, not suffix
Searching for `.git/` in file paths incorrectly matched directory names
ending with `.git` (e.g., `personal.git/cheat/hello`), causing sheets
under such paths to be silently skipped. Fix by requiring the path
separator on both sides (`/.git/`), so `.git` is only matched as a
complete path component.

Rewrites test suite with comprehensive coverage for all six documented
edge cases, including the #711 scenario and combination cases (e.g.,
a real .git directory inside a .git-suffixed parent).

Closes #711

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:32:35 -05:00
Christopher Allen Lane
1969423b5c fix: respect $VISUAL and $EDITOR env vars at runtime
Previously, env vars were only consulted during config generation
and baked into conf.yml. At runtime, the config file value was
always used, making it impossible to override the editor via
environment variables.

Now the precedence is: $VISUAL > $EDITOR > conf.yml > auto-detect.

Closes #589

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:54:42 -05:00
Christopher Allen Lane
4497ce1b84 ci: remove dead Homebrew formula bump workflow
This workflow has been failing for years due to an expired/missing
COMMITTER_TOKEN. Homebrew maintains their own automated version
bump pipeline, making this redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:43:01 -05:00
Christopher Allen Lane
5eee02bc40 build: produce static binaries with CGO_ENABLED=0
Eliminates glibc version mismatch errors when running release
binaries on systems with older glibc versions.

Closes #744

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:39:20 -05:00
Christopher Allen Lane
2d50c6a6eb chore: bump version to 4.5.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:26:17 -05:00
Christopher Allen Lane
6f919fd675 fix: comment out community cheatpath in --init output (#773)
cheat --init now comments out the community cheatpath by default and
includes a git clone instruction with the resolved path. This prevents
warnings about missing directories when users save the --init output
as their config without also cloning community cheatsheets.

Closes #773

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 06:21:48 -05:00
Christopher Allen Lane
fd1465ee38 fix: avoid stdin buffering bug in installer prompts
Prompt() created a new bufio.NewReader(os.Stdin) on each call, which
buffered all piped input on the first call and left nothing for
subsequent prompts. This made cheat un-scriptable (e.g., piping answers
via printf). Fix by reading one byte at a time from os.Stdin directly.

Also adds an end-to-end integration test for the first-run experience
(regression test for #721, #771, #730) and bumps the Dockerfile to
Go 1.26.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:51:30 -05:00
Christopher Allen Lane
00ec2c130d fix: resolve first-run experience errors (#721, #771, #730)
- cmdInit (--init) now substitutes EDITOR_PATH, PAGER_PATH, and
  WORK_PATH instead of leaving them as literal strings
- Installer now substitutes WORK_PATH and always creates personal
  and work directories regardless of community cheatsheet choice
- When community cheatsheets are declined, the community cheatpath
  is commented out in the generated config
- config.New() skips nonexistent cheatpaths with a warning instead
  of hard-erroring on EvalSymlinks failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:32:20 -05:00
Christopher Allen Lane
8eafa5adfe fix: cross-platform CI test fixes and parse bug fix
- Add .gitattributes to force LF in mock files (Windows autocrlf)
- Fix parse.go: detect line endings from content instead of runtime.GOOS
- Add fail-fast: false to CI matrix; trigger on all branch pushes
- Skip chmod-based tests on Windows (permissions work differently)
- Use filepath.Join for expected paths in Windows path tests
- Use platform-appropriate invalid paths in error tests
- Add Windows absolute path test case for ValidateSheetName
- Skip Unix-specific integration tests on Windows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:31:26 -05:00
Christopher Allen Lane
b604027205 fix: make tests pass on macOS CI runners
- Resolve symlinks in temp dir paths (macOS /var -> /private/var)
- Pre-create non-empty community dir to ensure clone fails reliably
  regardless of network access on CI runners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:05:16 -05:00
Christopher Allen Lane
2a19755804 chore: modernize CI and update Go toolchain
- Bump Go from 1.19 to 1.26 and update all dependencies
- Rewrite CI workflow with matrix strategy (Linux, macOS, Windows)
- Update GitHub Actions to current versions (checkout@v4, setup-go@v5)
- Update CodeQL actions from v1 to v3
- Fix cross-platform bug in mock/path.go (path.Join -> filepath.Join)
- Clean up dependabot config (weekly schedule, remove stale ignore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:58:51 -05:00
Christopher Allen Lane
cc85a4bdb1 chore: bump version to 4.5.0
Bug fixes:
- Fix inverted pager detection logic (returned error instead of path)
- Fix repo.Clone ignoring destination directory parameter
- Fix sheet loading using append on pre-sized slices
- Clean up partial files on copy failure
- Trim whitespace from editor config

Security:
- Add path traversal protection for cheatsheet names

Performance:
- Move regex compilation outside search loop
- Replace string concatenation with strings.Join in search

Build:
- Remove go:generate; embed config and usage as string literals
- Parallelize release builds
- Add fuzz testing infrastructure

Testing:
- Improve test coverage from 38.9% to 50.2%
- Add fuzz tests for search, filter, tags, and validation

Documentation:
- Fix inaccurate code examples in HACKING.md
- Add missing --conf and --all options to man page
- Add ADRs for path traversal, env parsing, and search parallelization
- Update CONTRIBUTING.md to reflect project policy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:56:19 -05:00
Chris Allen Lane
7908a678df Merge pull request #742 from cheat/dependabot/go_modules/github.com/cloudflare/circl-1.3.7
chore(deps): bump github.com/cloudflare/circl from 1.3.6 to 1.3.7
2024-01-08 15:34:48 -05:00
dependabot[bot]
7c0eacb53d chore(deps): bump github.com/cloudflare/circl from 1.3.6 to 1.3.7
Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.3.6 to 1.3.7.
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.3.6...v1.3.7)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-08 16:53:49 +00:00
Chris Allen Lane
4bf804ac60 Merge pull request #741 from cheat/dependabot/go_modules/golang.org/x/crypto-0.17.0
chore(deps): bump golang.org/x/crypto from 0.16.0 to 0.17.0
2023-12-19 14:51:50 -05:00
dependabot[bot]
33c5918087 chore(deps): bump golang.org/x/crypto from 0.16.0 to 0.17.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-19 00:07:38 +00:00
Christopher Allen Lane
d34177729d chore: bump version to 4.4.2 2023-12-15 12:48:39 -05:00
Chris Allen Lane
7fa50328d7 Merge pull request #740 from chrisallenlane/chroma
chore(deps): bump chroma to v2 #735
2023-12-13 12:59:52 -05:00
Christopher Allen Lane
1790aec85d chore(deps): bump chroma to v2 #735
Bump `alecthomas/chroma` to `v2`:
https://github.com/cheat/cheat/issues/735
2023-12-13 12:54:32 -05:00
Mikel Olasagasti Uranga
6bf51e758f Use yaml.v3 rather than .v2 and .v1 2023-12-13 09:57:58 -05:00
Christopher Allen Lane
242da8c89a chore(build): remove plan9 support
Remove `plan9` support from the `Makefile` because the executable now
fails to build.
2023-12-13 09:45:21 -05:00
Chris Allen Lane
2294f40ee0 Merge pull request #739 from chrisallenlane/v4.4.1
V4.4.1
2023-12-13 09:17:16 -05:00
Christopher Allen Lane
fe25019b14 chore: bump version to 4.4.1 2023-12-13 09:12:14 -05:00
Christopher Allen Lane
bfb071c0b2 chore(lint): various changes to appease revive
- Add `package` comments
- Rename `opts` to `_` where unused
2023-12-13 09:10:20 -05:00
Christopher Allen Lane
95a4e31b6c chore(deps): upgrade dependencies
Upgrade all dependencies to newest versions.
2023-12-13 08:29:02 -05:00
Chris Allen Lane
0d9c92c8c0 Merge pull request #707 from chrisallenlane/v4.4.0
V4.4.0
2022-11-05 13:11:22 -04:00
Christopher Allen Lane
16c50bb659 chore: bump version to 4.4.0 2022-11-05 12:56:43 -04:00
Christopher Allen Lane
1a85c9e9c8 feat: platform compatibility
Add experimental support for the following platforms:

- aix
- dragonfly
- illumos
- ios
- netbsd
- openbsd
- plan9
- solaris
2022-11-05 12:00:43 -04:00
Christopher Allen Lane
c9ccefa607 chore(deps): remove yaml.v1
Remove errant `yaml.v1` dependency, and use `yaml.v2` everywhere.
2022-11-05 11:39:48 -04:00
Christopher Allen Lane
3a6b6e58f0 chore(deps): update dependencies
`make vendor-update`
2022-11-05 10:15:15 -04:00
Christopher Allen Lane
2edc0ee299 chore: add a comment
Add a small comment regarding a tricky edge-case in `gitdir.go`.
2022-08-28 06:54:29 -04:00
Chris Allen Lane
bd9fa1ba70 Merge pull request #700 from chrisallenlane/4.3.3
4.3.3
2022-08-27 22:07:19 -04:00
Christopher Allen Lane
bb85e611f4 chore: bump version to 4.3.3 2022-08-27 21:29:06 -04:00
Christopher Allen Lane
a2f538f114 refactor(repo): create repo package
- Refactor `installer.clone` into new `repo.Clone` package and method.

- Refactor `sheets.isGitDir` into `repo.GitDir`.

Both of these changes read better, and will facilitate cleaner
architecture when `--update` is implemented.
2022-08-27 21:02:48 -04:00
Christopher Allen Lane
80c91cbdee feat(installer): use go-git to clone
Integrate `go-git` into the application, and use it to `git clone`
cheatsheets when the installer runs.

Previously, the installer required that `git` be installed on the system
`PATH`, so this change has to big advantages:

1. It removes that system dependency on `git`
2. It paves the way for implementing the `--update` command

Additionally, `cheat` now performs a `--depth=1` clone when installing
cheatsheets, which should at least somewhat improve installation times
(especially on slow network connections).
2022-08-27 21:00:46 -04:00
Christopher Allen Lane
ede2d2dbaa fix(Sheets): .gitignore in cheatpath (#699)
Fix an issue whereby `cheat` would crash if a cheatpath contained a file
that began with `.git`, like `.gitignore`.
2022-08-27 20:57:07 -04:00
Chris Allen Lane
db3d7e53a4 Merge pull request #698 from chrisallenlane/4.3.2
4.3.2
2022-08-26 14:07:14 -04:00
Christopher Allen Lane
06c4ff52fc chore: bump version to 4.3.2 2022-08-26 13:56:35 -04:00
Christopher Allen Lane
cbc2638d96 fix(docopt): whitespace typo in --help output
Fix a whitespace (alignment) typo in the `--help` output.
2022-08-26 13:56:35 -04:00
Christopher Allen Lane
fd93da799d fix(sheets): cheatsheets in submodules (#694)
Resolve an issue whereby cheatsheets contained within `git` submodules
were ignored due to a regression introduced in `4.3.1`.
2022-08-26 13:55:09 -04:00
Christopher Allen Lane
5c5ed7344f chore(docs): improve configuration docs (#656)
Improve the configuration documentation in `configs/conf.yml` (#656).
2022-08-26 13:55:09 -04:00
Christopher Allen Lane
d773383f70 fix(build): Makefile corrections
- Fix an issue whereby `make clean` filed to remove assets created by
  `make generate`.

- Fix a subsequent issue whereby `make generate` was being run too late
  in the `make build` target, which resulted in a build failure.
2022-08-26 13:55:09 -04:00
Christopher Allen Lane
2717044b62 chore(docs): update INSTALLING.md (#677)
Update `INSTALLING.md` with more package information. See: #677
2022-08-26 13:55:09 -04:00
Christopher Allen Lane
2d635293c5 refactor(Sheet): create parse method
Move `Frontmatter.Parse` to `Sheet.parse`, and delete the `frontmatter`
package. `Sheet.parse` more accurately describes the parser's behavior.
2022-08-26 13:55:09 -04:00
Christopher Allen Lane
f0bfeda47a fix(frontmatter): do not trim whitespace (#663)
Do not strip leading or trailing newlines. Doing so had interferred with
users' intended cheatsheet layouts.
2022-08-26 13:55:09 -04:00
Christopher Allen Lane
f1540290a7 chore(deps): update dependencies 2022-08-26 11:06:21 -04:00
Chris Allen Lane
0b80a608c3 Merge pull request #692 from chrisallenlane/4.3.1
4.3.1
2022-08-08 21:01:56 -04:00
Christopher Allen Lane
3c1e24a0e8 chore: bump version to 4.3.1 2022-08-08 20:38:23 -04:00
Christopher Allen Lane
2a6586b41b fix(installer): always use more pager on Windows 2022-08-08 20:38:23 -04:00
Christopher Allen Lane
6421953183 feat(installer): set default editor
Attempt to set and locate a default editor when running the installer.
2022-08-08 20:14:27 -04:00
Christopher Allen Lane
0c47f44ff9 fix: no colorization on default install (#687)
Fix an issue whereby a default installation (as created by the
installer) would (seemingly) fail to output colorized text, even when
the `-c` flag was passed.

The root cause of the problem was that the installer did not set a
default `style` for `chroma`, which in turn defaulted to using the `bw`
(black-and-white) style.

Thus, colorization actually *was* being applied with `-c` - it was
simply black and white!
2022-08-08 19:45:32 -04:00
Christopher Allen Lane
77f9c3fdd0 fix(Sheets): cheatsheets in hidden directories (#690)
Fix an issue whereby cheatsheets that were contained within hidden
directories were prevented from being loaded.
2022-08-08 19:17:59 -04:00
Chris Allen Lane
b53a14b1a7 Merge pull request #691 from chrisallenlane/4.3.0
4.3.0
2022-08-07 14:45:27 -04:00
Christopher Allen Lane
f1e8602369 chore: bump version to 4.3.0 2022-08-07 14:11:47 -04:00
Christopher Allen Lane
ddbe710881 feat: add the --conf command
Add the `--conf` command, which dipslay's the current `cheat`
configuration file path.
2022-08-07 14:08:25 -04:00
Christopher Allen Lane
d598d96fce fix(Config): colorization without pager (#687)
Fix an issue whereby colorization would output ANSI codes if a pager was
not configured.

The solution here is to stop guessing about the state of the user's
system at runtime, as well as the user's intention. The installer now
chooses an appropriate installer when generating configs, and no longer
bothers searching for pagers at runtime.
2022-08-07 10:19:56 -04:00
Christopher Allen Lane
4fdec50487 chore(deps): upgrade dependencies
- Upgrade to Go 1.19 in `go.mod`
- Upgrade dependencies
2022-08-07 10:19:56 -04:00
Chris Allen Lane
9de866dfb6 Merge pull request #689 from chrisallenlane/ci
chore(ci): YAML lints
2022-08-05 07:53:53 -04:00
Christopher Allen Lane
eb99a070ce chore: CI template nits
- Fix YAML nits per `yamllint`
- Upgrade Go version to `1.19`
2022-08-05 07:49:20 -04:00
Chris Allen Lane
73f80bde48 Merge pull request #688 from chrisallenlane/4.2.7
4.2.7
2022-08-05 07:01:51 -04:00
Chris Allen Lane
8130b2f3bd chore: bump version to 4.2.7 2022-08-05 06:41:16 -04:00
Christopher Allen Lane
f4e6c76e58 fix: escape sequences in search output (#687)
Fix an issue whereby ANSI escape characters could appear in search
output when a pager was not configured.

The root cause of the problem was code that was overzealously applying
an underlying effect to search terms.

This commit simply rips out underlying entirely, both as means of
resolving this problem, and also simply for removing needless visual
noise from search output.
2022-08-05 06:41:16 -04:00
Chris Allen Lane
85f5ae8ec7 chore: various lint corrections
Make various lint corrections in order to appease `staticcheck`.
2022-08-04 20:43:50 -04:00
Chris Allen Lane
484b447391 perf(Sheets): do not walk hidden directories
Modify `Sheets.Load` to not walk hidden directories like `.git`. This
optimization can potentially prevent thousands of system calls from
being made, because `.git` directories can contain many files.
2022-08-04 20:43:42 -04:00
Chris Allen Lane
cfd1702bc6 Merge pull request #685 from chrisallenlane/page-bug
Fix #681
2022-08-02 20:37:48 -04:00
Christopher Allen Lane
7406ebfb5e chore(deps): update dependencies 2022-08-02 20:30:31 -04:00
Christopher Allen Lane
0737af2fec fix: pagination error on Linux
Bury the `more` pager default on Linux in an effort to work around the
following problem:

https://github.com/cheat/cheat/issues/681#issuecomment-1201842334

We're satisficing for this kludge because it does not appear to be
possible to actually make `more` perform as expected in all
environments.
2022-08-02 20:27:56 -04:00
Christopher Allen Lane
a23d372d1f docs(INSTALLING): nix link
Update the `nix` package information.
2022-07-06 08:11:42 -04:00
Chris Allen Lane
fe66ff3768 Merge pull request #679 from chrisallenlane/hacking-md
docs: create `HACKING.md`
2022-07-05 15:11:09 -04:00
Christopher Allen Lane
7fed1f63a6 docs: create HACKING.md
Create a `HACKING.md` file for onboarding new developers to `cheat`.
2022-07-05 15:07:34 -04:00
Christopher Allen Lane
a297d1619c chore(build): remove make docker-run
Remove the `docker-run` `make` target, which was added in haste. It was
entirely redundant with `docker-sh`.
2022-07-05 14:40:15 -04:00
Chris Allen Lane
ef1da90a77 Merge pull request #678 from chrisallenlane/go-install
Build/CI corrections
2022-07-05 12:24:48 -04:00
Christopher Allen Lane
d8f405c112 chore(ci): use Go 1.18 in CI
Use Go 1.18 in the CI pipeline.
2022-07-05 11:59:08 -04:00
Chris Allen Lane
f8403ff241 Merge pull request #676 from chrisallenlane/install-md
docs: create `INSTALLING.md`
2022-07-05 11:24:02 -04:00
Christopher Allen Lane
65f6be3fd8 docs: create INSTALLING.md
- Create `INSTALLING.md`
- Update the `README.md`
2022-07-05 11:19:40 -04:00
Chris Allen Lane
1cb53697d2 Merge pull request #674 from cheat/4.2.5
4.2.5
2022-07-04 22:37:19 -04:00
Christopher Allen Lane
14f321b0e6 chore: bump version to 4.2.5 2022-07-04 22:00:35 -04:00
Christopher Allen Lane
d3250fda79 chore(deps): upgrade vendored dependencies 2022-07-04 22:00:35 -04:00
Zhizhen He
c482488c41 fix: replace Parse with ParseArgs
Parse() is deprecated
2022-07-04 22:00:35 -04:00
Chris Allen Lane
fe8f39013e Merge pull request #673 from chrisallenlane/win-compat
fix: Windows compatibility
2022-07-04 17:03:11 -04:00
Christopher Allen Lane
1016b20ef2 chore: bump version to 4.2.4
Bump version to `4.2.4`. This version contains numerous Windows fixes
and improvements.
2022-07-04 16:58:58 -04:00
Christopher Allen Lane
def8985dcd fix: Windows support
Fix an issue whereby the installer installed cheatsheets into the wrong
directory on Windows. This occurred because previously `path.Join` was
used where `path/filepath.Join` should have been used.

This matters, because the former always uses `/` as the path separator,
whereas the latter will use `/` or `\` as is appropriate for the
runtime environment.

This should resolve bullet point 4 in #665.
2022-07-04 16:55:57 -04:00
Christopher Allen Lane
e6f12147df fix: config fixes for Windows
- Update the default config file to use `more` instead of `less` as the
  default pager, in order to support Windows out-of-the-box. (#655, #665).

- Use `terminal` Chroma formatter (rather than `terminal16m`) in order
  to accommodate less capable terminal emulators like `cmd.exe` by
  default. Similarly, default to `colorize: false` in configs (changed
  from `true`) (#665).

- Comment out default `style` in order to avoid printing ANSI color
  codes into terminals without color support (#665)

- Attempt to intelligently choose a default editor, rather than rely on
  a hard-coded `vim` in the configs. This should make it easier to use
  `cheat` immediately without needing to specify configs. It should also
  improve `cheat`'s Windows compatibility. (#665)
2022-07-04 16:06:37 -04:00
Christopher Allen Lane
a8c2c396ed feat(build): crate docker-run target
Create a `docker-run` `make` target for opening a shell in an Alpine
container for development.
2022-07-04 13:13:27 -04:00
Christopher Allen Lane
35262df4f2 fix(build): Windows executable packaging
Fix an issue whereby the Windows zip release contained an extraneous
(and annoying) `dist` parent directory.
2022-07-04 12:34:06 -04:00
Chris Allen Lane
12ffa4cb5c Merge pull request #644 from cheat/develop
Windows fixes, Android support
2021-10-09 12:13:01 -04:00
Chris Allen Lane
d9c602f9e1 Merge pull request #643 from chrisallenlane/android
fix(Paths): Android support
2021-10-09 11:30:18 -04:00
Christopher Allen Lane
b67ff8b6a8 fix(Paths): Android support
Add `"android"` to the explicit whitelist of supported operating
systems.  This may resolve incompatibilities with certain Android
environments.
2021-10-09 11:27:38 -04:00
Christopher Allen Lane
a500a621a1 chore: bump version
Bump version to 4.2.3.
2021-10-09 10:59:02 -04:00
Chris Allen Lane
23b6928874 Merge pull request #639 from mattn/fix-windows
Fix Windows
2021-10-09 10:10:39 -04:00
Chris Allen Lane
9de39fb12b Merge pull request #634 from cheat/dependabot/go_modules/github.com/mattn/go-isatty-0.0.14
chore(deps): bump github.com/mattn/go-isatty from 0.0.13 to 0.0.14
2021-10-09 09:51:49 -04:00
Chris Allen Lane
ad501c4cbe Merge pull request #641 from OmgImAlexis/patch-1
chore: fix typo in comment
2021-10-09 09:39:41 -04:00
Christopher Allen Lane
f17de401e5 docs(CONTRIBUTING): pr against develop
Add a note to `CONTRIBUTING.md` requesting that contributors open
pull-requests against the `develop` branch.
2021-10-09 09:34:23 -04:00
Alexis Tyler
2c097adeda chore: fix typo in comment 2021-09-30 07:30:20 +09:30
Yasuhiro Matsumoto
b825e0f535 Fix Windows 2021-09-29 01:33:59 +09:00
dependabot[bot]
8385277b28 chore(deps): bump github.com/mattn/go-isatty from 0.0.13 to 0.0.14
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.13 to 0.0.14.
- [Release notes](https://github.com/mattn/go-isatty/releases)
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.13...v0.0.14)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-isatty
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-07 22:10:46 +00:00
Chris Lane
768d55e5d4 chore: bump version
Bump version to `4.2.2`.
2021-06-08 21:02:03 -04:00
Chris Lane
6aedc5c116 chore: whitespace edit on Makefile 2021-06-08 20:59:57 -04:00
Chris Lane
e881bb1f97 chore: update go.sum 2021-06-08 20:59:57 -04:00
Chris Lane
501f9c66ad deps: upgrade dependencies 2021-06-08 20:59:57 -04:00
Pablo Lecolinet
a2aa82d9f3 Add ARM64/ARMv8 build 2021-06-08 20:59:57 -04:00
PabloLec
018bce7ad5 Fix ZSH autocompletion 2021-06-07 11:42:47 +02:00
Chris Allen Lane
17acefdd9b Merge pull request #617 from bernermic/master
Adds some git helper scripts
2021-05-14 12:21:26 -04:00
Michael Berner
37918e09a4 Adds some git helper scripts 2021-05-07 20:53:54 +02:00
Chris Allen Lane
86967873a8 Merge pull request #623 from cheat/github-actions
chore: migrate into Github Actions
2021-05-03 17:02:27 -04:00
Chris Lane
d237d98c15 chore: migrate into Github Actions
Replace Travis CI integration in favor of Github Actions.
2021-05-03 16:43:27 -04:00
Chris Allen Lane
eb9b3e7798 Merge pull request #624 from cheat/dependabot/add-v2-config-file
chore: upgrade to GitHub-native Dependabot
2021-05-03 14:32:08 -04:00
dependabot-preview[bot]
b0a351033d Upgrade to GitHub-native Dependabot 2021-04-29 20:40:56 +00:00
Chris Allen Lane
1eb44e8809 Merge pull request #621 from chrisallenlane/v4.2.1
Squashed commit of the following:
2021-04-28 12:55:17 -04:00
Chris Lane
55b18b4897 Squashed commit of the following:
commit 95479c8ad744db48386a5c78e54ef8da80e9120b
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Wed Apr 28 12:26:32 2021 -0400

    chore(version): bump version to 4.2.1

commit 6956f51cae
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Wed Apr 28 12:24:21 2021 -0400

    fix(Makefile): `vendor-update`

    Update the `vendor-update` build target to run `go mod vendor` after
    updating dependencies.

commit 0aca411279
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Wed Apr 28 12:23:24 2021 -0400

    chore(deps): update dependencies

commit e847956b02
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Wed Apr 28 08:26:51 2021 -0400

    chore(deps): build updates

    - Upgrade `go` to `1.16.3`

    - Attempt to fix build errors regarding dependencies
2021-04-28 12:35:32 -04:00
Chris Allen Lane
883a17092f Merge pull request #606 from chrisallenlane/4.2.0
4.2.0
2020-11-28 11:27:09 -05:00
Chris Lane
4f2a57fce8 fix(view): whitespace corrections
- Fix bug whereby `--all` flag would conflict with pager

- Fix whitespace inconsistencies among view and search outputs
2020-11-28 11:18:16 -05:00
Chris Lane
ecc96c64f9 refactor(installer): externalize installer
Move installation-related code out of `main.go` and into a new
`installer.Run` method.
2020-11-28 10:32:37 -05:00
Chris Lane
a81dd96ff4 fix: rename display.go
Rename `display.go` to `write.go`. (I forgot to do this previously.)
2020-11-27 23:05:02 -05:00
Chris Lane
fb538baba5 chore(version): bump to 4.2.0 2020-11-27 22:57:25 -05:00
Chris Lane
1a7b5c6127 feat(display): make Faint respect Colorize
Make `display.Faint` respect the `Colorize` config value.
2020-11-27 22:50:55 -05:00
Chris Lane
cdddfbb516 chore: rename display.Display
Rename `display.Display` to `display.Write` for clarity and to reduce
"stutter".
2020-11-27 22:35:24 -05:00
Chris Lane
4ef4c35d8c feat(search): search all cheatpaths
Update the search function. It now searches all cheatpaths all the time,
as if `--all` were implicitly passed.
2020-11-27 22:31:16 -05:00
Chris Lane
a58294859e chore: spelling
`s/pathSheets/pathsheets/g` in `cmd_list` for consistency elsewhere.
2020-11-27 22:26:14 -05:00
Chris Lane
606092e288 feat(search): improve search output formatting
Improve the search output formatting.
2020-11-27 17:06:02 -05:00
Chris Lane
233a9de1aa feat: implement --all flag
Implement an `--all` flag that can be used to view cheatsheets on all
chaetpaths. (Resolves #548)
2020-11-27 16:39:34 -05:00
Chris Lane
aa16f68620 feat(display): add methods to display
- Add `indent`, `faint`, and `underline` methods to `display`
- Add tests for the above
2020-11-27 16:14:33 -05:00
Chris Lane
367673d5d9 chore(dependencies): update dependencies
Run `make vendor-update`.
2020-11-27 09:51:39 -05:00
Chris Lane
08fb9e11a9 feat(Makefile): add vendor-update
Add `vendor-update` target to `Makefile`, which updates all dependencies
to their newest versions.
2020-11-27 09:50:11 -05:00
Chris Lane
3f4d4bddb2 feat(tests): add unit-tests
Add unit-tests for `sheets.Load`.
2020-11-11 19:33:31 -05:00
Chris Allen Lane
6c6753b35c Merge pull request #599 from chrisallenlane/issue-597
fix: update installation instructions in README
2020-11-07 18:56:37 -05:00
Chris Lane
0718b606e1 fix(README): clarify installation verbiage
Update the installation verbiage in the `README` for clarity
(issue #597).
2020-11-07 18:48:24 -05:00
Chris Lane
857119b443 feat(Docker): create development Docker image
- Create Docker image to be used for experimentation during development
- Create targets in `Makefile` pertaining to the above
2020-11-07 18:47:24 -05:00
Chris Allen Lane
f421483eea Merge pull request #596 from chrisallenlane/v4.1.1
v4.1.1
2020-11-03 18:32:25 -05:00
Chris Lane
4adddbf504 chore: bump version to v4.1.1 2020-11-03 18:05:46 -05:00
Chris Lane
b9c86b6975 chore(dependencies): update dependencies 2020-11-03 17:59:56 -05:00
Chris Lane
0b21ccf6f8 feat(tests): improve test coverage 2020-11-03 17:29:49 -05:00
Chris Allen Lane
a3ad8c5101 Merge pull request #595 from chrisallenlane/codeql
feat: integrate CodeQL build action
2020-11-01 10:51:38 -05:00
Chris Lane
bacb74929a feat: integrate CodeQL build action 2020-11-01 10:47:25 -05:00
Chris Allen Lane
82e1c27494 Merge pull request #588 from chrisallenlane/bare-tag
feat: implement `cheat -t` shorthand
2020-09-05 09:05:09 -04:00
Chris Lane
45beeb2edb chore: bump version to 4.1.0 2020-09-05 08:56:51 -04:00
Chris Lane
c2c479b36c feat: support -t shorthand
Make `cheat -t <tag>` function as a shorthand for `cheat -l -t <tag>`.
2020-09-02 17:17:44 -04:00
Chris Allen Lane
cb0243e7fc Merge pull request #580 from ryaanwells/patch-1
Fixing "cheetsheet" typo in README.md
2020-08-23 15:25:55 -04:00
Ryan Wells
e5d04d41ea Fixing "cheetsheet" typo in tags_test.go 2020-08-21 15:45:49 +01:00
Ryan Wells
2474ea4fb1 Fixing "cheetsheet" typo in README.md 2020-08-18 14:53:15 +01:00
Chris Allen Lane
7467c9fbc0 Merge pull request #578 from chrisallenlane/v4.0.3
chore: update dependencies
2020-08-08 13:45:10 -04:00
Chris Lane
dfba3da003 chore: update dependencies 2020-08-08 10:29:29 -04:00
Chris Allen Lane
ad7ad64a75 Merge pull request #573 from chrisallenlane/linux-conf
fix(config): add /etc/cheat config path
2020-07-11 08:12:04 -04:00
Chris Lane
c4dcfd5da0 fix(config): add /etc/cheat config path
Add `/etc/cheat/conf.yml` to default config paths. See #568 for context.
2020-07-09 18:28:10 -04:00
Chris Allen Lane
278a5d9154 Merge pull request #570 from cheat/v4.0.1
fix(search): fix pagination error
2020-06-30 07:26:30 -04:00
Chris Lane
9fa0c466fd fix(search): fix pagination error
Fix the paginator when used in combination with the `-s` (search)
subcommand. Previously, it would not behave as intended, because `cheat`
was writing to `stdout` at inappropriate times.
2020-06-30 07:21:21 -04:00
Chris Allen Lane
4e9b2928b3 Merge pull request #569 from chrisallenlane/dev
v4.0.0
2020-06-25 19:05:37 -04:00
Chris Lane
fa5eb44be8 chore: bump version to 4.0.0 2020-06-25 18:53:27 -04:00
Chris Lane
49afd7c16b feat: modify return codes
Modify exit codes. `cheat` now returns an exit code value `2` on errors
pertaining to a cheatsheet not being found.

BREAKING CHANGE
2020-06-25 18:38:03 -04:00
Chris Lane
59d5c96c24 feat(pagination): implement paginated output
Implement a `pager` config option. If configured, `cheat` will
automatically pipe output through the configured pager (where
appropriate).
2020-06-25 18:21:51 -04:00
Chris Allen Lane
8e602b0e93 Merge pull request #563 from chrisallenlane/3.10.1
feat(makefile): support 32-bit systems
2020-05-14 20:11:14 -04:00
Chris Lane
fb04cb1fcd feat(makefile): support 32-bit systems
- Update the `Makefile` to additionally output a 386 binary (#562)
- Update the dependencies (#561)
- Bump version
2020-05-14 20:02:35 -04:00
Chris Allen Lane
d42726101e Merge pull request #560 from cheat/3.10.0
3.10.0
2020-05-11 20:20:09 -04:00
Chris Lane
93b3a711f5 chore: bump version to 3.10.0 2020-05-11 20:15:26 -04:00
Chris Lane
9c3d41c8bd Merge branch 'syohex-syohex/improve-completion' into 3.10.0 2020-05-11 20:13:58 -04:00
Chris Lane
4eeec6c868 chore(deps): resolve dependency conflict 2020-05-11 20:12:28 -04:00
dependabot-preview[bot]
1b17ab1914 chore(deps): bump github.com/alecthomas/chroma from 0.7.1 to 0.7.3
Bumps [github.com/alecthomas/chroma](https://github.com/alecthomas/chroma) from 0.7.1 to 0.7.3.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Changelog](https://github.com/alecthomas/chroma/blob/master/.goreleaser.yml)
- [Commits](https://github.com/alecthomas/chroma/compare/v0.7.1...v0.7.3)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-05-11 07:20:59 +00:00
Shohei YOSHIDA
477650ee44 Improve zsh completion 2020-04-12 11:03:08 +09:00
Chris Allen Lane
c4dd3b52fd Merge pull request #554 from chrisallenlane/master
v3.9.0
2020-04-07 18:59:18 -04:00
Chris Lane
e8a0ea0dc3 chore(version): bump version to 3.9.0 2020-04-07 18:50:00 -04:00
Chris Lane
992ee66a56 Merge branch 'add-homebrew-release-flow' of https://github.com/chenrui333/cheat into v3.9.0 2020-04-07 18:48:57 -04:00
Chris Lane
c9840c2d6f docs(autocompletion): update autocompletion docs
Update the autocompletion documentation (in `README` and `man` page) to
include information about configuring `zsh` autocompletion script.
2020-04-07 18:47:35 -04:00
Chris Lane
bd53768f67 chore(zsh): set execute perm on cheat.zsh
Set execute permission on the `zsh` autocompletion script.
2020-04-07 18:41:00 -04:00
raochsinai
8092687956 change taglist and pathlist to local variable. 2020-04-07 14:56:34 +08:00
raochsinai
16ade50672 add complete script for zsh. 2020-04-03 15:34:01 +08:00
Rui Chen
62c80d76eb Automate homebrew release flow 2020-03-24 21:21:54 -04:00
Chris Allen Lane
3e67eaa3b7 Merge pull request #546 from chrisallenlane/issue-272
docs(man): implement manpage
2020-03-24 20:55:57 -04:00
Chris Lane
38b13655fe chore(version): bump version to 3.8.0 2020-03-24 20:54:40 -04:00
Chris Lane
749d5c1182 docs(man): implement manpage
- Implement `make man` to generate a manpage

- Change verb tense in `make` help text
2020-03-24 20:46:48 -04:00
Chris Allen Lane
521f83377c Merge pull request #545 from chrisallenlane/issue-544
fix(frontmatter): resolve #544
2020-03-11 19:03:06 -04:00
Chris Lane
b15ff10537 chore(version): bump version to 3.7.1 2020-03-11 18:55:59 -04:00
Chris Lane
5288bd0c1c fix(frontmatter): resolve issue #544
- Fix error in `frontmatter.Parse` which would cause a `panic` when
  encountering malformed frontmatter

- Add a unit-test to cover the above
2020-03-11 18:54:46 -04:00
Chris Lane
bddbee4158 fix(logging): improve sheets load logging
Improve logging in `sheets.Load`:

- Make error verbage consistant with verbiage elsewhere

- Add more information to logging statements
2020-03-11 18:51:06 -04:00
Chris Lane
ce27cf2cc0 chore(version): bump version to 3.7.0 2020-03-06 20:19:31 -05:00
Chris Lane
5733b1d6d4 chore(vendor): update vendor dependencies 2020-03-06 20:18:41 -05:00
Chris Lane
2d221050d8 Merge branch '1-14' into 3.7.0 2020-03-06 20:17:54 -05:00
Chris Lane
ce37b670c7 feat(installer): implement "installer"
Squashed commit of the following:

commit 5c322e79b7
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Fri Mar 6 19:56:56 2020 -0500

    docs(README): update the `README`

    Update the `README` to document the improved config-generation
    mechanism.

commit 803e1f014c
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Fri Mar 6 19:19:49 2020 -0500

    feat(config-init): platform-specific pathing

    Update `--init` subcommand to rely upon the same platform-detection
    intelligence that was previously implemented by the "installer".

    The installer and `--init` should now produce identical config files.

commit 99c48097e2
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Fri Mar 6 18:26:33 2020 -0500

    feat(installer): platform-correct config templating

    Modify the "installer" to populate cheatpaths with sensible defaults
    based on the detection of the user's operating system and environment.

commit 8e1580ff5a
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Thu Mar 5 20:19:58 2020 -0500

    fix(tests): fix `config.Paths` tests

    Refactor `config.Paths` (by externalizing a call to `homedir.Dir`) to
    decouple it from filesystem paths, thus facilitating cleaner unit-tests.

commit a08dca70d9
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Thu Mar 5 18:14:27 2020 -0500

    feat(installer): default path selection

    Modify the installer to improve default config and cheatsheet path
    selection.

commit e15bc6c966
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Thu Mar 5 17:49:50 2020 -0500

    fix(typo): correct comment typo in `main.go`

commit efd09575df
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Thu Mar 5 17:46:49 2020 -0500

    feat(config): refactor config path detection

    Previously, failing other checks, on Unix and BSD systems,
    `config.Paths` would attempt to compute the user's home directory by
    reading the `HOME` environment variable.

    This change deprecates that approach with a call to `homedir.Dir`, which
    is used elsewhere throughout the application.

commit ec10244ebe
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Thu Mar 5 17:15:28 2020 -0500

    chore(installer): delete unused file

    Delete `installer/installer.go`, which (in hindsight) was unnecessary.

commit ebd9ec6287
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Wed Mar 4 19:31:13 2020 -0500

    wip(installer): stub experimental "installer"

    Stubs out an experimental "installer" that will help new users to
    quickly configure `cheat`.

commit ecac5a0971
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Wed Mar 4 19:30:12 2020 -0500

    chore(dependencies): updates vendored dependencies
2020-03-06 20:17:26 -05:00
Rui Chen
47a9eeb4fd go mod vendor 2020-03-03 18:28:13 -05:00
Rui Chen
be56c9cf0c Bump golang to v1.14 2020-03-03 18:27:31 -05:00
Chris Allen Lane
7be57cb01c Merge pull request #540 from chrisallenlane/master
fix: README and Makefil
2020-02-26 19:29:04 -05:00
Chris Lane
8453af8601 fix(Makefile): build targets
Adds missing dependencies to the `build` and `install` targets.
2020-02-26 19:21:07 -05:00
Chris Lane
6e388c3693 fix(README): typo fix
Fixes a bad link to the completion scripts in the `README`.
2020-02-26 19:20:24 -05:00
Chris Allen Lane
b13246978a Merge pull request #538 from chrisallenlane/improved-search
feat(search): issue #260
2020-02-15 18:07:40 -05:00
Chris Lane
a39d36cd34 chore: bumps version to 3.6.0 2020-02-15 16:13:33 -05:00
Chris Lane
87cba04ff2 chore: DRY out colorization code
Creates a `sheet.Colorize` method that DRYs out code that was duplicated
among `cmd_search` and `cmd_view`.
2020-02-15 16:11:15 -05:00
Chris Lane
bc623da74b feat: issue #260
Dramatically improves the usefulness of `--search` by outputting
"chunked" results. This removes the need (usually) to search and then
manually open a cheatsheet.
2020-02-15 15:56:25 -05:00
Chris Lane
a6c25d4b9c feat: --search improvements
- Deprecates the `Match` struct

- Applies syntax highlighting to search results output in a manner
  consistent with the 'View' output

- Refactors search to move colorization functionality outside of its
  concern
2020-02-15 14:42:06 -05:00
Chris Lane
e24ac2b385 chore: deprecates line numbers in search
Removes line numbers from search result output as part of the
refactoring effort.
2020-02-15 10:11:25 -05:00
Chris Lane
e0c35a74d4 chore: removes colorization from search
Deprecates `mgutz/ansi` and removes colorization from search as a first
step toward improving the functionality.
2020-02-15 10:01:44 -05:00
Chris Lane
3e4c1818a9 feat: single-sheet searching
`--search` will now honor the `<cheatsheet>` argument, making it
possible to constrain a search to a single cheatsheet.
2020-02-15 06:38:57 -05:00
Chris Lane
7b4a268ebd fix: resolves #301
Fixes an issue whereby `cheat` would crash when `$EDITOR` (or
equivalent) contained arguments.
2020-02-02 15:56:18 -05:00
Chris Allen Lane
f7183aa17a Merge pull request #536 from chrisallenlane/issue-301
fix: issue #301
2020-02-02 15:54:47 -05:00
Chris Lane
1ce6c29e6a fix: issue #301
Attempts to resolve issue #301, whereby `cheat` fails if `$EDITOR` (or
equivalent) contains flags.
2020-02-02 15:48:45 -05:00
Chris Allen Lane
219db679e1 Merge pull request #535 from chrisallenlane/master
chore: bumps version to 3.5.0
2020-02-02 14:54:46 -05:00
Chris Lane
53177cb09d chore: bumps version to 3.5.0
Squashed commit of the following:

commit 8b74d50f1f
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Sun Feb 2 14:40:23 2020 -0500

    chore: updates README

    Edits the `README` to provid updated information regarding the shell
    autocompletion scripts and `fzf` integration.

commit 9868ba2d68
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Sun Feb 2 14:39:04 2020 -0500

    chore: modifies envvar check

    Modifies the `CHEAT_USE_FZF` envvar check within the bash autocompletion
    script for clarity.

commit ac1012f743
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Sun Feb 2 14:25:34 2020 -0500

    chore: renames autocompletion scripts

    Renames autocompletion scripts to conform with the conventions
    established in `scop/bash-completion`.

commit c8747bd91d
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Sun Feb 2 14:23:03 2020 -0500

    feat: improved bash autocompletions

    - Dramatically improves quality of bash autocompletions

    - Provides optional integration with `fzf`

commit 825bd0139d
Author: Chris Lane <chris@chris-allen-lane.com>
Date:   Sun Feb 2 09:19:46 2020 -0500

    chore: deletes `fzf.bash`

    Deletes `fzf.bash`, which was always intended to be a temporary
    placeholder anticipating future improvements.
2020-02-02 14:50:16 -05:00
Chris Allen Lane
ef7a41f9a9 Merge pull request #533 from chrisallenlane/master
chore: creates `make coverage` target
2020-01-31 14:07:20 -05:00
Chris Lane
008316d030 chore: creates make coverage target
Creates a `make coverage` target that generates a unit-testing coverage
report.
2020-01-31 14:01:57 -05:00
Chris Lane
a59c019642 chore: removes bin directory
Removes the `bin` directory. It has been obsoleted by the `Makefile`,
and no downstream packages appeared to depend on it.
2020-01-31 13:30:21 -05:00
Chris Allen Lane
57225442be Merge pull request #532 from chrisallenlane/auto-config-bugfix
fix(config generation): issue #501
2020-01-30 20:12:24 -05:00
Chris Lane
2c7ce48859 chore: multi-os builds on travis
Modifies `.travis.yml` to specify that builds be performed on both Linux
and MacOSX. Experimental.
2020-01-30 20:08:11 -05:00
Chris Lane
a3fe4f40bb chore: bumps version to 3.4.1 2020-01-30 20:06:06 -05:00
Chris Lane
506fb8be15 fix: XDG_CONFIG_HOME mishandling
Attempts to resolve an issue regarding automatic config file generation,
as referenced in #501. This issue is believed occur when
`XDG_CONFIG_HOME` is unset.
2020-01-30 19:59:35 -05:00
Chris Lane
408e944eea chore: refactors config.path (small)
Performs a minor refactoring on `config.Paths` to consistently use
`path.Join` when computing config directory paths. Previously, both
`path.Join` and `fmt.Sprintf` were being used, strictly due to an
oversight.
2020-01-30 19:45:02 -05:00
Chris Lane
8a313b92ca chore: implements unit-tests for config.Paths 2020-01-30 19:25:53 -05:00
Chris Lane
6912771c39 chore: refactors config.Paths
Refactors the reading of multiple envvars out of `config.Paths` in order
to facilitate cleaner unit-testing.
2020-01-30 18:48:36 -05:00
Chris Allen Lane
d4c6200702 Merge pull request #531 from chrisallenlane/auto-config
feat(configs): config auto-generation
2020-01-29 14:22:12 -05:00
Chris Lane
9251849d23 chore: bumps version to 3.4.0 2020-01-29 14:17:06 -05:00
Chris Lane
313b5ebd27 feat: config auto-generation
`cheat` now attempts to auto-generate a config file if one cannot be
found on the filesystem.
2020-01-29 14:08:03 -05:00
Chris Lane
ca91b25b02 fix: logging on failed configs
Fixes an issue whereby the error message generated when a config file
could not be loaded (due to a symlink resolution failure) would fail to
print the config file path to `stderr`.
2020-01-29 14:05:27 -05:00
Chris Lane
bbf6af50b1 chore: modifies example config
- Generally reduces the complexity demonstrated in the example configs.
  The prior complexity appears to have confused some new users.

- Removes the `dotfiles` references in the example configs. This idiom
  likewise appears to have confused some new users.

- Adds some instruction regarding how to download and configure the
  "community" cheatsheets (`cheat/cheatsheets`).
2020-01-29 14:01:19 -05:00
Chris Lane
9f05442bce fix: Makefile
Resolves an error whereby `make build` would fail to call `go generate`
prior to calling `go build`.
2020-01-29 13:59:31 -05:00
Chris Lane
3fc4c2f89e chore: Makefile
Makes some adjustments to the behaviors of `make tags` and `make
distclean`.
2020-01-29 09:36:59 -05:00
Chris Lane
9e88ff2642 chore: Makefile adjustment
Previously, `build-release` would produce `.gz` files which had the
execute permission set. This modifies the `Makefile` to `chmod -x` the
`.gz` files after compression.
2020-01-29 09:11:06 -05:00
Chris Allen Lane
e3764b81e7 3.3.2 (#530)
* chore: trivial Makefile changes

Trivial `Makefile` changes regarding file pathing.

* chore: build flags

- Passes `-trimpath` flag to build tasks

- Creates `make ci` task and modifies `travis.yml` to use it

- Bumps version to `3.3.2`
2020-01-29 08:50:47 -05:00
Chris Allen Lane
3786ac96a5 3.3.1 (#529)
* makefile wip

* feat: adds Makefile

Adds a `Makefile` for managing build-related tasks.

* chore: updates dependencies

* chore: updates dependencies

* chore: updates bin scripts

- Removes `build_release.sh`
- Places deprecation notice in `build_devel.sh`, as its purpose has been
  superceded by the `Makefile`.

* chore: updates bin scripts

- Removes `build_release.sh`
- Places deprecation notice in `build_devel.sh`, as its purpose has been
  superceded by the `Makefile`.

* fix: Makefile

Makes several corrections and improvements to the `Makefile`:

- Previously, the `ifeq` rules were not behaving as intended, due to
  false assumptions regarding how `make` fundamentally behaves.
  Malfunctioning imperative-style programming has been replaced with
  declarative rules to repair this issue.

- Previously, all release executables were zipped after compilation. In
  order to spare non-Windows users from (possibly) needing to install a
  package to unzip the executables, all non-Windows binaries are now
  compressed with `gzip`. (Windows executables are still compressed with
  `zip`.)

- Removes a bit of needlessly verbosity in several rules and paths.

* chore: updates dependencies

* chore: bumps version to 3.3.1
2020-01-25 14:44:51 -05:00
aperum
4cb7a3b42c add FreeBSD to the supported systems in config.Path (#528) 2020-01-25 14:33:15 -05:00
dependabot-preview[bot]
ff6a866abe chore(deps): bump gopkg.in/yaml.v2 from 2.2.7 to 2.2.8 (#527)
Bumps [gopkg.in/yaml.v2](https://github.com/go-yaml/yaml) from 2.2.7 to 2.2.8.
- [Release notes](https://github.com/go-yaml/yaml/releases)
- [Commits](https://github.com/go-yaml/yaml/compare/v2.2.7...v2.2.8)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-01-25 14:20:06 -05:00
dependabot-preview[bot]
2e7ccb2a68 chore(deps): bump github.com/mattn/go-isatty from 0.0.11 to 0.0.12 (#526)
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.11 to 0.0.12.
- [Release notes](https://github.com/mattn/go-isatty/releases)
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.11...v0.0.12)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-01-25 14:19:57 -05:00
Chris Lane
126231db1f feat: makefile release compression
The `Makefile` `build-release` target now zips all executables after
compilation.
2020-01-20 13:54:53 -05:00
Chris Allen Lane
91f0d02de2 v3.3.0 (#525)
* feat: directory-scoped cheatpaths

`cheat` now searches for a `.cheat` directory in the current working
directory. If found, that directory is (temporarily) appended to the slice
of cheatpaths.

* makefile wip

* fix: appeases linter

Appeases linter (`go vet`) by adding quotation marks to YAML struct
tags.

* chore: modifies .gitignore

Adds `tag` to `.gitignore`

* feat: adds Makefile

Adds a `Makefile` for managing build-related tasks.

* chore: documents directory-local paths

Adds documentation regarding the new directory-local cheatpath
functionality.

* chore: updates dependencies

* chore: bumps version to 3.3.0

* chore: updates bin scripts

- Removes `build_release.sh`
- Places deprecation notice in `build_devel.sh`, as its purpose has been
  superceded by the `Makefile`.
2020-01-20 12:34:48 -05:00
Chris Allen Lane
815e714fb4 Merge pull request #523 from cheat/dependabot/go_modules/github.com/alecthomas/chroma-0.7.1
chore(deps): bump github.com/alecthomas/chroma from 0.7.0 to 0.7.1
2020-01-19 17:05:16 -05:00
dependabot-preview[bot]
bd3986a051 chore(deps): bump github.com/alecthomas/chroma from 0.7.0 to 0.7.1
Bumps [github.com/alecthomas/chroma](https://github.com/alecthomas/chroma) from 0.7.0 to 0.7.1.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Changelog](https://github.com/alecthomas/chroma/blob/master/.goreleaser.yml)
- [Commits](https://github.com/alecthomas/chroma/compare/v0.7.0...v0.7.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-01-09 07:03:22 +00:00
Chris Allen Lane
f47b75edc0 Merge pull request #521 from chrisallenlane/master
chore: dependencies and build script
2019-12-28 10:08:32 -05:00
Chris Lane
180ee20f77 chore: bumps version to 3.2.2 2019-12-28 09:58:48 -05:00
Chris Lane
9b86c583f8 chore: updates chroma dependency 2019-12-28 09:56:19 -05:00
Chris Lane
e1f7828869 Merge branch 'master' of github.com:cheat/cheat into issue-520 2019-12-28 09:54:39 -05:00
Chris Lane
7f3ae2ab30 feat: ARM support
Modifies `bin/build_release.sh` to compile releases for ARM versions 5,
6, and 7.
2019-12-28 09:53:55 -05:00
Chris Allen Lane
bbd03a1bb8 Merge pull request #515 from cheat/dependabot/go_modules/github.com/alecthomas/chroma-0.7.0
chore(deps): bump github.com/alecthomas/chroma from 0.6.9 to 0.7.0
2019-12-28 09:53:22 -05:00
Chris Lane
326c54147b chore: updates dependencies 2019-12-28 09:07:37 -05:00
dependabot-preview[bot]
efcedaedec chore(deps): bump github.com/alecthomas/chroma from 0.6.9 to 0.7.0
Bumps [github.com/alecthomas/chroma](https://github.com/alecthomas/chroma) from 0.6.9 to 0.7.0.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Changelog](https://github.com/alecthomas/chroma/blob/master/.goreleaser.yml)
- [Commits](https://github.com/alecthomas/chroma/compare/v0.6.9...v0.7.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-12-28 14:06:13 +00:00
Chris Allen Lane
301cbefb0c Merge pull request #519 from cheat/dependabot/go_modules/github.com/mattn/go-isatty-0.0.11
chore(deps): bump github.com/mattn/go-isatty from 0.0.10 to 0.0.11
2019-12-28 09:04:43 -05:00
dependabot-preview[bot]
9a481f7e75 chore(deps): bump github.com/mattn/go-isatty from 0.0.10 to 0.0.11
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.10 to 0.0.11.
- [Release notes](https://github.com/mattn/go-isatty/releases)
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.10...v0.0.11)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-12-11 07:07:50 +00:00
Chris Allen Lane
e2920bd922 Merge pull request #514 from chrisallenlane/master
fix: colorization errors
2019-11-24 12:17:19 -05:00
Chris Lane
973a1f59ea chore: version bump
Bumps version to `3.2.1`.
2019-11-24 12:03:26 -05:00
Chris Lane
3afea0972c fix: colorization errors
- Corrects an error with `--search`. Previously, `--search` was not
  aware of whether it was outputted to a TTY, and would apply colorization
  at all times. This resulted in unwanted behavior when, for example,
  piping search results into a paginator.

- Corrects an error with `--color`. Previously, `--color` would be
  ignored if output was being written to a non-TTY. This made it
  impossible, for example, to `cheat tar --color | less -R`, as
  colorization would always be stripped. The behavior of `--color` has
  been modified such that it now behaves similarly to `--color=always` in
  other applications.
2019-11-23 13:47:08 -05:00
Chris Allen Lane
f86633ca1c Merge pull request #511 from chrisallenlane/master
Resolves #504
2019-11-20 19:02:50 -05:00
Chris Lane
a01a3491a4 chore: version bump
Bumps version to 3.2.0.
2019-11-20 18:56:50 -05:00
Chris Lane
daa43d3867 feat: implements filter on -l
Implements filtering by pattern with `-l`. Resolves #504.
2019-11-20 18:55:43 -05:00
Chris Lane
e94a1e22df chore: updates vendored dependencies 2019-11-20 18:55:24 -05:00
Chris Allen Lane
5046975a0f Merge pull request #510 from cheat/dependabot/go_modules/gopkg.in/yaml.v2-2.2.7
chore(deps): bump gopkg.in/yaml.v2 from 2.2.5 to 2.2.7
2019-11-20 18:53:54 -05:00
dependabot-preview[bot]
198156a299 chore(deps): bump gopkg.in/yaml.v2 from 2.2.5 to 2.2.7
Bumps [gopkg.in/yaml.v2](https://github.com/go-yaml/yaml) from 2.2.5 to 2.2.7.
- [Release notes](https://github.com/go-yaml/yaml/releases)
- [Commits](https://github.com/go-yaml/yaml/compare/v2.2.5...v2.2.7)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-11-20 07:07:43 +00:00
Chris Lane
a7067279df v3.1.1
Bumps version to `3.1.1`.
2019-11-19 19:59:45 -05:00
Stefan Cosma
a8e6fdb18a Properly trim path separators on Windows 2019-11-20 01:10:19 +01:00
Chris Allen Lane
bbfa4efdb7 Merge pull request #508 from exploide/fish-autocompletion
fish autocompletion: updated to cheat 3.1.0
2019-11-17 18:12:41 -05:00
exploide
741ad91389 fish autocompletion: updated to cheat 3.1.0 2019-11-17 19:33:33 +01:00
Chris Allen Lane
573d43a7e6 Merge pull request #506 from chrisallenlane/3.1.0
3.1.0
2019-11-16 09:27:18 -05:00
Chris Lane
879e8f2be4 chore: trivial copy change in docopt.txt 2019-11-16 09:23:45 -05:00
Chris Lane
eab3c14f1f fix: broken logline in --search
Resolves a malformed logging statement in `cmd_search` which would never
actually write to stderr.
2019-11-16 09:18:45 -05:00
Chris Lane
9a6130b6b7 feat: --rm and --tags
- Implements the `--rm` command (#483)
- Implements the `--tags` command (#484)
- Bumps version to `3.1.0`
2019-11-16 09:18:32 -05:00
Chris Lane
aeaf01e1de feat: implements --tags
Implements `--tags`, which lists all tags in use.
2019-11-14 21:56:11 -05:00
Chris Lane
09c29a322f chore: bumps version to 3.0.7 2019-11-12 19:16:21 -05:00
Chris Lane
0525b2331b chore: updates vendored dependencies
Updates `alecthomas/chroma` and `gopkg.in/yaml.v2` to newer versions.
2019-11-12 19:03:59 -05:00
Chris Lane
27a4991a3a feat: updates build scripts
Build scripts now invoke `go mod vendor` and `go mod tidy` early in the
build process, to ensure that the `vendor` directory stays consistent
with the `go.mod` et. al.
2019-11-12 19:02:42 -05:00
Chris Lane
4dda412dcb Merge branch 'dependabot/go_modules/github.com/alecthomas/chroma-0.6.9' 2019-11-12 18:55:16 -05:00
Chris Lane
bfb60764ad Merge branch 'master' into dependabot/go_modules/github.com/alecthomas/chroma-0.6.9 2019-11-12 18:54:02 -05:00
Chris Lane
3a97c680bb Merge branch 'dependabot/go_modules/gopkg.in/yaml.v2-2.2.5' 2019-11-12 18:53:40 -05:00
dependabot-preview[bot]
edc0fe41ef Bump github.com/alecthomas/chroma from 0.6.8 to 0.6.9
Bumps [github.com/alecthomas/chroma](https://github.com/alecthomas/chroma) from 0.6.8 to 0.6.9.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Changelog](https://github.com/alecthomas/chroma/blob/master/.goreleaser.yml)
- [Commits](https://github.com/alecthomas/chroma/compare/v0.6.8...v0.6.9)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-11-12 23:42:53 +00:00
dependabot-preview[bot]
9e49bf8e9c Bump gopkg.in/yaml.v2 from 2.2.4 to 2.2.5
Bumps [gopkg.in/yaml.v2](https://github.com/go-yaml/yaml) from 2.2.4 to 2.2.5.
- [Release notes](https://github.com/go-yaml/yaml/releases)
- [Commits](https://github.com/go-yaml/yaml/compare/v2.2.4...v2.2.5)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2019-11-12 23:42:30 +00:00
Chris Lane
50dc3c8b29 fix: resolves #498
Resolves an issue whereby background colors were written to the terminal
output when colorization was applied.
2019-11-07 20:24:08 -05:00
Chris Lane
e08a4f3cec chore: bumps version to 3.0.5 2019-11-07 20:00:57 -05:00
Chris Lane
d6ebe0799d fix: repairs broken config unit-tests
Repairs the `config` unit-tests which were broken with `3.0.4`. It does
so by providing a simple switch that allows us to disable the resolution
of filesystem symlinks when running tests.
2019-11-07 19:59:18 -05:00
Chris Lane
51aaaf3423 chore: removes the tj/front dependency
Removes the `tj/front` dependency due to licensing concerns. See #490.
2019-11-07 06:46:34 -05:00
Chris Lane
197ff58796 fix: comments out more broken tests
Comments out another test broken by `3.0.4`.
2019-11-05 20:02:16 -05:00
Chris Lane
b8f512aae8 fix: comments out broken test
Comments out a test that was broken with changes introduced in `3.0.4`.
2019-11-05 19:58:08 -05:00
Chris Lane
e7a1a296e3 chore: version bump
Version bump to `3.0.4`.
2019-11-05 19:19:17 -05:00
Chris Lane
f7c093bec0 fix: resolves #474
Resolves #474, whereby `cheat` failed to resolve symlinks. The root
cause was that `path/filepath#Walk` simply does not resolve symlinks:

  https://golang.org/pkg/path/filepath/#Walk
2019-11-05 19:08:40 -05:00
Chris Lane
c47b7f81aa fix: file permissions
Set executable permission on `scripts/cheat-autocompletion.fish`.
2019-11-05 18:49:05 -05:00
Chris Lane
52081b97ac Merge branch 'fish-completions' of github.com:exploide/cheat into exploide-fish-completions 2019-11-05 18:46:58 -05:00
Chris Lane
1dda796e7c fix: resolves #492
Fixes an issue whereby hidden files (like `.DS_Store`) may be wrongly
loaded as cheatsheets.
2019-11-05 18:44:47 -05:00
exploide
67469b0afa added cheat 3.x autocompletion file for fish shell, see #476 2019-11-05 17:29:32 +01:00
exploide
4f8431a600 fixed some cli option typos 2019-11-05 15:53:21 +01:00
Chris Lane
5301442f7c chore: issue template labels 2019-11-04 18:43:52 -05:00
Chris Allen Lane
cd45efcdec Update issue templates 2019-11-04 18:42:21 -05:00
Chris Lane
c31786fc5b chore: updated the bug report template 2019-11-04 18:40:46 -05:00
Chris Allen Lane
934c36ad77 Update issue templates 2019-11-04 18:37:26 -05:00
Chris Allen Lane
2c0099c28a Merge pull request #479 from zhujian0805/master
adding the cheat autocomplete script back :)
2019-11-04 18:12:12 -05:00
Chris Lane
33ac3d34d1 fix: removed colorization on non-tty
Fixes a bug whereby `cheat` would apply ANSI colorization even when
outputting into a non-TTY.
2019-10-29 19:47:56 -04:00
James Zhu
749173f1f6 remove the title as cheat -l lists the title as well. 2019-10-22 11:58:51 +08:00
James Zhu
33e33dc7b7 adding the autocompletion back 2019-10-21 18:07:26 +08:00
James Zhu
201cd1d629 integrate with bash complete for autocompletion 2019-10-21 15:18:47 +08:00
1881 changed files with 322641 additions and 123728 deletions

3
.gitattributes vendored Normal file
View 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

23
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,23 @@
---
name: Bug report
about: Submit a bug report
title: ''
labels: 'bug'
assignees: ''
---
Thanks for submitting a bug report. Please provide the following information:
**A description of the problem**
Describe the problem here.
**cheat version info**
Please paste the output of `cheat -v` here.
**cheat configuration info**
If your bug pertains to how cheatsheets are loaded and/or displayed, please
paste here the following information:
1. The output of `cheat -d`
2. The contents of your `conf.yml` file

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: 'enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: gomod
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10

38
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: CI
on:
push:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- name: Install revive
run: go install github.com/mgechev/revive@latest
- name: Lint
run: revive -exclude vendor/... ./...
- name: Vet
run: go vet ./...
- name: Check formatting
run: test -z "$(gofmt -l . | grep -v vendor/)"
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- name: Build
run: go build -mod vendor ./cmd/cheat
- name: Test
run: go test ./...

30
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
---
name: CodeQL
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
- cron: '45 23 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [go]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

4
.gitignore vendored
View File

@@ -1 +1,5 @@
dist
tags
.tmp
*.test
.claude

View File

@@ -1,4 +0,0 @@
language: go
go:
- 1.13.x

122
CLAUDE.md Normal file
View File

@@ -0,0 +1,122 @@
# 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, argument parsing, command routing
- `cmd_*.go`: Individual command implementations (view, edit, list, search, etc.)
- Commands are selected based on docopt parsed arguments
### 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 `internal/mock` package for test data

View File

@@ -1,43 +1,17 @@
CONTRIBUTING
============
Do you want to contribute to `cheat`? There are a few ways to help:
# Contributing
#### Submit a cheatsheet ####
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].)
Thank you for your interest in `cheat`.
#### Report a bug ####
Did you find a bug? Report it in the [issue tracker][issues]. (But before you
do, please look through the open issues to make sure that it hasn't already
been reported.)
Pull requests are no longer being accepted, and have been disabled on this
repository. The maintainer is not currently reviewing or merging external code
contributions.
#### Add a feature ####
Do you have a feature that you'd like to contribute? Propose it in the [issue
tracker][issues] to discuss with the maintainer whether it would be considered
for merging.
Bug reports are still welcome. If you've found a bug, please open an issue in
the [issue tracker][issues]. Before doing so, please search through the
existing open issues to make sure it hasn't already been reported.
`cheat` is mostly mature and feature-complete, but may still have some room for
new features.
Feature requests may be filed, but are unlikely to be implemented. The project
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.
[cheat]: https://github.com/cheat/cheat
[cheatsheets]: https://github.com/cheat/cheatsheets
[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
[issues]: https://github.com/cheat/cheat/issues

8
Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
# NB: this image isn't used anywhere in the build pipeline. It exists to
# conveniently facilitate ad-hoc experimentation in a sandboxed environment
# during development.
FROM golang:1.26-alpine
RUN apk add git less make
WORKDIR /app

241
HACKING.md Normal file
View 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 with argument parsing and command routing
- **`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.Cheatpath `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
Path string
}
```
Key functions:
- `New(opts, 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 Cheatpath 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 `internal/mock` 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
sheet, err := sheet.New(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
View 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/4.7.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 (`4.7.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.17` 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

286
Makefile Normal file
View File

@@ -0,0 +1,286 @@
# paths
makefile := $(realpath $(lastword $(MAKEFILE_LIST)))
cmd_dir := ./cmd/cheat
dist_dir := ./dist
# parallel jobs for build-release (can be overridden)
JOBS ?= 8
# executables
CAT := cat
COLUMN := column
CTAGS := ctags
DOCKER := docker
GO := go
GREP := grep
GZIP := gzip --best
LINT := revive
MAN := man
MKDIR := mkdir -p
PANDOC := pandoc
RM := rm
SCC := scc
SED := sed
SORT := sort
ZIP := zip -m
docker_image := cheat-devel:latest
# build flags
export CGO_ENABLED := 0
BUILD_FLAGS := -ldflags="-s -w" -mod vendor -trimpath
GOBIN :=
TMPDIR := /tmp
# release binaries
releases := \
$(dist_dir)/cheat-darwin-amd64 \
$(dist_dir)/cheat-darwin-arm64 \
$(dist_dir)/cheat-linux-386 \
$(dist_dir)/cheat-linux-amd64 \
$(dist_dir)/cheat-linux-arm5 \
$(dist_dir)/cheat-linux-arm6 \
$(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
## build: build an executable for your architecture
.PHONY: build
build: | clean $(dist_dir) fmt lint vet vendor man
$(GO) build $(BUILD_FLAGS) -o $(dist_dir)/cheat $(cmd_dir)
## 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
build-release: prepare
$(MAKE) -j$(JOBS) $(releases)
# cheat-darwin-amd64
$(dist_dir)/cheat-darwin-amd64:
GOARCH=amd64 GOOS=darwin \
$(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
$(dist_dir)/cheat-linux-386:
GOARCH=386 GOOS=linux \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-amd64
$(dist_dir)/cheat-linux-amd64:
GOARCH=amd64 GOOS=linux \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm5
$(dist_dir)/cheat-linux-arm5:
GOARCH=arm GOOS=linux GOARM=5 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm6
$(dist_dir)/cheat-linux-arm6:
GOARCH=arm GOOS=linux GOARM=6 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm7
$(dist_dir)/cheat-linux-arm7:
GOARCH=arm GOOS=linux GOARM=7 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-linux-arm64
$(dist_dir)/cheat-linux-arm64:
GOARCH=arm64 GOOS=linux \
$(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-plan9-amd64
$(dist_dir)/cheat-plan9-amd64:
GOARCH=amd64 GOOS=plan9 \
$(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
$(dist_dir)/cheat-windows-amd64.exe:
GOARCH=amd64 GOOS=windows \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(ZIP) $@.zip $@ -j
# ./dist
$(dist_dir):
$(MKDIR) $(dist_dir)
# .tmp
.tmp:
$(MKDIR) .tmp
## install: build and install cheat on your PATH
.PHONY: install
install: build
$(GO) install $(BUILD_FLAGS) $(GOBIN) $(cmd_dir)
## clean: remove compiled executables
.PHONY: clean
clean:
$(RM) -f $(dist_dir)/*
$(RM) -rf .tmp
## distclean: remove the tags file
.PHONY: distclean
distclean:
$(RM) -f tags
@$(DOCKER) image rm -f $(docker_image)
## setup: install revive (linter) and scc (sloc tool)
.PHONY: setup
setup:
$(GO) install github.com/boyter/scc@latest
$(GO) install github.com/mgechev/revive@latest
## sloc: count "semantic lines of code"
.PHONY: sloc
sloc:
$(SCC) --exclude-dir=vendor
## tags: build a tags file
.PHONY: tags
tags:
$(CTAGS) -R --exclude=vendor --languages=go
## man: build a man page
# NB: pandoc may not be installed, so we're ignoring this error on failure
.PHONY: man
man:
-$(PANDOC) -s -t man doc/cheat.1.md -o doc/cheat.1
## vendor: download, tidy, and verify dependencies
.PHONY: vendor
vendor:
$(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
## vendor-update: update vendored dependencies
.PHONY: vendor-update
vendor-update:
$(GO) get -t -u ./... && $(GO) mod vendor && $(GO) mod tidy && $(GO) mod verify
## fmt: run go fmt
.PHONY: fmt
fmt:
$(GO) fmt ./...
## lint: lint go source files
.PHONY: lint
lint: vendor
$(LINT) -exclude vendor/... ./...
## vet: vet go source files
.PHONY: vet
vet:
$(GO) vet ./...
## test: run unit-tests
.PHONY: test
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:
@./build/fuzz.sh 15s
## test-fuzz-long: run extended fuzz tests (10 minutes each)
.PHONY: test-fuzz-long
test-fuzz-long:
@./build/fuzz.sh 10m
## coverage: generate a test coverage report
.PHONY: coverage
coverage: .tmp
$(GO) test ./... -coverprofile=.tmp/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 ./cmd/cheat | tee .tmp/benchmark-latest.txt && \
$(RM) -f cheat.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 ./cmd/cheat && \
$(RM) -f cheat.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 ./cmd/cheat && \
$(RM) -f cheat.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
.PHONY: check
check: | vendor fmt lint vet test
.PHONY: prepare
prepare: | clean $(dist_dir) vendor fmt lint vet test
## docker-setup: create a docker image for use during development
.PHONY: docker-setup
docker-setup:
$(DOCKER) build -t $(docker_image) -f Dockerfile .
## docker-sh: shell into the docker development container
.PHONY: docker-sh
docker-sh:
$(DOCKER) run -v $(shell pwd):/app -ti $(docker_image) /bin/ash
## help: display this help text
.PHONY: help
help:
@$(CAT) $(makefile) | \
$(SORT) | \
$(GREP) "^##" | \
$(SED) 's/## //g' | \
$(COLUMN) -t -s ':'

191
README.md
View File

@@ -1,7 +1,6 @@
cheat
=====
![Workflow status](https://github.com/cheat/cheat/actions/workflows/build.yml/badge.svg)
[![Build Status](https://travis-ci.com/cheat/cheat.svg?branch=master)](https://travis-ci.com/cheat/cheat)
# cheat
`cheat` allows you to create and view interactive cheatsheets on the
command-line. It was designed to help remind \*nix system administrators of
@@ -12,9 +11,7 @@ remember.
Use `cheat` with [cheatsheets][].
Example
-------
## Example
The next time you're forced to disarm a nuclear weapon without consulting
Google, you may run:
@@ -41,33 +38,74 @@ tar -xjvf '/path/to/foo.tgz'
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
```
## Installing
For installation and configuration instructions, see [INSTALLING.md][].
Installing
----------
`cheat` has no dependencies. To install it, download the executable from the
[releases][] page and place it on your `PATH`.
Configuring
-----------
### conf.yml ###
`cheat` is configured by a YAML file that can be generated with `cheat --init`:
## Usage
To view a cheatsheet:
```sh
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
cheat tar # a "top-level" cheatsheet
cheat foo/bar # a "nested" cheatsheet
```
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:
To edit a cheatsheet:
```sh
export CHEAT_CONFIG_PATH="~/.dotfiles/cheat/conf.yml"
cheat -e tar # opens the "tar" cheatsheet for editing, or creates it if it does not exist
cheat -e foo/bar # nested cheatsheets are accessed like this
```
Cheatsheets
-----------
To view the configured cheatpaths:
```sh
cheat -d
```
To list all available cheatsheets:
```sh
cheat -l
```
To briefly list all cheatsheets (names and tags only):
```sh
cheat -b
```
To list all cheatsheets that are tagged with "networking":
```sh
cheat -l -t networking
```
To list all cheatsheets on the "personal" path:
```sh
cheat -l -p personal
```
To search for the phrase "ssh" among cheatsheets:
```sh
cheat -s ssh
```
To search (by regex) for cheatsheets that contain an IP address:
```sh
cheat -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
```
Flags may be combined in intuitive ways. Example: to search sheets on the
"personal" cheatpath that are tagged with "networking" and match a regex:
```sh
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:
@@ -76,7 +114,7 @@ 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
Cheatsheet text may optionally be preceded by a YAML frontmatter header that
assigns tags and specifies syntax:
```
@@ -88,14 +126,17 @@ tags: [ array, map ]
const squares = [1, 2, 3, 4].map(x => x * x);
```
Syntax highlighting is provided by [Chroma][], and the `syntax` value may be
set to any lexer name that Chroma supports. See Chroma's [supported
languages][] for a complete list.
The `cheat` executable includes no cheatsheets, but [community-sourced
cheatsheets are available][cheatsheets].
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
----------
## Cheatpaths
Cheatsheets are stored on "cheatpaths", which are directories that contain
cheetsheets. Cheatpaths are specified in the `conf.yml` file.
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
@@ -125,74 +166,30 @@ 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.
Usage
-----
To view a cheatsheet:
## Autocompletion
Shell autocompletion is currently available for `bash`, `fish`, and `zsh`. Copy
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.)
```sh
cheat tar # a "top-level" cheatsheet
cheat foo/bar # a "nested" cheatsheet
```
Additionally, `cheat` supports enhanced autocompletion via integration with
[fzf][]. To enable `fzf` integration:
To edit a cheatsheet:
1. Ensure that `fzf` is available on your `$PATH`
2. Set an envvar: `export CHEAT_USE_FZF=true`
```sh
cheat -e tar # opens the "tar" cheatsheet for editing, or creates it if it does not exist
cheat -e foo/bar # nested cheatsheets are accessed like this
```
To view the configured cheatpaths:
```sh
cheat -d
```
To list all available cheatsheets:
```sh
cheat -l
```
To list all cheatsheets that are tagged with "networking":
```sh
cheat -l -t networking
```
To list all cheatsheets on the "personal" path:
```sh
cheat -l -p personal
```
To search for the phrase "ssh" among cheatsheets:
```sh
cheat -s ssh
```
To search (by regex) for cheatsheets that contain an IP address:
```sh
cheat -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
```
Flags may be combined in inuitive ways. Example: to search sheets on the
"personal" cheatpath that are tagged with "networking" and match a regex:
```sh
cheat -p personal -t networking -s --regex '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
```
Advanced Usage
--------------
`cheat` may be integrated with [fzf][]. See [fzf.bash][bash] for instructions.
(Support for other shells will be added in future releases.)
[Releases]: https://github.com/cheat/cheat/releases
[bash]: https://github.com/cheat/cheat/blob/master/scripts/fzf.bash
[cheatsheets]: https://github.com/cheat/cheatsheets
[fzf]: https://github.com/junegunn/fzf
[INSTALLING.md]: INSTALLING.md
[Releases]: https://github.com/cheat/cheat/releases
[cheatsheets]: https://github.com/cheat/cheatsheets
[completions]: https://github.com/cheat/cheat/tree/master/scripts
[Chroma]: https://github.com/alecthomas/chroma
[supported languages]: https://github.com/alecthomas/chroma#supported-languages
[fzf]: https://github.com/junegunn/fzf

View 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/cheatpath/validate.go`:
```go
func ValidateSheetName(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/cheatpath/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

View File

@@ -0,0 +1,100 @@
# ADR-002: No Defensive Checks for Environment Variable Parsing
Date: 2025-01-21
## Status
Accepted
## Context
In `cmd/cheat/main.go` lines 47-52, 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

View 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: cmd/cheat/search_bench_test.go
- Reverted parallel implementation: see git history (commit 82eb918)

View File

@@ -1,13 +0,0 @@
#!/bin/bash
# locate the cheat project root
BINDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APPDIR=$(readlink -f "$BINDIR/..")
# compile the executable
cd "$APPDIR/cmd/cheat"
go clean && go generate && go build -mod vendor
mv "$APPDIR/cmd/cheat/cheat" "$APPDIR/dist/cheat"
# display a build checksum
md5sum "$APPDIR/dist/cheat"

View File

@@ -1,19 +0,0 @@
#!/bin/bash
# locate the cheat project root
BINDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APPDIR=$(readlink -f "$BINDIR/..")
# build embeds
cd "$APPDIR/cmd/cheat"
go clean && go generate
# compile AMD64 for Linux, OSX, and Windows
env GOOS=darwin GOARCH=amd64 go build -mod vendor -o \
"$APPDIR/dist/cheat-darwin-amd64" "$APPDIR/cmd/cheat"
env GOOS=linux GOARCH=amd64 go build -mod vendor -o \
"$APPDIR/dist/cheat-linux-amd64" "$APPDIR/cmd/cheat"
env GOOS=windows GOARCH=amd64 go build -mod vendor -o \
"$APPDIR/dist/cheat-win-amd64.exe" "$APPDIR/cmd/cheat"

View File

@@ -1,93 +0,0 @@
// +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"
"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(path.Join(root, file.Out))
// read the static template
bytes, err := ioutil.ReadFile(path.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 := path.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+"`")
}

39
build/fuzz.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
#
# Run fuzz tests for cheat
# Usage: ./scripts/fuzz.sh [duration]
#
# Note: Go's fuzzer will fail immediately if it finds a known failing input
# in the corpus (testdata/fuzz/*). This is by design - it ensures you fix
# known bugs before searching for new ones. To see failing inputs:
# ls internal/*/testdata/fuzz/*/
#
set -e
DURATION="${1:-15s}"
# Define fuzz tests: "TestName:Package:Description"
TESTS=(
"FuzzParse:./internal/sheet:YAML frontmatter parsing"
"FuzzValidateSheetName:./internal/cheatpath:sheet name validation (path traversal protection)"
"FuzzSearchRegex:./internal/sheet:regex search operations"
"FuzzSearchCatastrophicBacktracking:./internal/sheet:catastrophic backtracking"
"FuzzTagged:./internal/sheet:tag matching with malicious input"
"FuzzFilter:./internal/sheets:tag filtering operations"
"FuzzTags:./internal/sheets:tag aggregation and sorting"
"FuzzFindLocalCheatpath:./internal/config:recursive .cheat directory discovery"
"FuzzFindLocalCheatpathNearestWins:./internal/config:nearest .cheat wins invariant"
)
echo "Running fuzz tests ($DURATION each)..."
echo
for i in "${!TESTS[@]}"; do
IFS=':' read -r test_name package description <<< "${TESTS[$i]}"
echo "$((i+1)). Testing $description..."
go test -fuzz="^${test_name}$" -fuzztime="$DURATION" "$package"
echo
done
echo "All fuzz tests passed!"

View File

@@ -0,0 +1,128 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestBriefFlagIntegration exercises the -b/--brief flag end-to-end.
func TestBriefFlagIntegration(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env vars")
}
// Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, ".")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}
// Set up a temp environment with some cheatsheets.
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
os.WriteFile(
filepath.Join(sheetsDir, "tar"),
[]byte("---\nsyntax: bash\ntags: [ compression ]\n---\ntar xf archive.tar\n"),
0644,
)
os.WriteFile(
filepath.Join(sheetsDir, "curl"),
[]byte("---\nsyntax: bash\ntags: [ networking, http ]\n---\ncurl https://example.com\n"),
0644,
)
confPath := filepath.Join(root, "conf.yml")
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: test\n path: %s\n readonly: true\n", sheetsDir)
os.WriteFile(confPath, []byte(conf), 0644)
env := []string{
"CHEAT_CONFIG_PATH=" + confPath,
"HOME=" + root,
"PATH=" + os.Getenv("PATH"),
"EDITOR=vi",
}
run := func(t *testing.T, args ...string) string {
t.Helper()
cmd := exec.Command(binPath, args...)
cmd.Dir = root
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat %v failed: %v\nOutput: %s", args, err, output)
}
return string(output)
}
t.Run("brief output omits file path column", func(t *testing.T) {
output := run(t, "-b")
lines := strings.Split(strings.TrimSpace(output), "\n")
// Header should have title and tags but not file
if !strings.Contains(lines[0], "title:") {
t.Errorf("expected title: in header, got: %s", lines[0])
}
if !strings.Contains(lines[0], "tags:") {
t.Errorf("expected tags: in header, got: %s", lines[0])
}
if strings.Contains(lines[0], "file:") {
t.Errorf("brief output should not contain file: column, got: %s", lines[0])
}
// Data lines should not contain the sheets directory path
for _, line := range lines[1:] {
if strings.Contains(line, sheetsDir) {
t.Errorf("brief output should not contain file paths, got: %s", line)
}
}
})
t.Run("list output still includes file path column", func(t *testing.T) {
output := run(t, "-l")
lines := strings.Split(strings.TrimSpace(output), "\n")
if !strings.Contains(lines[0], "file:") {
t.Errorf("list output should contain file: column, got: %s", lines[0])
}
})
t.Run("brief with filter works", func(t *testing.T) {
output := run(t, "-b", "tar")
if !strings.Contains(output, "tar") {
t.Errorf("expected tar in output, got: %s", output)
}
if strings.Contains(output, "curl") {
t.Errorf("filter should exclude curl, got: %s", output)
}
})
t.Run("combined -lb works identically to -b", func(t *testing.T) {
briefOnly := run(t, "-b", "tar")
combined := run(t, "-lb", "tar")
if briefOnly != combined {
t.Errorf("-b and -lb should produce identical output\n-b:\n%s\n-lb:\n%s", briefOnly, combined)
}
})
t.Run("brief with tag filter works", func(t *testing.T) {
output := run(t, "-b", "-t", "networking")
if !strings.Contains(output, "curl") {
t.Errorf("expected curl in tag-filtered output, got: %s", output)
}
if strings.Contains(output, "tar") {
// tar is tagged "compression", not "networking"
t.Errorf("tag filter should exclude tar, got: %s", output)
}
if strings.Contains(output, "file:") {
t.Errorf("brief output should not contain file: column, got: %s", output)
}
})
}

View File

@@ -0,0 +1,245 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// hasCwdCheatpath checks whether the --directories output contains a
// cheatpath named "cwd". The output format is "name: path\n" per line
// (tabwriter-aligned), so we look for a line beginning with "cwd".
func hasCwdCheatpath(output string) bool {
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "cwd") {
return true
}
}
return false
}
// TestLocalCheatpathIntegration exercises the recursive .cheat directory
// discovery end-to-end: it builds the real cheat binary, sets up filesystem
// layouts, and verifies behaviour from the user's perspective.
func TestLocalCheatpathIntegration(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env vars")
}
// Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, ".")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}
// cheatEnv returns a minimal environment for the cheat binary.
cheatEnv := func(confPath, home string) []string {
return []string{
"CHEAT_CONFIG_PATH=" + confPath,
"HOME=" + home,
"PATH=" + os.Getenv("PATH"),
"EDITOR=vi",
}
}
// writeConfig writes a minimal valid config file referencing sheetsDir.
writeConfig := func(t *testing.T, dir, sheetsDir string) string {
t.Helper()
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: base\n path: %s\n readonly: true\n", sheetsDir)
confPath := filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confPath, []byte(conf), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
return confPath
}
t.Run("parent .cheat is discovered from subdirectory", func(t *testing.T) {
root := t.TempDir()
// Configured cheatpath (empty but must exist for validation)
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
// .cheat at root with a cheatsheet
dotCheat := filepath.Join(root, ".cheat")
os.Mkdir(dotCheat, 0755)
os.WriteFile(
filepath.Join(dotCheat, "localsheet"),
[]byte("---\nsyntax: bash\n---\necho hello from local\n"),
0644,
)
confPath := writeConfig(t, root, sheetsDir)
// Work from a subdirectory
workDir := filepath.Join(root, "src", "pkg")
os.MkdirAll(workDir, 0755)
env := cheatEnv(confPath, root)
// --directories should list "cwd" cheatpath
cmd := exec.Command(binPath, "--directories")
cmd.Dir = workDir
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if !hasCwdCheatpath(string(output)) {
t.Errorf("expected 'cwd' cheatpath in --directories output:\n%s", output)
}
// Viewing the cheatsheet should show its content
cmd2 := exec.Command(binPath, "localsheet")
cmd2.Dir = workDir
cmd2.Env = env
output2, err := cmd2.CombinedOutput()
if err != nil {
t.Fatalf("cheat localsheet failed: %v\nOutput: %s", err, output2)
}
if !strings.Contains(string(output2), "echo hello from local") {
t.Errorf("expected cheatsheet content, got:\n%s", output2)
}
})
t.Run("grandparent .cheat is discovered from deep subdirectory", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
dotCheat := filepath.Join(root, ".cheat")
os.Mkdir(dotCheat, 0755)
os.WriteFile(
filepath.Join(dotCheat, "deepsheet"),
[]byte("---\nsyntax: bash\n---\ndeep discovery works\n"),
0644,
)
confPath := writeConfig(t, root, sheetsDir)
deepDir := filepath.Join(root, "a", "b", "c", "d", "e")
os.MkdirAll(deepDir, 0755)
cmd := exec.Command(binPath, "deepsheet")
cmd.Dir = deepDir
cmd.Env = cheatEnv(confPath, root)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat deepsheet failed: %v\nOutput: %s", err, output)
}
if !strings.Contains(string(output), "deep discovery works") {
t.Errorf("expected cheatsheet content, got:\n%s", output)
}
})
t.Run("nearest .cheat wins over ancestor .cheat", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
// .cheat at root
rootCheat := filepath.Join(root, ".cheat")
os.Mkdir(rootCheat, 0755)
os.WriteFile(
filepath.Join(rootCheat, "shared"),
[]byte("---\nsyntax: bash\n---\nfrom root\n"),
0644,
)
// .cheat at project/ (nearer)
projectDir := filepath.Join(root, "project")
os.MkdirAll(projectDir, 0755)
projectCheat := filepath.Join(projectDir, ".cheat")
os.Mkdir(projectCheat, 0755)
os.WriteFile(
filepath.Join(projectCheat, "shared"),
[]byte("---\nsyntax: bash\n---\nfrom project nearest\n"),
0644,
)
confPath := writeConfig(t, root, sheetsDir)
workDir := filepath.Join(projectDir, "src")
os.MkdirAll(workDir, 0755)
env := cheatEnv(confPath, root)
// --directories should list the nearer cheatpath
cmd := exec.Command(binPath, "--directories")
cmd.Dir = workDir
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if !strings.Contains(string(output), projectCheat) {
t.Errorf("expected project .cheat path in output, got:\n%s", output)
}
// "shared" sheet should come from the nearer .cheat
cmd2 := exec.Command(binPath, "shared")
cmd2.Dir = workDir
cmd2.Env = env
output2, err := cmd2.CombinedOutput()
if err != nil {
t.Fatalf("cheat shared failed: %v\nOutput: %s", err, output2)
}
if !strings.Contains(string(output2), "from project nearest") {
t.Errorf("expected nearest .cheat content, got:\n%s", output2)
}
})
t.Run("no .cheat directory means no cwd cheatpath", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
// Need at least one sheet for --directories to work without error
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
confPath := writeConfig(t, root, sheetsDir)
// No .cheat anywhere under root
cmd := exec.Command(binPath, "--directories")
cmd.Dir = root
cmd.Env = cheatEnv(confPath, root)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if hasCwdCheatpath(string(output)) {
t.Errorf("'cwd' cheatpath should not appear when no .cheat exists:\n%s", output)
}
})
t.Run(".cheat file (not directory) is ignored", func(t *testing.T) {
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
os.WriteFile(filepath.Join(sheetsDir, "placeholder"),
[]byte("---\nsyntax: bash\n---\nplaceholder\n"), 0644)
// Create .cheat as a regular file
os.WriteFile(filepath.Join(root, ".cheat"), []byte("not a dir"), 0644)
confPath := writeConfig(t, root, sheetsDir)
cmd := exec.Command(binPath, "--directories")
cmd.Dir = root
cmd.Env = cheatEnv(confPath, root)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat --directories failed: %v\nOutput: %s", err, output)
}
if hasCwdCheatpath(string(output)) {
t.Errorf("'cwd' should not appear for a .cheat file:\n%s", output)
}
})
}

11
cmd/cheat/cmd_conf.go Normal file
View File

@@ -0,0 +1,11 @@
package main
import (
"fmt"
"github.com/cheat/cheat/internal/config"
)
func cmdConf(_ map[string]interface{}, conf config.Config) {
fmt.Println(conf.Path)
}

View File

@@ -1,28 +1,27 @@
package main
import (
"bytes"
"fmt"
"os"
"text/tabwriter"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
)
// cmdDirectories lists the configured cheatpaths.
func cmdDirectories(opts map[string]interface{}, conf config.Config) {
func cmdDirectories(_ map[string]interface{}, conf config.Config) {
// initialize a tabwriter to produce cleanly columnized output
w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0)
// generate sorted, columnized output
for _, path := range conf.Cheatpaths {
fmt.Fprintln(w, fmt.Sprintf(
"%s:\t%s",
path.Name,
path.Path,
))
fmt.Fprintf(w, "%s:\t%s\n", path.Name, path.Path)
}
// write columnized output to stdout
w.Flush()
display.Write(out.String(), conf)
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/cheatpath"
@@ -17,10 +17,16 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--edit"].(string)
// validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1)
}
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
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)
}
@@ -58,10 +64,10 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
}
// compute the new edit path
editpath = path.Join(writepath.Path, sheet.Title)
editpath = filepath.Join(writepath.Path, sheet.Title)
// create any necessary subdirectories
dirs := path.Dir(editpath)
dirs := filepath.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)
@@ -87,10 +93,10 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
}
// compute the new edit path
editpath = path.Join(writepath.Path, cheatsheet)
editpath = filepath.Join(writepath.Path, cheatsheet)
// create any necessary subdirectories
dirs := path.Dir(editpath)
dirs := filepath.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)
@@ -99,8 +105,15 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
}
}
// split `conf.Editor` into parts to separate the editor's executable from
// any arguments it may have been passed. If this is not done, the nearby
// call to `exec.Command` will fail.
parts := strings.Fields(conf.Editor)
editor := parts[0]
args := append(parts[1:], editpath)
// edit the cheatsheet
cmd := exec.Command(conf.Editor, editpath)
cmd := exec.Command(editor, args...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr

View File

@@ -2,9 +2,80 @@ package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config"
)
// cmdInit displays an example config file.
func cmdInit() {
fmt.Println(configs())
// get the user's home directory
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)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
os.Exit(1)
}
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confpath := confpaths[0]
confdir := filepath.Dir(confpath)
// create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal")
work := filepath.Join(confdir, "cheatsheets", "work")
// template the above paths into the default configs
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// comment out the community cheatpath by default, since the directory
// won't exist until the user clones it
configs = strings.Replace(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
-1,
)
// output the templated configs
fmt.Println(configs)
}

View File

@@ -1,13 +1,16 @@
package main
import (
"bytes"
"fmt"
"os"
"regexp"
"sort"
"strings"
"text/tabwriter"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets"
)
@@ -18,11 +21,11 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
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)
}
// filter cheatcheats by tag if --tag was provided
// filter cheatsheets by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
@@ -34,8 +37,8 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
// sheets with local sheets), here we simply want to create a slice
// containing all sheets.
flattened := []sheet.Sheet{}
for _, pathSheets := range cheatsheets {
for _, s := range pathSheets {
for _, pathsheets := range cheatsheets {
for _, s := range pathsheets {
flattened = append(flattened, s)
}
}
@@ -45,25 +48,59 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
return flattened[i].Title < flattened[j].Title
})
// exit early if no cheatsheets are available
// filter if <cheatsheet> was specified
// NB: our docopt specification is misleading here. When used in conjunction
// with `-l`, `<cheatsheet>` is really a pattern against which to filter
// sheet titles.
if opts["<cheatsheet>"] != nil {
// initialize a slice of filtered sheets
filtered := []sheet.Sheet{}
// initialize our filter pattern
pattern := "(?i)" + opts["<cheatsheet>"].(string)
// compile the regex
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 cheatsheet, and pass-through those which match the
// filter pattern
for _, s := range flattened {
if reg.MatchString(s.Title) {
filtered = append(filtered, s)
}
}
flattened = filtered
}
// return exit code 2 if no cheatsheets are available
if len(flattened) == 0 {
os.Exit(0)
os.Exit(2)
}
// initialize a tabwriter to produce cleanly columnized output
w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0)
// generate sorted, columnized output
fmt.Fprintln(w, "title:\tfile:\ttags:")
for _, sheet := range flattened {
fmt.Fprintln(w, fmt.Sprintf(
"%s\t%s\t%s",
sheet.Title,
sheet.Path,
strings.Join(sheet.Tags, ","),
))
if opts["--brief"].(bool) {
fmt.Fprintln(w, "title:\ttags:")
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\n", sheet.Title, 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
w.Flush()
display.Write(out.String(), conf)
}

62
cmd/cheat/cmd_remove.go Normal file
View File

@@ -0,0 +1,62 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheets"
)
// cmdRemove removes (deletes) a cheatsheet.
func cmdRemove(opts map[string]interface{}, conf config.Config) {
cheatsheet := opts["--rm"].(string)
// validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1)
}
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(opts["--tag"].(string), ","),
)
}
// consolidate the cheatsheets found on all paths into a single map of
// `title` => `sheet` (ie, allow more local cheatsheets to override less
// local cheatsheets)
consolidated := sheets.Consolidate(cheatsheets)
// fail early if the requested cheatsheet does not exist
sheet, ok := consolidated[cheatsheet]
if !ok {
fmt.Fprintf(os.Stderr, "No cheatsheet found for '%s'.\n", cheatsheet)
os.Exit(2)
}
// fail early if the sheet is read-only
if sheet.ReadOnly {
fmt.Fprintf(os.Stderr, "cheatsheet '%s' is read-only.\n", cheatsheet)
os.Exit(1)
}
// otherwise, attempt to delete the sheet
if err := os.Remove(sheet.Path); err != nil {
fmt.Fprintf(os.Stderr, "failed to delete sheet: %s, %v\n", sheet.Title, err)
os.Exit(1)
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
@@ -18,7 +19,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
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)
}
@@ -30,46 +31,67 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
)
}
// consolidate the cheatsheets found on all paths into a single map of
// `title` => `sheet` (ie, allow more local cheatsheets to override less
// local cheatsheets)
consolidated := sheets.Consolidate(cheatsheets)
// prepare the search pattern
pattern := "(?i)" + phrase
// sort the cheatsheets alphabetically, and search for matches
for _, sheet := range sheets.Sort(consolidated) {
// unless --regex is provided, in which case we pass the regex unaltered
if opts["--regex"] == true {
pattern = phrase
}
// colorize output?
colorize := false
if conf.Colorize == true || opts["--colorize"] == true {
colorize = true
}
// 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)
}
// assume that we want to perform a case-insensitive search for <phrase>
pattern := "(?i)" + phrase
// iterate over each cheatpath
out := ""
for _, pathcheats := range cheatsheets {
// unless --regex is provided, in which case we pass the regex unaltered
if opts["--regex"] == true {
pattern = phrase
}
// sort the cheatsheets alphabetically, and search for matches
for _, sheet := range sheets.Sort(pathcheats) {
// compile the regex
reg, err := regexp.Compile(pattern)
if err != nil {
fmt.Errorf("failed to compile regexp: %s, %v", pattern, err)
os.Exit(1)
}
// search the sheet
matches := sheet.Search(reg, colorize)
// display the results
if len(matches) > 0 {
fmt.Printf("%s:\n", sheet.Title)
for _, m := range matches {
fmt.Printf(" %d: %s\n", m.Line, m.Text)
// if <cheatsheet> was provided, constrain the search only to
// matching cheatsheets
if opts["<cheatsheet>"] != nil && sheet.Title != opts["<cheatsheet>"] {
continue
}
fmt.Print("\n")
// `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)
// if the sheet did not match the search, ignore it and move on
if sheet.Text == "" {
continue
}
// if colorization was requested, apply it here
if conf.Color(opts) {
sheet.Colorize(conf)
}
// display the cheatsheet body
out += fmt.Sprintf(
"%s %s\n%s\n",
// append the cheatsheet title
sheet.Title,
// append the cheatsheet path
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
// indent each line of content
display.Indent(sheet.Text),
)
}
}
// trim superfluous newlines
out = strings.TrimSpace(out)
// display the output
// NB: resist the temptation to call `display.Write` multiple times in the
// loop above. That will not play nicely with the paginator.
display.Write(out, conf)
}

30
cmd/cheat/cmd_tags.go Normal file
View File

@@ -0,0 +1,30 @@
package main
import (
"fmt"
"os"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
// cmdTags lists all tags in use.
func cmdTags(_ map[string]interface{}, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
// assemble the output
out := ""
for _, tag := range sheets.Tags(cheatsheets) {
out += fmt.Sprintln(tag)
}
// display the output
display.Write(out, conf)
}

View File

@@ -5,9 +5,8 @@ import (
"os"
"strings"
"github.com/alecthomas/chroma/quick"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
@@ -19,7 +18,7 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
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)
}
@@ -31,42 +30,53 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
)
}
// consolidate the cheatsheets found on all paths into a single map of
// `title` => `sheet` (ie, allow more local cheatsheets to override less
// local cheatsheets)
// if --all was passed, display cheatsheets from all cheatpaths
if opts["--all"].(bool) {
// iterate over the cheatpaths
out := ""
for _, cheatpath := range cheatsheets {
// if the cheatpath contains the specified cheatsheet, display it
if sheet, ok := cheatpath[cheatsheet]; ok {
// identify the matching cheatsheet
out += fmt.Sprintf("%s %s\n",
sheet.Title,
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
)
// apply colorization if requested
if conf.Color(opts) {
sheet.Colorize(conf)
}
// display the cheatsheet
out += display.Indent(sheet.Text) + "\n"
}
}
// display and exit
display.Write(strings.TrimSuffix(out, "\n"), conf)
os.Exit(0)
}
// otherwise, consolidate the cheatsheets found on all paths into a single
// map of `title` => `sheet` (ie, allow more local cheatsheets to override
// less local cheatsheets)
consolidated := sheets.Consolidate(cheatsheets)
// fail early if the requested cheatsheet does not exist
sheet, ok := consolidated[cheatsheet]
if !ok {
fmt.Printf("No cheatsheet found for '%s'.\n", cheatsheet)
os.Exit(0)
os.Exit(2)
}
// if colorization is not desired, output un-colorized text and exit
if conf.Colorize == false && opts["--colorize"] == false {
fmt.Print(sheet.Text)
os.Exit(0)
// apply colorization if requested
if conf.Color(opts) {
sheet.Colorize(conf)
}
// otherwise, colorize the output
// if the syntax was not specified, default to bash
lex := sheet.Syntax
if lex == "" {
lex = "bash"
}
// apply syntax highlighting
err = quick.Highlight(
os.Stdout,
sheet.Text,
lex,
conf.Formatter,
conf.Style,
)
// if colorization somehow failed, output non-colorized text
if err != nil {
fmt.Print(sheet.Text)
}
// display the cheatsheet
display.Write(sheet.Text, conf)
}

74
cmd/cheat/config.go Normal file
View 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`
}

View File

@@ -1,43 +0,0 @@
Usage:
cheat [options] [<cheatsheet>]
Options:
--init Write a default config file to stdout
-c --colorize Colorize output
-d --directories List cheatsheet directories
-e --edit=<sheet> Edit cheatsheet
-l --list List cheatsheets
-p --path=<name> Return only sheets found on path <name>
-r --regex Treat search <phrase> as a regex
-s --search=<phrase> Search cheatsheets for <phrase>
-t --tag=<tag> Return only sheets matching <tag>
-v --version Print the version number
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 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}'

View File

@@ -0,0 +1,304 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestFirstRunIntegration exercises the end-to-end first-run experience:
// no config exists, the binary creates one, and subsequent runs succeed.
// This is the regression test for issues #721, #771, and #730.
func TestFirstRunIntegration(t *testing.T) {
// Build the cheat binary
binName := "cheat_test"
if runtime.GOOS == "windows" {
binName += ".exe"
}
binPath := filepath.Join(t.TempDir(), binName)
build := exec.Command("go", "build", "-o", binPath, ".")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}
t.Run("init comments out community", func(t *testing.T) {
testHome := t.TempDir()
env := firstRunEnv(testHome)
cmd := exec.Command(binPath, "--init")
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("--init failed: %v\nOutput: %s", err, output)
}
outStr := string(output)
// No placeholder strings should survive (regression for #721)
assertNoPlaceholders(t, outStr)
// Community cheatpath should be commented out
assertCommunityCommentedOut(t, outStr)
// Personal and work cheatpaths should be active (uncommented)
assertCheatpathActive(t, outStr, "personal")
assertCheatpathActive(t, outStr, "work")
// Should include clone instructions
if !strings.Contains(outStr, "git clone") {
t.Error("expected git clone instructions in --init output")
}
// Save the config and verify it loads without errors.
// --init only outputs config, it doesn't create directories,
// so we need to create the cheatpath dirs the config references.
confpath := filepath.Join(testHome, "conf.yml")
if err := os.WriteFile(confpath, output, 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Determine the confdir that --init used (same logic as cmd_init.go)
initConfpaths := firstRunConfpaths(testHome)
initConfdir := filepath.Dir(initConfpaths[0])
for _, name := range []string{"personal", "work"} {
dir := filepath.Join(initConfdir, "cheatsheets", name)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("failed to create %s dir: %v", name, err)
}
}
cmd2 := exec.Command(binPath, "--directories")
cmd2.Env = append(append([]string{}, env...), "CHEAT_CONFIG_PATH="+confpath)
output2, err := cmd2.CombinedOutput()
if err != nil {
t.Fatalf("config from --init failed to load: %v\nOutput: %s", err, output2)
}
})
t.Run("decline config creation", func(t *testing.T) {
testHome := t.TempDir()
env := firstRunEnv(testHome)
cmd := exec.Command(binPath)
cmd.Env = env
cmd.Stdin = strings.NewReader("n\n")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat exited with error: %v\nOutput: %s", err, output)
}
// Verify no config was created
if firstRunConfigExists(testHome) {
t.Error("config file was created despite user declining")
}
})
t.Run("accept config decline community", func(t *testing.T) {
testHome := t.TempDir()
env := firstRunEnv(testHome)
// First run: yes to create config, no to community cheatsheets
cmd := exec.Command(binPath)
cmd.Env = env
cmd.Stdin = strings.NewReader("y\nn\n")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("first run failed: %v\nOutput: %s", err, output)
}
outStr := string(output)
// Parse the config path from output
confpath := parseCreatedConfPath(t, outStr)
if confpath == "" {
t.Fatalf("could not find config path in output:\n%s", outStr)
}
// Verify config file exists
if _, err := os.Stat(confpath); os.IsNotExist(err) {
t.Fatalf("config file not found at %s", confpath)
}
// Verify config file contents
content, err := os.ReadFile(confpath)
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
contentStr := string(content)
// No placeholder strings should survive (regression for #721)
assertNoPlaceholders(t, contentStr)
// Community cheatpath should be commented out
assertCommunityCommentedOut(t, contentStr)
// Personal and work cheatpaths should be active (uncommented)
assertCheatpathActive(t, contentStr, "personal")
assertCheatpathActive(t, contentStr, "work")
// Verify personal and work directories were created
confdir := filepath.Dir(confpath)
for _, name := range []string{"personal", "work"} {
dir := filepath.Join(confdir, "cheatsheets", name)
if _, err := os.Stat(dir); os.IsNotExist(err) {
t.Errorf("expected %s directory at %s", name, dir)
}
}
// Community directory should NOT exist
communityDir := filepath.Join(confdir, "cheatsheets", "community")
if _, err := os.Stat(communityDir); err == nil {
t.Error("community directory should not exist when declined")
}
// --- Second run: verify the config loads successfully ---
// This is the core regression test for #721/#771/#730:
// previously, the second run would fail because config.New()
// hard-errored on the missing community cheatpath directory.
// Use --directories (not --list, which exits 2 when no sheets exist).
cmd2 := exec.Command(binPath, "--directories")
cmd2.Env = append(append([]string{}, env...), "CHEAT_CONFIG_PATH="+confpath)
output2, err := cmd2.CombinedOutput()
if err != nil {
t.Fatalf(
"second run failed (regression for #721/#771/#730): %v\nOutput: %s",
err, output2,
)
}
// Verify the output lists the expected cheatpaths
outStr2 := string(output2)
if !strings.Contains(outStr2, "personal") {
t.Errorf("expected 'personal' cheatpath in --directories output:\n%s", outStr2)
}
if !strings.Contains(outStr2, "work") {
t.Errorf("expected 'work' cheatpath in --directories output:\n%s", outStr2)
}
})
}
// firstRunEnv returns a minimal environment for a clean first-run test.
func firstRunEnv(home string) []string {
env := []string{
"PATH=" + os.Getenv("PATH"),
}
switch runtime.GOOS {
case "windows":
env = append(env,
"APPDATA="+filepath.Join(home, "AppData", "Roaming"),
"USERPROFILE="+home,
"SystemRoot="+os.Getenv("SystemRoot"),
)
default:
env = append(env,
"HOME="+home,
"EDITOR=vi",
)
}
return env
}
// parseCreatedConfPath extracts the config file path from the installer's
// "Created config file: <path>" output. The message may appear mid-line
// (after prompt text), so we search for the substring anywhere in the output.
func parseCreatedConfPath(t *testing.T, output string) string {
t.Helper()
const marker = "Created config file: "
idx := strings.Index(output, marker)
if idx < 0 {
return ""
}
rest := output[idx+len(marker):]
// the path ends at the next newline
if nl := strings.IndexByte(rest, '\n'); nl >= 0 {
rest = rest[:nl]
}
return strings.TrimSpace(rest)
}
// firstRunConfpaths returns the config file paths that cheat would check
// for the given home directory, matching the logic in config.Paths().
func firstRunConfpaths(home string) []string {
switch runtime.GOOS {
case "windows":
return []string{
filepath.Join(home, "AppData", "Roaming", "cheat", "conf.yml"),
}
default:
return []string{
filepath.Join(home, ".config", "cheat", "conf.yml"),
}
}
}
// assertNoPlaceholders verifies that no template placeholder strings survived
// in the config output. This is the regression check for #721 (literal
// PAGER_PATH appearing in the config).
func assertNoPlaceholders(t *testing.T, content string) {
t.Helper()
placeholders := []string{
"PAGER_PATH",
"COMMUNITY_PATH",
"PERSONAL_PATH",
"WORK_PATH",
}
for _, p := range placeholders {
if strings.Contains(content, p) {
t.Errorf("placeholder %q was not replaced in config", p)
}
}
// EDITOR_PATH is special: it survives if no editor is found.
// In our test env EDITOR=vi is set, so it should be replaced.
if strings.Contains(content, "editor: EDITOR_PATH") {
t.Error("placeholder EDITOR_PATH was not replaced in config")
}
}
// assertCommunityCommentedOut verifies that the community cheatpath entry
// is commented out (not active) in the config.
func assertCommunityCommentedOut(t *testing.T, content string) {
t.Helper()
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "- name: community" {
t.Error("community cheatpath should be commented out")
return
}
}
if !strings.Contains(content, "#- name: community") {
t.Error("expected commented-out community cheatpath")
}
}
// assertCheatpathActive verifies that a named cheatpath is present and
// uncommented in the config.
func assertCheatpathActive(t *testing.T, content string, name string) {
t.Helper()
marker := "- name: " + name
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == marker {
return
}
}
t.Errorf("expected active (uncommented) cheatpath %q", name)
}
// firstRunConfigExists checks whether a cheat config file exists under the
// given home directory at any of the standard locations.
func firstRunConfigExists(home string) bool {
candidates := []string{
filepath.Join(home, ".config", "cheat", "conf.yml"),
filepath.Join(home, ".cheat", "conf.yml"),
filepath.Join(home, "AppData", "Roaming", "cheat", "conf.yml"),
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}

View File

@@ -1,24 +1,26 @@
// Package main serves as the executable entrypoint.
package main
//go:generate go run ../../build/embed.go
import (
"fmt"
"os"
"runtime"
"strings"
"github.com/docopt/docopt-go"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
)
const version = "3.0.2"
const version = "4.7.0"
func main() {
// initialize options
opts, err := docopt.Parse(usage(), nil, true, version, false)
opts, err := docopt.ParseArgs(usage(), nil, version)
if err != nil {
// panic here, because this should never happen
panic(fmt.Errorf("docopt failed to parse: %v", err))
@@ -31,15 +33,66 @@ func main() {
os.Exit(0)
}
// load the config file
confpath, err := config.Path(runtime.GOOS)
// get the user's home directory
home, err := homedir.Dir()
if err != nil {
fmt.Fprintln(os.Stderr, "could not locate config file")
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() {
// os.Environ() guarantees "key=value" format (see ADR-002)
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
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
}
// search for the config file in the above paths
confpath, err := config.Path(confpaths)
if err != nil {
// prompt the user to create a config file
yes, err := installer.Prompt(
"A config file was not found. Would you like to create one now? [Y/n]",
true,
)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create config: %v\n", err)
os.Exit(1)
}
// exit early on a negative answer
if !yes {
os.Exit(0)
}
// choose a confpath
confpath = confpaths[0]
// run the installer
if err := installer.Run(configs(), confpath); err != nil {
fmt.Fprintf(os.Stderr, "failed to run installer: %v\n", err)
os.Exit(1)
}
// notify the user and exit
fmt.Printf("Created config file: %s\n", confpath)
fmt.Println("Please read this file for advanced configuration information.")
os.Exit(0)
}
// initialize the configs
conf, err := config.New(opts, confpath)
conf, err := config.New(opts, confpath, true)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
@@ -67,21 +120,33 @@ func main() {
var cmd func(map[string]interface{}, config.Config)
switch {
case opts["--conf"].(bool):
cmd = cmdConf
case opts["--directories"].(bool):
cmd = cmdDirectories
case opts["--edit"] != nil:
cmd = cmdEdit
case opts["--list"].(bool):
case opts["--list"].(bool), opts["--brief"].(bool):
cmd = cmdList
case opts["--tags"].(bool):
cmd = cmdTags
case opts["--search"] != nil:
cmd = cmdSearch
case opts["--rm"] != nil:
cmd = cmdRemove
case opts["<cheatsheet>"] != nil:
cmd = cmdView
case opts["--tag"] != nil && opts["--tag"].(string) != "":
cmd = cmdList
default:
fmt.Println(usage())
os.Exit(0)

View File

@@ -0,0 +1,225 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestPathTraversalIntegration tests that the cheat binary properly blocks
// path traversal attempts when invoked as a subprocess.
func TestPathTraversalIntegration(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env and tools")
}
// Build the cheat binary
binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil {
t.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}
// Set up test environment
testDir := t.TempDir()
sheetsDir := filepath.Join(testDir, "sheets")
os.MkdirAll(sheetsDir, 0755)
// Create config
config := fmt.Sprintf(`---
editor: echo
colorize: false
pager: cat
cheatpaths:
- name: test
path: %s
readonly: false
`, sheetsDir)
configPath := filepath.Join(testDir, "config.yml")
if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
t.Fatalf("Failed to write config: %v", err)
}
// Test table
tests := []struct {
name string
command []string
wantFail bool
wantMsg string
}{
// Blocked patterns
{
name: "block parent traversal edit",
command: []string{"--edit", "../evil"},
wantFail: true,
wantMsg: "cannot contain '..'",
},
{
name: "block absolute path edit",
command: []string{"--edit", "/etc/passwd"},
wantFail: true,
wantMsg: "cannot be an absolute path",
},
{
name: "block home dir edit",
command: []string{"--edit", "~/.ssh/config"},
wantFail: true,
wantMsg: "cannot start with '~'",
},
{
name: "block parent traversal remove",
command: []string{"--rm", "../evil"},
wantFail: true,
wantMsg: "cannot contain '..'",
},
{
name: "block complex traversal",
command: []string{"--edit", "foo/../../bar"},
wantFail: true,
wantMsg: "cannot contain '..'",
},
{
name: "block just dots",
command: []string{"--edit", ".."},
wantFail: true,
wantMsg: "cannot contain '..'",
},
{
name: "block empty name",
command: []string{"--edit", ""},
wantFail: true,
wantMsg: "cannot be empty",
},
// Allowed patterns
{
name: "allow simple name",
command: []string{"--edit", "docker"},
wantFail: false,
},
{
name: "allow nested name",
command: []string{"--edit", "lang/go"},
wantFail: false,
},
{
name: "block hidden file",
command: []string{"--edit", ".gitignore"},
wantFail: true,
wantMsg: "cannot start with '.'",
},
{
name: "allow current dir",
command: []string{"--edit", "./local"},
wantFail: false,
},
}
// Run tests
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(binPath, tc.command...)
cmd.Env = []string{
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configPath),
fmt.Sprintf("HOME=%s", testDir),
}
output, err := cmd.CombinedOutput()
if tc.wantFail {
if err == nil {
t.Errorf("Expected failure but command succeeded. Output: %s", output)
}
if !strings.Contains(string(output), "invalid cheatsheet name") {
t.Errorf("Expected 'invalid cheatsheet name' error, got: %s", output)
}
if tc.wantMsg != "" && !strings.Contains(string(output), tc.wantMsg) {
t.Errorf("Expected message %q in output, got: %s", tc.wantMsg, output)
}
} else {
// Command might fail for other reasons (e.g., editor not found)
// but should NOT fail with "invalid cheatsheet name"
if strings.Contains(string(output), "invalid cheatsheet name") {
t.Errorf("Command incorrectly blocked. Output: %s", output)
}
}
})
}
}
// TestPathTraversalRealWorld tests with more realistic scenarios
func TestPathTraversalRealWorld(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env and tools")
}
// This test ensures our protection works with actual file operations
// Build cheat
binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil {
t.Fatalf("Failed to build: %v\n%s", err, output)
}
// Create test structure
testRoot := t.TempDir()
sheetsDir := filepath.Join(testRoot, "cheatsheets")
secretDir := filepath.Join(testRoot, "secrets")
os.MkdirAll(sheetsDir, 0755)
os.MkdirAll(secretDir, 0755)
// Create a "secret" file that should not be accessible
secretFile := filepath.Join(secretDir, "secret.txt")
os.WriteFile(secretFile, []byte("SECRET DATA"), 0644)
// Create config using vim in non-interactive mode
config := fmt.Sprintf(`---
editor: vim -u NONE -n --cmd "set noswapfile" --cmd "wq"
colorize: false
pager: cat
cheatpaths:
- name: personal
path: %s
readonly: false
`, sheetsDir)
configPath := filepath.Join(testRoot, "config.yml")
os.WriteFile(configPath, []byte(config), 0644)
// Test 1: Try to edit a file outside cheatsheets using traversal
cmd := exec.Command(binPath, "--edit", "../secrets/secret")
cmd.Env = []string{
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configPath),
fmt.Sprintf("HOME=%s", testRoot),
}
output, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(output), "invalid cheatsheet name") {
t.Errorf("Path traversal was not blocked! Output: %s", output)
}
// Test 2: Verify the secret file is still intact
content, _ := os.ReadFile(secretFile)
if string(content) != "SECRET DATA" {
t.Errorf("Secret file was modified!")
}
// Test 3: Verify no files were created outside sheets directory
err = filepath.Walk(testRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() &&
path != configPath &&
path != secretFile &&
!strings.HasPrefix(path, sheetsDir) {
t.Errorf("File created outside allowed directory: %s", path)
}
return nil
})
if err != nil {
t.Errorf("Walk error: %v", err)
}
}

View File

@@ -0,0 +1,200 @@
//go:build integration
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// BenchmarkSearchCommand benchmarks the actual cheat search command
func BenchmarkSearchCommand(b *testing.B) {
// Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err)
}
cheatBin := filepath.Join(tmpDir, "cheat-bench")
// Clean up the binary when done
b.Cleanup(func() {
os.Remove(cheatBin)
})
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir
if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}
// Set up test environment in .tmp
configDir := filepath.Join(tmpDir, "config")
cheatsheetDir := filepath.Join(configDir, "cheatsheets", "community")
// Clone community cheatsheets (or reuse if already exists)
if _, err := os.Stat(cheatsheetDir); os.IsNotExist(err) {
b.Logf("Cloning community cheatsheets to %s...", cheatsheetDir)
_, err := git.PlainClone(cheatsheetDir, false, &git.CloneOptions{
URL: "https://github.com/cheat/cheatsheets.git",
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.ReferenceName("refs/heads/master"),
Progress: nil,
})
if err != nil {
b.Fatalf("Failed to clone cheatsheets: %v", err)
}
}
// Create a minimal config file
configFile := filepath.Join(configDir, "conf.yml")
configContent := fmt.Sprintf(`---
cheatpaths:
- name: community
path: %s
tags: [ community ]
readonly: true
`, cheatsheetDir)
if err := os.MkdirAll(configDir, 0755); err != nil {
b.Fatalf("Failed to create config dir: %v", err)
}
if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil {
b.Fatalf("Failed to write config: %v", err)
}
// Set environment to use our config
env := append(os.Environ(),
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configFile),
)
// Define test cases
testCases := []struct {
name string
args []string
}{
{"SimpleSearch", []string{"-s", "echo"}},
{"RegexSearch", []string{"-r", "-s", "^#.*example"}},
{"ColorizedSearch", []string{"-c", "-s", "grep"}},
{"ComplexRegex", []string{"-r", "-s", "(git|hg|svn)\\s+(add|commit|push)"}},
{"AllCheatpaths", []string{"-a", "-s", "list"}},
}
// Warm up - run once to ensure everything is loaded
warmupCmd := exec.Command(cheatBin, "-l")
warmupCmd.Env = env
warmupCmd.Run()
// Run benchmarks
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
// Reset timer to exclude setup
b.ResetTimer()
for i := 0; i < b.N; i++ {
cmd := exec.Command(cheatBin, tc.args...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
b.Fatalf("Command failed: %v\nStderr: %s", err, stderr.String())
}
if stdout.Len() == 0 {
b.Fatal("No output from search")
}
}
})
}
}
// BenchmarkListCommand benchmarks the list command for comparison
func BenchmarkListCommand(b *testing.B) {
// Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err)
}
cheatBin := filepath.Join(tmpDir, "cheat-bench")
// Clean up the binary when done
b.Cleanup(func() {
os.Remove(cheatBin)
})
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir
if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}
// Set up test environment (simplified - reuse if possible)
configDir := filepath.Join(tmpDir, "config")
cheatsheetDir := filepath.Join(configDir, "cheatsheets", "community")
// Check if we need to clone
if _, err := os.Stat(cheatsheetDir); os.IsNotExist(err) {
_, err := git.PlainClone(cheatsheetDir, false, &git.CloneOptions{
URL: "https://github.com/cheat/cheatsheets.git",
Depth: 1,
SingleBranch: true,
ReferenceName: plumbing.ReferenceName("refs/heads/master"),
Progress: nil,
})
if err != nil {
b.Fatalf("Failed to clone cheatsheets: %v", err)
}
}
// Create config
configFile := filepath.Join(configDir, "conf.yml")
configContent := fmt.Sprintf(`---
cheatpaths:
- name: community
path: %s
tags: [ community ]
readonly: true
`, cheatsheetDir)
os.MkdirAll(configDir, 0755)
os.WriteFile(configFile, []byte(configContent), 0644)
env := append(os.Environ(),
fmt.Sprintf("CHEAT_CONFIG_PATH=%s", configFile),
)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cmd := exec.Command(cheatBin, "-l")
cmd.Env = env
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
b.Fatalf("Command failed: %v", err)
}
}
}

View File

@@ -1,65 +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
# 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 -f <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 -f 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 just examples. You may freely
# change them to suit your needs.
- name: community
path: ~/.dotfiles/cheat/community
tags: [ community ]
readonly: true
# Maybe your company or department maintains a repository of cheatsheets as
# well. It's probably sensible to list those second.
- name: work
path: ~/.dotfiles/cheat/work
tags: [ work ]
readonly: false
# If you have personalized cheatsheets, list them last. They will take
# precedence over the more global cheatsheets.
- name: personal
path: ~/.dotfiles/cheat/personal
tags: [ personal ]
readonly: false
`)
}

View File

@@ -1,54 +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
-c --colorize Colorize output
-d --directories List cheatsheet directories
-e --edit=<sheet> Edit cheatsheet
-l --list List cheatsheets
-p --path=<name> Return only sheets found on path <name>
-r --regex Treat search <phrase> as a regex
-s --search=<phrase> Search cheatsheets for <phrase>
-t --tag=<tag> Return only sheets matching <tag>
-v --version Print the version number
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 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}'
`)
}

65
cmd/cheat/usage.go Normal file
View File

@@ -0,0 +1,65 @@
package main
// usage returns the usage text for the cheat command
func usage() string {
return `Usage:
cheat [options] [<cheatsheet>]
Options:
--init Write a default config file to stdout
-a --all Search among all cheatpaths
-b --brief List cheatsheets without file paths
-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>
--conf Display the config file path
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 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 view the configuration file path:
cheat --conf`
}

View File

@@ -1,54 +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
# 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 -f <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 -f 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 just examples. You may freely
# change them to suit your needs.
- name: community
path: ~/.dotfiles/cheat/community
tags: [ community ]
readonly: true
# Maybe your company or department maintains a repository of cheatsheets as
# well. It's probably sensible to list those second.
- name: work
path: ~/.dotfiles/cheat/work
tags: [ work ]
readonly: false
# If you have personalized cheatsheets, list them last. They will take
# precedence over the more global cheatsheets.
- name: personal
path: ~/.dotfiles/cheat/personal
tags: [ personal ]
readonly: false

View 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/config.go`

185
doc/cheat.1 Normal file
View File

@@ -0,0 +1,185 @@
.\" Automatically generated by Pandoc 3.1.11.1
.\"
.TH "CHEAT" "1" "" "" "General Commands Manual"
.SH NAME
\f[B]cheat\f[R] \[em] create and view command\-line cheatsheets
.SH SYNOPSIS
.PP
\f[B]cheat\f[R] [options] [\f[I]CHEATSHEET\f[R]]
.SH DESCRIPTION
\f[B]cheat\f[R] 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.
.SH OPTIONS
.TP
\[en]init
Print a config file to stdout.
.TP
\[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.
.TP
\-d, \[en]directories
List cheatsheet directories.
.TP
\-e, \[en]edit=\f[I]CHEATSHEET\f[R]
Open \f[I]CHEATSHEET\f[R] for editing.
.TP
\-l, \[en]list
List available cheatsheets.
.TP
\-p, \[en]path=\f[I]PATH\f[R]
Filter only to sheets found on path \f[I]PATH\f[R].
.TP
\-r, \[en]regex
Treat search \f[I]PHRASE\f[R] as a regular expression.
.TP
\-s, \[en]search=\f[I]PHRASE\f[R]
Search cheatsheets for \f[I]PHRASE\f[R].
.TP
\-t, \[en]tag=\f[I]TAG\f[R]
Filter only to sheets tagged with \f[I]TAG\f[R].
.TP
\-T, \[en]tags
List all tags in use.
.TP
\-v, \[en]version
Print the version number.
.TP
\[en]rm=\f[I]CHEATSHEET\f[R]
Remove (deletes) \f[I]CHEATSHEET\f[R].
.SH EXAMPLES
.TP
To view the foo cheatsheet:
cheat \f[I]foo\f[R]
.TP
To edit (or create) the foo cheatsheet:
cheat \-e \f[I]foo\f[R]
.TP
To edit (or create) the foo/bar cheatsheet on the `work' cheatpath:
cheat \-p \f[I]work\f[R] \-e \f[I]foo/bar\f[R]
.TP
To view all cheatsheet directories:
cheat \-d
.TP
To list all available cheatsheets:
cheat \-l
.TP
To briefly list all cheatsheets whose titles match `apt':
cheat \-b \f[I]apt\f[R]
.TP
To list all tags in use:
cheat \-T
.TP
To list available cheatsheets that are tagged as `personal':
cheat \-l \-t \f[I]personal\f[R]
.TP
To search for `ssh' among all cheatsheets, and colorize matches:
cheat \-c \-s \f[I]ssh\f[R]
.TP
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[R]
.TP
To remove (delete) the foo/bar cheatsheet:
cheat \[en]rm \f[I]foo/bar\f[R]
.TP
To view the configuration file path:
cheat \[en]conf
.SH FILES
.SS Configuration
\f[B]cheat\f[R] is configured via a YAML file that is conventionally
named \f[I]conf.yaml\f[R].
\f[B]cheat\f[R] will search for \f[I]conf.yaml\f[R] in varying
locations, depending upon your platform:
.SS Linux, OSX, and other Unixes
.IP "1." 3
\f[B]CHEAT_CONFIG_PATH\f[R]
.IP "2." 3
\f[B]XDG_CONFIG_HOME\f[R]/cheat/conf.yaml
.IP "3." 3
\f[B]$HOME\f[R]/.config/cheat/conf.yml
.IP "4." 3
\f[B]$HOME\f[R]/.cheat/conf.yml
.SS Windows
.IP "1." 3
\f[B]CHEAT_CONFIG_PATH\f[R]
.IP "2." 3
\f[B]APPDATA\f[R]/cheat/conf.yml
.IP "3." 3
\f[B]PROGRAMDATA\f[R]/cheat/conf.yml
.PP
\f[B]cheat\f[R] will search in the order specified above.
The first \f[I]conf.yaml\f[R] encountered will be respected.
.PP
If \f[B]cheat\f[R] cannot locate a config file, it will ask if you\[cq]d
like to generate one automatically.
Alternatively, you may also generate a config file manually by running
\f[B]cheat \[en]init\f[R] and saving its output to the appropriate
location for your platform.
.SS Cheatpaths
\f[B]cheat\f[R] reads its cheatsheets from \[lq]cheatpaths\[rq], which
are the directories in which cheatsheets are stored.
Cheatpaths may be configured in \f[I]conf.yaml\f[R], and viewed via
\f[B]cheat \-d\f[R].
.PP
For detailed instructions on how to configure cheatpaths, please refer
to the comments in conf.yml.
.SS Autocompletion
Autocompletion scripts for \f[B]bash\f[R], \f[B]zsh\f[R], and
\f[B]fish\f[R] are available for download:
.IP \[bu] 2
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.bash
.UE \c
.IP \[bu] 2
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.fish
.UE \c
.IP \[bu] 2
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh
.UE \c
.PP
The \f[B]bash\f[R] and \f[B]zsh\f[R] scripts provide optional
integration with \f[B]fzf\f[R], if the latter is available on your
\f[B]PATH\f[R].
.PP
The installation process will vary per system and shell configuration,
and thus will not be discussed here.
.SH ENVIRONMENT
.TP
\f[B]CHEAT_CONFIG_PATH\f[R]
The path at which the config file is available.
If \f[B]CHEAT_CONFIG_PATH\f[R] is set, all other config paths will be
ignored.
.TP
\f[B]CHEAT_USE_FZF\f[R]
If set, autocompletion scripts will attempt to integrate with
\f[B]fzf\f[R].
.SH RETURN VALUES
.IP "0." 3
Successful termination
.IP "1." 3
Application error
.IP "2." 3
Cheatsheet(s) not found
.SH BUGS
See GitHub issues: \c
.UR https://github.com/cheat/cheat/issues
.UE \c
.SH AUTHOR
Christopher Allen Lane \c
.MT chris@chris-allen-lane.com
.ME \c
.SH SEE ALSO
\f[B]fzf(1)\f[R]

204
doc/cheat.1.md Normal file
View File

@@ -0,0 +1,204 @@
% CHEAT(1) | General Commands Manual
NAME
====
**cheat** — create and view command-line cheatsheets
SYNOPSIS
========
| **cheat** \[options] \[_CHEATSHEET_]
DESCRIPTION
===========
**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.
OPTIONS
=======
--init
: 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
: Colorize output.
-d, --directories
: List cheatsheet directories.
-e, --edit=_CHEATSHEET_
: Open _CHEATSHEET_ for editing.
-l, --list
: List available cheatsheets.
-p, --path=_PATH_
: Filter only to sheets found on path _PATH_.
-r, --regex
: Treat search _PHRASE_ as a regular expression.
-s, --search=_PHRASE_
: Search cheatsheets for _PHRASE_.
-t, --tag=_TAG_
: Filter only to sheets tagged with _TAG_.
-T, --tags
: List all tags in use.
-v, --version
: Print the version number.
--rm=_CHEATSHEET_
: Remove (deletes) _CHEATSHEET_.
EXAMPLES
========
To view the foo cheatsheet:
: cheat _foo_
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 view the configuration file path:
: cheat --conf
FILES
=====
Configuration
-------------
**cheat** is configured via a YAML file that is conventionally named
_conf.yaml_. **cheat** will search for _conf.yaml_ in varying locations,
depending upon your platform:
### Linux, OSX, and other Unixes ###
1. **CHEAT_CONFIG_PATH**
2. **XDG_CONFIG_HOME**/cheat/conf.yaml
3. **$HOME**/.config/cheat/conf.yml
4. **$HOME**/.cheat/conf.yml
### Windows ###
1. **CHEAT_CONFIG_PATH**
2. **APPDATA**/cheat/conf.yml
3. **PROGRAMDATA**/cheat/conf.yml
**cheat** will search in the order specified above. The first _conf.yaml_
encountered will be respected.
If **cheat** cannot locate a config file, it will ask if you'd like to generate
one automatically. Alternatively, you may also generate a config file manually
by running **cheat --init** and saving its output to the appropriate location
for your platform.
Cheatpaths
----------
**cheat** reads its cheatsheets from "cheatpaths", which are the directories in
which cheatsheets are stored. Cheatpaths may be configured in _conf.yaml_, and
viewed via **cheat -d**.
For detailed instructions on how to configure cheatpaths, please refer to the
comments in conf.yml.
Autocompletion
--------------
Autocompletion scripts for **bash**, **zsh**, and **fish** are available for
download:
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
The **bash** and **zsh** scripts provide optional integration with **fzf**, if
the latter is available on your **PATH**.
The installation process will vary per system and shell configuration, and thus
will not be discussed here.
ENVIRONMENT
===========
**CHEAT_CONFIG_PATH**
: The path at which the config file is available. If **CHEAT_CONFIG_PATH** is
set, all other config paths will be ignored.
**CHEAT_USE_FZF**
: If set, autocompletion scripts will attempt to integrate with **fzf**.
RETURN VALUES
=============
0. Successful termination
1. Application error
2. Cheatsheet(s) not found
BUGS
====
See GitHub issues: <https://github.com/cheat/cheat/issues>
AUTHOR
======
Christopher Allen Lane <chris@chris-allen-lane.com>
SEE ALSO
========
**fzf(1)**

35
go.mod
View File

@@ -1,14 +1,37 @@
module github.com/cheat/cheat
go 1.13
go 1.26
require (
github.com/alecthomas/chroma v0.6.7
github.com/alecthomas/chroma/v2 v2.23.1
github.com/davecgh/go-spew v1.1.1
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/go-git/go-git/v5 v5.16.5
github.com/mattn/go-isatty v0.0.20
github.com/mitchellh/go-homedir v1.1.0
github.com/tj/front v0.0.0-20170212063142-739be213b0a1
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
gopkg.in/yaml.v2 v2.2.4
gopkg.in/yaml.v3 v3.0.1
)
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/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/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
)

159
go.sum
View File

@@ -1,62 +1,121 @@
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.6.7 h1:1hKci+AyKOxJrugR9veaocu9DQGR2/GecI72BpaO0Rg=
github.com/alecthomas/chroma v0.6.7/go.mod h1:zVlgtbRS7BJDrDY9SB238RmpoCBCYFlLmcfZ3durxTk=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
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/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
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/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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/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.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tj/front v0.0.0-20170212063142-739be213b0a1 h1:lA+aPRvltlx2fwv/BnxyYSDQo3pIeqzHgMO5GvK0T9E=
github.com/tj/front v0.0.0-20170212063142-739be213b0a1/go.mod h1:deJrtusCTptAW4EUn5vBLpl3dhNqPqUwEjWJz5UNxpQ=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 h1:YAFjXN64LMvktoUZH9zgY4lGc/msGN7HQfoSuKCgaDU=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
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/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
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.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=

View File

@@ -1,9 +1,28 @@
// Package cheatpath implements functions pertaining to cheatsheet file path
// management.
package cheatpath
import "fmt"
// Cheatpath encapsulates cheatsheet path information
type Cheatpath struct {
Name string `yaml:name`
Path string `yaml:path`
ReadOnly bool `yaml:readonly`
Tags []string `yaml:tags`
Name string `yaml:"name"`
Path string `yaml:"path"`
ReadOnly bool `yaml:"readonly"`
Tags []string `yaml:"tags"`
}
// Validate ensures that the Cheatpath is valid
func (c Cheatpath) Validate() error {
// Check that name is not empty
if c.Name == "" {
return fmt.Errorf("cheatpath name cannot be empty")
}
// Check that path is not empty
if c.Path == "" {
return fmt.Errorf("cheatpath path cannot be empty")
}
return nil
}

View File

@@ -0,0 +1,90 @@
package cheatpath
import (
"strings"
"testing"
)
func TestCheatpathValidate(t *testing.T) {
tests := []struct {
name string
cheatpath Cheatpath
wantErr bool
errMsg string
}{
{
name: "valid cheatpath",
cheatpath: Cheatpath{
Name: "personal",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
Tags: []string{"personal"},
},
wantErr: false,
},
{
name: "empty name",
cheatpath: Cheatpath{
Name: "",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
Tags: []string{"personal"},
},
wantErr: true,
errMsg: "cheatpath name cannot be empty",
},
{
name: "empty path",
cheatpath: Cheatpath{
Name: "personal",
Path: "",
ReadOnly: false,
Tags: []string{"personal"},
},
wantErr: true,
errMsg: "cheatpath path cannot be empty",
},
{
name: "both empty",
cheatpath: Cheatpath{
Name: "",
Path: "",
ReadOnly: true,
Tags: nil,
},
wantErr: true,
errMsg: "cheatpath name cannot be empty",
},
{
name: "minimal valid",
cheatpath: Cheatpath{
Name: "x",
Path: "/",
},
wantErr: false,
},
{
name: "with readonly and tags",
cheatpath: Cheatpath{
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)
}
})
}
}

64
internal/cheatpath/doc.go Normal file
View File

@@ -0,0 +1,64 @@
// Package cheatpath manages collections of cheat sheets organized in filesystem directories.
//
// A Cheatpath represents a directory containing cheat sheets, with associated
// metadata such as tags and read-only status. Multiple cheatpaths can be
// configured to organize sheets from different sources (personal, community, work, etc.).
//
// # Cheatpath Structure
//
// Each cheatpath has:
// - Name: A friendly identifier (e.g., "personal", "community")
// - Path: The filesystem path to the directory
// - Tags: Tags automatically applied to all sheets in this path
// - ReadOnly: Whether sheets in this path can be modified
//
// Example configuration:
//
// cheatpaths:
// - name: personal
// path: ~/cheat
// tags: []
// readonly: false
// - name: community
// path: ~/cheat/community
// tags: [community]
// readonly: true
//
// # Directory-Scoped Cheatpaths
//
// The package supports directory-scoped cheatpaths via `.cheat` directories.
// When running cheat, the tool walks upward from the current working directory
// to the filesystem root, stopping at the first `.cheat` directory found. That
// directory is temporarily added to the available cheatpaths.
//
// # Precedence and Overrides
//
// When multiple cheatpaths contain a sheet with the same name, the sheet
// from the most "local" cheatpath takes precedence. This allows users to
// override community sheets with personal versions.
//
// Key Functions
//
// - Filter: Filters cheatpaths by name
// - Validate: Ensures cheatpath configuration is valid
// - Writeable: Returns the first writeable cheatpath
//
// Example Usage
//
// // Filter cheatpaths to only "personal"
// filtered, err := cheatpath.Filter(paths, "personal")
// if err != nil {
// log.Fatal(err)
// }
//
// // Find a writeable cheatpath
// writeable, err := cheatpath.Writeable(paths)
// if err != nil {
// log.Fatal(err)
// }
//
// // Validate cheatpath configuration
// if err := cheatpath.Validate(paths); err != nil {
// log.Fatal(err)
// }
package cheatpath

View File

@@ -46,7 +46,7 @@ func TestFilterFailure(t *testing.T) {
}
// filter the paths
paths, err := Filter(paths, "qux")
_, err := Filter(paths, "qux")
if err == nil {
t.Errorf("failed to return an error on non-existent cheatpath")
}

View File

@@ -2,16 +2,38 @@ package cheatpath
import (
"fmt"
"path/filepath"
"strings"
)
// Validate returns an error if the cheatpath is invalid
func (c *Cheatpath) Validate() error {
if c.Name == "" {
return fmt.Errorf("invalid cheatpath: name must be specified")
// ValidateSheetName ensures that a cheatsheet name does not contain
// directory traversal sequences or other potentially dangerous patterns.
func ValidateSheetName(name string) error {
// Reject empty names
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
}
if c.Path == "" {
return fmt.Errorf("invalid cheatpath: path must be specified")
// 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)
// We don't display hidden files, so we shouldn't create them
filename := filepath.Base(name)
if strings.HasPrefix(filename, ".") {
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
}
return nil

View File

@@ -0,0 +1,169 @@
package cheatpath
import (
"strings"
"testing"
"unicode/utf8"
)
// FuzzValidateSheetName tests the ValidateSheetName function with fuzzing
// to ensure it properly prevents path traversal and other security issues
func FuzzValidateSheetName(f *testing.F) {
// Add seed corpus with various valid and malicious inputs
// Valid names
f.Add("docker")
f.Add("docker/compose")
f.Add("lang/go/slice")
f.Add("my-cheat_sheet")
f.Add("file.txt")
f.Add("a")
f.Add("123")
// Path traversal attempts
f.Add("..")
f.Add("../etc/passwd")
f.Add("foo/../bar")
f.Add("foo/../../etc/passwd")
f.Add("..\\windows\\system32")
f.Add("foo\\..\\..\\windows")
// Encoded traversal attempts
f.Add("%2e%2e")
f.Add("%2e%2e%2f")
f.Add("..%2f")
f.Add("%2e.")
f.Add(".%2e")
f.Add("\x2e\x2e")
f.Add("\\x2e\\x2e")
// Unicode and special characters
f.Add("€test")
f.Add("test€")
f.Add("中文")
f.Add("🎉emoji")
f.Add("\x00null")
f.Add("test\x00null")
f.Add("\nnewline")
f.Add("test\ttab")
// Absolute paths
f.Add("/etc/passwd")
f.Add("C:\\Windows\\System32")
f.Add("\\\\server\\share")
f.Add("//server/share")
// Home directory
f.Add("~")
f.Add("~/config")
f.Add("~user/file")
// Hidden files
f.Add(".hidden")
f.Add("dir/.hidden")
f.Add(".git/config")
// Edge cases
f.Add("")
f.Add(" ")
f.Add(" ")
f.Add("\t")
f.Add(".")
f.Add("./")
f.Add("./file")
f.Add(".../")
f.Add("...")
f.Add("....")
// Very long names
f.Add(strings.Repeat("a", 255))
f.Add(strings.Repeat("a/", 100) + "file")
f.Add(strings.Repeat("../", 50) + "etc/passwd")
f.Fuzz(func(t *testing.T, input string) {
// The function should never panic
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with input %q: %v", input, r)
}
}()
err := ValidateSheetName(input)
// Security invariants that must always hold
if err == nil {
// If validation passed, verify security properties
// Should not contain ".." for path traversal
if strings.Contains(input, "..") {
t.Errorf("validation passed but input contains '..': %q", input)
}
// Should not be empty
if input == "" {
t.Error("validation passed for empty input")
}
// Should not start with ~ (home directory)
if strings.HasPrefix(input, "~") {
t.Errorf("validation passed but input starts with '~': %q", input)
}
// Base filename should not start with .
parts := strings.Split(input, "/")
if len(parts) > 0 {
lastPart := parts[len(parts)-1]
if strings.HasPrefix(lastPart, ".") && lastPart != "." {
t.Errorf("validation passed but filename starts with '.': %q", input)
}
}
// Additional check: result should be valid UTF-8
if !utf8.ValidString(input) {
// While the function doesn't explicitly check this,
// we want to ensure it handles invalid UTF-8 gracefully
t.Logf("validation passed for invalid UTF-8: %q", input)
}
}
}()
})
}
// FuzzValidateSheetNamePathTraversal specifically targets path traversal bypasses
func FuzzValidateSheetNamePathTraversal(f *testing.F) {
// Seed corpus focusing on path traversal variations
f.Add("..", "/", "")
f.Add("", "..", "/")
f.Add("a", "b", "c")
f.Fuzz(func(t *testing.T, prefix string, middle string, suffix string) {
// Construct various path traversal attempts
inputs := []string{
prefix + ".." + suffix,
prefix + "/.." + suffix,
prefix + "\\.." + suffix,
prefix + middle + ".." + suffix,
prefix + "../" + middle + suffix,
prefix + "..%2f" + suffix,
prefix + "%2e%2e" + suffix,
prefix + "%2e%2e%2f" + suffix,
}
for _, input := range inputs {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with constructed input %q: %v", input, r)
}
}()
err := ValidateSheetName(input)
// If the input contains literal "..", it must be rejected
if strings.Contains(input, "..") && err == nil {
t.Errorf("validation incorrectly passed for input containing '..': %q", input)
}
}()
}
})
}

View File

@@ -1,56 +1,113 @@
package cheatpath
import (
"runtime"
"strings"
"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{},
func TestValidateSheetName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
errMsg string
}{
// Valid names
{
name: "simple name",
input: "docker",
wantErr: false,
},
{
name: "name with slash",
input: "docker/compose",
wantErr: false,
},
{
name: "name with multiple slashes",
input: "lang/go/slice",
wantErr: false,
},
{
name: "name with dash and underscore",
input: "my-cheat_sheet",
wantErr: false,
},
// Invalid names
{
name: "empty name",
input: "",
wantErr: true,
errMsg: "empty",
},
{
name: "parent directory traversal",
input: "../etc/passwd",
wantErr: true,
errMsg: "'..'",
},
{
name: "complex traversal",
input: "foo/../../etc/passwd",
wantErr: true,
errMsg: "'..'",
},
{
name: "absolute path unix",
input: "/etc/passwd",
wantErr: runtime.GOOS != "windows", // /etc/passwd is not absolute on Windows
errMsg: "absolute",
},
{
name: "absolute path windows",
input: `C:\evil`,
wantErr: runtime.GOOS == "windows", // C:\evil is not absolute on Unix
errMsg: "absolute",
},
{
name: "home directory",
input: "~/secrets",
wantErr: true,
errMsg: "'~'",
},
{
name: "just dots",
input: "..",
wantErr: true,
errMsg: "'..'",
},
{
name: "hidden file not allowed",
input: ".hidden",
wantErr: true,
errMsg: "cannot start with '.'",
},
{
name: "current dir is ok",
input: "./current",
wantErr: false,
},
{
name: "nested hidden file not allowed",
input: "config/.gitignore",
wantErr: true,
errMsg: "cannot start with '.'",
},
}
// 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")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSheetName(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if err != nil && tt.errMsg != "" {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateName(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg)
}
}
})
}
}

View File

@@ -11,12 +11,10 @@ func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
// NB: we're going backwards because we assume that the most "local"
// cheatpath will be specified last in the configs
for i := len(cheatpaths) - 1; i >= 0; i-- {
// 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
}
}
// otherwise, return an error

26
internal/config/color.go Normal file
View File

@@ -0,0 +1,26 @@
package config
import (
"os"
"github.com/mattn/go-isatty"
)
// Color indicates whether colorization should be applied to the output
func (c *Config) Color(opts map[string]interface{}) bool {
// default to the colorization specified in the configs...
colorize := c.Colorize
// ... however, only apply colorization if we're writing to a tty...
if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) {
colorize = false
}
// ... *unless* the --colorize flag was passed
if opts["--colorize"] == true {
colorize = true
}
return colorize
}

View File

@@ -0,0 +1,22 @@
package config
import (
"testing"
)
// TestColor asserts that colorization rules are properly respected
func TestColor(t *testing.T) {
// mock a config
conf := Config{}
opts := map[string]interface{}{"--colorize": false}
if conf.Color(opts) {
t.Errorf("failed to respect --colorize (false)")
}
opts = map[string]interface{}{"--colorize": true}
if !conf.Color(opts) {
t.Errorf("failed to respect --colorize (true)")
}
}

View File

@@ -1,30 +1,34 @@
// Package config implements functions pertaining to configuration management.
package config
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v2"
"gopkg.in/yaml.v3"
)
// Config encapsulates configuration parameters
type Config struct {
Colorize bool `yaml:colorize`
Editor string `yaml:editor`
Cheatpaths []cp.Cheatpath `yaml:cheatpaths`
Style string `yaml:style`
Formatter string `yaml:formatter`
Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
Path string
}
// New returns a new Config struct
func New(opts map[string]interface{}, confPath string) (Config, error) {
func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := ioutil.ReadFile(confPath)
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
@@ -32,31 +36,91 @@ func New(opts map[string]interface{}, confPath string) (Config, error) {
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.UnmarshalStrict(buf, &conf)
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// expand ~ in config paths
for i, cheatpath := range conf.Cheatpaths {
// 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.Cheatpath{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
var validPaths []cp.Cheatpath
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)
}
conf.Cheatpaths[i].Path = expanded
// 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 not provided in the configs, look to envvars
// if an editor was still not determined, attempt to choose one
// that's appropriate for the environment
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 conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
@@ -67,8 +131,29 @@ func New(opts map[string]interface{}, confPath string) (Config, error) {
// if a chroma formatter was not provided, set a default
if conf.Formatter == "" {
conf.Formatter = "terminal16m"
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
}
}

View File

@@ -0,0 +1,148 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/cheat/cheat/internal/mock"
)
// 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(map[string]interface{}{}, 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(map[string]interface{}{}, mock.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(map[string]interface{}{}, 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(map[string]interface{}{}, 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))
}
}

View 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)
}
})
}

View File

@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"github.com/davecgh/go-spew/spew"
@@ -13,11 +14,279 @@ import (
"github.com/cheat/cheat/internal/mock"
)
// 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
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
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"))
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
t.Errorf("failed to parse config file: %v", err)
}
@@ -39,17 +308,17 @@ func TestConfigSuccessful(t *testing.T) {
// assert that the cheatpaths are correct
want := []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Path: filepath.Join(home, ".dotfiles/cheat/community"),
Path: filepath.Join(home, ".dotfiles", "cheat", "community"),
ReadOnly: true,
Tags: []string{"community"},
},
cheatpath.Cheatpath{
Path: filepath.Join(home, ".dotfiles/cheat/work"),
Path: filepath.Join(home, ".dotfiles", "cheat", "work"),
ReadOnly: false,
Tags: []string{"work"},
},
cheatpath.Cheatpath{
Path: filepath.Join(home, ".dotfiles/cheat/personal"),
Path: filepath.Join(home, ".dotfiles", "cheat", "personal"),
ReadOnly: false,
Tags: []string{"personal"},
},
@@ -69,43 +338,84 @@ func TestConfigSuccessful(t *testing.T) {
func TestConfigFailure(t *testing.T) {
// attempt to read a non-existent config file
_, err := New(map[string]interface{}{}, "/does-not-exit")
_, err := New(map[string]interface{}{}, "/does-not-exit", false)
if err == nil {
t.Errorf("failed to error on unreadable config")
}
}
// TestEmptyEditor asserts that envvars are respected if an editor is not
// specified in the configs
func TestEmptyEditor(t *testing.T) {
// TestEditorEnvOverride asserts that $VISUAL and $EDITOR override the
// config file value at runtime (regression test for #589)
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
os.Setenv("VISUAL", "")
os.Setenv("EDITOR", "")
// initialize a config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"))
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"))
// with no env vars, the config file value should be used
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
t.Errorf("failed to init configs: %v", err)
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "foo" {
t.Errorf("failed to respect editor: want: foo, got: %s", conf.Editor)
if conf.Editor != "vim" {
t.Errorf("expected config file editor: want: vim, got: %s", conf.Editor)
}
// set visual, and assert that it overrides editor
os.Setenv("VISUAL", "bar")
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"))
// $EDITOR should override the config file value
os.Setenv("EDITOR", "nano")
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
if err != nil {
t.Errorf("failed to init configs: %v", err)
t.Fatalf("failed to init configs: %v", err)
}
if conf.Editor != "bar" {
t.Errorf("failed to respect editor: want: bar, got: %s", conf.Editor)
if conf.Editor != "nano" {
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(map[string]interface{}{}, mock.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(map[string]interface{}{}, mock.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(map[string]interface{}{}, mock.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)
}
}

52
internal/config/doc.go Normal file
View File

@@ -0,0 +1,52 @@
// Package config manages application configuration and settings.
//
// The config package provides functionality to:
// - Load configuration from YAML files
// - Validate configuration values
// - Manage platform-specific configuration paths
// - Handle editor and pager settings
// - Configure colorization and formatting options
//
// # Configuration Structure
//
// The main configuration file (conf.yml) contains:
// - Editor preferences
// - Pager settings
// - Colorization options
// - Cheatpath definitions
// - Formatting preferences
//
// Example configuration:
//
// ---
// editor: vim
// colorize: true
// style: monokai
// formatter: terminal256
// pager: less -FRX
// cheatpaths:
// - name: personal
// path: ~/cheat
// tags: []
// readonly: false
// - name: community
// path: ~/cheat/.cheat
// tags: [community]
// readonly: true
//
// # Platform-Specific Paths
//
// The package automatically detects configuration paths based on the operating system:
// - Linux/Unix: $XDG_CONFIG_HOME/cheat/conf.yml or ~/.config/cheat/conf.yml
// - macOS: ~/Library/Application Support/cheat/conf.yml
// - Windows: %APPDATA%\cheat\conf.yml
//
// # Environment Variables
//
// The following environment variables are respected:
// - CHEAT_CONFIG_PATH: Override the configuration file location
// - CHEAT_USE_FZF: Enable fzf integration when set to "true"
// - EDITOR: Default editor if not specified in config
// - VISUAL: Fallback editor if EDITOR is not set
// - PAGER: Default pager if not specified in config
package config

41
internal/config/editor.go Normal file
View 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")
}

View 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")
}
})
}

23
internal/config/init.go Normal file
View File

@@ -0,0 +1,23 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// Init initializes a config file
func Init(confpath string, configs string) error {
// assert that the config directory exists
if err := os.MkdirAll(filepath.Dir(confpath), 0755); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
// write the config file
if err := os.WriteFile(confpath, []byte(configs), 0644); err != nil {
return fmt.Errorf("failed to create file: %v", err)
}
return nil
}

View File

@@ -0,0 +1,123 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestInit asserts that configs are properly initialized
func TestInit(t *testing.T) {
// initialize a temporary config file
confFile, err := os.CreateTemp("", "cheat-test")
if err != nil {
t.Errorf("failed to create temp file: %v", err)
}
// clean up the temp file
defer os.Remove(confFile.Name())
// initialize the config file
conf := "mock config data"
if err = Init(confFile.Name(), conf); err != nil {
t.Errorf("failed to init config file: %v", err)
}
// read back the config file contents
bytes, err := os.ReadFile(confFile.Name())
if err != nil {
t.Errorf("failed to read config file: %v", err)
}
// assert that the contents were written correctly
got := string(bytes)
if got != conf {
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)
}
}

135
internal/config/new_test.go Normal file
View 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(map[string]interface{}{}, 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(map[string]interface{}{}, 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(map[string]interface{}{}, 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
View 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 ""
}

View 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)
}
})
}

View File

@@ -3,58 +3,10 @@ package config
import (
"fmt"
"os"
"path"
"github.com/mitchellh/go-homedir"
)
// Path returns the config file path
func Path(sys string) (string, error) {
var paths []string
// if CHEAT_CONFIG_PATH is set, return it
if os.Getenv("CHEAT_CONFIG_PATH") != "" {
// expand ~
expanded, err := homedir.Expand(os.Getenv("CHEAT_CONFIG_PATH"))
if err != nil {
return "", fmt.Errorf("failed to expand ~: %v", err)
}
return expanded, nil
// OSX config paths
} else if sys == "darwin" {
paths = []string{
path.Join(os.Getenv("XDG_CONFIG_HOME"), "/cheat/conf.yml"),
path.Join(os.Getenv("HOME"), ".config/cheat/conf.yml"),
path.Join(os.Getenv("HOME"), ".cheat/conf.yml"),
}
// Linux config paths
} else if sys == "linux" {
paths = []string{
path.Join(os.Getenv("XDG_CONFIG_HOME"), "/cheat/conf.yml"),
path.Join(os.Getenv("HOME"), ".config/cheat/conf.yml"),
path.Join(os.Getenv("HOME"), ".cheat/conf.yml"),
"/etc/cheat/conf.yml",
}
// Windows config paths
} else if sys == "windows" {
paths = []string{
fmt.Sprintf("%s/cheat/conf.yml", os.Getenv("APPDATA")),
fmt.Sprintf("%s/cheat/conf.yml", os.Getenv("PROGRAMDATA")),
}
// Unsupported platforms
} else {
return "", fmt.Errorf("unsupported os: %s", sys)
}
func Path(paths []string) (string, error) {
// check if the config file exists on any paths
for _, p := range paths {

View File

@@ -0,0 +1,52 @@
package config
import (
"os"
"testing"
)
// TestPathConfigNotExists asserts that `Path` identifies non-existent config
// files
func TestPathConfigNotExists(t *testing.T) {
// package (invalid) cheatpaths
paths := []string{"/cheat-test-conf-does-not-exist"}
// assert
if _, err := Path(paths); err == nil {
t.Errorf("failed to identify non-existent config file")
}
}
// TestPathConfigExists asserts that `Path` identifies existent config files
func TestPathConfigExists(t *testing.T) {
// initialize a temporary config file
confFile, err := os.CreateTemp("", "cheat-test")
if err != nil {
t.Errorf("failed to create temp file: %v", err)
}
// clean up the temp file
defer os.Remove(confFile.Name())
// package cheatpaths
paths := []string{
"/cheat-test-conf-does-not-exist",
confFile.Name(),
}
// assert
got, err := Path(paths)
if err != nil {
t.Errorf("failed to identify config file: %v", err)
}
if got != confFile.Name() {
t.Errorf(
"failed to return config path: want: %s, got: %s",
confFile.Name(),
got,
)
}
}

59
internal/config/paths.go Normal file
View File

@@ -0,0 +1,59 @@
package config
import (
"fmt"
"path/filepath"
"github.com/mitchellh/go-homedir"
)
// Paths returns config file paths that are appropriate for the operating
// system
func Paths(
sys string,
home string,
envvars map[string]string,
) ([]string, error) {
// if `CHEAT_CONFIG_PATH` is set, expand ~ and return it
if confpath, ok := envvars["CHEAT_CONFIG_PATH"]; ok {
// expand ~
expanded, err := homedir.Expand(confpath)
if err != nil {
return []string{}, fmt.Errorf("failed to expand ~: %v", err)
}
return []string{expanded}, nil
}
switch sys {
// darwin/linux/unix
case "aix", "android", "darwin", "dragonfly", "freebsd", "illumos", "ios",
"linux", "netbsd", "openbsd", "plan9", "solaris":
paths := []string{}
// don't include the `XDG_CONFIG_HOME` path if that envvar is not set
if xdgpath, ok := envvars["XDG_CONFIG_HOME"]; ok {
paths = append(paths, filepath.Join(xdgpath, "cheat", "conf.yml"))
}
paths = append(paths, []string{
filepath.Join(home, ".config", "cheat", "conf.yml"),
filepath.Join(home, ".cheat", "conf.yml"),
"/etc/cheat/conf.yml",
}...)
return paths, nil
// windows
case "windows":
return []string{
filepath.Join(envvars["APPDATA"], "cheat", "conf.yml"),
filepath.Join(envvars["PROGRAMDATA"], "cheat", "conf.yml"),
}, nil
default:
return []string{}, fmt.Errorf("unsupported os: %s", sys)
}
}

View File

@@ -0,0 +1,184 @@
package config
import (
"path/filepath"
"reflect"
"runtime"
"testing"
"github.com/davecgh/go-spew/spew"
)
// TestValidatePathsNix asserts that the proper config paths are returned on
// *nix platforms
func TestValidatePathsNix(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("filepath.Join uses backslashes on Windows")
}
// mock the user's home directory
home := "/home/foo"
// mock some envvars
envvars := map[string]string{
"XDG_CONFIG_HOME": "/home/bar",
}
// specify the platforms to test
oses := []string{
"android",
"darwin",
"freebsd",
"linux",
}
// test each *nix os
for _, os := range oses {
// get the paths for the platform
paths, err := Paths(os, home, envvars)
if err != nil {
t.Errorf("paths returned an error: %v", err)
}
// specify the expected output
want := []string{
"/home/bar/cheat/conf.yml",
"/home/foo/.config/cheat/conf.yml",
"/home/foo/.cheat/conf.yml",
"/etc/cheat/conf.yml",
}
// assert that output matches expectations
if !reflect.DeepEqual(paths, want) {
t.Errorf(
"failed to return expected paths: want:\n%s, got:\n%s",
spew.Sdump(want),
spew.Sdump(paths),
)
}
}
}
// TestValidatePathsNixNoXDG asserts that the proper config paths are returned
// on *nix platforms when `XDG_CONFIG_HOME is not set
func TestValidatePathsNixNoXDG(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("filepath.Join uses backslashes on Windows")
}
// mock the user's home directory
home := "/home/foo"
// mock some envvars
envvars := map[string]string{}
// specify the platforms to test
oses := []string{
"darwin",
"freebsd",
"linux",
}
// test each *nix os
for _, os := range oses {
// get the paths for the platform
paths, err := Paths(os, home, envvars)
if err != nil {
t.Errorf("paths returned an error: %v", err)
}
// specify the expected output
want := []string{
"/home/foo/.config/cheat/conf.yml",
"/home/foo/.cheat/conf.yml",
"/etc/cheat/conf.yml",
}
// assert that output matches expectations
if !reflect.DeepEqual(paths, want) {
t.Errorf(
"failed to return expected paths: want:\n%s, got:\n%s",
spew.Sdump(want),
spew.Sdump(paths),
)
}
}
}
// TestValidatePathsWindows asserts that the proper config paths are returned
// on Windows platforms
func TestValidatePathsWindows(t *testing.T) {
// mock the user's home directory
home := "not-used-on-windows"
// mock some envvars
envvars := map[string]string{
"APPDATA": filepath.Join("C:", "apps"),
"PROGRAMDATA": filepath.Join("C:", "programs"),
}
// get the paths for the platform
paths, err := Paths("windows", home, envvars)
if err != nil {
t.Errorf("paths returned an error: %v", err)
}
// specify the expected output
want := []string{
filepath.Join("C:", "apps", "cheat", "conf.yml"),
filepath.Join("C:", "programs", "cheat", "conf.yml"),
}
// assert that output matches expectations
if !reflect.DeepEqual(paths, want) {
t.Errorf(
"failed to return expected paths: want:\n%s, got:\n%s",
spew.Sdump(want),
spew.Sdump(paths),
)
}
}
// TestValidatePathsUnsupported asserts that an error is returned on
// unsupported platforms
func TestValidatePathsUnsupported(t *testing.T) {
_, err := Paths("unsupported", "", map[string]string{})
if err == nil {
t.Errorf("failed to return error on unsupported platform")
}
}
// TestValidatePathsCheatConfigPath asserts that the proper config path is
// returned when `CHEAT_CONFIG_PATH` is explicitly specified.
func TestValidatePathsCheatConfigPath(t *testing.T) {
// mock the user's home directory
home := "/home/foo"
// mock some envvars
envvars := map[string]string{
"XDG_CONFIG_HOME": "/home/bar",
"CHEAT_CONFIG_PATH": "/home/baz/conf.yml",
}
// get the paths for the platform
paths, err := Paths("linux", home, envvars)
if err != nil {
t.Errorf("paths returned an error: %v", err)
}
// specify the expected output
want := []string{
"/home/baz/conf.yml",
}
// assert that output matches expectations
if !reflect.DeepEqual(paths, want) {
t.Errorf(
"failed to return expected paths: want:\n%s, got:\n%s",
spew.Sdump(want),
spew.Sdump(paths),
)
}
}

View File

@@ -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
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{
Colorize: true,
Editor: "vim",
Colorize: true,
Editor: "vim",
Formatter: "html",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
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 {
t.Errorf("failed to invalidate config without formatter")
t.Errorf("failed to invalidate config with invalid formatter")
}
}

45
internal/display/doc.go Normal file
View File

@@ -0,0 +1,45 @@
// Package display handles output formatting and presentation for the cheat application.
//
// The display package provides utilities for:
// - Writing output to stdout or a pager
// - Formatting text with indentation
// - Creating faint (dimmed) text for de-emphasis
// - Managing colored output
//
// # Pager Integration
//
// The package integrates with system pagers (less, more, etc.) to handle
// long output. If a pager is configured and the output is to a terminal,
// content is automatically piped through the pager.
//
// # Text Formatting
//
// Various formatting utilities are provided:
// - Faint: Creates dimmed text using ANSI escape codes
// - Indent: Adds consistent indentation to text blocks
// - Write: Intelligent output that uses stdout or pager as appropriate
//
// Example Usage
//
// // Write output, using pager if configured
// if err := display.Write(output, config); err != nil {
// log.Fatal(err)
// }
//
// // Create faint text for de-emphasis
// fainted := display.Faint("(read-only)", config)
//
// // Indent a block of text
// indented := display.Indent(text, " ")
//
// # Color Support
//
// The package respects the colorization settings from the config.
// When colorization is disabled, formatting functions like Faint
// return unmodified text.
//
// # Terminal Detection
//
// The package uses isatty to detect if output is to a terminal,
// which affects decisions about using a pager and applying colors.
package display

21
internal/display/faint.go Normal file
View File

@@ -0,0 +1,21 @@
// Package display implement functions pertaining to writing formatted
// cheatsheet content to stdout, or alternatively the system pager.
package display
import (
"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, conf config.Config) string {
// make `str` faint only if colorization has been requested
if conf.Colorize {
return fmt.Sprintf("\033[2m%s\033[0m", str)
}
// otherwise, return the string unmodified
return str
}

View File

@@ -0,0 +1,27 @@
package display
import (
"testing"
"github.com/cheat/cheat/internal/config"
)
// TestFaint asserts that Faint applies faint formatting
func TestFaint(t *testing.T) {
// case: apply colorization
conf := config.Config{Colorize: true}
want := "\033[2mfoo\033[0m"
got := Faint("foo", conf)
if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got)
}
// case: do not apply colorization
conf.Colorize = false
want = "foo"
got = Faint("foo", conf)
if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got)
}
}

View File

@@ -0,0 +1,21 @@
package display
import (
"fmt"
"strings"
)
// Indent prepends each line of a string with a tab
func Indent(str string) string {
// trim superfluous whitespace
str = strings.TrimSpace(str)
// prepend each line with a tab character
out := ""
for _, line := range strings.Split(str, "\n") {
out += fmt.Sprintf("\t%s\n", line)
}
return out
}

View File

@@ -0,0 +1,12 @@
package display
import "testing"
// TestIndent asserts that Indent prepends a tab to each line
func TestIndent(t *testing.T) {
got := Indent("foo\nbar\nbaz")
want := "\tfoo\n\tbar\n\tbaz\n"
if got != want {
t.Errorf("failed to indent: want: %s, got: %s", want, got)
}
}

41
internal/display/write.go Normal file
View File

@@ -0,0 +1,41 @@
package display
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/cheat/cheat/internal/config"
)
// Write writes output either directly to stdout, or through a pager,
// depending upon configuration.
func Write(out string, conf config.Config) {
// if no pager was configured, print the output to stdout and exit
if conf.Pager == "" {
fmt.Print(out)
os.Exit(0)
}
// otherwise, pipe output through the pager
writeToPager(out, conf)
}
// writeToPager writes output through a pager command
func writeToPager(out string, conf config.Config) {
parts := strings.Split(conf.Pager, " ")
pager := parts[0]
args := parts[1:]
// configure the pager
cmd := exec.Command(pager, args...)
cmd.Stdin = strings.NewReader(out)
cmd.Stdout = os.Stdout
// run the pager and handle errors
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "failed to write to pager: %v\n", err)
os.Exit(1)
}
}

View 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))
}
}

View File

@@ -0,0 +1,52 @@
// Package installer implements functions that provide a first-time
// installation wizard.
package installer
import (
"fmt"
"os"
"strings"
)
// Prompt prompts the user for a answer
func Prompt(prompt string, def bool) (bool, error) {
// display the prompt
fmt.Printf("%s: ", prompt)
// read one byte at a time until newline to avoid buffering past the
// end of the current line, which would consume input intended for
// subsequent Prompt calls on the same stdin
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
ans := strings.ToLower(strings.TrimSpace(string(line)))
// return the appropriate response
switch ans {
case "y":
return true, nil
case "":
return def, nil
default:
return false, nil
}
}

View 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)
}
}

82
internal/installer/run.go Normal file
View File

@@ -0,0 +1,82 @@
package installer
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/repo"
)
// Run runs the installer
func Run(configs string, confpath string) error {
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confdir := filepath.Dir(confpath)
// create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal")
work := filepath.Join(confdir, "cheatsheets", "work")
// set default cheatpaths
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// prompt the user to download the community cheatsheets
yes, err := Prompt(
"Would you like to download the community cheatsheets? [Y/n]",
true,
)
if err != nil {
return fmt.Errorf("failed to prompt: %v", err)
}
// clone the community cheatsheets if so instructed
if yes {
fmt.Printf("Cloning community cheatsheets to %s.\n", community)
if err := repo.Clone(community); err != nil {
return fmt.Errorf("failed to clone cheatsheets: %v", err)
}
} else {
// comment out the community cheatpath in the config since
// the directory won't exist
configs = strings.Replace(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
-1,
)
}
// always create personal and work directories
for _, dir := range []string{personal, work} {
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
}
// the config file does not exist, so we'll try to create one
if err = config.Init(confpath, configs); err != nil {
return fmt.Errorf("failed to create config file: %v", err)
}
return nil
}

View 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)
}
}

View File

@@ -1,8 +1,8 @@
// Package mock implements mock functions used in unit-tests.
package mock
import (
"fmt"
"path"
"path/filepath"
"runtime"
)
@@ -13,16 +13,16 @@ func Path(filename string) string {
// determine the path of this file during runtime
_, thisfile, _, _ := runtime.Caller(0)
// compute the config path
// compute the mock path
file, err := filepath.Abs(
path.Join(
filepath.Join(
filepath.Dir(thisfile),
"../../mocks",
filename,
),
)
if err != nil {
panic(fmt.Errorf("failed to resolve config path: %v", err))
panic(fmt.Errorf("failed to resolve mock path: %v", err))
}
return file

26
internal/repo/clone.go Normal file
View 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
}

View 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)
}
}

View 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
View 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
}

View 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)
}
}
}

1
internal/repo/update.go Normal file
View File

@@ -0,0 +1 @@
package repo

View File

@@ -0,0 +1,37 @@
package sheet
import (
"bytes"
"github.com/cheat/cheat/internal/config"
"github.com/alecthomas/chroma/v2/quick"
)
// Colorize applies syntax-highlighting to a cheatsheet's Text.
func (s *Sheet) Colorize(conf config.Config) {
// if the syntax was not specified, default to bash
lex := s.Syntax
if lex == "" {
lex = "bash"
}
// write colorized text into a buffer
var buf bytes.Buffer
err := quick.Highlight(
&buf,
s.Text,
lex,
conf.Formatter,
conf.Style,
)
// if colorization somehow failed, do nothing
if err != nil {
return
}
// otherwise, swap the cheatsheet's Text with its colorized equivalent
s.Text = buf.String()
}

View File

@@ -0,0 +1,42 @@
package sheet
import (
"strings"
"testing"
"github.com/cheat/cheat/internal/config"
)
// TestColorize asserts that syntax-highlighting is correctly applied
func TestColorize(t *testing.T) {
// mock configs
conf := config.Config{
Formatter: "terminal16m",
Style: "solarized-dark",
}
// mock a sheet
original := "echo 'foo'"
s := Sheet{
Text: original,
}
// colorize the sheet text
s.Colorize(conf)
// assert that the text was modified (colorization applied)
if s.Text == original {
t.Error("Colorize did not modify sheet text")
}
// assert that ANSI escape codes are present
if !strings.Contains(s.Text, "\x1b[") && !strings.Contains(s.Text, "[0m") {
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)
}
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"io"
"os"
"path"
"path/filepath"
)
// Copy copies a cheatsheet to a new location
@@ -22,7 +22,7 @@ func (s *Sheet) Copy(dest string) error {
defer infile.Close()
// create any necessary subdirectories
dirs := path.Dir(dest)
dirs := filepath.Dir(dest)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
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
_, err = io.Copy(outfile, infile)
if err != nil {
// Clean up the partially written file on error
os.Remove(dest)
return fmt.Errorf(
"failed to copy file: infile: %s, outfile: %s, err: %v",
s.Path,

View 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")
}
}

View File

@@ -1,7 +1,6 @@
package sheet
import (
"io/ioutil"
"os"
"path"
"testing"
@@ -13,7 +12,7 @@ func TestCopyFlat(t *testing.T) {
// mock a cheatsheet file
text := "this is the cheatsheet text"
src, err := ioutil.TempFile("", "foo-src")
src, err := os.CreateTemp("", "foo-src")
if err != nil {
t.Errorf("failed to mock cheatsheet: %v", err)
}
@@ -25,7 +24,7 @@ func TestCopyFlat(t *testing.T) {
}
// mock a cheatsheet struct
sheet, err := New("foo", src.Name(), []string{}, false)
sheet, err := New("foo", "community", src.Name(), []string{}, false)
if err != nil {
t.Errorf("failed to init cheatsheet: %v", err)
}
@@ -41,7 +40,7 @@ func TestCopyFlat(t *testing.T) {
}
// assert that the destination file contains the correct text
got, err := ioutil.ReadFile(outpath)
got, err := os.ReadFile(outpath)
if err != nil {
t.Errorf("failed to read destination file: %v", err)
}
@@ -60,7 +59,7 @@ func TestCopyDeep(t *testing.T) {
// mock a cheatsheet file
text := "this is the cheatsheet text"
src, err := ioutil.TempFile("", "foo-src")
src, err := os.CreateTemp("", "foo-src")
if err != nil {
t.Errorf("failed to mock cheatsheet: %v", err)
}
@@ -72,7 +71,13 @@ func TestCopyDeep(t *testing.T) {
}
// mock a cheatsheet struct
sheet, err := New("/cheat-tests/alpha/bravo/foo", src.Name(), []string{}, false)
sheet, err := New(
"/cheat-tests/alpha/bravo/foo",
"community",
src.Name(),
[]string{},
false,
)
if err != nil {
t.Errorf("failed to init cheatsheet: %v", err)
}
@@ -88,7 +93,7 @@ func TestCopyDeep(t *testing.T) {
}
// assert that the destination file contains the correct text
got, err := ioutil.ReadFile(outpath)
got, err := os.ReadFile(outpath)
if err != nil {
t.Errorf("failed to read destination file: %v", err)
}

65
internal/sheet/doc.go Normal file
View File

@@ -0,0 +1,65 @@
// Package sheet provides functionality for parsing and managing individual cheat sheets.
//
// A sheet represents a single cheatsheet file containing helpful commands, notes,
// or documentation. Sheets can include optional YAML frontmatter for metadata
// such as tags and syntax highlighting preferences.
//
// # Sheet Format
//
// Sheets are plain text files that may begin with YAML frontmatter:
//
// ---
// syntax: bash
// tags: [networking, linux, ssh]
// ---
// # Connect to remote server
// ssh user@hostname
//
// # Copy files over SSH
// scp local_file user@hostname:/remote/path
//
// The frontmatter is optional. If omitted, the sheet will use default values.
//
// # Core Types
//
// The Sheet type contains:
// - Title: The sheet's name (derived from filename)
// - Path: Full filesystem path to the sheet
// - Text: The content of the sheet (without frontmatter)
// - Tags: Categories assigned to the sheet
// - Syntax: Language hint for syntax highlighting
// - ReadOnly: Whether the sheet can be modified
//
// Key Functions
//
// - New: Creates a new Sheet from a file path
// - Parse: Extracts frontmatter and content from sheet text
// - Search: Searches sheet content using regular expressions
// - Colorize: Applies syntax highlighting to sheet content
//
// # Syntax Highlighting
//
// The package integrates with the Chroma library to provide syntax highlighting.
// Supported languages include bash, python, go, javascript, and many others.
// The syntax can be specified in the frontmatter or auto-detected.
//
// Example Usage
//
// // Load a sheet from disk
// s, err := sheet.New("/path/to/sheet", []string{"personal"}, false)
// if err != nil {
// log.Fatal(err)
// }
//
// // Search for content
// matches, err := s.Search("ssh", false)
// if err != nil {
// log.Fatal(err)
// }
//
// // Apply syntax highlighting
// colorized, err := s.Colorize(config)
// if err != nil {
// log.Fatal(err)
// }
package sheet

View File

@@ -1,7 +0,0 @@
package sheet
// Match encapsulates search matches within cheatsheets
type Match struct {
Line int
Text string
}

Some files were not shown because too many files have changed in this diff Show More