1 Commits
Author SHA1 Message Date
RandyTheSilly 353b6a67de Prepare for release v0.3.1 2025-11-23 04:25:14 -05:00
28 changed files with 342 additions and 526 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
/*.exe*
/mutn /mutn
/mutn.exe
/MUTN /MUTN
/MUTN.exe
/main /main
/main.exe
/version.go /version.go
1output 1output
+1 -1
View File
@@ -1,5 +1,5 @@
MUTN MUTN
Copyright (c) 2024-2026 Randall Winkhart Copyright (c) 2024-2025 Randall Winkhart
MUTN is licensed under the AGPL 3.0 due to its usage of MUTN is licensed under the AGPL 3.0 due to its usage of
a custom fork of https://github.com/jessp01/gohighlight. a custom fork of https://github.com/jessp01/gohighlight.
+8 -2
View File
@@ -2,7 +2,7 @@
--- ---
Pronounced as: "mutton", "muh·tn" Pronounced as: "mutton", "muh·tn"
MUTN is a simple, self-hosted, vertically integrated, SSH-synchronized password manager. It is based on and is the reference implementation of [libmutton](https://github.com/rwinkhart/libmutton). MUTN is a simple, self-hosted, SSH-synchronized password and note manager based on [libmutton](https://github.com/rwinkhart/libmutton). It is the successor to [sshyp](https://github.com/rwinkhart/sshyp).
> [!WARNING] > [!WARNING]
>It is your responsibility to assess the security and stability of MUTN and to ensure it meets your needs before using it. >It is your responsibility to assess the security and stability of MUTN and to ensure it meets your needs before using it.
@@ -23,8 +23,14 @@ Additionally, MUTN is available as a source package ("[mutn](https://aur.archlin
After installing, please review the [usage guide](https://github.com/rwinkhart/MUTN/blob/main/wiki/usage.md). After installing, please review the [usage guide](https://github.com/rwinkhart/MUTN/blob/main/wiki/usage.md).
# Roadmap # Roadmap
### Release v0.4.0
- [ ] Switch to fully compliant Markdown (do not preserve new lines)
- [ ] Switch to alternative Markdown renderer (minimark or BEAN)
### Release v0.5.0 ### Release v0.5.0
- Switch to alternative Markdown renderer (minimark or BEAN) - [ ] libmutton v0.5.0
- [ ] Implement "netpin" (quick-unlock)
- [ ] Password aging support
- [ ] Add yellow/red dot indicators to entry list readout for when passwords should be changed
### Release v1.0.0 - Distribution packages ### Release v1.0.0 - Distribution packages
- [ ] Create packaging scripts - [ ] Create packaging scripts
- [x] Stable source PKGBUILD - [x] Stable source PKGBUILD
+1 -1
View File
@@ -28,7 +28,7 @@ _mutnCompletions() {
while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password note folder" -- "$cur" ) while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password note folder" -- "$cur" )
;; ;;
copy ) copy )
while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password username totp url note menu" -- "$cur" ) while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password username totp url note" -- "$cur" )
;; ;;
edit ) edit )
while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password username totp url note rename" -- "$cur" ) while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password username totp url note rename" -- "$cur" )
+1 -1
View File
@@ -86,7 +86,7 @@ function cliMUTN-optionCompleter {
$possibleValues = @{ $possibleValues = @{
add = @('password', 'note', 'folder') add = @('password', 'note', 'folder')
copy = @('password', 'username', 'totp', 'url', 'note', 'menu') copy = @('password', 'username', 'totp', 'url', 'note')
edit = @('password', 'username', 'totp', 'url', 'note', 'rename') edit = @('password', 'username', 'totp', 'url', 'note', 'rename')
gen = @('update') gen = @('update')
} }
+1 -1
View File
@@ -20,7 +20,7 @@ case ${words[-2]} in
compadd {password,note,folder} compadd {password,note,folder}
;; ;;
copy ) copy )
compadd {password,username,totp,url,note,menu} compadd {password,username,totp,url,note}
;; ;;
edit ) edit )
compadd {password,username,totp,url,note,rename} compadd {password,username,totp,url,note,rename}
+5 -7
View File
@@ -1,7 +1,7 @@
.TH MUTN 1 "17 February 2026" "v0.4.0" "MUTN man page" .TH MUTN 1 "23 November 2025" "v0.3.1" "MUTN man page"
.SH NAME .SH NAME
\fBmutn\fR - Simple, self-hosted, SSH-synchronized password and note management based on libmutton. \fBmutn\fR - Simple, self-hosted, SSH-synchronized password and note management based on libmutton. It is the successor to sshyp.
.SH SYNOPSIS .SH SYNOPSIS
Usage: mutn [/<entry name> [argument] [option]] | [argument] Usage: mutn [/<entry name> [argument] [option]] | [argument]
@@ -63,7 +63,6 @@ copy:
totp|-t Generate and copy the TOTP token for an entry to your clipboard totp|-t Generate and copy the TOTP token for an entry to your clipboard
url|-l Copy the URL in an entry to your clipboard url|-l Copy the URL in an entry to your clipboard
note|-n Copy the first note line in an entry to your clipboard note|-n Copy the first note line in an entry to your clipboard
menu|-m Open interactive menu to copy any combination of fields
edit: edit:
password|-pw Change the password in an entry password|-pw Change the password in an entry
@@ -84,11 +83,11 @@ add:
.SH TIPS .SH TIPS
1. You can quickly read an entry with "mutn /<entry name>" 1. You can quickly read an entry with "mutn /<entry name>"
2. Using "add", "edit", or "copy" without specifying an option (field) will default to "password" 2. Type "mutn" (no arguments/options) to view a list of saved entries
3. Various changes can be made to the configuration and stored entries from the "mutn tweak" menu 3. Provide "add", "edit", "copy", or "gen" as the only argument to receive more specific help
4. Type "mutn" (no arguments) to view a list of saved entries w/colored markers indicating the ages of stored passwords 4. Using "add", "edit", or "copy" without specifying an option (field) will default to "password"
.SH SETUP .SH SETUP
libmutton operates on a client-server model, and thus sync support requires you to have access to your own server (whether it be a physical home server or a cloud rental) with remote access via SSH. libmutton operates on a client-server model, and thus sync support requires you to have access to your own server (whether it be a physical home server or a cloud rental) with remote access via SSH.
@@ -102,7 +101,6 @@ Once this has been done, you may proceed with the client setup:
2. Run "mutn init" and follow the prompts 2. Run "mutn init" and follow the prompts
3. If you already have entries on the server, sync them using "mutn sync" 3. If you already have entries on the server, sync them using "mutn sync"
4. Optionally, shell completions (Bash, ZSH, and PowerShell 7+) can be enabled with your shell's respective method 4. Optionally, shell completions (Bash, ZSH, and PowerShell 7+) can be enabled with your shell's respective method
5. If importing passwords from another password manager, you may desire to use the "Age all entries" option from the "mutn tweak" menu to add initial age tracking data to the imported passwords
.SH TROUBLESHOOTING .SH TROUBLESHOOTING
.B Clipboard not clearing: .B Clipboard not clearing:
-31
View File
@@ -1,31 +0,0 @@
**WARNING: AS LIBMUTTON HAS NOT YET REACHED v1.0.0, [BREAKING CHANGES](https://github.com/rwinkhart/libmutton/blob/main/wiki/breaking.md) IN FUTURE UPDATES ARE PLANNED**
**MUTN v0.4.0**
Built with libmutton v0.5.0
February 17, 2026
# The "Hardy Herd" Update
This release adds password age-tracking support, a new interactive copy menu, and features many under-the-hood (breaking) changes.
## libmutton-derived Changes
- **PLEASE** read [libmutton's release notes](https://github.com/rwinkhart/libmutton/releases/tag/v0.5.0), as they detail severe breaking changes. You **NEED** to update your `libmuttonserver` and run `mutn init` after this update!
## Features
- (41b4c4b6ca2f5a74899e87c6d520df7b140671b3) (c1c95655114a689daf80d2410e19ba35b670b1b1) (107a6f4f1250de8f94d87c192d58cc47bef80c1f) (fc1e3b11fc810a79cc0673b6edf96d4753c0300c) (a75416a6792559179e39497cb033407f18c00620) (b58461b9c458795278f050ff10e91a2d05c21745) (b26c2ce17c0491890de03dc131717d557876a3e4) (8112496006c7d48dd4a8f21a9eea51eadc30f732) (12167b1eeddb49e8f5e62f788dc1da6265706f68) (aaf69eb590af4dc37013102dcff79ef115b9f809) (cac35e1d25b7a9a4bd7263406f642834418f6b76) Add new interactive copy menu
- This menu aims to reduce the number of times the user is decrypting each entry
- (1a9de90f890f133e6ab8f298241fe3a6237ba4c8) (7016a8a1309b06c63b9dd63fb126103d4cab6ccc) Password age tracking (from libmutton)
- The `tweak` menu now allows adding age data to old (existing) entries
- (926eb1f1766f28a69e194c74758c300f233856fb) Option to verify entries (integrity) in `tweak` menu
## Changes
- (f4abb4cc7e5f303f68358fcdda659056422ebeb3) MUTN no longer preserves new lines in Markdown notes (more compliant)
## Dependencies (direct/replaced)
- Dropped (libmutton)
- golang.design/x/clipboard
- gopkg.in/ini.v1
- Bumped
- Go: v1.25.4 => 1.26.0
- github.com/rwinkhart/go-boilerplate: v0.1.0 => v0.3.1
- github.com/rwinkhart/libmutton: v0.4.4 => v0.5.0
- golang.org/x/term: v0.36.0 => v0.40.0
+19 -15
View File
@@ -1,12 +1,12 @@
module github.com/rwinkhart/MUTN module github.com/rwinkhart/MUTN
go 1.26.0 go 1.25.4
require ( require (
github.com/charmbracelet/glamour v0.7.0 github.com/charmbracelet/glamour v0.7.0
github.com/rwinkhart/go-boilerplate v0.3.1 github.com/rwinkhart/go-boilerplate v0.1.1-0.20251110055016-10ee4f91fcb6
github.com/rwinkhart/libmutton v0.5.0 github.com/rwinkhart/libmutton v0.4.4
golang.org/x/term v0.40.0 golang.org/x/term v0.37.0
) )
require ( require (
@@ -14,12 +14,11 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect github.com/boombuler/barcode v1.1.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/kr/fs v0.1.0 // indirect github.com/kr/fs v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
@@ -28,18 +27,23 @@ require (
github.com/pquerna/otp v1.5.0 // indirect github.com/pquerna/otp v1.5.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rwinkhart/go-highlite v0.1.1 // indirect github.com/rwinkhart/go-highlite v0.1.1 // indirect
github.com/rwinkhart/peercred-mini v0.1.4 // indirect github.com/rwinkhart/peercred-mini v0.1.2 // indirect
github.com/rwinkhart/rcw v0.3.0 // indirect github.com/rwinkhart/rcw v0.2.4 // indirect
github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.design/x/clipboard v0.7.1 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/exp/shiny v0.0.0-20251017212417-90e834f514db // indirect
golang.org/x/image v0.32.0 // indirect
golang.org/x/mobile v0.0.0-20251021151156-188f512ec823 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
) )
replace golang.org/x/sys => github.com/rwinkhart/sys v0.41.0 replace golang.org/x/sys => github.com/rwinkhart/sys v0.38.0
replace github.com/Microsoft/go-winio => github.com/rwinkhart/go-winio v0.1.1 replace github.com/Microsoft/go-winio => github.com/rwinkhart/go-winio v0.1.0
replace github.com/charmbracelet/glamour => github.com/rwinkhart/glamour-temp-MUTN v0.7.2 replace github.com/charmbracelet/glamour => github.com/rwinkhart/glamour-temp-MUTN v0.7.2
+34 -26
View File
@@ -5,8 +5,6 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -14,14 +12,14 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
@@ -42,36 +40,46 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rwinkhart/glamour-temp-MUTN v0.7.2 h1:/CBexwkGdL8HuZjO7M0zzjGHaHCr9/r4XTOtUJa+pPE= github.com/rwinkhart/glamour-temp-MUTN v0.7.2 h1:/CBexwkGdL8HuZjO7M0zzjGHaHCr9/r4XTOtUJa+pPE=
github.com/rwinkhart/glamour-temp-MUTN v0.7.2/go.mod h1:DN/eJaNQ5h7dgxzfmWFo7By+b82CKEUdNaY6z4R4v3c= github.com/rwinkhart/glamour-temp-MUTN v0.7.2/go.mod h1:DN/eJaNQ5h7dgxzfmWFo7By+b82CKEUdNaY6z4R4v3c=
github.com/rwinkhart/go-boilerplate v0.3.1 h1:vkVRuptO2s1yPzzwpvtiiB0c/hPB6yr0p1mzZ2Myb9E= github.com/rwinkhart/go-boilerplate v0.1.1-0.20251110055016-10ee4f91fcb6 h1:tGGtw5Wr4laDrU7Ooe95g4E+O6c+oY18atamcMPaMY8=
github.com/rwinkhart/go-boilerplate v0.3.1/go.mod h1:ES13A2r9fnCVfyezwMBgY/RgA4pOIudOUXz3Jk/ikes= github.com/rwinkhart/go-boilerplate v0.1.1-0.20251110055016-10ee4f91fcb6/go.mod h1:cnzIF45I0FCOvE4YIB+26pLCUx2kWyY2llKYZruNaRY=
github.com/rwinkhart/go-highlite v0.1.1 h1:9TxbRhYVfD/3YaEgNk1BtEiZ0t8/5mxDUZqNMXHodT0= github.com/rwinkhart/go-highlite v0.1.1 h1:9TxbRhYVfD/3YaEgNk1BtEiZ0t8/5mxDUZqNMXHodT0=
github.com/rwinkhart/go-highlite v0.1.1/go.mod h1:mWLMtCWcyV0BG4NeyPyAUGMjPvI0ds6vXmUq89QwBFA= github.com/rwinkhart/go-highlite v0.1.1/go.mod h1:mWLMtCWcyV0BG4NeyPyAUGMjPvI0ds6vXmUq89QwBFA=
github.com/rwinkhart/go-winio v0.1.1 h1:kAJKiqneR7cUR01Wn5/doAAV4kOGTEGPug4oinXc5N4= github.com/rwinkhart/go-winio v0.1.0 h1:b72agLW+dETGmhR3VbcbwnStfgKfc5AfgJOXBJDkaHg=
github.com/rwinkhart/go-winio v0.1.1/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/rwinkhart/go-winio v0.1.0/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0=
github.com/rwinkhart/libmutton v0.5.0 h1:Du/o5Hyb26uE6lubqBISN/ZF9JZ5oObHVveQjAqrP0E= github.com/rwinkhart/libmutton v0.4.4 h1:tUGU0JLf0aQ0YQy+1RSzOy5yujfpJ4b3iXuxAhUjhzE=
github.com/rwinkhart/libmutton v0.5.0/go.mod h1:peh8A1f3gnaF97m8J06U28JloKv04Kw8WtFCH+Pcq6s= github.com/rwinkhart/libmutton v0.4.4/go.mod h1:2GV6ILW7dawIGvBoOcV0K3Uw7llc0ZWFRYcV+kM9K8g=
github.com/rwinkhart/peercred-mini v0.1.4 h1:93+phjLknvJadEd2cu/ZPPWdfRSPOwFJzDBEn4ZtWVc= github.com/rwinkhart/peercred-mini v0.1.2 h1:4cGWDbv0whvLeVvbUdx84V/9p+2fS+DEXgrA1KxlRFo=
github.com/rwinkhart/peercred-mini v0.1.4/go.mod h1:E8eApo/izzmq4nGGR+kpJ0ZLAXjofnVRMGCeVMPQ/Ik= github.com/rwinkhart/peercred-mini v0.1.2/go.mod h1:LLHG7YshHEpbpJJP+Il9nx2dnGj5O3VGE32rWmflj0c=
github.com/rwinkhart/rcw v0.3.0 h1:OdgA++IGcDrZqN6FzyYnzbz04fL8Q1MHvlgPjhtgJM4= github.com/rwinkhart/rcw v0.2.4 h1:FrRSsl+ui1WLmDwqjLSROtFPKqSeGLEfF2SPENt4qP0=
github.com/rwinkhart/rcw v0.3.0/go.mod h1:UUY7kSku6kV2X1RWPUcK0Re+rQ6RvngWI8DVZJ11cqk= github.com/rwinkhart/rcw v0.2.4/go.mod h1:nhW4RAHvEjW2QcOlqEdWqRQOFJLjoOGSPkSRZlMjU3s=
github.com/rwinkhart/sys v0.41.0 h1:pHB6HphVC132UXYZ6yeOk62abqgl+pyl5ZZnsk4iUKg= github.com/rwinkhart/sys v0.38.0 h1:V1PGKcUutWtD3+VdHvyvfGtPy7PDXzKHNM1IzVq1ysY=
github.com/rwinkhart/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= github.com/rwinkhart/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/exp/shiny v0.0.0-20251017212417-90e834f514db h1:NmsmaSkEAq6A8r0Q78WxD0IygJNCs6J3uYDgNuPkXYM=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/exp/shiny v0.0.0-20251017212417-90e834f514db/go.mod h1:QMAAUorQ8fzCK0C6mr4X4XV9BEp7Al6+jlejJvfYKw4=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mobile v0.0.0-20251021151156-188f512ec823 h1:M0DtBf/UvJoTH+tk6tgHT2NVxNEJCYhVu1g/xeD+GEk=
golang.org/x/mobile v0.0.0-20251021151156-188f512ec823/go.mod h1:3QSlP0AtP6HPTLbsxfgfefGN76jpIB9yBsMqB8UY37I=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+51 -75
View File
@@ -11,13 +11,12 @@ import (
"github.com/rwinkhart/go-boilerplate/back" "github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/front" "github.com/rwinkhart/go-boilerplate/front"
"github.com/rwinkhart/go-boilerplate/other" "github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/age" "github.com/rwinkhart/libmutton/cfg"
"github.com/rwinkhart/libmutton/clip"
"github.com/rwinkhart/libmutton/config"
"github.com/rwinkhart/libmutton/core" "github.com/rwinkhart/libmutton/core"
"github.com/rwinkhart/libmutton/crypt" "github.com/rwinkhart/libmutton/crypt"
"github.com/rwinkhart/libmutton/global" "github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/syncclient" "github.com/rwinkhart/libmutton/syncclient"
"github.com/rwinkhart/libmutton/synccycles"
) )
func main() { func main() {
@@ -34,31 +33,33 @@ func main() {
// if the first argument is an entry... // if the first argument is an entry...
if strings.HasPrefix(args[1], "/") { if strings.HasPrefix(args[1], "/") {
// store realPath of target entry // store location of target entry
realPath := global.GetRealPath(args[1]) targetLocation := global.TargetLocationFormat(args[1])
// entry reader shortcut (if no other arguments are supplied) // entry reader shortcut (if no other arguments are supplied)
if argsCount == 2 { if argsCount == 2 {
cli.EntryReaderDecrypt(realPath, true) cli.EntryReaderDecrypt(targetLocation, true)
// perform other operations on the entry (if other arguments are supplied) // perform other operations on the entry (if other arguments are supplied)
} else if argsCount == 3 || (argsCount == 4 && (args[3] == "show" || args[3] == "-s")) { } else if argsCount == 3 || (argsCount == 4 && (args[3] == "show" || args[3] == "-s")) {
if argsCount == 3 { // default to "password" if no field is specified (for copy, edit, and add) if argsCount == 3 { // default to "password" if no field is specified (for copy, edit, and add)
switch args[2] { switch args[2] {
case "show", "-s": case "show", "-s":
cli.EntryReaderDecrypt(realPath, false) cli.EntryReaderDecrypt(targetLocation, false)
case "copy": case "copy":
if err := clip.CopyShortcut(realPath, 0, nil); err != nil { err := core.CopyArgument(targetLocation, 0)
if err != nil {
other.PrintError("Failed to copy password to clipboard: "+err.Error(), global.ErrorClipboard) other.PrintError("Failed to copy password to clipboard: "+err.Error(), global.ErrorClipboard)
} }
case "edit": case "edit":
cli.EditEntryField(realPath, 0) cli.EditEntryField(targetLocation, true, 0)
case "gen": case "gen":
cli.AddEntry(realPath, 1) cli.AddEntry(targetLocation, true, 1)
case "add": case "add":
cli.AddEntry(realPath, 0) cli.AddEntry(targetLocation, true, 0)
case "shear": case "shear":
if err := syncclient.ShearRemote(args[1], false); err != nil { err := syncclient.ShearRemoteFromClient(args[1]) // pass the incomplete path as the server and all clients (reading from the deletions directory) will have a different home directory
if err != nil {
other.PrintError("Failed to shear target: "+err.Error(), back.ErrorWrite) other.PrintError("Failed to shear target: "+err.Error(), back.ErrorWrite)
} }
default: default:
@@ -67,11 +68,11 @@ func main() {
} else { // handle "show" or "-s" argument for gen, edit, and add } else { // handle "show" or "-s" argument for gen, edit, and add
switch args[2] { switch args[2] {
case "edit": case "edit":
cli.EditEntryField(realPath, 0) cli.EditEntryField(targetLocation, false, 0)
case "gen": case "gen":
cli.AddEntry(realPath, 1) cli.AddEntry(targetLocation, false, 1)
case "add": case "add":
cli.AddEntry(realPath, 0) cli.AddEntry(targetLocation, false, 0)
default: default:
cli.HelpMain() cli.HelpMain()
} }
@@ -93,12 +94,11 @@ func main() {
field = 3 field = 3
case "note", "-n": case "note", "-n":
field = 4 field = 4
case "menu", "-m":
cli.CopyMenu(args[1], nil, nil)
default: default:
cli.HelpCopy() cli.HelpCopy()
} }
if err := clip.CopyShortcut(realPath, field, nil); err != nil { err := core.CopyArgument(targetLocation, field)
if err != nil {
other.PrintError("Failed to copy field to clipboard: "+err.Error(), global.ErrorClipboard) other.PrintError("Failed to copy field to clipboard: "+err.Error(), global.ErrorClipboard)
} }
case "edit": case "edit":
@@ -115,38 +115,38 @@ func main() {
case "note", "-n": case "note", "-n":
field = 4 field = 4
case "rename", "-r": case "rename", "-r":
isAccessible, _ := back.TargetIsFile(realPath, true) // error is ignored because dir/file status is irrelevant isAccessible, _ := back.TargetIsFile(targetLocation, true) // error is ignored because dir/file status is irrelevant
if !isAccessible { if !isAccessible {
other.PrintError("Failed to access location ("+realPath+")", back.ErrorTargetNotFound) other.PrintError("Failed to access location ("+targetLocation+")", back.ErrorTargetNotFound)
} }
cli.RenameCli(args[1]) // pass the incomplete path as the server and all clients (reading from the deletions directory) will have a different home directory cli.RenameCli(args[1]) // pass the incomplete path as the server and all clients (reading from the deletions directory) will have a different home directory
default: default:
cli.HelpEdit() cli.HelpEdit()
} }
if argsCount == 4 { if argsCount == 4 {
cli.EditEntryField(realPath, field) cli.EditEntryField(targetLocation, true, field)
} else { } else {
switch args[4] { switch args[4] {
case "show", "-s": case "show", "-s":
cli.EditEntryField(realPath, field) cli.EditEntryField(targetLocation, false, field)
default: default:
cli.EditEntryField(realPath, field) cli.EditEntryField(targetLocation, true, field)
} }
} }
case "gen": case "gen":
if argsCount == 4 { if argsCount == 4 {
switch args[3] { switch args[3] {
case "update", "-u": case "update", "-u":
cli.GenUpdate(realPath) cli.GenUpdate(targetLocation, true)
default: default:
cli.HelpGen() cli.HelpGen()
} }
} else if args[3] == "update" || args[3] == "-u" { } else if args[3] == "update" || args[3] == "-u" {
switch args[4] { switch args[4] {
case "show", "-s": case "show", "-s":
cli.GenUpdate(realPath) cli.GenUpdate(targetLocation, false)
default: default:
cli.GenUpdate(realPath) cli.GenUpdate(targetLocation, true)
} }
} }
cli.HelpGen() cli.HelpGen()
@@ -154,19 +154,20 @@ func main() {
switch args[3] { switch args[3] {
case "password", "-pw": case "password", "-pw":
if argsCount == 4 { if argsCount == 4 {
cli.AddEntry(realPath, 0) cli.AddEntry(targetLocation, true, 0)
} else { } else {
switch args[4] { switch args[4] {
case "show", "-s": case "show", "-s":
cli.AddEntry(realPath, 0) cli.AddEntry(targetLocation, false, 0)
default: default:
cli.AddEntry(realPath, 0) cli.AddEntry(targetLocation, true, 0)
} }
} }
case "note", "-n": case "note", "-n":
cli.AddEntry(realPath, 2) cli.AddEntry(targetLocation, true, 2)
case "folder", "-f": case "folder", "-f":
if err := syncclient.AddFolderRemote(args[1]); err != nil { err := syncclient.AddFolderRemoteFromClient(args[1]) // pass the incomplete path as the server will have a different home directory
if err != nil {
other.PrintError("Failed to add folder: "+err.Error(), back.ErrorWrite) other.PrintError("Failed to add folder: "+err.Error(), back.ErrorWrite)
} }
default: default:
@@ -181,65 +182,50 @@ func main() {
} else { } else {
switch args[1] { switch args[1] {
case "clipclear": case "clipclear":
if err := clip.ClearArgument(); err != nil { err := core.ClipClearArgument()
if err != nil {
other.PrintError("Failure occurred in clipboard clearing process: "+err.Error(), global.ErrorClipboard) other.PrintError("Failure occurred in clipboard clearing process: "+err.Error(), global.ErrorClipboard)
} }
case "startrcwd": case "startrcwd":
crypt.RCWDArgument() crypt.RCWDArgument()
case "sync": case "sync":
_, err := syncclient.RunJob() _, err := syncclient.RunJob(true)
if err != nil { if err != nil {
other.PrintError("Failed to sync entries: "+err.Error(), global.ErrorSyncProcess) other.PrintError("Failed to sync entries: "+err.Error(), global.ErrorSyncProcess)
} }
case "init": case "init":
err := core.LibmuttonInit( err := core.LibmuttonInit(front.Input,
front.Input, [][3]string{{"MUTN", "textEditor", getTextEditorInput()}},
confirmRCWPassword("new"), confirmRCWPassword("new"), false, false)
front.InputBinary("Preserve existing config directory?"),
false,
"",
map[string]any{"mutnTextEditor": getTextEditorInput()},
)
if err != nil { if err != nil {
other.PrintError("Initialization failed: "+err.Error(), 0) other.PrintError("Initialization failed: "+err.Error(), 0)
} }
case "tweak": case "tweak":
choice := front.InputMenuGen("Action:", []string{"Set text editor", "Change device ID", "Age all entries", "Verify all entries", "Change master password/Optimize entries"}) choice := front.InputMenuGen("Action:", []string{"Change device ID", "Change master password/Optimize entries", "Set text editor"})
switch choice { switch choice {
case 1: case 1:
newCfg := &config.CfgT{}
newClientSpecificCfg := map[string]any{"mutnTextEditor": getTextEditorInput()}
newCfg.ClientSpecific = &newClientSpecificCfg
if err := config.Write(newCfg, true); err != nil {
other.PrintError("Failed to set text editor: "+err.Error(), back.ErrorWrite)
}
case 2:
oldDeviceID, err := global.GetCurrentDeviceID() oldDeviceID, err := global.GetCurrentDeviceID()
if err != nil { if err != nil {
other.PrintError("Failed to get current device ID: "+err.Error(), back.ErrorRead) other.PrintError("Failed to get current device ID: "+err.Error(), back.ErrorRead)
} }
_, _, _, err = syncclient.GenDeviceID(oldDeviceID, "") _, _, err = synccycles.DeviceIDGen(oldDeviceID, "")
if err != nil { if err != nil {
other.PrintError("Failed to change device ID: "+err.Error(), global.ErrorSyncProcess) other.PrintError("Failed to change device ID: "+err.Error(), global.ErrorSyncProcess)
} }
fmt.Println("\nDevice ID changed successfully.") fmt.Println("\nDevice ID changed successfully.")
case 3: case 2:
forceReage := front.InputBinary("Re-age aged entries?")
fmt.Println("Aging entries; please wait - do not terminate this process")
if err := age.AllPasswordEntries(forceReage, nil); err != nil {
other.PrintError("Failed to age entries: "+err.Error(), 1)
}
case 4:
verifyEntries(front.InputSecret("RCW Password:"))
case 5:
oldPassword := confirmRCWPassword("old") oldPassword := confirmRCWPassword("old")
newPassword := confirmRCWPassword("new") newPassword := confirmRCWPassword("new")
fmt.Print("Re-encrypting entries; please wait - do not terminate this process.\n\n") fmt.Print("\nRe-encrypting entries. Please wait; do not force close this process.\n")
if err := core.EntryRefresh(oldPassword, newPassword, false); err != nil { err := core.EntryRefresh(oldPassword, newPassword, false)
if err != nil {
other.PrintError("Re-encryption failed: "+err.Error(), global.ErrorEncryption) other.PrintError("Re-encryption failed: "+err.Error(), global.ErrorEncryption)
} }
if front.InputBinary("\nRe-encryption complete. Verify new entries (recommended)?") { fmt.Println("\nRe-encryption complete.")
verifyEntries(newPassword) case 3:
err := cfg.WriteConfig([][3]string{{"MUTN", "textEditor", getTextEditorInput()}}, nil, true)
if err != nil {
other.PrintError("Failed to set text editor: "+err.Error(), back.ErrorWrite)
} }
} }
case "copy": case "copy":
@@ -264,8 +250,8 @@ func main() {
func confirmRCWPassword(lowercasePrefix string) []byte { func confirmRCWPassword(lowercasePrefix string) []byte {
for { for {
// get master password // get master password
rcwPass := front.InputSecret(strings.ToUpper(string(lowercasePrefix[0])) + lowercasePrefix[1:] + " master password:") rcwPass := front.InputHidden(strings.ToUpper(string(lowercasePrefix[0])) + lowercasePrefix[1:] + " master password:")
if !bytes.Equal(rcwPass, front.InputSecret("Confirm "+lowercasePrefix+" master password:")) || len(rcwPass) == 0 { if !bytes.Equal(rcwPass, front.InputHidden("Confirm "+lowercasePrefix+" master password:")) || len(rcwPass) == 0 {
fmt.Println(back.AnsiError + "Passwords do not match or password is invalid" + back.AnsiReset) fmt.Println(back.AnsiError + "Passwords do not match or password is invalid" + back.AnsiReset)
continue continue
} }
@@ -277,13 +263,3 @@ func confirmRCWPassword(lowercasePrefix string) []byte {
func getTextEditorInput() string { func getTextEditorInput() string {
return cmp.Or(front.Input("Text editor (leave blank for $EDITOR, falls back to \""+cli.FallbackEditor+"\"):"), os.Getenv("EDITOR"), cli.FallbackEditor) return cmp.Or(front.Input("Text editor (leave blank for $EDITOR, falls back to \""+cli.FallbackEditor+"\"):"), os.Getenv("EDITOR"), cli.FallbackEditor)
} }
// verifyEntries attempts to verify all entries using the provided RCW password.
func verifyEntries(rcwPassword []byte) {
fmt.Println("Verification in progress; please wait - do not terminate this process...")
err := core.VerifyEntries(rcwPassword)
if err != nil {
other.PrintError("Verification failed: "+err.Error(), global.ErrorDecryption)
}
fmt.Println("Verification successful")
}
+1 -1
View File
@@ -3,7 +3,7 @@
# get version number from cli package # get version number from cli package
cd .. cd ..
printf 'package main\nimport (\n"fmt"\n"github.com/rwinkhart/MUTN/src/cli"\n)\nfunc main() {\nfmt.Println(cli.MUTNVersion)\n}' > ./version.go printf 'package main\nimport (\n"fmt"\n"github.com/rwinkhart/MUTN/src/cli"\n)\nfunc main() {\nfmt.Println(cli.MUTNVersion)\n}' > ./version.go
version=$(go run -ldflags="-s -w" ./version.go) version=$(go run -ldflags="-s -w" -tags noMarkdown ./version.go)
rm ./version.go rm ./version.go
cd ./packaging cd ./packaging
@@ -11,7 +11,7 @@ pkgrel="$((revision-1))"
pkgdesc='A simple, self-hosted, SSH-synchronized password/note manager for the CLI (based on libmutton)' pkgdesc='A simple, self-hosted, SSH-synchronized password/note manager for the CLI (based on libmutton)'
arch='all' arch='all'
url='https://github.com/rwinkhart/MUTN' url='https://github.com/rwinkhart/MUTN'
license='AGPL-3.0-only' license='MIT'
depends='' depends=''
makedepends='busybox go' makedepends='busybox go'
options='!check net' options='!check net'
@@ -41,7 +41,7 @@ build() {
esac esac
# compile binary # compile binary
CGO_ENABLED=0 go build -ldflags=\"-s -w\" -trimpath -tags=stxAll ./mutn.go CGO_ENABLED=1 go build -ldflags=\"-s -w\" -trimpath -tags=stxAll ./mutn.go
} }
package() { package() {
@@ -10,7 +10,7 @@ pkgrel="$revision"
pkgdesc='A simple, self-hosted, SSH-synchronized password/note manager for the CLI (based on libmutton)' pkgdesc='A simple, self-hosted, SSH-synchronized password/note manager for the CLI (based on libmutton)'
arch=('x86_64' 'i686' 'i486' 'pentium4' 'aarch64' 'armv7h' 'riscv64') arch=('x86_64' 'i686' 'i486' 'pentium4' 'aarch64' 'armv7h' 'riscv64')
url='https://github.com/rwinkhart/MUTN' url='https://github.com/rwinkhart/MUTN'
license=('AGPL-3.0-only') license=('MIT')
depends=() depends=()
makedepends=(go grep gzip) makedepends=(go grep gzip)
optdepends=( optdepends=(
@@ -45,7 +45,7 @@ build() {
esac esac
# compile binary # compile binary
CGO_ENABLED=0 go build -ldflags=\"-s -w\" -trimpath -tags=stxAll ./mutn.go CGO_ENABLED=1 go build -ldflags=\"-s -w\" -trimpath -tags=stxAll ./mutn.go
} }
package() { package() {
+3 -3
View File
@@ -10,9 +10,9 @@ create_release_binaries() {
printf '\nGenerating release binaries...\n' printf '\nGenerating release binaries...\n'
cd .. cd ..
GOOS=linux CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/mutn-linux-x86_64_v2 -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go GOOS=linux CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/mutn-linux-x86_64_v2 -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go
GOOS=linux CGO_ENABLED=0 GOARCH=arm64 GOARM64=v8.0 CGO_ENABLED=0 go build -o ./packaging/1output/mutn-linux-arm64v8.0 -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go GOOS=linux GOARCH=arm64 GOARM64=v8.0 CGO_ENABLED=0 go build -o ./packaging/1output/mutn-linux-arm64v8.0 -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go
GOOS=linux CGO_ENABLED=0 GOARCH=arm64 GOARM64=v8.7,crypto CGO_ENABLED=0 go build -o ./packaging/1output/mutn-linux-arm64v8.7+crypto -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go GOOS=linux GOARCH=arm64 GOARM64=v8.7,crypto CGO_ENABLED=0 go build -o ./packaging/1output/mutn-linux-arm64v8.7+crypto -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go
GOOS=windows CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/mutn-windows-x86_64_v2.exe -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go GOOS=windows CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/mutn-windows-x86_64_v2.exe -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go
GOOS=windows CGO_ENABLED=0 GOARCH=arm64 GOARM64=v8.7,crypto CGO_ENABLED=0 go build -o ./packaging/1output/mutn-windows-arm64v8.7+crypto.exe -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go GOOS=windows GOARCH=arm64 GOARM64=v8.7,crypto CGO_ENABLED=0 go build -o ./packaging/1output/mutn-windows-arm64v8.7+crypto.exe -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go
cd ./packaging cd ./packaging
} }
+2 -2
View File
@@ -13,11 +13,11 @@ var (
) )
const ( const (
MUTNVersion = "0.4.0" // untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.B.0" -> "0.2.0", "0.2.A" -> "0.2.1" MUTNVersion = "0.3.1" // untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.2.A" -> "0.2.0", "0.2.B" -> "0.2.1"
ansiBlackOnWhite = "\033[38;5;0;48;5;15m" ansiBlackOnWhite = "\033[38;5;0;48;5;15m"
) )
func init() { func init() {
global.GetPassword = front.InputSecret global.GetPassword = front.InputHidden
} }
+12 -16
View File
@@ -7,45 +7,41 @@ import (
"github.com/rwinkhart/libmutton/core" "github.com/rwinkhart/libmutton/core"
) )
// AddEntry creates a new entry at realPath by taking user input via CLI prompts. // AddEntry creates a new entry at targetLocation by taking user input via CLI prompts.
// Requires: entryType (0 = standard password entry, 1 = auto-generated password entry, 2 = note-only entry). // Requires: entryType (0 = standard password entry, 1 = auto-generated password entry, 2 = note-only entry).
func AddEntry(realPath string, entryType uint8) { func AddEntry(targetLocation string, hideSecrets bool, entryType uint8) {
// ensure realPath is valid // ensure targetLocation is valid
_, err := core.EntryAddPrecheck(realPath) _, err := core.EntryAddPrecheck(targetLocation)
if err != nil { if err != nil {
other.PrintError("Failed to add entry: "+err.Error(), back.ErrorWrite) other.PrintError("Failed to add entry: "+err.Error(), back.ErrorWrite)
} }
var decSlice []string var decryptedEntry []string
var password []byte
if entryType < 2 { if entryType < 2 {
username := front.Input("Username:") username := front.Input("Username:")
// determine whether to generate the password // determine whether to generate the password
var password string
if entryType == 0 { if entryType == 0 {
password = front.InputSecret("Password:") password = string(front.InputHidden("Password:"))
} else { } else {
password = inputPasswordGen() password = inputPasswordGen()
} }
totp := string(front.InputSecret("TOTP secret:")) totp := string(front.InputHidden("TOTP secret:"))
url := front.Input("URL:") url := front.Input("URL:")
if front.InputBinary("Add a note to this entry?") { if front.InputBinary("Add a note to this entry?") {
note, _ := editNote([]string{}) note, _ := editNote([]string{})
decSlice = append([]string{string(password), username, totp, url}, note...) decryptedEntry = append([]string{password, username, totp, url}, note...)
} else { } else {
decSlice = []string{string(password), username, totp, url} decryptedEntry = []string{password, username, totp, url}
} }
} else { } else {
note, _ := editNote([]string{}) note, _ := editNote([]string{})
decSlice = append([]string{"", "", "", ""}, note...) decryptedEntry = append([]string{"", "", "", ""}, note...)
} }
// write and preview the new entry // write and preview the new entry
if password != nil { writeEntryCLI(targetLocation, decryptedEntry, hideSecrets)
writeEntryCLI(realPath, decSlice, true, nil)
} else {
writeEntryCLI(realPath, decSlice, false, nil)
}
} }
-138
View File
@@ -1,138 +0,0 @@
package cli
import (
"fmt"
"os"
"os/signal"
"slices"
"syscall"
"time"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/front"
"github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/clip"
"github.com/rwinkhart/libmutton/crypt"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/syncclient"
)
// CopyMenu decrypts an entry and allows the user to interactively copy
// fields without having to re-decrypt each time. decSlice can be left nil
// to decrypt the entry specified by vanityPath. If the entry was provided
// decrypted, CopyMenu assumes it was edited and triggers a sync.
func CopyMenu(vanityPath string, decSlice []string, oldPassword []byte) {
realPath := global.GetRealPath(vanityPath)
var err error
if decSlice == nil {
// decrypt entry
decSlice, err = crypt.DecryptFileToSlice(realPath, nil)
if err != nil {
other.PrintError("Failed to decrypt entry: "+err.Error(), global.ErrorDecryption)
}
} else {
fmt.Print("\n")
_, err := syncclient.RunJob()
if err != nil {
other.PrintError("Failed to sync on copy menu entry: "+err.Error(), global.ErrorSyncProcess)
}
}
// determine populated fields in entry
var fieldStrings = []string{"Username", "Password", "TOTP Code", "URL", "Note (first line)"}
var indices = []int{1, 0, 2, 3, 4}
var fieldOptions []string
for i := range indices {
if len(decSlice) > indices[i] && decSlice[indices[i]] != "" {
fieldOptions = append(fieldOptions, fieldStrings[i])
if indices[i] == 0 && oldPassword != nil {
fieldOptions = append(fieldOptions, "Old Password")
}
}
}
// if notes are included, preview them
if len(decSlice) > 4 {
EntryReader(vanityPath, append([]string{"", "", "", ""}, decSlice[4:]...), true)
if len(fieldOptions) < 1 { // if notes are the only thing included, exit
fmt.Printf("\r%sNo copyable fields present, exiting copy menu...%s\n", back.AnsiWarning, back.AnsiReset)
os.Exit(0)
}
}
// set up signal handling for ctrl+c to clear clipboard
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
if err = clip.ClearProcess(nil); err != nil {
other.PrintError("Failed to clear clipboard on exit: "+err.Error(), global.ErrorClipboard)
}
os.Exit(0)
}()
// set up timer to close copy menu if an item is
// not selected (useful to avoid extra user input
// when the copy menu is displayed automatically
// and is not desired)
const timeoutSeconds uint8 = 5
selectedChan := make(chan bool, 1)
go func() {
for i := uint8(1); i <= timeoutSeconds; i++ {
select {
case <-selectedChan:
return
case <-time.After(1 * time.Second):
if i == timeoutSeconds {
fmt.Printf("\r%sNo field selected, exiting copy menu...%s\n", back.AnsiWarning, back.AnsiReset)
os.Exit(0)
}
fmt.Printf("\rField to copy (exiting in %d seconds): ", timeoutSeconds-i)
}
}
}()
// copy selected field to clipboard
var choice int
var selectedField string
for {
fmt.Println()
if selectedField != "" {
choice = front.InputMenuGen("Field to copy:", fieldOptions)
} else {
choice = front.InputMenuGen("Field to copy (exiting in 5 seconds):", fieldOptions)
selectedChan <- true
}
selectedField = fieldOptions[choice-1]
if selectedField == "Old Password" {
if err = clip.CopyBytes(false, oldPassword); err != nil {
other.PrintError("Failed to copy old password to clipboard: "+err.Error(), global.ErrorClipboard)
}
continue
}
choice = indices[slices.Index(fieldStrings, selectedField)]
if choice == 2 {
fmt.Println(back.AnsiWarning + "[Starting]" + back.AnsiReset + " TOTP clipboard refresher")
errorChan := make(chan error, 1)
done := make(chan bool)
go clip.TOTPCopier(decSlice[2], errorChan, done)
// block until first successful copy
if err = <-errorChan; err != nil {
other.PrintError("Failed to copy TOTP token: "+err.Error(), global.ErrorClipboard)
}
// block until the user sends "q"
for {
input := front.Input("Service will run until 'q' is entered:")
if input == "q" {
break
}
}
close(done)
fmt.Println(back.AnsiBlue + "[Stopped]" + back.AnsiReset + " TOTP clipboard refresher")
} else {
if err = clip.CopyBytes(false, []byte(decSlice[choice])); err != nil {
other.PrintError("Failed to copy field to clipboard: "+err.Error(), global.ErrorClipboard)
}
}
}
}
+35 -38
View File
@@ -9,77 +9,71 @@ import (
"github.com/rwinkhart/go-boilerplate/back" "github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/front" "github.com/rwinkhart/go-boilerplate/front"
"github.com/rwinkhart/go-boilerplate/other" "github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/config" "github.com/rwinkhart/libmutton/cfg"
"github.com/rwinkhart/libmutton/core" "github.com/rwinkhart/libmutton/core"
"github.com/rwinkhart/libmutton/syncclient" "github.com/rwinkhart/libmutton/syncclient"
) )
// RenameCli renames an entry at oldLocationIncomplete to a new location (user input) on both the client and the server. // RenameCli renames an entry at oldLocationIncomplete to a new location (user input) on both the client and the server.
func RenameCli(oldVanityPath string) { func RenameCli(oldLocationIncomplete string) {
// prompt user for new location and rename // prompt user for new location and rename
newVanityPath := front.Input("New location:") newLocationIncomplete := front.Input("New location:")
if err := syncclient.RenameRemote(oldVanityPath, newVanityPath); err != nil { err := syncclient.RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete)
if err != nil {
other.PrintError("Failed to rename entry: "+err.Error(), back.ErrorWrite) other.PrintError("Failed to rename entry: "+err.Error(), back.ErrorWrite)
} }
// exit is done from sync.RenameRemote // exit is done from sync.RenameRemoteFromClient
} }
// EditEntryField edits a field of an entry at realPath (user input). // EditEntryField edits a field of an entry at targetLocation (user input).
func EditEntryField(realPath string, field int) { func EditEntryField(targetLocation string, hideSecrets bool, field int) {
// fetch old entry data (with all required lines present) // fetch old entry data (with all required lines present)
decSlice, err := core.GetOldEntryData(realPath, field, nil) decryptedEntry, err := core.GetOldEntryData(targetLocation, field)
if err != nil { if err != nil {
other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead) other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead)
} }
// edit the field // edit the field
var oldPassword []byte
switch field { switch field {
case 0: case 0:
oldPassword = []byte(decSlice[field]) decryptedEntry[field] = string(front.InputHidden("Password:"))
decSlice[field] = string(front.InputSecret("Password:"))
case 1: case 1:
decSlice[field] = front.Input("Username:") decryptedEntry[field] = front.Input("Username:")
case 2: case 2:
decSlice[field] = string(front.InputSecret("TOTP secret:")) decryptedEntry[field] = string(front.InputHidden("TOTP secret:"))
case 3: case 3:
decSlice[field] = front.Input("URL:") decryptedEntry[field] = front.Input("URL:")
case 4: // edit notes fields case 4: // edit notes fields
// store note and non-note data separately // store note and non-note data separately
fieldsMain := decSlice[:4] fieldsMain := decryptedEntry[:4]
fieldsNote := decSlice[4:] fieldsNote := decryptedEntry[4:]
// edit the note // edit the note
editedNote, noteEdited := editNote(fieldsNote) editedNote, noteEdited := editNote(fieldsNote)
if !noteEdited { // exit early if the note was not edited if !noteEdited { // exit early if the note was not edited
other.PrintError("Entry is unchanged", 0) other.PrintError("Entry is unchanged", 0)
} }
decSlice = append(fieldsMain, editedNote...) decryptedEntry = append(fieldsMain, editedNote...)
} }
// write and preview the modified entry // write and preview the modified entry
if field == 0 { writeEntryCLI(targetLocation, decryptedEntry, hideSecrets)
writeEntryCLI(realPath, decSlice, true, oldPassword)
} else {
writeEntryCLI(realPath, decSlice, false, oldPassword)
}
} }
// GenUpdate generates a new password for an entry at realPath (user input). // GenUpdate generates a new password for an entry at targetLocation (user input).
func GenUpdate(realPath string) { func GenUpdate(targetLocation string, hideSecrets bool) {
// fetch old entry data // fetch old entry data
decSlice, err := core.GetOldEntryData(realPath, 0, nil) decryptedEntry, err := core.GetOldEntryData(targetLocation, 0)
if err != nil { if err != nil {
other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead) other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead)
} }
// generate a new password // generate a new password
oldPassword := []byte(decSlice[0]) decryptedEntry[0] = inputPasswordGen()
decSlice[0] = string(inputPasswordGen())
// write and preview the modified entry // write and preview the modified entry
writeEntryCLI(realPath, decSlice, true, oldPassword) writeEntryCLI(targetLocation, decryptedEntry, hideSecrets)
} }
// editNote uses the user-specified text editor to edit an existing note (or create a new one if baseNote is empty). // editNote uses the user-specified text editor to edit an existing note (or create a new one if baseNote is empty).
@@ -94,16 +88,11 @@ func editNote(baseNote []string) ([]string, bool) {
}(tempFile.Name()) }(tempFile.Name())
// fetch the user's text editor // fetch the user's text editor
cfg, err := config.Load() editorCfg, err := cfg.ParseConfig([][2]string{{"MUTN", "textEditor"}})
if err != nil { if err != nil {
other.PrintError("Failed to load libmuttoncfg.json: "+err.Error(), back.ErrorRead) other.PrintError("Failed to retrieve text editor from libmutton.ini\n\nPlease specify one with \"mutn tweak\"", back.ErrorRead)
}
var editor string
if value, exists := (*cfg.ClientSpecific)["mutnTextEditor"]; !exists {
other.PrintError("Failed to retrieve text editor from libmuttoncfg.json\n\nPlease specify one with \"mutn tweak\"", back.ErrorRead)
} else {
editor = value.(string)
} }
editor := editorCfg[0]
// write baseNote to tempFile (if it is not empty) // write baseNote to tempFile (if it is not empty)
baseNoteLen := len(baseNote) baseNoteLen := len(baseNote)
@@ -130,7 +119,8 @@ func editNote(baseNote []string) ([]string, bool) {
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
if err = cmd.Run(); err != nil { err = cmd.Run()
if err != nil {
panic(back.AnsiError + "Failed to write note with " + editor + back.AnsiReset) // panic is used to ensure the tempFile is removed, as per the defer statement panic(back.AnsiError + "Failed to write note with " + editor + back.AnsiReset) // panic is used to ensure the tempFile is removed, as per the defer statement
} }
@@ -150,6 +140,12 @@ func editNote(baseNote []string) ([]string, bool) {
// close tempFile // close tempFile
_ = tempFile.Close() // error ignored; if the file could be opened, it can probably be closed _ = tempFile.Close() // error ignored; if the file could be opened, it can probably be closed
// remove trailing empty strings from the edited note
note = back.RemoveTrailingEmptyStrings(note)
// clamp trailing whitespace in each note line
core.ClampTrailingWhitespace(note)
// return the edited note if: // return the edited note if:
// it is different from baseNote AND // it is different from baseNote AND
// [it is not empty (prevent needless writes when adding blank notes) OR // [it is not empty (prevent needless writes when adding blank notes) OR
@@ -157,6 +153,7 @@ func editNote(baseNote []string) ([]string, bool) {
noteLen := len(note) noteLen := len(note)
if !reflect.DeepEqual(note, baseNote) && (noteLen > 0 || baseNoteLen != noteLen) { if !reflect.DeepEqual(note, baseNote) && (noteLen > 0 || baseNoteLen != noteLen) {
return note, true return note, true
} else {
return nil, false
} }
return nil, false
} }
+103 -124
View File
@@ -3,179 +3,158 @@ package cli
import ( import (
"fmt" "fmt"
"os" "os"
"sort"
"strings" "strings"
"github.com/rwinkhart/go-boilerplate/back" "github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/other" "github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/age"
"github.com/rwinkhart/libmutton/synccommon" "github.com/rwinkhart/libmutton/synccommon"
) )
// ANSI color constants used only in this file // ANSI color constants used only in this file
const ( const (
ansiAlternateEntryColor = "\033[38;5;8m" ansiAlternateEntryColor = "\033[38;5;8m"
ansiDirectoryHeader = "\033[38;5;7;48;5;8m" ansiDirectoryHeader = "\033[38;5;7;48;5;8m"
ansiEmptyDirectoryWarning = "\033[38;5;3m"
) )
func writeIndent(sb *strings.Builder, indent int) { sb.WriteString(strings.Repeat(" ", indent*2)) } // determineIndentation calculates and returns the final visual indentation multiplier (needed to adjust indentation for skipped parent directories); also subtracts "old" text from directory header.
// determineIndentation calculates the final visual indentation multiplier (adjusts for skipped parents)
// and trims the parent directory name from the directory header (if applicable).
func determineIndentation(skippedDirList []bool, dirList []string, currentDirIndex int) (int, string) { func determineIndentation(skippedDirList []bool, dirList []string, currentDirIndex int) (int, string) {
trimmed := dirList[currentDirIndex] var subtractor int // tracks how much to subtract from expected indentation multiplier
indent := strings.Count(trimmed, "/") - 1 // avoid indenting root-level dirs var lastPrefixIndex int // tracks the index (in both skippedDirList and dirList) of the last displayed parent directory
var trimmedDirectory = dirList[currentDirIndex]
subtractor, lastPrefixIndex := 0, 0 // determine initial indentation multiplier based on "/" occurrences
for i, skipped := range skippedDirList[:currentDirIndex] { indent := strings.Count(trimmedDirectory, "/") - 1 // subtract 1 to avoid indenting root-level directories
if strings.HasPrefix(trimmed, dirList[i]+"/") {
if skipped { for i, skipped := range skippedDirList[:currentDirIndex] { // checks each skipped directory to determine if it is a parent to the current directory
subtractor++ if strings.HasPrefix(trimmedDirectory, dirList[i]+"/") { // if the current directory is the child of this iteration's directory...
if skipped { // ...and this iteration's directory was skipped...
subtractor++ // increment the subtractor to indicate that the visual indentation should be reduced
} else { } else {
lastPrefixIndex = i lastPrefixIndex = i
} }
} }
} }
if indent -= subtractor; indent < 0 {
return 0, trimmed indent = indent - subtractor // calculates final visual indentation multiplier
if indent < 0 { // disallow negative indentation multipliers
indent = 0
} else if indent > 0 { // trim the most recently displayed parent directory from the directory header to avoid displaying redundant information
trimmedDirectory = strings.Replace(trimmedDirectory, dirList[lastPrefixIndex], "", 1)
} }
if indent > 0 {
trimmed = strings.TrimPrefix(trimmed, dirList[lastPrefixIndex]) return indent, trimmedDirectory
}
return indent, trimmed
} }
// printFileEntry handles processing for printing file entries (determines color, adds aging indicator, wraps lines, and prints). // printFileEntry handles processing for printing file entries (determines color, wraps lines, and prints).
func printFileEntry(sb *strings.Builder, entry string, lastSlash int, charCounter *int, indent int, colorAlternator int8, agingTimestamp *int64) int8 { func printFileEntry(entry string, lastSlash, charCounter, indent int, colorAlternator int8) (int, int8) {
colorCode := "" // determine color to print fileEntryName (alternate each time function is run)
if colorAlternator <= 0 { var colorCode string
if colorAlternator > 0 {
colorCode = ""
} else {
colorCode = ansiAlternateEntryColor colorCode = ansiAlternateEntryColor
} }
colorAlternator = -colorAlternator colorAlternator = -colorAlternator
agingDot := "" // trim the containing directory from the entry to determine fileEntryName
switch age.TranslateAgeTimestamp(agingTimestamp) { fileEntryName := entry[lastSlash:]
case 1:
agingDot = back.AnsiGreen + "⁍" + back.AnsiReset if charCounter == 0 { // indent first line of entries for each directory header
case 2: fmt.Print(strings.Repeat(" ", indent*2))
agingDot = back.AnsiWarning + "⁍" + back.AnsiReset
case 3:
agingDot = back.AnsiError + "⁍" + back.AnsiReset
} }
name := entry[lastSlash:] // determine whether to wrap to a new line (+1 is to account for trailing spaces)
if *charCounter == 0 { charCounter += len(fileEntryName) + 1
writeIndent(sb, indent) if indentation := indent * 2; charCounter+(indentation) >= width {
charCounter = len(fileEntryName) + 1
fmt.Print("\n" + strings.Repeat(" ", indentation)) // indent each line
} }
// wrap if needed; +1 accounts for trailing space, +1 more if aging dot is present // print fileEntryName to screen
inc := len(name) + 1 fmt.Printf("%s%s%s ", colorCode, fileEntryName, back.AnsiReset)
if agingDot != "" {
inc++
}
if *charCounter += inc; *charCounter+indent*2 >= width {
*charCounter = len(name) + 1
if agingDot != "" {
*charCounter++
}
sb.WriteString("\n")
writeIndent(sb, indent)
}
sb.WriteString(agingDot) return charCounter, colorAlternator
sb.WriteString(colorCode)
sb.WriteString(name)
sb.WriteString(back.AnsiReset)
sb.WriteString(" ")
return colorAlternator
} }
// EntryListGen generates and displays the full libmutton entry list. // EntryListGen generates and displays the full libmutton entry list.
func EntryListGen() { func EntryListGen() {
_, dirList, err := synccommon.WalkEntryDir() fileList, dirList, err := synccommon.WalkEntryDir()
if err != nil { if err != nil {
other.PrintError("Failed to generate entry list: "+err.Error(), back.ErrorRead) other.PrintError("Failed to generate entry list: "+err.Error(), back.ErrorRead)
} }
entryMap, err := synccommon.GetAllEntryData()
if err != nil {
other.PrintError("Failed to retrieve entry aging data: "+err.Error(), back.ErrorRead)
}
var sb strings.Builder
// print header bar w/total entry count // print header bar w/total entry count
sb.WriteString("\n") fmt.Print("\n"+ansiBlackOnWhite, len(fileList), " libmutton entries:"+back.AnsiReset)
sb.WriteString(ansiBlackOnWhite)
_, _ = fmt.Fprint(&sb, len(entryMap))
sb.WriteString(" libmutton entries:")
sb.WriteString(back.AnsiReset)
entriesByDir := make(map[string][]string, len(dirList))
for vanityPath := range entryMap {
if lastSlash := strings.LastIndex(vanityPath, "/"); lastSlash >= 0 {
entriesByDir[vanityPath[:lastSlash]] = append(entriesByDir[vanityPath[:lastSlash]], vanityPath)
}
}
for _, entries := range entriesByDir {
sort.Strings(entries)
}
// precompute whether each directory contains subdirectories;
// handle root-level child dirs by treating "" as their parent.
hasChildDir := make(map[string]bool, len(dirList))
for _, d := range dirList {
switch parentEnd := strings.LastIndex(d, "/"); {
case parentEnd > 0:
hasChildDir[d[:parentEnd]] = true
case parentEnd == -1:
hasChildDir[""] = true
}
}
dirListLength := len(dirList)
skippedDirList := make([]bool, dirListLength)
charCounter := 0
var colorAlternator int8 = 1
printDirHeader := func(i int) int {
skippedDirList[i] = false
indent, vanityDirectory := determineIndentation(skippedDirList, dirList, i)
sb.WriteString("\n\n")
writeIndent(&sb, indent)
sb.WriteString(ansiDirectoryHeader)
sb.WriteString(vanityDirectory)
sb.WriteString("/" + back.AnsiReset + "\n")
return indent
}
// dirList iteration
dirListLength := len(dirList) // save length for multiple references below
var skippedDirList = make([]bool, dirListLength) // stores whether each directory was skipped during printout (later used to determine appropriate visual indentation)
charCounter := 0 // track whether to line-wrap based on character count in line
var colorAlternator int8 = 1 // track alternating colors for each printed entry name
var containsSubdirectory bool // indicates whether the current directory contains a subdirectory
var indent int // visual indentation multiplier
var vanityDirectory string // directory header printed to end-user - visual only, not used in any processing
for i, directory := range dirList { for i, directory := range dirList {
charCounter, colorAlternator = 0, 1
skippedDirList[i] = i != 0
if entries := entriesByDir[directory]; len(entries) > 0 { // reset formatting variables for new directory
indent := printDirHeader(i) charCounter = 0
for _, vanityPath := range entries { colorAlternator = 1
lastSlash := strings.LastIndex(vanityPath, "/") + 1
colorAlternator = printFileEntry(&sb, vanityPath, lastSlash, &charCounter, indent, colorAlternator, entryMap[vanityPath].AgeTimestamp) // default to assuming this directory will be skipped (unless it is the root)
if i == 0 {
skippedDirList[i] = false
} else {
skippedDirList[i] = true
}
// check if next directory is within the current one
if dirListLength > i+1 {
if nextDir := dirList[i+1]; directory == nextDir[:strings.LastIndex(nextDir, "/")] {
containsSubdirectory = true
} else {
containsSubdirectory = false
} }
continue } else {
containsSubdirectory = false
} }
if hasChildDir[directory] { // fileList iteration
continue containsFiles := false // indicates whether the current directory contains files (entries)
for _, file := range fileList {
// print the current file if it belongs in the current directory - otherwise, break the loop and move on to the next directory
if lastSlash := strings.LastIndex(file, "/") + 1; file[:lastSlash-1] == directory {
// print directory header if this is the first run of the loop
if !containsFiles {
containsFiles = true
skippedDirList[i] = false // the directory header is being printed, indicate that it is not being skipped
indent, vanityDirectory = determineIndentation(skippedDirList, dirList, i) // calculate the final indentation multiplier
fmt.Printf("\n\n"+strings.Repeat(" ", indent*2)+ansiDirectoryHeader+"%s/"+back.AnsiReset+"\n", vanityDirectory)
}
charCounter, colorAlternator = printFileEntry(file, lastSlash, charCounter, indent, colorAlternator)
}
} }
if dirListLength > 1 { if !containsFiles { // if the current directory contains no files...
indent := printDirHeader(i) if !containsSubdirectory { // nor does it contain any subdirectories...
writeIndent(&sb, indent) if dirListLength > 1 { // and directories besides the root-level exist... display directory header and empty directory warning
sb.WriteString(back.AnsiWarning + "-empty directory-" + back.AnsiReset) skippedDirList[i] = false // the directory header is being printed, indicate that it is not being skipped
continue indent, vanityDirectory = determineIndentation(skippedDirList, dirList, i) // calculate the final indentation multiplier
fmt.Printf("\n\n"+strings.Repeat(" ", indent*2)+ansiDirectoryHeader+"%s/"+back.AnsiReset+"\n", vanityDirectory)
fmt.Print(strings.Repeat(" ", indent*2) + ansiEmptyDirectoryWarning + "-empty directory-" + back.AnsiReset)
} else { // warn if the only thing that exists is the root-level directory
fmt.Print("\n\nNothing's here! For help creating your first entry, run \"mutn help\".")
}
}
} }
sb.WriteString("\n\nNothing's here! For help creating your first entry, run \"mutn help\".")
} }
sb.WriteString("\n\n") // print trailing new lines for proper spacing after entry list is complete
fmt.Print(sb.String()) fmt.Print("\n\n")
os.Exit(0) os.Exit(0)
} }
+37 -20
View File
@@ -2,6 +2,7 @@ package cli
import ( import (
"fmt" "fmt"
"os"
"strings" "strings"
"github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour"
@@ -9,53 +10,59 @@ import (
"github.com/rwinkhart/go-boilerplate/other" "github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/crypt" "github.com/rwinkhart/libmutton/crypt"
"github.com/rwinkhart/libmutton/global" "github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/syncclient"
) )
const ansiShownPassword = "\033[38;5;10m" const ansiShownPassword = "\033[38;5;10m"
// EntryReader prints the decrypted contents of a libmutton entry in a human-readable format. // EntryReader prints the decrypted contents of a libmutton entry in a human-readable format.
func EntryReader(vanityPath string, decSlice []string, hideSecrets bool) { func EntryReader(decryptedEntry []string, hideSecrets, syncEnabled bool) {
fmt.Print("\n" + back.AnsiBold + vanityPath + back.AnsiReset + "\n\n") fmt.Println()
fieldLoop: fieldLoop:
for field := range decSlice { for field := range decryptedEntry {
switch field { switch field {
case 0: case 0:
// if the first field (password) is not empty, print it // if the first field (password) is not empty, print it
if decSlice[0] != "" { if decryptedEntry[0] != "" {
if !hideSecrets { if !hideSecrets {
fmt.Print(ansiDirectoryHeader + "Password:" + back.AnsiReset + "\n" + ansiShownPassword + decSlice[0] + back.AnsiReset + "\n\n") fmt.Print(ansiDirectoryHeader + "Password:" + back.AnsiReset + "\n" + ansiShownPassword + decryptedEntry[0] + back.AnsiReset + "\n\n")
} else { } else {
fmt.Print(ansiDirectoryHeader + "Password:" + back.AnsiReset + "\n" + back.AnsiWarning + "End command in \"show\" or \"-s\" to view" + back.AnsiReset + "\n\n") fmt.Print(ansiDirectoryHeader + "Password:" + back.AnsiReset + "\n" + ansiEmptyDirectoryWarning + "End command in \"show\" or \"-s\" to view" + back.AnsiReset + "\n\n")
} }
} }
case 1: case 1:
// if the second field (username) is not empty, print it // if the second field (username) is not empty, print it
if decSlice[1] != "" { if decryptedEntry[1] != "" {
fmt.Print(ansiDirectoryHeader + "Username:" + back.AnsiReset + "\n" + decSlice[1] + "\n\n") fmt.Print(ansiDirectoryHeader + "Username:" + back.AnsiReset + "\n" + decryptedEntry[1] + "\n\n")
} }
case 2: case 2:
// if the third field (TOTP secret) is not empty, print it // if the third field (TOTP secret) is not empty, print it
if decSlice[2] != "" { if decryptedEntry[2] != "" {
if !hideSecrets { if !hideSecrets {
fmt.Print(ansiDirectoryHeader + "TOTP Secret:" + back.AnsiReset + "\n" + ansiShownPassword + decSlice[2] + back.AnsiReset + "\n\n") fmt.Print(ansiDirectoryHeader + "TOTP Secret:" + back.AnsiReset + "\n" + ansiShownPassword + decryptedEntry[2] + back.AnsiReset + "\n\n")
} else { } else {
fmt.Print(ansiDirectoryHeader + "TOTP Secret:" + back.AnsiReset + "\n" + back.AnsiWarning + "End command in \"show\" or \"-s\" to view" + back.AnsiReset + "\n\n") fmt.Print(ansiDirectoryHeader + "TOTP Secret:" + back.AnsiReset + "\n" + ansiEmptyDirectoryWarning + "End command in \"show\" or \"-s\" to view" + back.AnsiReset + "\n\n")
} }
} }
case 3: case 3:
// if the fourth field (url) is not empty, print it // if the fourth field (url) is not empty, print it
if decSlice[3] != "" { if decryptedEntry[3] != "" {
fmt.Print(ansiDirectoryHeader + "URL:" + back.AnsiReset + "\n" + decSlice[3] + "\n\n") fmt.Print(ansiDirectoryHeader + "URL:" + back.AnsiReset + "\n" + decryptedEntry[3] + "\n\n")
} }
case 4: case 4:
// print the notes header // print the notes header
fmt.Println(ansiDirectoryHeader + "Notes:" + back.AnsiReset) fmt.Println(ansiDirectoryHeader + "Notes:" + back.AnsiReset)
// combine remaining fields into a single string (to support Markdown rendering)
var noteLines []string
for ; field < len(decryptedEntry); field++ {
noteLines = append(noteLines, decryptedEntry[field])
}
// render notes as Markdown // render notes as Markdown
r, _ := glamour.NewTermRenderer(glamour.WithStylesFromJSONBytes(glamourStyle()), glamour.WithWordWrap(width)) r, _ := glamour.NewTermRenderer(glamour.WithStylesFromJSONBytes(glamourStyle()), glamour.WithPreservedNewLines(), glamour.WithWordWrap(width))
markdownNotesString, _ := r.Render(strings.Join(decSlice[4:], "\n")) markdownNotesString, _ := r.Render(strings.Join(noteLines, "\n"))
_ = r.Close()
// print the rendered Markdown notes // print the rendered Markdown notes
fmt.Print(markdownNotesString) fmt.Print(markdownNotesString)
@@ -64,17 +71,27 @@ fieldLoop:
break fieldLoop break fieldLoop
} }
} }
if syncEnabled {
_, err := syncclient.RunJob(false)
if err != nil {
other.PrintError("Failed to sync entries: "+err.Error(), global.ErrorSyncProcess)
}
}
os.Exit(0)
} }
// EntryReaderDecrypt is a wrapper for EntryReader that first decrypts an RCW-wrapped file before sending it to EntryReader. // EntryReaderDecrypt is a wrapper for EntryReader that first decrypts an RCW-wrapped file before sending it to EntryReader.
func EntryReaderDecrypt(realPath string, hideSecrets bool) { func EntryReaderDecrypt(targetLocation string, hideSecrets bool) {
_, err := back.TargetIsFile(realPath, true) _, err := back.TargetIsFile(targetLocation, true)
if err != nil { // if the location does not exist or is a directory... if err != nil { // if the location does not exist or is a directory...
other.PrintError("Failed to verify target location: "+err.Error(), back.ErrorTargetNotFound) other.PrintError("Failed to verify target location: "+err.Error(), back.ErrorTargetNotFound)
} }
decSlice, err := crypt.DecryptFileToSlice(realPath, nil) decSlice, err := crypt.DecryptFileToSlice(targetLocation)
if err != nil { if err != nil {
other.PrintError("Failed to decrypt entry: "+err.Error(), global.ErrorDecryption) other.PrintError("Failed to decrypt entry: "+err.Error(), global.ErrorDecryption)
} }
EntryReader(global.GetVanityPath(realPath), decSlice, hideSecrets) EntryReader(decSlice, hideSecrets, false) // never sync if decrypting straight to EntryReader, as this means the entry could not have been modified
// do not exit, as this is the job of EntryReader
} }
+7 -9
View File
@@ -15,7 +15,7 @@ const (
) )
func HelpMain() { func HelpMain() {
fmt.Print(back.AnsiBold + "\nMUTN | Copyright (c) 2024-2026 Randall Winkhart\n" + back.AnsiReset + ` fmt.Print(back.AnsiBold + "\nMUTN | Copyright (c) 2024-2025 Randall Winkhart\n" + back.AnsiReset + `
This software exists under the MIT license; you may redistribute it under certain conditions. This software exists under the MIT license; you may redistribute it under certain conditions.
This program comes with absolutely no warranty; type "mutn version" for details. This program comes with absolutely no warranty; type "mutn version" for details.
@@ -40,7 +40,6 @@ This program comes with absolutely no warranty; type "mutn version" for details.
totp|-t Generate and copy the TOTP token for an entry to your clipboard totp|-t Generate and copy the TOTP token for an entry to your clipboard
url|-l Copy the URL in an entry to your clipboard url|-l Copy the URL in an entry to your clipboard
note|-n Copy the first note line in an entry to your clipboard note|-n Copy the first note line in an entry to your clipboard
menu|-m Open interactive menu to copy any combination of fields
edit: edit:
password|-pw|<blank> Change the password in an entry password|-pw|<blank> Change the password in an entry
username|-u Change the username in an entry username|-u Change the username in an entry
@@ -56,9 +55,9 @@ This program comes with absolutely no warranty; type "mutn version" for details.
folder|-f Add a new folder for entries folder|-f Add a new folder for entries
` + back.AnsiBold + "Tip 1:" + back.AnsiReset + ` You can quickly read an entry with "mutn /<entry name>" ` + back.AnsiBold + "Tip 1:" + back.AnsiReset + ` You can quickly read an entry with "mutn /<entry name>"
` + back.AnsiBold + "Tip 2:" + back.AnsiReset + ` Using "add", "edit", or "copy" without specifying an option (field) will default to "password" ` + back.AnsiBold + "Tip 2:" + back.AnsiReset + ` Type "mutn" (no arguments/options) to view a list of saved entries
` + back.AnsiBold + "Tip 3:" + back.AnsiReset + ` Various changes can be made to the configuration and stored entries from the "mutn tweak" menu ` + back.AnsiBold + "Tip 3:" + back.AnsiReset + ` Provide "add", "edit", "copy", or "gen" as the only argument to receive more specific help
` + back.AnsiBold + "Tip 4:" + back.AnsiReset + " Type \"mutn\" (no arguments) to view a list of saved entries w/colored markers indicating the ages of stored passwords\n\n") ` + back.AnsiBold + "Tip 4:" + back.AnsiReset + " Using \"add\", \"edit\", or \"copy\" without specifying an option (field) will default to \"password\"\n\n")
os.Exit(0) os.Exit(0)
} }
@@ -96,8 +95,7 @@ func HelpCopy() {
username|-u Copy the username in an entry to your clipboard username|-u Copy the username in an entry to your clipboard
totp|-t Generate and copy the TOTP token for an entry to your clipboard totp|-t Generate and copy the TOTP token for an entry to your clipboard
url|-l Copy the URL in an entry to your clipboard url|-l Copy the URL in an entry to your clipboard
note|-n Copy the first note line in an entry to your clipboard note|-n Copy the first note line in an entry to your clipboard` + "\n\n")
menu|-m Open interactive menu to copy any combination of fields` + "\n\n")
os.Exit(0) os.Exit(0)
} }
@@ -152,11 +150,11 @@ func Version() {
ansiVersionOutline + "<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\n" + ansiVersionOutline + "<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\n" +
"\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" +
"\\" + ansiBlackOnWhite + " MUTN v" + MUTNVersion + " " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " MUTN v" + MUTNVersion + " " + back.AnsiReset + ansiVersionOutline + "/\n" +
"\\" + ansiBlackOnWhite + " The \"Hardy Herd\" Update " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " The Cryptic Cuts Update " + back.AnsiReset + ansiVersionOutline + "/\n" +
"\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" +
"\\" + ansiBlackOnWhite + " Built with libmutton v" + global.LibmuttonVersion + " " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " Built with libmutton v" + global.LibmuttonVersion + " " + back.AnsiReset + ansiVersionOutline + "/\n" +
"\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" +
"\\" + ansiBlackOnWhite + " Copyright (c) 2024-2026: Randall Winkhart " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " Copyright (c) 2024-2025: Randall Winkhart " + back.AnsiReset + ansiVersionOutline + "/\n" +
"\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" + "\\" + ansiBlackOnWhite + " " + back.AnsiReset + ansiVersionOutline + "/\n" +
"<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\n" + back.AnsiReset + "<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\n" + back.AnsiReset +
"\n For more information, see:\n\n" + "\n For more information, see:\n\n" +
+12 -8
View File
@@ -2,16 +2,17 @@ package cli
import ( import (
"fmt" "fmt"
"strings"
"github.com/rwinkhart/go-boilerplate/back" "github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/front" "github.com/rwinkhart/go-boilerplate/front"
"github.com/rwinkhart/go-boilerplate/other" "github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/go-boilerplate/security"
"github.com/rwinkhart/libmutton/core" "github.com/rwinkhart/libmutton/core"
"github.com/rwinkhart/libmutton/synccycles"
) )
// inputPasswordGen prompts the user for password generation parameters and returns a generated password as a string. // inputPasswordGen prompts the user for password generation parameters and returns a generated password as a string.
func inputPasswordGen() []byte { func inputPasswordGen() string {
passLength := front.InputInt("Password length:", 1, -1) passLength := front.InputInt("Password length:", 1, -1)
fmt.Println() fmt.Println()
passCharset := uint8(front.InputMenuGen("Password complexity:", []string{"Simple", "Complex", "Ultra Complex (not compatible with many services)"})) passCharset := uint8(front.InputMenuGen("Password complexity:", []string{"Simple", "Complex", "Ultra Complex (not compatible with many services)"}))
@@ -24,17 +25,20 @@ func inputPasswordGen() []byte {
complexity = 0.2 // (ultra) complex complexity = 0.2 // (ultra) complex
// 2 and 3 indicate complex and ultra complex charsets, respectively // 2 and 3 indicate complex and ultra complex charsets, respectively
} }
return security.BytesGen(passLength, complexity, passCharset) return synccycles.StringGen(passLength, complexity, passCharset)
} }
// writeEntryCLI writes decSlice to realPath and previews it (errors if no data is supplied). // writeEntryCLI writes an entry to targetLocation and previews it (errors if no data is supplied).
func writeEntryCLI(realPath string, decSlice []string, passwordIsNew bool, oldPassword []byte) { func writeEntryCLI(targetLocation string, decryptedEntry []string, hideSecrets bool) {
if core.EntryIsNotEmpty(decSlice) { if core.EntryIsNotEmpty(decryptedEntry) {
if err := core.WriteEntry(realPath, decSlice, passwordIsNew, nil); err != nil { // write the entry to the target location
err := core.WriteEntry(targetLocation, []byte(strings.Join(decryptedEntry, "\n")))
if err != nil {
other.PrintError("Failed to write entry: "+err.Error(), back.ErrorWrite) other.PrintError("Failed to write entry: "+err.Error(), back.ErrorWrite)
} }
// preview the entry // preview the entry
CopyMenu("", decSlice, oldPassword) fmt.Println(back.AnsiBold + "\nEntry Preview:" + back.AnsiReset)
EntryReader(decryptedEntry, hideSecrets, true)
} else { } else {
other.PrintError("No data supplied for entry", back.ErrorTargetNotFound) other.PrintError("No data supplied for entry", back.ErrorTargetNotFound)
} }
+1
View File
@@ -6,3 +6,4 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -tags=stxAll ./mutn.go
Additionally, some custom build tags can be used to create different binaries. The following tags are not used in official builds: Additionally, some custom build tags can be used to create different binaries. The following tags are not used in official builds:
- `wsl`: Allows creating a Linux binary that can interact with the Windows clipboard (for WSL) - `wsl`: Allows creating a Linux binary that can interact with the Windows clipboard (for WSL)
- `termux`: Allows creating a Linux binary that can interact with the Termux clipboard (for Android)
+1 -1
View File
@@ -14,4 +14,4 @@ Please see the [usage guide](https://github.com/rwinkhart/MUTN/blob/main/wiki/us
### Dependencies (required for all installations) ### Dependencies (required for all installations)
- A text editor (preferably CLI-based) for writing entry notes - A text editor (preferably CLI-based) for writing entry notes
- A private key for SSH key-based authentication is required to use MUTN/libmutton in online/synced mode - A private key for SSH key-based authentication is required to use MUTN/libmutton in online/synced mode
- Linux/BSD: xclip (X11) or wl-clipboard (Wayland) for clipboard support - Linux: xclip (X11) or wl-clipboard (Wayland) for clipboard support
-1
View File
@@ -15,7 +15,6 @@ Once this has been done, you may proceed with the client setup:
2. Run `mutn init` and follow the prompts 2. Run `mutn init` and follow the prompts
3. If you already have entries on the server, sync them using `mutn sync` 3. If you already have entries on the server, sync them using `mutn sync`
4. Optionally, shell completions (Bash, ZSH, and PowerShell 7+) can be [enabled with your shell's respective method](https://github.com/rwinkhart/MUTN/blob/main/wiki/completions.md) 4. Optionally, shell completions (Bash, ZSH, and PowerShell 7+) can be [enabled with your shell's respective method](https://github.com/rwinkhart/MUTN/blob/main/wiki/completions.md)
5. Optionally, [import passwords](https://github.com/rwinkhart/libmutton/blob/main/wiki/migration.md) from another password manager. After doing this, you may desire to use the "Age all entries" option from the `mutn tweak` menu to add initial age tracking data to the imported passwords
### Tips ### Tips
#### Operating on Passwords #### Operating on Passwords