Compare commits
1
Commits
edee37ebb1
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edf6e79327 |
No files matched your search
@@ -4,8 +4,9 @@ name: Release
|
|||||||
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
||||||
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
|
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
|
||||||
# release with the archives, checksums and this release's section of the
|
# release with the archives, checksums and this release's section of the
|
||||||
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after
|
# changelog (git-cliff, cliff.toml), then copies it to the GitHub
|
||||||
# deleting it) replaces the release.
|
# mirror. Pushing the same tag again (after deleting it) replaces the
|
||||||
|
# release in both places.
|
||||||
#
|
#
|
||||||
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
|
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
|
||||||
|
|
||||||
@@ -81,3 +82,14 @@ jobs:
|
|||||||
args: release --clean --release-notes ${{ runner.temp }}/notes.md
|
args: release --clean --release-notes ${{ runner.temp }}/notes.md
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Copy the release to GitHub
|
||||||
|
# The same notes and files on the GitHub mirror, which lazysecrets
|
||||||
|
# updates from first (internal/update). GH_RELEASE_TOKEN is a
|
||||||
|
# fine-grained token that can write the mirror's contents; without
|
||||||
|
# it the step does nothing.
|
||||||
|
run: node scripts/github-release.mjs
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in lazysecrets doesn't work as it should.
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting! Please leave out secret values, tokens and anything else private: names of vaults and secrets are enough.
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: If you can, try `lazysecrets --demo` first.
|
||||||
|
placeholder: |
|
||||||
|
1. lazysecrets --demo
|
||||||
|
2. ctrl+s, type …
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: The status pane shows it, e.g. `v0.1.0`.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: source
|
||||||
|
attributes:
|
||||||
|
label: Where
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Azure Key Vault
|
||||||
|
- Databricks
|
||||||
|
- SOPS or .env files
|
||||||
|
- Links
|
||||||
|
- Everywhere / not sure
|
||||||
|
- type: input
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: OS and terminal
|
||||||
|
placeholder: macOS 26, kitty + tmux
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Docs
|
||||||
|
url: https://git.kebcloud.no/keb/lazysecrets/src/branch/main/docs
|
||||||
|
about: Keys, sources, links, config and more.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: An idea for lazysecrets, or something that could work better.
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
lazysecrets stays focused on finding, reading and sharing secrets, so not every idea will make it in, but they're all read.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What you're trying to do
|
||||||
|
description: The problem or the situation, more than the solution.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: Your idea
|
||||||
|
description: How you'd like it to work, if you have one in mind.
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Every release of lazysecrets, newest first.
|
Every release of lazysecrets, newest first.
|
||||||
|
|
||||||
## v0.1.0 – 2026-10-10
|
## v0.1.0 – 2026-10-12
|
||||||
|
|
||||||
Hello world 👋
|
Hello world 👋
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
# lazysecrets
|
# lazysecrets
|
||||||
|
|
||||||
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit.
|
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic [lazygit](https://github.com/jesseduffield/lazygit).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Main features
|
## Main features
|
||||||
|
|
||||||
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so
|
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
|
||||||
it's safe to point at production. That's by design and may stay that way.
|
That's by design and may stay that way.
|
||||||
(`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`;
|
|
||||||
any change is yours.)
|
|
||||||
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
||||||
CLI, `sops`, `git`). No config file needed.
|
CLI, `sops`, `git`). No config file needed.
|
||||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||||
fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps
|
fzf-style matching.
|
||||||
straight to the result.
|
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
||||||
- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it
|
|
||||||
with their own access, from the terminal or with a click in the browser
|
with their own access, from the terminal or with a click in the browser
|
||||||
or chat.
|
or chat.
|
||||||
|
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||||
- **Careful with values**: values stay hidden until you reveal them, and
|
- **Careful with values**: values stay hidden until you reveal them, and
|
||||||
copied values are cleared from the clipboard after 30 seconds.
|
copied values are cleared from the clipboard after 30 seconds.
|
||||||
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
||||||
pipes.
|
pipes.
|
||||||
- **Favourites**: star the secrets you use most and get back to them in one
|
- **Favourites**: star the secrets you use most for easy access.
|
||||||
key.
|
|
||||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||||
marked, and listed together in the status pane.
|
marked, listed together in the status pane.
|
||||||
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||||
on a secret.
|
on a secret.
|
||||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||||
@@ -39,8 +38,9 @@ On macOS or Linux (arm64 or x86_64):
|
|||||||
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
|
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases)
|
It installs the latest release, from GitHub or else
|
||||||
into `~/.local/bin` after checking it against the release's checksums.
|
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
|
||||||
|
`~/.local/bin` after checking it against the release's checksums.
|
||||||
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
||||||
somewhere else.
|
somewhere else.
|
||||||
|
|
||||||
@@ -98,9 +98,20 @@ lazysecrets never stores, caches or sends your secret values. It keeps the
|
|||||||
names and metadata of what you've loaded in a cache file readable only by
|
names and metadata of what you've loaded in a cache file readable only by
|
||||||
you, to show it at once next time (`cache: false` turns it off). It talks
|
you, to show it at once next time (`cache: false` turns it off). It talks
|
||||||
only to the services you use it with, and collects no usage data. The one
|
only to the services you use it with, and collects no usage data. The one
|
||||||
call of its own is a daily check for a new release on this server, which
|
call of its own is a daily check for a new release, on GitHub or else
|
||||||
`updateCheck: false` turns off.
|
git.kebcloud.no, which `updateCheck: false` turns off.
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
|
||||||
|
The main repo of this codebase is at [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets).
|
||||||
|
The [GitHub mirror](https://github.com/karleinarb/lazysecrets) is for discoverability, issue-tracking and releases.
|
||||||
|
It also makes it possible to give a ⭐ if you like lazysecrets!
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
I don't take pull requests as I don't have time to review and test them.
|
||||||
|
But bug reports and ideas are very welcome!
|
||||||
|
Open an [issue on GitHub](https://github.com/karleinarb/lazysecrets/issues) and I'll have a look.
|
||||||
|
|
||||||
## AI disclosure
|
## AI disclosure
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ Flags:
|
|||||||
case len(args) > 0 && args[0] == "launch":
|
case len(args) > 0 && args[0] == "launch":
|
||||||
return launch(*configPath, args[1:])
|
return launch(*configPath, args[1:])
|
||||||
case len(args) > 0 && args[0] == "update":
|
case len(args) > 0 && args[0] == "update":
|
||||||
return updateCmd(args[1:])
|
return updateCmd(*configPath, args[1:])
|
||||||
case len(args) == 0:
|
case len(args) == 0:
|
||||||
case args[0] == "open" && len(args) == 2:
|
case args[0] == "open" && len(args) == 2:
|
||||||
r, err := ref.Parse(args[1])
|
r, err := ref.Parse(args[1])
|
||||||
@@ -243,9 +243,10 @@ Flags:
|
|||||||
if *demo {
|
if *demo {
|
||||||
// Two Azure accounts, like a personal and an admin login in az.
|
// Two Azure accounts, like a personal and an admin login in az.
|
||||||
providers = []provider.Provider{
|
providers = []provider.Provider{
|
||||||
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
|
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
|
||||||
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
|
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
||||||
fake.Databricks(600 * time.Millisecond),
|
fake.Databricks(600 * time.Millisecond),
|
||||||
|
fake.Repos(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pc := cfg.Providers
|
pc := cfg.Providers
|
||||||
@@ -363,7 +364,7 @@ Flags:
|
|||||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||||
HandlerSetup: setup,
|
HandlerSetup: setup,
|
||||||
Updater: updater(),
|
Updater: updater(cfg.Releases),
|
||||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||||
Keys: &keymap,
|
Keys: &keymap,
|
||||||
Commands: cfg.CustomCommands,
|
Commands: cfg.CustomCommands,
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
// updater is the app's way to find and install releases, replacing the
|
// updater is the app's way to find and install releases, replacing the
|
||||||
// binary that's running.
|
// binary that's running, from the servers (config releases) or the
|
||||||
func updater() *app.Updater {
|
// default ones.
|
||||||
c := &update.Client{}
|
func updater(servers []string) *app.Updater {
|
||||||
|
c := &update.Client{Servers: servers}
|
||||||
return &app.Updater{
|
return &app.Updater{
|
||||||
Latest: c.Latest,
|
Latest: c.Latest,
|
||||||
Install: func(ctx context.Context, r update.Release) error {
|
Install: func(ctx context.Context, r update.Release) error {
|
||||||
@@ -29,15 +31,19 @@ func updater() *app.Updater {
|
|||||||
|
|
||||||
// updateCmd is lazysecrets update [--check]: the newer release's
|
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||||
// changelog, then (unless --check) install it.
|
// changelog, then (unless --check) install it.
|
||||||
func updateCmd(args []string) error {
|
func updateCmd(configPath string, args []string) error {
|
||||||
check := len(args) == 1 && args[0] == "--check"
|
check := len(args) == 1 && args[0] == "--check"
|
||||||
if len(args) > 0 && !check {
|
if len(args) > 0 && !check {
|
||||||
return errors.New("usage: lazysecrets update [--check]")
|
return errors.New("usage: lazysecrets update [--check]")
|
||||||
}
|
}
|
||||||
|
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
current := versionString()
|
current := versionString()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c := &update.Client{}
|
c := &update.Client{Servers: cfg.Releases}
|
||||||
r, err := c.Latest(ctx)
|
r, err := c.Latest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
+12
-6
@@ -15,6 +15,9 @@ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s
|
|||||||
sourcesView: grouped # or flat; ` toggles it
|
sourcesView: grouped # or flat; ` toggles it
|
||||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||||
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
|
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
|
||||||
|
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
|
||||||
|
- https://github.com/karleinarb/lazysecrets
|
||||||
|
- https://git.kebcloud.no/keb/lazysecrets
|
||||||
history: true # count uses of items for Recent (R)
|
history: true # count uses of items for Recent (R)
|
||||||
cache: true # keep what was loaded (never values) for the next start
|
cache: true # keep what was loaded (never values) for the next start
|
||||||
providers:
|
providers:
|
||||||
@@ -56,7 +59,7 @@ theme:
|
|||||||
activeBorderColor: ["#ff966c", bold] # focused pane
|
activeBorderColor: ["#ff966c", bold] # focused pane
|
||||||
inactiveBorderColor: ["#589ed7"]
|
inactiveBorderColor: ["#589ed7"]
|
||||||
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
||||||
inactiveViewSelectedLineBgColor: ["#2f334d"]
|
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
|
||||||
defaultFgColor: ["#c8d3f5"]
|
defaultFgColor: ["#c8d3f5"]
|
||||||
optionsTextColor: ["#82aaff"] # keys and labels
|
optionsTextColor: ["#82aaff"] # keys and labels
|
||||||
searchMatchColor: ["#ffc777", bold]
|
searchMatchColor: ["#ffc777", bold]
|
||||||
@@ -125,14 +128,17 @@ are read from disk anyway. Deleting the file is always safe, and
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Once a day (`updateCheck`), in the background, lazysecrets asks the
|
Once a day (`updateCheck`), in the background, lazysecrets asks for the
|
||||||
release server for the latest release; within the day it reuses the
|
latest release; within the day it reuses the answer from the state file.
|
||||||
answer from the state file. Offline, or with the server down, it says
|
Releases are published on GitHub and on git.kebcloud.no, and it asks
|
||||||
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
them in that order, the next when one doesn't answer within 5 seconds;
|
||||||
|
`releases` lists others, such as a mirror of your own. Offline, or with
|
||||||
|
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
||||||
`lazysecrets update --check`) shows its changelog.
|
`lazysecrets update --check`) shows its changelog.
|
||||||
|
|
||||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
||||||
your OS and CPU, checks it against the release's `checksums.txt`, makes
|
your OS and CPU from the first server that has it, checks it against
|
||||||
|
that server's `checksums.txt`, makes
|
||||||
sure it runs, and only then replaces the binary, atomically: a
|
sure it runs, and only then replaces the binary, atomically: a
|
||||||
lazysecrets that's running keeps working, and the next start is the new
|
lazysecrets that's running keeps working, and the next start is the new
|
||||||
version. A binary a package manager owns (Homebrew, Nix, a distro
|
version. A binary a package manager owns (Homebrew, Nix, a distro
|
||||||
|
|||||||
+6
-2
@@ -56,8 +56,12 @@ from the pull request titles. The tag runs the release workflow
|
|||||||
(`.gitea/workflows/release.yml`): it tests, then
|
(`.gitea/workflows/release.yml`): it tests, then
|
||||||
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
||||||
archives and publishes a Gitea release with them and the release's
|
archives and publishes a Gitea release with them and the release's
|
||||||
changelog section. `goreleaser release --snapshot --clean` builds the same
|
changelog section. Then `scripts/github-release.mjs` copies that release,
|
||||||
archives locally, into `dist/`, without publishing.
|
notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN`
|
||||||
|
secret (a fine-grained token that can write the mirror's contents);
|
||||||
|
without the secret it skips that. `goreleaser release --snapshot
|
||||||
|
--clean` builds the same archives locally, into `dist/`, without
|
||||||
|
publishing.
|
||||||
|
|
||||||
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
|
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
|
||||||
alone.
|
alone.
|
||||||
+12
-8
@@ -84,20 +84,24 @@ under every account. Both are kept in the [state file](config.md#state).
|
|||||||
starred) and `R` on Recent (what you used last). `enter` goes to the
|
starred) and `R` on Recent (what you used last). `enter` goes to the
|
||||||
selected item.
|
selected item.
|
||||||
|
|
||||||
Search has the scope on the left (All; all of Azure, Databricks or repos;
|
Search has the scope on the left and what's in it on the right. The
|
||||||
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in
|
scopes are All, then grouped like the Sources pane: each account (or
|
||||||
it on the right. All repos searches every repo without asking Azure or
|
Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
|
||||||
Databricks for anything. It opens on the
|
much of each is loaded. Repos searches every repo without asking Azure
|
||||||
source you're in. Typing a search loads what it covers: everything on
|
or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
|
||||||
All, else the source picked; results appear as lists arrive. That's once
|
it to the source or group selected in the Sources pane, and once more
|
||||||
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads
|
goes back to All. Typing a search loads what it covers: everything on
|
||||||
again, lists that failed included.
|
All, else the account or source picked; results appear as lists
|
||||||
|
arrive. That's once each time Go to opens, and only what isn't loaded
|
||||||
|
yet; `ctrl+a` loads again, lists that failed included.
|
||||||
|
|
||||||
Like a pane, Go to is either browsing or typing a filter:
|
Like a pane, Go to is either browsing or typing a filter:
|
||||||
|
|
||||||
| Browsing | Typing | Action |
|
| Browsing | Typing | Action |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `j`/`k` | `ctrl+j`/`ctrl+k` | move |
|
| `j`/`k` | `ctrl+j`/`ctrl+k` | move |
|
||||||
|
| `g`/`G` | | top / bottom |
|
||||||
|
| `ctrl+s` | `ctrl+s` | only the source or group you're in / All |
|
||||||
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
|
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
|
||||||
| `[` / `]` | | previous / next tab |
|
| `[` / `]` | | previous / next tab |
|
||||||
| `f` | `ctrl+f` | star |
|
| `f` | `ctrl+f` | star |
|
||||||
|
|||||||
+40
-14
@@ -7,10 +7,12 @@
|
|||||||
#
|
#
|
||||||
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
||||||
# and x86_64.
|
# and x86_64.
|
||||||
|
#
|
||||||
|
# Releases are on GitHub and git.kebcloud.no, tried in that order;
|
||||||
|
# LAZYSECRETS_RELEASES (repository addresses, space-separated) uses others.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
repo=https://git.kebcloud.no/keb/lazysecrets
|
repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}"
|
||||||
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
|
|
||||||
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
|
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
@@ -18,12 +20,13 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Short timeouts, so a server that's down leaves time for the next.
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
fetch() { curl -fsSL "$1"; }
|
fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; }
|
||||||
download() { curl -fsSL -o "$2" "$1"; }
|
download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; }
|
||||||
elif command -v wget >/dev/null 2>&1; then
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
fetch() { wget -qO- "$1"; }
|
fetch() { wget -qO- -T 15 "$1"; }
|
||||||
download() { wget -qO "$2" "$1"; }
|
download() { wget -qO "$2" -T 30 "$1"; }
|
||||||
else
|
else
|
||||||
fail "needs curl or wget"
|
fail "needs curl or wget"
|
||||||
fi
|
fi
|
||||||
@@ -39,11 +42,26 @@ arm64 | aarch64) arch=arm64 ;;
|
|||||||
*) fail "no release for $(uname -m)" ;;
|
*) fail "no release for $(uname -m)" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# api is a repository's API: GitHub's, or Gitea's (and Forgejo's).
|
||||||
|
api() {
|
||||||
|
case "$1" in
|
||||||
|
https://github.com/*) echo "https://api.github.com/repos/${1#https://github.com/}" ;;
|
||||||
|
*)
|
||||||
|
host=${1#*://}
|
||||||
|
host=${host%%/*}
|
||||||
|
echo "${1%%://*}://$host/api/v1/repos/${1#*://"$host"/}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
tag="${1:-}"
|
tag="${1:-}"
|
||||||
if [ -z "$tag" ]; then
|
if [ -z "$tag" ]; then
|
||||||
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
|
for repo in $repos; do
|
||||||
fail "couldn't look up the latest release"
|
tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null |
|
||||||
[ -n "$tag" ] || fail "couldn't look up the latest release"
|
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag=
|
||||||
|
[ -n "$tag" ] && break
|
||||||
|
done
|
||||||
|
[ -n "$tag" ] || fail "couldn't look up the latest release on $repos"
|
||||||
fi
|
fi
|
||||||
case "$tag" in
|
case "$tag" in
|
||||||
v*) ;;
|
v*) ;;
|
||||||
@@ -55,11 +73,19 @@ name="lazysecrets_${version}_${os}_${arch}"
|
|||||||
tmp=$(mktemp -d)
|
tmp=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
echo "Downloading lazysecrets $tag for $os/$arch"
|
# The archive and its checksums.txt from the first server that has both.
|
||||||
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
|
from=
|
||||||
fail "couldn't download $tag for $os/$arch: see $repo/releases"
|
for repo in $repos; do
|
||||||
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
|
base="$repo/releases/download/$tag"
|
||||||
fail "couldn't download $tag's checksums.txt"
|
if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then
|
||||||
|
echo "Downloading lazysecrets $tag for $os/$arch from $repo"
|
||||||
|
if download "$base/$name.tar.gz" "$tmp/$name.tar.gz"; then
|
||||||
|
from=$repo
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "$from" ] || fail "couldn't download $tag for $os/$arch from $repos"
|
||||||
|
|
||||||
want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt")
|
want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt")
|
||||||
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
|
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -40,6 +41,10 @@ type Config struct {
|
|||||||
// background; defaults to true. U and lazysecrets update work either
|
// background; defaults to true. U and lazysecrets update work either
|
||||||
// way.
|
// way.
|
||||||
UpdateCheck *bool `yaml:"updateCheck"`
|
UpdateCheck *bool `yaml:"updateCheck"`
|
||||||
|
// Releases are the repositories updates come from, tried in order:
|
||||||
|
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
|
||||||
|
// git.kebcloud.no (update.DefaultServers).
|
||||||
|
Releases []string `yaml:"releases"`
|
||||||
// Cache keeps what was loaded (never values) between runs, so lists
|
// Cache keeps what was loaded (never values) between runs, so lists
|
||||||
// show at once and refresh in the background; defaults to true.
|
// show at once and refresh in the background; defaults to true.
|
||||||
Cache *bool `yaml:"cache"`
|
Cache *bool `yaml:"cache"`
|
||||||
@@ -211,6 +216,11 @@ func (c Config) validate(path string) error {
|
|||||||
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||||
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
||||||
}
|
}
|
||||||
|
for i, r := range c.Releases {
|
||||||
|
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
|
||||||
|
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||||
|
}
|
||||||
|
}
|
||||||
for i, v := range c.Providers.Azure.Vaults {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
if !strings.HasPrefix(v, "https://") {
|
if !strings.HasPrefix(v, "https://") {
|
||||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||||
"duration": "timeout: soon\n",
|
"duration": "timeout: soon\n",
|
||||||
"view": "sourcesView: tree\n",
|
"view": "sourcesView: tree\n",
|
||||||
|
"releases": "releases: [git.example.com/team/lazysecrets]\n",
|
||||||
|
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
|
||||||
} {
|
} {
|
||||||
if _, err := Load(write(t, content)); err == nil {
|
if _, err := Load(write(t, content)); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
t.Errorf("%s: no error", name)
|
||||||
@@ -113,6 +115,13 @@ theme:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleases(t *testing.T) {
|
||||||
|
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
|
||||||
|
if err != nil || len(c.Releases) != 1 {
|
||||||
|
t.Errorf("releases = %v, %v", c.Releases, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDelay(t *testing.T) {
|
func TestLoadDelay(t *testing.T) {
|
||||||
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||||
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Provider struct {
|
|||||||
errs map[listKey]error
|
errs map[listKey]error
|
||||||
values map[string]string // by object ID
|
values map[string]string // by object ID
|
||||||
versions map[string][]provider.Object // by object ID
|
versions map[string][]provider.Object // by object ID
|
||||||
|
details map[string][]provider.Attr // by container ID
|
||||||
}
|
}
|
||||||
|
|
||||||
type listKey struct {
|
type listKey struct {
|
||||||
@@ -101,7 +102,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
|||||||
if err := p.wait(ctx); err != nil {
|
if err := p.wait(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if p.kind == provider.KindDatabricks {
|
if p.kind != provider.KindAzure { // only Key Vault keeps versions
|
||||||
return nil, provider.ErrUnsupported
|
return nil, provider.ErrUnsupported
|
||||||
}
|
}
|
||||||
vs := p.versions[o.ID]
|
vs := p.versions[o.ID]
|
||||||
@@ -209,7 +210,7 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
|
|||||||
if p.kind == provider.KindDatabricks {
|
if p.kind == provider.KindDatabricks {
|
||||||
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
||||||
}
|
}
|
||||||
if p.kind == provider.KindAzure || vault != detail {
|
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
|
||||||
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
||||||
}
|
}
|
||||||
p.containers[src.ID] = append(p.containers[src.ID], c)
|
p.containers[src.ID] = append(p.containers[src.ID], c)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package fake
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ provider.ContainerDetails = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Details returns a file's details, like the repo provider's.
|
||||||
|
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||||
|
attrs, ok := p.details[c.ID]
|
||||||
|
if !ok {
|
||||||
|
return nil, provider.ErrUnsupported
|
||||||
|
}
|
||||||
|
return attrs, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repos returns a fake repo provider: three git repos with SOPS and plain
|
||||||
|
// .env files, the first one the repo lazysecrets was started in.
|
||||||
|
func Repos() *Provider {
|
||||||
|
p := newProvider(provider.KindRepo, 0)
|
||||||
|
p.id, p.label = "Repos", "Repos"
|
||||||
|
p.details = map[string][]provider.Attr{}
|
||||||
|
|
||||||
|
repo := func(name string, here bool) provider.Source {
|
||||||
|
s := p.addSource("/home/dev/git/"+name, name, "github.com/acme/"+name)
|
||||||
|
s.Here = here
|
||||||
|
p.sources[len(p.sources)-1] = s
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// file adds a file and its keys, alternating name and value. git and
|
||||||
|
// commit are what git says of it: "tracked", "ignored" or "untracked".
|
||||||
|
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
|
||||||
|
sops := method != ""
|
||||||
|
detail := "env · plain text"
|
||||||
|
if sops {
|
||||||
|
detail = "sops · " + method
|
||||||
|
}
|
||||||
|
c := p.addContainer(src, path, detail, provider.TypeSecret)
|
||||||
|
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
|
||||||
|
if sops {
|
||||||
|
attrs = []provider.Attr{{Label: "format", Value: "sops " + format(path)}, {Label: "encryption", Value: method}}
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(kv); i += 2 {
|
||||||
|
o := provider.Object{Name: kv[i], Attrs: attrs}
|
||||||
|
if sops {
|
||||||
|
o.Updated = base
|
||||||
|
}
|
||||||
|
p.add(c, provider.TypeSecret, o, kv[i+1])
|
||||||
|
}
|
||||||
|
|
||||||
|
d := []provider.Attr{{Label: "encryption", Value: "none: plain text"}}
|
||||||
|
if sops {
|
||||||
|
d = []provider.Attr{{Label: "encryption", Value: detail}}
|
||||||
|
keys := map[string]string{
|
||||||
|
"age": "age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p",
|
||||||
|
"azure_kv": "azure_kv https://kv-sops-prod.vault.azure.net/keys/sops/1f2e3d4c",
|
||||||
|
}
|
||||||
|
for i, m := range strings.Split(method, ", ") {
|
||||||
|
label := ""
|
||||||
|
if i == 0 {
|
||||||
|
label = "keys" // on the first only, like the repo provider
|
||||||
|
}
|
||||||
|
d = append(d, provider.Attr{Label: label, Value: keys[m]})
|
||||||
|
}
|
||||||
|
d = append(d, provider.Attr{Label: "encrypted", Value: base.Local().Format("2006-01-02 15:04")})
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case git == "tracked" && !sops:
|
||||||
|
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||||
|
case git == "untracked":
|
||||||
|
d = append(d, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !sops})
|
||||||
|
default:
|
||||||
|
d = append(d, provider.Attr{Label: "git", Value: git})
|
||||||
|
}
|
||||||
|
if commit != "" {
|
||||||
|
d = append(d, provider.Attr{Label: "commit", Value: commit})
|
||||||
|
}
|
||||||
|
p.details[c.ID] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
api := repo("lazysecrets-demo", true)
|
||||||
|
file(api, "deploy/prod/secrets.yaml", "age, azure_kv", "tracked", "4f9c2ab · 3 days ago",
|
||||||
|
"database.password", "pr0d-Pg-7f3k9QwZ",
|
||||||
|
"database.url", "postgres://app:pr0d-Pg-7f3k9QwZ@pg-demo-prod:5432/app",
|
||||||
|
"stripe.secret_key", "sk_live_demo_51NxDemoKey0000",
|
||||||
|
"jwt.signing_key", "c2lnbmluZy1rZXktZm9yLXRoZS1kZW1v",
|
||||||
|
"gcp.service_account", `{"type":"service_account","project_id":"lazysecrets-demo","client_email":"app@lazysecrets-demo.iam.gserviceaccount.com","private_key_id":"demo1234"}`)
|
||||||
|
file(api, "deploy/staging/secrets.yaml", "age", "tracked", "a71e05d · 2 weeks ago",
|
||||||
|
"database.password", "stg-Pg-2b8d1L",
|
||||||
|
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
|
||||||
|
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
|
||||||
|
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
|
||||||
|
"github.client_secret", "ghs_demo0000000000000000000000",
|
||||||
|
"google.client_secret", "GOCSPX-demo-000000000000")
|
||||||
|
file(api, ".env", "", "ignored", "",
|
||||||
|
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
|
||||||
|
"STRIPE_SECRET_KEY", "sk_test_demo_local",
|
||||||
|
"LOG_LEVEL", "debug")
|
||||||
|
|
||||||
|
infra := repo("platform-infra", false)
|
||||||
|
file(infra, "terraform/prod/secrets.enc.yaml", "azure_kv", "tracked", "9d40c3e · yesterday",
|
||||||
|
"postgres.admin_password", "infra-Pg-admin-0Xq",
|
||||||
|
"grafana.admin_password", "grafana-demo-admin",
|
||||||
|
"acr.pull_token", "acr-demo-pull-token")
|
||||||
|
file(infra, "k8s/monitoring/alertmanager.enc.yaml", "age", "tracked", "1c58e7a · 5 days ago",
|
||||||
|
"slack.webhook_url", "https://hooks.slack.com/services/T000/B000/demo",
|
||||||
|
"pagerduty.routing_key", "pd-demo-routing-key")
|
||||||
|
file(infra, "scripts/.env", "", "tracked", "77b2d90 · 8 months ago",
|
||||||
|
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
|
||||||
|
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
|
||||||
|
|
||||||
|
web := repo("web-frontend", false)
|
||||||
|
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
|
||||||
|
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
|
||||||
|
"NEXT_PUBLIC_API_URL", "https://api.acme.example")
|
||||||
|
file(web, ".env.local", "", "untracked", "",
|
||||||
|
"NEXT_PUBLIC_API_URL", "http://localhost:8080",
|
||||||
|
"ANALYTICS_KEY", "demo-analytics-key")
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// format is a SOPS file's format, by its name.
|
||||||
|
func format(path string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, ".json"):
|
||||||
|
return "json"
|
||||||
|
case strings.HasSuffix(path, ".yaml"):
|
||||||
|
return "yaml"
|
||||||
|
}
|
||||||
|
return "dotenv"
|
||||||
|
}
|
||||||
@@ -489,7 +489,7 @@ func (m *Model) setVersions(items []provider.Object) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
|
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor}
|
||||||
if m.cachePath != "" || m.placeDir != "" {
|
if m.cachePath != "" || m.placeDir != "" {
|
||||||
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
||||||
}
|
}
|
||||||
@@ -514,6 +514,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.BackgroundColorMsg:
|
||||||
|
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
|
||||||
|
return m, nil
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.w, m.h = msg.Width, msg.Height
|
m.w, m.h = msg.Width, msg.Height
|
||||||
|
|
||||||
@@ -1716,7 +1719,8 @@ func (m Model) footerView() string {
|
|||||||
} else if l != nil && l.Filtering() {
|
} else if l != nil && l.Filtering() {
|
||||||
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
||||||
} else {
|
} else {
|
||||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
|
revealed := m.detail.State() == panes.ValueShown
|
||||||
|
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
|
||||||
}
|
}
|
||||||
var lines []string
|
var lines []string
|
||||||
if !m.clipDeadline.IsZero() {
|
if !m.clipDeadline.IsZero() {
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ func TestVersionHistoryInDetail(t *testing.T) {
|
|||||||
screen := d.screen()
|
screen := d.screen()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"versions 3 · v to browse",
|
"versions 3 · v to browse",
|
||||||
"▸ 3f9c2a 2026-09-01 current", // the version shown above
|
" 3f9c2a 2026-09-01 current",
|
||||||
" 91d3e0 2026-03-01 expired",
|
" 91d3e0 2026-03-01 expired",
|
||||||
" 0a77c4 2025-09-01 disabled",
|
" 0a77c4 2025-09-01 disabled",
|
||||||
} {
|
} {
|
||||||
@@ -502,6 +502,9 @@ func TestVersionHistoryInDetail(t *testing.T) {
|
|||||||
t.Errorf("missing %q:\n%s", want, screen)
|
t.Errorf("missing %q:\n%s", want, screen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(screen, "▸ 3f9c2a") {
|
||||||
|
t.Errorf("a cursor on a list that can't be moved in:\n%s", screen)
|
||||||
|
}
|
||||||
d.keys("3", "j") // api-key: only its current version
|
d.keys("3", "j") // api-key: only its current version
|
||||||
if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") {
|
if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") {
|
||||||
t.Errorf("wrong history for api-key:\n%s", d.screen())
|
t.Errorf("wrong history for api-key:\n%s", d.screen())
|
||||||
@@ -988,9 +991,14 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
|||||||
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
||||||
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
||||||
}
|
}
|
||||||
if !strings.Contains(lastLine(scr), "z reveal/hide") {
|
if !strings.Contains(lastLine(scr), "z show") {
|
||||||
t.Errorf("help bar: %q", lastLine(scr))
|
t.Errorf("help bar: %q", lastLine(scr))
|
||||||
}
|
}
|
||||||
|
d.keys("z")
|
||||||
|
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
|
||||||
|
t.Errorf("help bar with the value shown: %q", bar)
|
||||||
|
}
|
||||||
|
d.keys("z")
|
||||||
if strings.Contains(scr, "to browse") {
|
if strings.Contains(scr, "to browse") {
|
||||||
t.Errorf("hint for unbound versions:\n%s", scr)
|
t.Errorf("hint for unbound versions:\n%s", scr)
|
||||||
}
|
}
|
||||||
@@ -1176,6 +1184,18 @@ func TestDetailsFollowFocus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatusLineCountsRepos(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := newDriver(t, fake.Azure(0), fake.Repos())
|
||||||
|
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
|
||||||
|
t.Errorf("status line: %q", line)
|
||||||
|
}
|
||||||
|
d = newDriver(t, fake.Azure(0))
|
||||||
|
if line := strings.Split(d.screen(), "\n")[1]; strings.Contains(line, "repo") {
|
||||||
|
t.Errorf("repos counted without any: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStatusPane(t *testing.T) {
|
func TestStatusPane(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var opened []string
|
var opened []string
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (m Model) expiringFields() []panes.Field {
|
|||||||
}
|
}
|
||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
cov := m.coverageOf(provider.KindAzure)
|
cov := m.coverageOf(ofKind(provider.KindAzure))
|
||||||
switch {
|
switch {
|
||||||
case cov.vaults == 0 && len(items) == 0:
|
case cov.vaults == 0 && len(items) == 0:
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+11
-3
@@ -38,6 +38,10 @@ type contextHelp struct {
|
|||||||
commands bool // custom commands apply here
|
commands bool // custom commands apply here
|
||||||
account bool // the fourth pane shows Fields (fields.go)
|
account bool // the fourth pane shows Fields (fields.go)
|
||||||
of pane // what the fourth pane describes
|
of pane // what the fourth pane describes
|
||||||
|
// revealed, when set, says whether the selected value is shown, so
|
||||||
|
// the reveal key says just what it'll do: show or hide, the same
|
||||||
|
// width so the bar doesn't shift.
|
||||||
|
revealed *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c contextHelp) ShortHelp() []key.Binding {
|
func (c contextHelp) ShortHelp() []key.Binding {
|
||||||
@@ -59,11 +63,15 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
|||||||
}
|
}
|
||||||
switch c.focus {
|
switch c.focus {
|
||||||
case paneSources:
|
case paneSources:
|
||||||
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter)
|
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
|
||||||
case paneContainers:
|
case paneContainers:
|
||||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
|
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
|
||||||
default:
|
default:
|
||||||
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
|
reveal := k.Reveal
|
||||||
|
if c.revealed != nil {
|
||||||
|
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
|
||||||
|
}
|
||||||
|
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
|
||||||
}
|
}
|
||||||
if c.commands {
|
if c.commands {
|
||||||
b = append(b, k.Commands)
|
b = append(b, k.Commands)
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ func sourceMatches(r ref.Ref, src provider.Source) bool {
|
|||||||
return src.ID == r.Subscription
|
return src.ID == r.Subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountOf is the account part of a source's provider label ("dev" in
|
// accountOf is the account part of a source's provider label ("alice" in
|
||||||
// "Azure · dev"), or "" when there's a single account.
|
// "Azure · alice"), or "" when there's a single account.
|
||||||
func accountOf(src provider.Source) string {
|
func accountOf(src provider.Source) string {
|
||||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
|
|||||||
func TestContextHelpBar(t *testing.T) {
|
func TestContextHelpBar(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") {
|
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||||
t.Errorf("Sources help bar: %q", bar)
|
t.Errorf("Sources help bar: %q", bar)
|
||||||
}
|
}
|
||||||
d.keys("3")
|
d.keys("3")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share…") {
|
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
||||||
t.Errorf("Secrets help bar: %q", bar)
|
t.Errorf("Secrets help bar: %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,9 +206,9 @@ func TestSearchAllRepos(t *testing.T) {
|
|||||||
for d.m.search.src > 0 {
|
for d.m.search.src > 0 {
|
||||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||||
}
|
}
|
||||||
for d.m.search.scope().id != kindScope+string(provider.KindRepo) {
|
for d.m.search.scope().id != accountScope+"Repos" {
|
||||||
if d.m.search.src == len(d.m.search.scopes)-1 {
|
if d.m.search.src == len(d.m.search.scopes)-1 {
|
||||||
t.Fatalf("no All repos scope: %v", d.m.search.scopes)
|
t.Fatalf("no Repos scope: %v", d.m.search.scopes)
|
||||||
}
|
}
|
||||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) {
|
|||||||
if azure() != azureBefore || d.m.crawl.running {
|
if azure() != azureBefore || d.m.crawl.running {
|
||||||
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
|
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
|
||||||
}
|
}
|
||||||
if !strings.Contains(d.screen(), "Results in all repos") {
|
if !strings.Contains(d.screen(), "Results in Repos") {
|
||||||
t.Errorf("title:\n%s", d.screen())
|
t.Errorf("title:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,13 +131,25 @@ func (c coverage) complete() bool {
|
|||||||
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
|
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
||||||
|
|
||||||
// coverageOf is searchCoverage for one kind of provider ("" for all).
|
// providerFilter picks the providers a search or a load covers; nil is
|
||||||
func (m *Model) coverageOf(kind provider.Kind) coverage {
|
// all of them.
|
||||||
|
type providerFilter func(provider.Provider) bool
|
||||||
|
|
||||||
|
func ofKind(k provider.Kind) providerFilter {
|
||||||
|
return func(p provider.Provider) bool { return p.Kind() == k }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ofProvider(id string) providerFilter {
|
||||||
|
return func(p provider.Provider) bool { return p.ID() == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverageOf is searchCoverage for the providers in.
|
||||||
|
func (m *Model) coverageOf(in providerFilter) coverage {
|
||||||
var c coverage
|
var c coverage
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if kind != "" && p.Kind() != kind {
|
if in != nil && !in(p) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, src := range m.sourceResults[p.ID()].sources {
|
for _, src := range m.sourceResults[p.ID()].sources {
|
||||||
@@ -197,12 +209,12 @@ func plural(n int, word string) string {
|
|||||||
|
|
||||||
// startCrawl loads every unlisted subscription's vaults and every vault's
|
// startCrawl loads every unlisted subscription's vaults and every vault's
|
||||||
// secrets not loaded yet.
|
// secrets not loaded yet.
|
||||||
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") }
|
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
|
||||||
|
|
||||||
// startCrawlOf is startCrawl for one kind of provider ("" for all).
|
// startCrawlOf is startCrawl for the providers in.
|
||||||
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
|
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if kind != "" && p.Kind() != kind {
|
if in != nil && !in(p) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, src := range m.sourceResults[p.ID()].sources {
|
for _, src := range m.sourceResults[p.ID()].sources {
|
||||||
|
|||||||
@@ -42,11 +42,15 @@ func TestSearchLoaded(t *testing.T) {
|
|||||||
t.Fatalf("missing %q:\n%s", want, d.screen())
|
t.Fatalf("missing %q:\n%s", want, d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.typeText("api-key")
|
d.typeText("api-key") // on All: found at once
|
||||||
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
|
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
|
||||||
|
t.Errorf("no hint to narrow to the source:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.send(ctrl('s'))
|
||||||
|
if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") {
|
||||||
t.Errorf("no hint to search everything:\n%s", d.screen())
|
t.Errorf("no hint to search everything:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
d.searchAll()
|
d.send(ctrl('s'))
|
||||||
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
|
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||||
t.Fatalf("loaded secret not found:\n%s", d.screen())
|
t.Fatalf("loaded secret not found:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,8 @@ func TestSearchLoaded(t *testing.T) {
|
|||||||
func TestSearchLoadsAsYouType(t *testing.T) {
|
func TestSearchLoadsAsYouType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.openSearch() // on the source the cursor is on: platform-prod
|
d.openSearch()
|
||||||
|
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
|
||||||
// Typing loads the scope: that source, without ctrl+a.
|
// Typing loads the scope: that source, without ctrl+a.
|
||||||
d.typeText("smtp")
|
d.typeText("smtp")
|
||||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
|
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
|
||||||
@@ -76,10 +81,7 @@ func TestSearchLoadsAsYouType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On All: everything.
|
// On All: everything.
|
||||||
d.keys("tab") // to the scopes
|
d.send(ctrl('s'))
|
||||||
for d.m.search.src > 0 {
|
|
||||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
|
||||||
}
|
|
||||||
d.typeText("-")
|
d.typeText("-")
|
||||||
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
|
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
|
||||||
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
|
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
|
||||||
@@ -183,19 +185,61 @@ func TestSearchStartsWhereYouAre(t *testing.T) {
|
|||||||
d.keys("2", "j") // platform-prod / kv-shared-prod
|
d.keys("2", "j") // platform-prod / kv-shared-prod
|
||||||
d.openSearch()
|
d.openSearch()
|
||||||
v := d.m.search
|
v := d.m.search
|
||||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
|
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
|
||||||
t.Errorf("source not preselected: %q", got)
|
if scope() != "All" {
|
||||||
|
t.Errorf("opened on %q, want All", scope())
|
||||||
}
|
}
|
||||||
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
||||||
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
|
d.send(ctrl('s')) // narrows to the source, and back
|
||||||
|
if !strings.Contains(scope(), "platform-prod") {
|
||||||
|
t.Errorf("ctrl+s narrowed to %q", scope())
|
||||||
|
}
|
||||||
|
d.send(ctrl('s'))
|
||||||
|
if scope() != "All" {
|
||||||
|
t.Errorf("second ctrl+s on %q, want All", scope())
|
||||||
|
}
|
||||||
|
|
||||||
// From the Sources pane: just the source.
|
// From Favourites: to Search, on All.
|
||||||
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
|
d.keys("esc", "]", "]") // stop typing; Favourites
|
||||||
|
d.send(ctrl('s'))
|
||||||
|
if v.tab != tabSearch || scope() != "All" {
|
||||||
|
t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope())
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the Sources pane: the source.
|
||||||
|
d.keys("esc", "1", "j") // close; platform-dev
|
||||||
d.openSearch()
|
d.openSearch()
|
||||||
v = d.m.search
|
v = d.m.search
|
||||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
|
d.send(ctrl('s'))
|
||||||
t.Errorf("source not preselected: %q", got)
|
if !strings.Contains(scope(), "platform-dev") {
|
||||||
|
t.Errorf("ctrl+s narrowed to %q", scope())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchNarrowsToAccount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := newDriver(t)
|
||||||
|
d.keys("1", "g") // the first group header
|
||||||
|
header, _ := d.m.sources.Selected()
|
||||||
|
d.openSearch()
|
||||||
|
d.send(ctrl('s'))
|
||||||
|
v := d.m.search
|
||||||
|
if v.scope().id != accountScope+header.provider {
|
||||||
|
t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id)
|
||||||
|
}
|
||||||
|
if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) {
|
||||||
|
t.Errorf("no results in the account:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
for _, o := range v.shown {
|
||||||
|
if providerOf(o.group) != header.provider {
|
||||||
|
t.Errorf("result from another account: %q", ansiStrip(o.label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The account's sources are under it, indented.
|
||||||
|
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
|
||||||
|
t.Errorf("after the account: %q", ansiStrip(next.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +283,24 @@ func TestGoToTabsAndModes(t *testing.T) {
|
|||||||
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
|
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// g and G: top and bottom of the list with the cursor.
|
||||||
|
d.keys("G")
|
||||||
|
if v.res != len(v.shown)-1 || v.res == 0 {
|
||||||
|
t.Errorf("G on results: %d of %d", v.res, len(v.shown))
|
||||||
|
}
|
||||||
|
d.keys("g")
|
||||||
|
if v.res != 0 {
|
||||||
|
t.Errorf("g on results: %d", v.res)
|
||||||
|
}
|
||||||
|
d.keys("h", "G")
|
||||||
|
if v.src != len(v.scopes)-1 {
|
||||||
|
t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes))
|
||||||
|
}
|
||||||
|
d.keys("g", "l")
|
||||||
|
if v.src != 0 {
|
||||||
|
t.Errorf("g on scopes: %d", v.src)
|
||||||
|
}
|
||||||
|
|
||||||
d.keys("/")
|
d.keys("/")
|
||||||
d.typeText("shared")
|
d.typeText("shared")
|
||||||
if !v.typing || len(v.shown) == 0 {
|
if !v.typing || len(v.shown) == 0 {
|
||||||
|
|||||||
+139
-62
@@ -20,16 +20,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Go to is one popup for finding a place and going there. Tabs at the
|
// Go to is one popup for finding a place and going there. Tabs at the
|
||||||
// top: Favourites and Recent (from the store, so they work before
|
// top: Search has two lists side by side, the scope on the left (All, or
|
||||||
// anything loads) are one list each; Search has two lists side by side,
|
// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
|
||||||
// the scope on the left (All, or one source with how much of it is
|
// on the right; Favourites and Recent (from the store, so they work
|
||||||
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
|
// before anything loads) are one list each.
|
||||||
//
|
//
|
||||||
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
|
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
|
||||||
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
|
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
|
||||||
// clears the text and goes back to browsing; esc while browsing closes.
|
// clears the text and goes back to browsing; esc while browsing closes.
|
||||||
// F opens it on Favourites and R on Recent, browsing; search on Search,
|
// F opens it on Favourites and R on Recent, browsing; search on Search,
|
||||||
// typing.
|
// typing, on All. search again narrows to the source you were in, and
|
||||||
|
// back to All.
|
||||||
//
|
//
|
||||||
// The results don't move by themselves: as lists load, rows already shown
|
// The results don't move by themselves: as lists load, rows already shown
|
||||||
// keep their order and new ones come after them, with the cursor on the
|
// keep their order and new ones come after them, with the cursor on the
|
||||||
@@ -39,7 +40,7 @@ import (
|
|||||||
type searchView struct {
|
type searchView struct {
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
typing bool // the text field has the keyboard
|
typing bool // the text field has the keyboard
|
||||||
tab int // tabFavs, tabRecent or tabSearch
|
tab int // tabSearch, tabFavs or tabRecent
|
||||||
results bool // in Search, the results have the cursor (else the scopes)
|
results bool // in Search, the results have the cursor (else the scopes)
|
||||||
|
|
||||||
scopes []option // All, then the sources (Search)
|
scopes []option // All, then the sources (Search)
|
||||||
@@ -49,6 +50,7 @@ type searchView struct {
|
|||||||
shown []option // the tab's (or scope's) entries matching the input
|
shown []option // the tab's (or scope's) entries matching the input
|
||||||
src int // cursor in scopes
|
src int // cursor in scopes
|
||||||
res int // cursor in shown
|
res int // cursor in shown
|
||||||
|
here string // the scope of the source Go to was opened from, if any
|
||||||
|
|
||||||
// loaded are the scopes a search has loaded since the popup opened:
|
// loaded are the scopes a search has loaded since the popup opened:
|
||||||
// typing loads the scope once, so lists that fail aren't asked for
|
// typing loads the scope once, so lists that fail aren't asked for
|
||||||
@@ -57,25 +59,25 @@ type searchView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tabFavs = iota
|
tabSearch = iota
|
||||||
|
tabFavs
|
||||||
tabRecent
|
tabRecent
|
||||||
tabSearch
|
|
||||||
tabCount
|
tabCount
|
||||||
)
|
)
|
||||||
|
|
||||||
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
|
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
scopeFavs = "#favs"
|
scopeFavs = "#favs"
|
||||||
scopeRecent = "#recent"
|
scopeRecent = "#recent"
|
||||||
allSources = "#all"
|
allSources = "#all"
|
||||||
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
|
accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
|
||||||
)
|
)
|
||||||
|
|
||||||
const goToTitle = "Go to"
|
const goToTitle = "Go to"
|
||||||
|
|
||||||
// showSearch opens Go to on Search, typing, in the source you're in
|
// showSearch opens Go to on Search, typing, on All, with the selected
|
||||||
// (else All), with the selected vault under the cursor.
|
// vault under the cursor.
|
||||||
func (m *Model) showSearch() tea.Cmd {
|
func (m *Model) showSearch() tea.Cmd {
|
||||||
cmd := m.openGoTo(tabSearch, true)
|
cmd := m.openGoTo(tabSearch, true)
|
||||||
m.preselect()
|
m.preselect()
|
||||||
@@ -131,17 +133,20 @@ func (v *searchView) scope() option {
|
|||||||
// onSource reports whether the results come from one source.
|
// onSource reports whether the results come from one source.
|
||||||
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
|
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
|
||||||
|
|
||||||
// preselect starts where the user is: the selected source picked on the
|
// preselect starts where the user is: the selected source is the one
|
||||||
// left, and the selected vault or scope under the cursor on the right.
|
// search narrows to, and the selected vault or scope is under the cursor.
|
||||||
func (m *Model) preselect() {
|
func (m *Model) preselect() {
|
||||||
v := m.search
|
v := m.search
|
||||||
row := m.sources.SelectedID()
|
row := m.sources.SelectedID()
|
||||||
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
|
account := ""
|
||||||
|
if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header
|
||||||
|
account = accountScope + id
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row })
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v.src = i
|
v.here = v.scopes[i].id
|
||||||
m.fillSearch(false)
|
|
||||||
if m.focus == paneSources {
|
if m.focus == paneSources {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,25 +176,36 @@ func (m *Model) fillSearch(keep bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sources, items := m.searchEntries()
|
sources, items := m.searchEntries()
|
||||||
all := option{label: "All", id: allSources, mark: m.coverageMark("")}
|
all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
|
||||||
scopes := []option{all}
|
// Grouped like the Sources pane: each account (or Databricks, or the
|
||||||
// All of one kind, so a search can stay on repos (local, quick)
|
// repos) is a scope too, its sources under it, so a search can stay on
|
||||||
// without asking Azure for every vault; only with several kinds.
|
// one, like the repos (local, quick), without asking Azure for every
|
||||||
var kinds []provider.Kind
|
// vault. With only one, it's the same as All.
|
||||||
|
var groups []provider.Provider
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) {
|
if len(m.sourceResults[p.ID()].sources) > 0 {
|
||||||
kinds = append(kinds, p.Kind())
|
groups = append(groups, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(kinds) > 1 {
|
scopes := []option{all}
|
||||||
for _, k := range kinds {
|
if len(groups) < 2 {
|
||||||
|
scopes = append(scopes, sources...)
|
||||||
|
} else {
|
||||||
|
for _, p := range groups {
|
||||||
|
k := p.Kind()
|
||||||
scopes = append(scopes, option{
|
scopes = append(scopes, option{
|
||||||
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
|
label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
|
||||||
id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
|
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
|
||||||
})
|
})
|
||||||
|
for _, o := range sources {
|
||||||
|
if providerOf(o.group) == p.ID() {
|
||||||
|
o.label = " " + o.label
|
||||||
|
scopes = append(scopes, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
v.scopes, v.items = append(scopes, sources...), items
|
}
|
||||||
|
}
|
||||||
|
v.scopes, v.items = scopes, items
|
||||||
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
|
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
|
||||||
|
|
||||||
v.shown = v.match()
|
v.shown = v.match()
|
||||||
@@ -202,17 +218,9 @@ func (m *Model) fillSearch(keep bool) {
|
|||||||
v.res = max(0, min(v.res, len(v.shown)-1))
|
v.res = max(0, min(v.res, len(v.shown)-1))
|
||||||
}
|
}
|
||||||
|
|
||||||
// kindScopeLabel names the scope of all of a kind's sources.
|
// coverageMark is ○ ◐ ● for how much of the providers in is loaded.
|
||||||
func kindScopeLabel(k provider.Kind) string {
|
func (m *Model) coverageMark(in providerFilter) string {
|
||||||
if k == provider.KindRepo {
|
switch c := m.coverageOf(in); {
|
||||||
return "All repos"
|
|
||||||
}
|
|
||||||
return "All " + string(k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
|
|
||||||
func (m *Model) coverageMark(kind provider.Kind) string {
|
|
||||||
switch c := m.coverageOf(kind); {
|
|
||||||
case c.complete():
|
case c.complete():
|
||||||
return m.st.Success.Render("●")
|
return m.st.Success.Render("●")
|
||||||
case c.vaultsLoaded > 0:
|
case c.vaultsLoaded > 0:
|
||||||
@@ -221,15 +229,28 @@ func (m *Model) coverageMark(kind provider.Kind) string {
|
|||||||
return m.st.Muted.Render("○")
|
return m.st.Muted.Render("○")
|
||||||
}
|
}
|
||||||
|
|
||||||
// scopeKind is the kind a scope covers all of, if it's one of those.
|
// scopeAccount is the provider whose sources the scope covers, if it's
|
||||||
func (v *searchView) scopeKind() (provider.Kind, bool) {
|
// an account's.
|
||||||
k, ok := strings.CutPrefix(v.scope().id, kindScope)
|
func (v *searchView) scopeAccount() (string, bool) {
|
||||||
|
id, ok := strings.CutPrefix(v.scope().id, accountScope)
|
||||||
if !ok || v.tab != tabSearch {
|
if !ok || v.tab != tabSearch {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return provider.Kind(k), true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scopeFilter is what loading everything in the scope covers: an
|
||||||
|
// account's, else all of them.
|
||||||
|
func (v *searchView) scopeFilter() providerFilter {
|
||||||
|
if id, ok := v.scopeAccount(); ok {
|
||||||
|
return ofProvider(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeName is the scope's label without colours or indent.
|
||||||
|
func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) }
|
||||||
|
|
||||||
// match lists the scope's entries matching the input, best first.
|
// match lists the scope's entries matching the input, best first.
|
||||||
func (v *searchView) match() []option {
|
func (v *searchView) match() []option {
|
||||||
var cand []option
|
var cand []option
|
||||||
@@ -239,9 +260,9 @@ func (v *searchView) match() []option {
|
|||||||
case scopeRecent:
|
case scopeRecent:
|
||||||
cand = v.recent
|
cand = v.recent
|
||||||
default:
|
default:
|
||||||
kind, ofKind := v.scopeKind()
|
account, ofAccount := v.scopeAccount()
|
||||||
for _, o := range v.items {
|
for _, o := range v.items {
|
||||||
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
|
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
|
||||||
cand = append(cand, o)
|
cand = append(cand, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +341,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
// The actions shared by both modes, by their keys in the mode.
|
// The actions shared by both modes, by their keys in the mode.
|
||||||
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
|
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
|
||||||
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
|
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
|
||||||
|
if key.Matches(msg, km.Search) {
|
||||||
|
return m.narrow()
|
||||||
|
}
|
||||||
if v.typing {
|
if v.typing {
|
||||||
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
|
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
|
||||||
if key.Matches(msg, km.PopupClose) {
|
if key.Matches(msg, km.PopupClose) {
|
||||||
@@ -338,6 +362,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
case key.Matches(msg, km.GoFilter):
|
case key.Matches(msg, km.GoFilter):
|
||||||
return m.setTyping(true)
|
return m.setTyping(true)
|
||||||
|
case key.Matches(msg, km.GoTop), key.Matches(msg, km.GoBottom):
|
||||||
|
n := len(v.shown)
|
||||||
|
if !v.results {
|
||||||
|
n = len(v.scopes)
|
||||||
|
}
|
||||||
|
i := 0
|
||||||
|
if key.Matches(msg, km.GoBottom) {
|
||||||
|
i = max(0, n-1)
|
||||||
|
}
|
||||||
|
if v.results {
|
||||||
|
v.res = i
|
||||||
|
} else if v.src != i {
|
||||||
|
v.src, v.res = i, 0
|
||||||
|
m.fillSearch(false)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
case key.Matches(msg, km.Help):
|
case key.Matches(msg, km.Help):
|
||||||
return m.showKeybindingsFor(keys.GoTo)
|
return m.showKeybindingsFor(keys.GoTo)
|
||||||
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
|
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
|
||||||
@@ -401,11 +441,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
if v.onSource() {
|
if v.onSource() {
|
||||||
return v.scope().reload(m)
|
return v.scope().reload(m)
|
||||||
}
|
}
|
||||||
kind, _ := v.scopeKind()
|
return m.startCrawlOf(v.scopeFilter())
|
||||||
return m.startCrawlOf(kind)
|
|
||||||
case inSearch && key.Matches(msg, k.loadAll):
|
case inSearch && key.Matches(msg, k.loadAll):
|
||||||
kind, _ := v.scopeKind()
|
return m.startCrawlOf(v.scopeFilter())
|
||||||
return m.startCrawlOf(kind)
|
|
||||||
case key.Matches(msg, k.star):
|
case key.Matches(msg, k.star):
|
||||||
if o, ok := v.selected(); ok && o.star != "" {
|
if o, ok := v.selected(); ok && o.star != "" {
|
||||||
m.toggleStarKey(o.star, o.starAccount)
|
m.toggleStarKey(o.star, o.starAccount)
|
||||||
@@ -436,6 +474,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// narrow switches Search between All and the source Go to was opened
|
||||||
|
// from; from another tab, or another scope, it goes to Search on All.
|
||||||
|
func (m *Model) narrow() tea.Cmd {
|
||||||
|
v := m.search
|
||||||
|
src := 0
|
||||||
|
if v.tab == tabSearch && v.scope().id == allSources {
|
||||||
|
src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
|
||||||
|
if src < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.tab, v.src, v.res, v.results = tabSearch, src, 0, true
|
||||||
|
m.fillSearch(false)
|
||||||
|
return m.searchLoads()
|
||||||
|
}
|
||||||
|
|
||||||
// searchLoads loads what a search in the Search tab covers, like the
|
// searchLoads loads what a search in the Search tab covers, like the
|
||||||
// load-all key: everything on All, else the source picked. Only what
|
// load-all key: everything on All, else the source picked. Only what
|
||||||
// isn't loaded yet, once a popup; repos load by themselves anyway.
|
// isn't loaded yet, once a popup; repos load by themselves anyway.
|
||||||
@@ -452,8 +506,8 @@ func (m *Model) searchLoads() tea.Cmd {
|
|||||||
v.loaded = map[string]bool{}
|
v.loaded = map[string]bool{}
|
||||||
}
|
}
|
||||||
v.loaded[sc.id] = true
|
v.loaded[sc.id] = true
|
||||||
if kind, ok := v.scopeKind(); ok {
|
if _, ok := v.scopeAccount(); ok {
|
||||||
return m.startCrawlOf(kind)
|
return m.startCrawlOf(v.scopeFilter())
|
||||||
}
|
}
|
||||||
if !v.onSource() {
|
if !v.onSource() {
|
||||||
return m.startCrawl()
|
return m.startCrawl()
|
||||||
@@ -497,6 +551,35 @@ func (m *Model) toggleStarKey(key, account string) {
|
|||||||
// searchHint tops the results when what they come from isn't all
|
// searchHint tops the results when what they come from isn't all
|
||||||
// loaded, saying how to load it.
|
// loaded, saying how to load it.
|
||||||
func (m Model) searchHint() string {
|
func (m Model) searchHint() string {
|
||||||
|
load, narrow := m.loadHint(), m.narrowHint()
|
||||||
|
switch {
|
||||||
|
case narrow == "":
|
||||||
|
return load
|
||||||
|
case load != "":
|
||||||
|
narrow = " · " + narrow
|
||||||
|
}
|
||||||
|
return load + m.st.Muted.Render(narrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// narrowHint names the key for the other of All and the source Go to was
|
||||||
|
// opened from.
|
||||||
|
func (m Model) narrowHint() string {
|
||||||
|
v := m.search
|
||||||
|
if v.tab != tabSearch || m.crawl.running {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v.scope().id == v.here && v.here != "" {
|
||||||
|
return withKey(m.keys.Search, "%s searches all")
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
|
||||||
|
if i < 0 || v.scope().id != allSources {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadHint says how much of the scope is loaded, and the key to load it.
|
||||||
|
func (m Model) loadHint() string {
|
||||||
v := m.search
|
v := m.search
|
||||||
if m.crawl.running {
|
if m.crawl.running {
|
||||||
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
|
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
|
||||||
@@ -504,8 +587,8 @@ func (m Model) searchHint() string {
|
|||||||
if v.tab != tabSearch {
|
if v.tab != tabSearch {
|
||||||
return "" // from the store: nothing to load
|
return "" // from the store: nothing to load
|
||||||
}
|
}
|
||||||
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
|
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
|
||||||
c := m.coverageOf(kind)
|
c := m.coverageOf(v.scopeFilter())
|
||||||
if c.complete() {
|
if c.complete() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -637,14 +720,8 @@ func (m Model) searchViewBox() string {
|
|||||||
}
|
}
|
||||||
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
|
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
|
||||||
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
|
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
|
||||||
if v.onSource() {
|
if _, ok := v.scopeAccount(); ok || v.onSource() {
|
||||||
resultsTitle = "Results in " + ansi.Strip(v.scope().label)
|
resultsTitle = "Results in " + scopeName(v.scope())
|
||||||
}
|
|
||||||
if kind, ok := v.scopeKind(); ok {
|
|
||||||
resultsTitle = "Results in " + string(kind)
|
|
||||||
if kind == provider.KindRepo {
|
|
||||||
resultsTitle = "Results in all repos"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var lists string
|
var lists string
|
||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ func (m Model) statusLine() string {
|
|||||||
case m.newer != nil:
|
case m.newer != nil:
|
||||||
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
|
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
|
||||||
}
|
}
|
||||||
accounts, failing := 0, 0
|
accounts, failing, repos := 0, 0, 0
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if p.Kind() == provider.KindRepo {
|
if p.Kind() == provider.KindRepo {
|
||||||
|
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
accounts++
|
accounts++
|
||||||
@@ -58,6 +59,9 @@ func (m Model) statusLine() string {
|
|||||||
}
|
}
|
||||||
parts = append(parts, s)
|
parts = append(parts, s)
|
||||||
}
|
}
|
||||||
|
if repos > 0 {
|
||||||
|
parts = append(parts, plural(repos, "repo"))
|
||||||
|
}
|
||||||
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
|
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +122,9 @@ func (m Model) statusFields() []panes.Field {
|
|||||||
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
|
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
|
||||||
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
|
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
|
||||||
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
|
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
|
||||||
add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"})
|
// Issues are on the GitHub mirror.
|
||||||
|
github := strings.TrimPrefix(update.GitHubRepo, "https://")
|
||||||
|
add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"})
|
||||||
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
|
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
|
||||||
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
|
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
|
||||||
return append(out, m.changelog...)
|
return append(out, m.changelog...)
|
||||||
|
|||||||
+1
-1
@@ -30,4 +30,4 @@
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||||
enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit
|
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
||||||
@@ -61,7 +61,8 @@ type Map struct {
|
|||||||
SearchSources, SearchResults, SearchSwitch key.Binding
|
SearchSources, SearchResults, SearchSwitch key.Binding
|
||||||
SearchLoad, SearchLoadAll key.Binding
|
SearchLoad, SearchLoadAll key.Binding
|
||||||
// goto
|
// goto
|
||||||
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
|
GoDown, GoUp, GoTop, GoBottom key.Binding
|
||||||
|
GoPrevTab, GoNextTab key.Binding
|
||||||
GoSources, GoResults, GoSwitch key.Binding
|
GoSources, GoResults, GoSwitch key.Binding
|
||||||
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
||||||
GoSelect, GoClose key.Binding
|
GoSelect, GoClose key.Binding
|
||||||
@@ -102,7 +103,7 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
||||||
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
||||||
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
||||||
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."),
|
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
||||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||||
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
|
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
|
||||||
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
|
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
|
||||||
@@ -116,7 +117,7 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
||||||
|
|
||||||
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
||||||
a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
|
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
|
||||||
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
|
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
|
||||||
|
|
||||||
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
||||||
@@ -160,7 +161,9 @@ func (m *Map) Actions() []Action {
|
|||||||
|
|
||||||
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
|
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
|
||||||
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
|
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
|
||||||
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
|
a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."),
|
||||||
|
a(GoTo, "bottom", &m.GoBottom, "bottom", "go to", "Jump to the bottom of the list in Go to while browsing."),
|
||||||
|
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Search, Favourites, Recent."),
|
||||||
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
|
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
|
||||||
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
|
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
|
||||||
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
|
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
|
||||||
@@ -206,7 +209,7 @@ var defaults = map[string][]string{
|
|||||||
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
||||||
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
||||||
|
|
||||||
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
||||||
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
|
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
|
||||||
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
|
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
|
||||||
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
|
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
|
||||||
|
|||||||
@@ -362,11 +362,13 @@ func (d Detail) versionLines(st styles.Styles) []string {
|
|||||||
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
marker := " "
|
// A list to read, not to move in (v browses them): no cursor. The
|
||||||
if d.secret.Version != "" && v.Version == d.secret.Version {
|
// version shown above, when it isn't the current one, is named.
|
||||||
marker = "▸ " // the version shown above
|
line := " " + VersionLine(st, v, d.Now(), current)
|
||||||
|
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
|
||||||
|
line += muted(" · shown above")
|
||||||
}
|
}
|
||||||
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
|
out = append(out, line)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
package styles
|
package styles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
@@ -32,6 +34,10 @@ type Styles struct {
|
|||||||
KindAzure lipgloss.Style
|
KindAzure lipgloss.Style
|
||||||
KindDatabricks lipgloss.Style
|
KindDatabricks lipgloss.Style
|
||||||
KindRepo lipgloss.Style
|
KindRepo lipgloss.Style
|
||||||
|
|
||||||
|
// themed marks the selection colours as set by the theme, so
|
||||||
|
// OnBackground leaves them.
|
||||||
|
themed, themedBlurred bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Styles {
|
func Default() Styles {
|
||||||
@@ -43,9 +49,12 @@ func Default() Styles {
|
|||||||
TitleBlurred: lipgloss.NewStyle(),
|
TitleBlurred: lipgloss.NewStyle(),
|
||||||
Footer: lipgloss.NewStyle().Faint(true),
|
Footer: lipgloss.NewStyle().Faint(true),
|
||||||
|
|
||||||
// A background rather than reverse, so the row keeps its colours.
|
// A background rather than reverse, so the row keeps its colours;
|
||||||
|
// in every pane, so each shows what's selected in it. The focused
|
||||||
|
// pane's is bold too. Palette 8 until the terminal says what its
|
||||||
|
// background is (OnBackground).
|
||||||
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
||||||
SelectedBlurred: lipgloss.NewStyle().Bold(true),
|
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
|
||||||
Muted: lipgloss.NewStyle().Faint(true),
|
Muted: lipgloss.NewStyle().Faint(true),
|
||||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||||
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||||
@@ -74,3 +83,23 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
|
|||||||
}
|
}
|
||||||
return s.KindAzure
|
return s.KindAzure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnBackground shades the selection colours from the terminal's
|
||||||
|
// background, a little lighter on a dark one and darker on a light one:
|
||||||
|
// a quiet band that keeps the row's colours readable, where palette 8 is
|
||||||
|
// often too bright. Colours the theme sets are kept.
|
||||||
|
func (s Styles) OnBackground(bg color.Color, dark bool) Styles {
|
||||||
|
shade := func(p float64) color.Color {
|
||||||
|
if dark {
|
||||||
|
return lipgloss.Lighten(bg, p)
|
||||||
|
}
|
||||||
|
return lipgloss.Darken(bg, p)
|
||||||
|
}
|
||||||
|
if !s.themed {
|
||||||
|
s.Selected = s.Selected.Background(shade(0.09))
|
||||||
|
}
|
||||||
|
if !s.themedBlurred {
|
||||||
|
s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -70,8 +70,10 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
||||||
case "selectedLineBgColor":
|
case "selectedLineBgColor":
|
||||||
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||||
|
st.themed = true
|
||||||
case "inactiveViewSelectedLineBgColor":
|
case "inactiveViewSelectedLineBgColor":
|
||||||
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||||
|
st.themedBlurred = true
|
||||||
case "defaultFgColor":
|
case "defaultFgColor":
|
||||||
fg = v.color
|
fg = v.color
|
||||||
case "optionsTextColor":
|
case "optionsTextColor":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package styles
|
package styles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -62,3 +63,32 @@ func TestEmptyThemeIsDefault(t *testing.T) {
|
|||||||
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOnBackground(t *testing.T) {
|
||||||
|
hex := func(c any) string {
|
||||||
|
r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA()
|
||||||
|
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||||
|
}
|
||||||
|
st := Default().OnBackground(lipgloss.Color("#282c34"), true)
|
||||||
|
if got := hex(st.Selected.GetBackground()); got != "#3e424a" {
|
||||||
|
t.Errorf("dark: selected %s", got)
|
||||||
|
}
|
||||||
|
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
|
||||||
|
t.Errorf("dark: blurred %s", got)
|
||||||
|
}
|
||||||
|
if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" {
|
||||||
|
t.Errorf("light: selected %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st = themed.OnBackground(lipgloss.Color("#282c34"), true)
|
||||||
|
if got := hex(st.Selected.GetBackground()); got != "#2d3f76" {
|
||||||
|
t.Errorf("themed: selected %s (want the theme's)", got)
|
||||||
|
}
|
||||||
|
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
|
||||||
|
t.Errorf("themed: blurred %s (not themed: shaded)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
-29
@@ -3,6 +3,9 @@
|
|||||||
// against the release's checksums.txt, tried (--version), then renamed
|
// against the release's checksums.txt, tried (--version), then renamed
|
||||||
// over the binary. A running lazysecrets keeps its old binary until it
|
// over the binary. A running lazysecrets keeps its old binary until it
|
||||||
// restarts.
|
// restarts.
|
||||||
|
//
|
||||||
|
// Releases are published in more than one place; each is tried in turn
|
||||||
|
// (GitHub, then git.kebcloud.no), so one being down doesn't stop updates.
|
||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,6 +21,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
@@ -30,12 +34,20 @@ import (
|
|||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Where releases are published.
|
// DefaultRepo is lazysecrets' home: its source, docs and releases.
|
||||||
|
// GitHubRepo mirrors it, with the issues and the releases too.
|
||||||
const (
|
const (
|
||||||
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
|
|
||||||
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
|
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
|
||||||
|
GitHubRepo = "https://github.com/karleinarb/lazysecrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultServers are where releases are looked for, in order.
|
||||||
|
var DefaultServers = []string{GitHubRepo, DefaultRepo}
|
||||||
|
|
||||||
|
// latestTimeout bounds asking one server for the latest release, so a
|
||||||
|
// server that's down leaves time for the next.
|
||||||
|
const latestTimeout = 5 * time.Second
|
||||||
|
|
||||||
// maxDownload bounds what's read from the server.
|
// maxDownload bounds what's read from the server.
|
||||||
const maxDownload = 200 << 20
|
const maxDownload = 200 << 20
|
||||||
|
|
||||||
@@ -60,15 +72,20 @@ func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
|||||||
// and a terminal shows them as their text.
|
// and a terminal shows them as their text.
|
||||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||||
|
|
||||||
// Client talks to the release server.
|
// Client talks to the release servers.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
API string // the repository's API, DefaultAPI when empty
|
// Servers are the repositories' web addresses, on GitHub or a Gitea
|
||||||
Repo string // the repository's web address, DefaultRepo when empty
|
// or Forgejo server, tried in order; DefaultServers when empty.
|
||||||
|
Servers []string
|
||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
|
func (c *Client) servers() []string {
|
||||||
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
|
if len(c.Servers) > 0 {
|
||||||
|
return c.Servers
|
||||||
|
}
|
||||||
|
return DefaultServers
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) client() *http.Client {
|
func (c *Client) client() *http.Client {
|
||||||
if c.HTTP != nil {
|
if c.HTTP != nil {
|
||||||
@@ -77,25 +94,50 @@ func (c *Client) client() *http.Client {
|
|||||||
return &http.Client{Timeout: 2 * time.Minute}
|
return &http.Client{Timeout: 2 * time.Minute}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmpOr(a, b string) string {
|
// API is a repository's API: GitHub's for github.com, else Gitea's (and
|
||||||
if a != "" {
|
// Forgejo's).
|
||||||
return a
|
func API(repo string) (string, error) {
|
||||||
|
u, err := url.Parse(strings.TrimSuffix(repo, "/"))
|
||||||
|
if err != nil || u.Host == "" || strings.Count(u.Path, "/") != 2 {
|
||||||
|
return "", fmt.Errorf("%q isn't a repository's address, like %s", repo, GitHubRepo)
|
||||||
}
|
}
|
||||||
return b
|
if u.Host == "github.com" {
|
||||||
|
return "https://api.github.com/repos" + u.Path, nil
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host + "/api/v1/repos" + u.Path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest is the newest release (pre-releases aside).
|
// Latest is the newest release (pre-releases aside), from the first
|
||||||
|
// server that answers.
|
||||||
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
||||||
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
|
var errs []error
|
||||||
|
for _, repo := range c.servers() {
|
||||||
|
r, err := c.latest(ctx, repo)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
return Release{}, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
||||||
|
api, err := API(repo)
|
||||||
|
if err != nil {
|
||||||
|
return Release{}, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Release{}, err
|
return Release{}, err
|
||||||
}
|
}
|
||||||
var r Release
|
var r Release
|
||||||
if err := json.Unmarshal(b, &r); err != nil {
|
if err := json.Unmarshal(b, &r); err != nil {
|
||||||
return Release{}, fmt.Errorf("reading the latest release: %w", err)
|
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
|
||||||
}
|
}
|
||||||
if !semver.IsValid(r.Tag) {
|
if !semver.IsValid(r.Tag) {
|
||||||
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
|
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
|
||||||
}
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
@@ -147,23 +189,10 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
|||||||
}
|
}
|
||||||
version := strings.TrimPrefix(r.Tag, "v")
|
version := strings.TrimPrefix(r.Tag, "v")
|
||||||
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
||||||
base := c.repo() + "/releases/download/" + r.Tag + "/"
|
archive, err := c.download(ctx, r.Tag, name+".tar.gz")
|
||||||
|
|
||||||
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
want, err := checksum(sums, name+".tar.gz")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
|
|
||||||
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
|
|
||||||
}
|
|
||||||
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -198,6 +227,38 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// download fetches a release's file from the first server that has it,
|
||||||
|
// checked against that server's checksums.txt. A file that doesn't match
|
||||||
|
// stops there: it's damaged, or not the release.
|
||||||
|
func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error) {
|
||||||
|
var errs []error
|
||||||
|
for _, repo := range c.servers() {
|
||||||
|
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
|
||||||
|
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||||
|
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want, err := checksum(sums, file)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", repo, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := c.get(ctx, base+file, maxDownload)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got := sha256.Sum256(b); hex.EncodeToString(got[:]) != want {
|
||||||
|
return nil, fmt.Errorf("%s from %s doesn't match checksums.txt: damaged, or not the release", file, repo)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
|
|||||||
sum[0] ^= 0xff
|
sum[0] ^= 0xff
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
|
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
|
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Write(archive.Bytes())
|
w.Write(archive.Bytes())
|
||||||
})
|
})
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
@@ -89,7 +89,7 @@ func TestInstall(t *testing.T) {
|
|||||||
t.Skip("releases are for macOS and Linux")
|
t.Skip("releases are for macOS and Linux")
|
||||||
}
|
}
|
||||||
srv := server(t, false)
|
srv := server(t, false)
|
||||||
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
|
||||||
r, err := c.Latest(context.Background())
|
r, err := c.Latest(context.Background())
|
||||||
if err != nil || r.Tag != "v0.2.0" {
|
if err != nil || r.Tag != "v0.2.0" {
|
||||||
t.Fatalf("latest %+v %v", r, err)
|
t.Fatalf("latest %+v %v", r, err)
|
||||||
@@ -107,12 +107,59 @@ func TestInstall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A server that's down, or doesn't have the release, is skipped.
|
||||||
|
func TestFallsBackToTheNextServer(t *testing.T) {
|
||||||
|
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||||
|
t.Skip("releases are for macOS and Linux")
|
||||||
|
}
|
||||||
|
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "down", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
t.Cleanup(down.Close)
|
||||||
|
srv := server(t, false)
|
||||||
|
c := &Client{Servers: []string{down.URL + "/keb/lazysecrets", srv.URL + "/keb/lazysecrets"}}
|
||||||
|
r, err := c.Latest(context.Background())
|
||||||
|
if err != nil || r.Tag != "v0.2.0" {
|
||||||
|
t.Fatalf("latest %+v %v", r, err)
|
||||||
|
}
|
||||||
|
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
||||||
|
os.WriteFile(exe, []byte("old"), 0o755)
|
||||||
|
if err := c.Install(context.Background(), r, exe); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") {
|
||||||
|
t.Errorf("not replaced: %q", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every server down: both say why.
|
||||||
|
c = &Client{Servers: []string{down.URL + "/a/b", down.URL + "/c/d"}}
|
||||||
|
if _, err := c.Latest(context.Background()); err == nil || strings.Count(err.Error(), "502") != 2 {
|
||||||
|
t.Errorf("all down: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPI(t *testing.T) {
|
||||||
|
for repo, want := range map[string]string{
|
||||||
|
"https://github.com/karleinarb/lazysecrets": "https://api.github.com/repos/karleinarb/lazysecrets",
|
||||||
|
"https://git.kebcloud.no/keb/lazysecrets/": "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets",
|
||||||
|
"https://codeberg.org/someone/lazysecrets": "https://codeberg.org/api/v1/repos/someone/lazysecrets",
|
||||||
|
"https://git.kebcloud.no/keb": "",
|
||||||
|
"git.kebcloud.no/keb/lazysecrets": "",
|
||||||
|
"https://github.com/karleinarb/lazysecrets/x": "",
|
||||||
|
} {
|
||||||
|
got, err := API(repo)
|
||||||
|
if got != want || (err != nil) != (want == "") {
|
||||||
|
t.Errorf("API(%q) = %q, %v", repo, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInstallRefusesATamperedArchive(t *testing.T) {
|
func TestInstallRefusesATamperedArchive(t *testing.T) {
|
||||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||||
t.Skip("releases are for macOS and Linux")
|
t.Skip("releases are for macOS and Linux")
|
||||||
}
|
}
|
||||||
srv := server(t, true)
|
srv := server(t, true)
|
||||||
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
|
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
|
||||||
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
||||||
os.WriteFile(exe, []byte("old"), 0o755)
|
os.WriteFile(exe, []byte("old"), 0o755)
|
||||||
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Copies a release GoReleaser published on Gitea to the GitHub mirror:
|
||||||
|
// the same notes and the same files (dist/), so lazysecrets can update
|
||||||
|
// from either (internal/update tries GitHub first). Run by the release
|
||||||
|
// workflow after GoReleaser; a release already on GitHub for the tag is
|
||||||
|
// replaced, like GoReleaser's mode: replace on Gitea.
|
||||||
|
//
|
||||||
|
// TAG=v0.2.0 GH_TOKEN=… GITEA_TOKEN=… node scripts/github-release.mjs
|
||||||
|
//
|
||||||
|
// GH_TOKEN is a fine-grained token that can write the mirror's contents;
|
||||||
|
// without it, this does nothing. No dependencies: Node 20's fetch.
|
||||||
|
|
||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const github = "karleinarb/lazysecrets";
|
||||||
|
const { TAG: tag, GH_TOKEN: token, GITEA_TOKEN: giteaToken, GITHUB_SHA: sha } = process.env;
|
||||||
|
const gitea = `${process.env.GITHUB_SERVER_URL ?? "https://git.kebcloud.no"}/api/v1/repos/${process.env.GITHUB_REPOSITORY ?? "keb/lazysecrets"}`;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log("No GH_TOKEN: not copying the release to GitHub.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (!tag) throw new Error("TAG isn't set");
|
||||||
|
|
||||||
|
const gh = (path, init = {}) =>
|
||||||
|
fetch(path.startsWith("https://") ? path : `https://api.github.com/repos/${github}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function ok(res, what) {
|
||||||
|
if (!res.ok) throw new Error(`${what}: ${res.status} ${await res.text()}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The notes as GoReleaser wrote them on Gitea.
|
||||||
|
const rel = await (
|
||||||
|
await ok(await fetch(`${gitea}/releases/tags/${tag}`, { headers: giteaToken ? { Authorization: `token ${giteaToken}` } : {} }), "reading the Gitea release")
|
||||||
|
).json();
|
||||||
|
|
||||||
|
// Replace what's there for the tag.
|
||||||
|
const old = await gh(`/releases/tags/${tag}`);
|
||||||
|
if (old.ok) {
|
||||||
|
const { id } = await old.json();
|
||||||
|
await ok(await gh(`/releases/${id}`, { method: "DELETE" }), "deleting the old GitHub release");
|
||||||
|
console.log(`Deleted the GitHub release for ${tag}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mirror may not have the tag's commit yet: it pushes on its own.
|
||||||
|
// Wait for it a while.
|
||||||
|
const body = JSON.stringify({ tag_name: tag, target_commitish: sha, name: rel.name || tag, body: rel.body, prerelease: rel.prerelease });
|
||||||
|
let created;
|
||||||
|
for (let i = 0; ; i++) {
|
||||||
|
const res = await gh("/releases", { method: "POST", body });
|
||||||
|
if (res.ok) {
|
||||||
|
created = await res.json();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (res.status !== 422 || i === 9) await ok(res, "creating the GitHub release");
|
||||||
|
console.log("GitHub doesn't have the commit yet; waiting for the mirror…");
|
||||||
|
await new Promise((r) => setTimeout(r, 30_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = (await readdir("dist")).filter((f) => f.endsWith(".tar.gz") || f === "checksums.txt");
|
||||||
|
if (files.length === 0) throw new Error("nothing to upload in dist/");
|
||||||
|
for (const f of files) {
|
||||||
|
const url = `https://uploads.github.com/repos/${github}/releases/${created.id}/assets?name=${encodeURIComponent(f)}`;
|
||||||
|
await ok(await gh(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: await readFile(`dist/${f}`) }), `uploading ${f}`);
|
||||||
|
console.log(`Uploaded ${f}.`);
|
||||||
|
}
|
||||||
|
console.log(`Published ${tag} on GitHub: ${created.html_url}`);
|
||||||
Reference in new issue
Block a user