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.exe
/MUTN
/MUTN.exe
/main
/main.exe
/version.go
1output
+1 -1
View File
@@ -1,5 +1,5 @@
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
a custom fork of https://github.com/jessp01/gohighlight.
+8 -2
View File
@@ -2,7 +2,7 @@
---
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]
>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).
# 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
- 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
- [ ] Create packaging scripts
- [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" )
;;
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 )
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 = @{
add = @('password', 'note', 'folder')
copy = @('password', 'username', 'totp', 'url', 'note', 'menu')
copy = @('password', 'username', 'totp', 'url', 'note')
edit = @('password', 'username', 'totp', 'url', 'note', 'rename')
gen = @('update')
}
+1 -1
View File
@@ -20,7 +20,7 @@ case ${words[-2]} in
compadd {password,note,folder}
;;
copy )
compadd {password,username,totp,url,note,menu}
compadd {password,username,totp,url,note}
;;
edit )
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
\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
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
url|-l Copy the URL 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:
password|-pw Change the password in an entry
@@ -84,11 +83,11 @@ add:
.SH TIPS
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
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
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
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
.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
go 1.26.0
go 1.25.4
require (
github.com/charmbracelet/glamour v0.7.0
github.com/rwinkhart/go-boilerplate v0.3.1
github.com/rwinkhart/libmutton v0.5.0
golang.org/x/term v0.40.0
github.com/rwinkhart/go-boilerplate v0.1.1-0.20251110055016-10ee4f91fcb6
github.com/rwinkhart/libmutton v0.4.4
golang.org/x/term v0.37.0
)
require (
@@ -14,12 +14,11 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.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/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-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/muesli/reflow v0.3.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/rivo/uniseg v0.4.7 // indirect
github.com/rwinkhart/go-highlite v0.1.1 // indirect
github.com/rwinkhart/peercred-mini v0.1.4 // indirect
github.com/rwinkhart/rcw v0.3.0 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/rwinkhart/peercred-mini v0.1.2 // indirect
github.com/rwinkhart/rcw v0.2.4 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.design/x/clipboard v0.7.1 // indirect
golang.org/x/crypto v0.45.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
)
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
+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.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
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.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
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/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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
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/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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/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/go-boilerplate v0.3.1 h1:vkVRuptO2s1yPzzwpvtiiB0c/hPB6yr0p1mzZ2Myb9E=
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 h1:tGGtw5Wr4laDrU7Ooe95g4E+O6c+oY18atamcMPaMY8=
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/go.mod h1:mWLMtCWcyV0BG4NeyPyAUGMjPvI0ds6vXmUq89QwBFA=
github.com/rwinkhart/go-winio v0.1.1 h1:kAJKiqneR7cUR01Wn5/doAAV4kOGTEGPug4oinXc5N4=
github.com/rwinkhart/go-winio v0.1.1/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0=
github.com/rwinkhart/libmutton v0.5.0 h1:Du/o5Hyb26uE6lubqBISN/ZF9JZ5oObHVveQjAqrP0E=
github.com/rwinkhart/libmutton v0.5.0/go.mod h1:peh8A1f3gnaF97m8J06U28JloKv04Kw8WtFCH+Pcq6s=
github.com/rwinkhart/peercred-mini v0.1.4 h1:93+phjLknvJadEd2cu/ZPPWdfRSPOwFJzDBEn4ZtWVc=
github.com/rwinkhart/peercred-mini v0.1.4/go.mod h1:E8eApo/izzmq4nGGR+kpJ0ZLAXjofnVRMGCeVMPQ/Ik=
github.com/rwinkhart/rcw v0.3.0 h1:OdgA++IGcDrZqN6FzyYnzbz04fL8Q1MHvlgPjhtgJM4=
github.com/rwinkhart/rcw v0.3.0/go.mod h1:UUY7kSku6kV2X1RWPUcK0Re+rQ6RvngWI8DVZJ11cqk=
github.com/rwinkhart/sys v0.41.0 h1:pHB6HphVC132UXYZ6yeOk62abqgl+pyl5ZZnsk4iUKg=
github.com/rwinkhart/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
github.com/rwinkhart/go-winio v0.1.0 h1:b72agLW+dETGmhR3VbcbwnStfgKfc5AfgJOXBJDkaHg=
github.com/rwinkhart/go-winio v0.1.0/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0=
github.com/rwinkhart/libmutton v0.4.4 h1:tUGU0JLf0aQ0YQy+1RSzOy5yujfpJ4b3iXuxAhUjhzE=
github.com/rwinkhart/libmutton v0.4.4/go.mod h1:2GV6ILW7dawIGvBoOcV0K3Uw7llc0ZWFRYcV+kM9K8g=
github.com/rwinkhart/peercred-mini v0.1.2 h1:4cGWDbv0whvLeVvbUdx84V/9p+2fS+DEXgrA1KxlRFo=
github.com/rwinkhart/peercred-mini v0.1.2/go.mod h1:LLHG7YshHEpbpJJP+Il9nx2dnGj5O3VGE32rWmflj0c=
github.com/rwinkhart/rcw v0.2.4 h1:FrRSsl+ui1WLmDwqjLSROtFPKqSeGLEfF2SPENt4qP0=
github.com/rwinkhart/rcw v0.2.4/go.mod h1:nhW4RAHvEjW2QcOlqEdWqRQOFJLjoOGSPkSRZlMjU3s=
github.com/rwinkhart/sys v0.38.0 h1:V1PGKcUutWtD3+VdHvyvfGtPy7PDXzKHNM1IzVq1ysY=
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/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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
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/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c=
golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp/shiny v0.0.0-20251017212417-90e834f514db h1:NmsmaSkEAq6A8r0Q78WxD0IygJNCs6J3uYDgNuPkXYM=
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/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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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/front"
"github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/age"
"github.com/rwinkhart/libmutton/clip"
"github.com/rwinkhart/libmutton/config"
"github.com/rwinkhart/libmutton/cfg"
"github.com/rwinkhart/libmutton/core"
"github.com/rwinkhart/libmutton/crypt"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/syncclient"
"github.com/rwinkhart/libmutton/synccycles"
)
func main() {
@@ -34,31 +33,33 @@ func main() {
// if the first argument is an entry...
if strings.HasPrefix(args[1], "/") {
// store realPath of target entry
realPath := global.GetRealPath(args[1])
// store location of target entry
targetLocation := global.TargetLocationFormat(args[1])
// entry reader shortcut (if no other arguments are supplied)
if argsCount == 2 {
cli.EntryReaderDecrypt(realPath, true)
cli.EntryReaderDecrypt(targetLocation, true)
// perform other operations on the entry (if other arguments are supplied)
} 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)
switch args[2] {
case "show", "-s":
cli.EntryReaderDecrypt(realPath, false)
cli.EntryReaderDecrypt(targetLocation, false)
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)
}
case "edit":
cli.EditEntryField(realPath, 0)
cli.EditEntryField(targetLocation, true, 0)
case "gen":
cli.AddEntry(realPath, 1)
cli.AddEntry(targetLocation, true, 1)
case "add":
cli.AddEntry(realPath, 0)
cli.AddEntry(targetLocation, true, 0)
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)
}
default:
@@ -67,11 +68,11 @@ func main() {
} else { // handle "show" or "-s" argument for gen, edit, and add
switch args[2] {
case "edit":
cli.EditEntryField(realPath, 0)
cli.EditEntryField(targetLocation, false, 0)
case "gen":
cli.AddEntry(realPath, 1)
cli.AddEntry(targetLocation, false, 1)
case "add":
cli.AddEntry(realPath, 0)
cli.AddEntry(targetLocation, false, 0)
default:
cli.HelpMain()
}
@@ -93,12 +94,11 @@ func main() {
field = 3
case "note", "-n":
field = 4
case "menu", "-m":
cli.CopyMenu(args[1], nil, nil)
default:
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)
}
case "edit":
@@ -115,38 +115,38 @@ func main() {
case "note", "-n":
field = 4
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 {
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
default:
cli.HelpEdit()
}
if argsCount == 4 {
cli.EditEntryField(realPath, field)
cli.EditEntryField(targetLocation, true, field)
} else {
switch args[4] {
case "show", "-s":
cli.EditEntryField(realPath, field)
cli.EditEntryField(targetLocation, false, field)
default:
cli.EditEntryField(realPath, field)
cli.EditEntryField(targetLocation, true, field)
}
}
case "gen":
if argsCount == 4 {
switch args[3] {
case "update", "-u":
cli.GenUpdate(realPath)
cli.GenUpdate(targetLocation, true)
default:
cli.HelpGen()
}
} else if args[3] == "update" || args[3] == "-u" {
switch args[4] {
case "show", "-s":
cli.GenUpdate(realPath)
cli.GenUpdate(targetLocation, false)
default:
cli.GenUpdate(realPath)
cli.GenUpdate(targetLocation, true)
}
}
cli.HelpGen()
@@ -154,19 +154,20 @@ func main() {
switch args[3] {
case "password", "-pw":
if argsCount == 4 {
cli.AddEntry(realPath, 0)
cli.AddEntry(targetLocation, true, 0)
} else {
switch args[4] {
case "show", "-s":
cli.AddEntry(realPath, 0)
cli.AddEntry(targetLocation, false, 0)
default:
cli.AddEntry(realPath, 0)
cli.AddEntry(targetLocation, true, 0)
}
}
case "note", "-n":
cli.AddEntry(realPath, 2)
cli.AddEntry(targetLocation, true, 2)
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)
}
default:
@@ -181,65 +182,50 @@ func main() {
} else {
switch args[1] {
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)
}
case "startrcwd":
crypt.RCWDArgument()
case "sync":
_, err := syncclient.RunJob()
_, err := syncclient.RunJob(true)
if err != nil {
other.PrintError("Failed to sync entries: "+err.Error(), global.ErrorSyncProcess)
}
case "init":
err := core.LibmuttonInit(
front.Input,
confirmRCWPassword("new"),
front.InputBinary("Preserve existing config directory?"),
false,
"",
map[string]any{"mutnTextEditor": getTextEditorInput()},
)
err := core.LibmuttonInit(front.Input,
[][3]string{{"MUTN", "textEditor", getTextEditorInput()}},
confirmRCWPassword("new"), false, false)
if err != nil {
other.PrintError("Initialization failed: "+err.Error(), 0)
}
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 {
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()
if err != nil {
other.PrintError("Failed to get current device ID: "+err.Error(), back.ErrorRead)
}
_, _, _, err = syncclient.GenDeviceID(oldDeviceID, "")
_, _, err = synccycles.DeviceIDGen(oldDeviceID, "")
if err != nil {
other.PrintError("Failed to change device ID: "+err.Error(), global.ErrorSyncProcess)
}
fmt.Println("\nDevice ID changed successfully.")
case 3:
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:
case 2:
oldPassword := confirmRCWPassword("old")
newPassword := confirmRCWPassword("new")
fmt.Print("Re-encrypting entries; please wait - do not terminate this process.\n\n")
if err := core.EntryRefresh(oldPassword, newPassword, false); err != nil {
fmt.Print("\nRe-encrypting entries. Please wait; do not force close this process.\n")
err := core.EntryRefresh(oldPassword, newPassword, false)
if err != nil {
other.PrintError("Re-encryption failed: "+err.Error(), global.ErrorEncryption)
}
if front.InputBinary("\nRe-encryption complete. Verify new entries (recommended)?") {
verifyEntries(newPassword)
fmt.Println("\nRe-encryption complete.")
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":
@@ -264,8 +250,8 @@ func main() {
func confirmRCWPassword(lowercasePrefix string) []byte {
for {
// get master password
rcwPass := front.InputSecret(strings.ToUpper(string(lowercasePrefix[0])) + lowercasePrefix[1:] + " master password:")
if !bytes.Equal(rcwPass, front.InputSecret("Confirm "+lowercasePrefix+" master password:")) || len(rcwPass) == 0 {
rcwPass := front.InputHidden(strings.ToUpper(string(lowercasePrefix[0])) + lowercasePrefix[1:] + " master password:")
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)
continue
}
@@ -277,13 +263,3 @@ func confirmRCWPassword(lowercasePrefix string) []byte {
func getTextEditorInput() string {
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
cd ..
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
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)'
arch='all'
url='https://github.com/rwinkhart/MUTN'
license='AGPL-3.0-only'
license='MIT'
depends=''
makedepends='busybox go'
options='!check net'
@@ -41,7 +41,7 @@ build() {
esac
# 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() {
@@ -10,7 +10,7 @@ pkgrel="$revision"
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')
url='https://github.com/rwinkhart/MUTN'
license=('AGPL-3.0-only')
license=('MIT')
depends=()
makedepends=(go grep gzip)
optdepends=(
@@ -45,7 +45,7 @@ build() {
esac
# 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() {
+3 -3
View File
@@ -10,9 +10,9 @@ create_release_binaries() {
printf '\nGenerating release binaries...\n'
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 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.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.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 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
}
+2 -2
View File
@@ -13,11 +13,11 @@ var (
)
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"
)
func init() {
global.GetPassword = front.InputSecret
global.GetPassword = front.InputHidden
}
+12 -16
View File
@@ -7,45 +7,41 @@ import (
"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).
func AddEntry(realPath string, entryType uint8) {
// ensure realPath is valid
_, err := core.EntryAddPrecheck(realPath)
func AddEntry(targetLocation string, hideSecrets bool, entryType uint8) {
// ensure targetLocation is valid
_, err := core.EntryAddPrecheck(targetLocation)
if err != nil {
other.PrintError("Failed to add entry: "+err.Error(), back.ErrorWrite)
}
var decSlice []string
var decryptedEntry []string
var password []byte
if entryType < 2 {
username := front.Input("Username:")
// determine whether to generate the password
var password string
if entryType == 0 {
password = front.InputSecret("Password:")
password = string(front.InputHidden("Password:"))
} else {
password = inputPasswordGen()
}
totp := string(front.InputSecret("TOTP secret:"))
totp := string(front.InputHidden("TOTP secret:"))
url := front.Input("URL:")
if front.InputBinary("Add a note to this entry?") {
note, _ := editNote([]string{})
decSlice = append([]string{string(password), username, totp, url}, note...)
decryptedEntry = append([]string{password, username, totp, url}, note...)
} else {
decSlice = []string{string(password), username, totp, url}
decryptedEntry = []string{password, username, totp, url}
}
} else {
note, _ := editNote([]string{})
decSlice = append([]string{"", "", "", ""}, note...)
decryptedEntry = append([]string{"", "", "", ""}, note...)
}
// write and preview the new entry
if password != nil {
writeEntryCLI(realPath, decSlice, true, nil)
} else {
writeEntryCLI(realPath, decSlice, false, nil)
}
writeEntryCLI(targetLocation, decryptedEntry, hideSecrets)
}
-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/front"
"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/syncclient"
)
// 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
newVanityPath := front.Input("New location:")
if err := syncclient.RenameRemote(oldVanityPath, newVanityPath); err != nil {
newLocationIncomplete := front.Input("New location:")
err := syncclient.RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete)
if err != nil {
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).
func EditEntryField(realPath string, field int) {
// EditEntryField edits a field of an entry at targetLocation (user input).
func EditEntryField(targetLocation string, hideSecrets bool, field int) {
// 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 {
other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead)
}
// edit the field
var oldPassword []byte
switch field {
case 0:
oldPassword = []byte(decSlice[field])
decSlice[field] = string(front.InputSecret("Password:"))
decryptedEntry[field] = string(front.InputHidden("Password:"))
case 1:
decSlice[field] = front.Input("Username:")
decryptedEntry[field] = front.Input("Username:")
case 2:
decSlice[field] = string(front.InputSecret("TOTP secret:"))
decryptedEntry[field] = string(front.InputHidden("TOTP secret:"))
case 3:
decSlice[field] = front.Input("URL:")
decryptedEntry[field] = front.Input("URL:")
case 4: // edit notes fields
// store note and non-note data separately
fieldsMain := decSlice[:4]
fieldsNote := decSlice[4:]
fieldsMain := decryptedEntry[:4]
fieldsNote := decryptedEntry[4:]
// edit the note
editedNote, noteEdited := editNote(fieldsNote)
if !noteEdited { // exit early if the note was not edited
other.PrintError("Entry is unchanged", 0)
}
decSlice = append(fieldsMain, editedNote...)
decryptedEntry = append(fieldsMain, editedNote...)
}
// write and preview the modified entry
if field == 0 {
writeEntryCLI(realPath, decSlice, true, oldPassword)
} else {
writeEntryCLI(realPath, decSlice, false, oldPassword)
}
writeEntryCLI(targetLocation, decryptedEntry, hideSecrets)
}
// GenUpdate generates a new password for an entry at realPath (user input).
func GenUpdate(realPath string) {
// GenUpdate generates a new password for an entry at targetLocation (user input).
func GenUpdate(targetLocation string, hideSecrets bool) {
// fetch old entry data
decSlice, err := core.GetOldEntryData(realPath, 0, nil)
decryptedEntry, err := core.GetOldEntryData(targetLocation, 0)
if err != nil {
other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead)
}
// generate a new password
oldPassword := []byte(decSlice[0])
decSlice[0] = string(inputPasswordGen())
decryptedEntry[0] = inputPasswordGen()
// 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).
@@ -94,16 +88,11 @@ func editNote(baseNote []string) ([]string, bool) {
}(tempFile.Name())
// fetch the user's text editor
cfg, err := config.Load()
editorCfg, err := cfg.ParseConfig([][2]string{{"MUTN", "textEditor"}})
if err != nil {
other.PrintError("Failed to load libmuttoncfg.json: "+err.Error(), 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)
other.PrintError("Failed to retrieve text editor from libmutton.ini\n\nPlease specify one with \"mutn tweak\"", back.ErrorRead)
}
editor := editorCfg[0]
// write baseNote to tempFile (if it is not empty)
baseNoteLen := len(baseNote)
@@ -130,7 +119,8 @@ func editNote(baseNote []string) ([]string, bool) {
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
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
}
@@ -150,6 +140,12 @@ func editNote(baseNote []string) ([]string, bool) {
// close tempFile
_ = 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:
// it is different from baseNote AND
// [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)
if !reflect.DeepEqual(note, baseNote) && (noteLen > 0 || baseNoteLen != noteLen) {
return note, true
} else {
return nil, false
}
return nil, false
}
+103 -124
View File
@@ -3,179 +3,158 @@ package cli
import (
"fmt"
"os"
"sort"
"strings"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/age"
"github.com/rwinkhart/libmutton/synccommon"
)
// ANSI color constants used only in this file
const (
ansiAlternateEntryColor = "\033[38;5;8m"
ansiDirectoryHeader = "\033[38;5;7;48;5;8m"
ansiAlternateEntryColor = "\033[38;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 the final visual indentation multiplier (adjusts for skipped parents)
// and trims the parent directory name from the directory header (if applicable).
// determineIndentation calculates and returns the final visual indentation multiplier (needed to adjust indentation for skipped parent directories); also subtracts "old" text from directory header.
func determineIndentation(skippedDirList []bool, dirList []string, currentDirIndex int) (int, string) {
trimmed := dirList[currentDirIndex]
indent := strings.Count(trimmed, "/") - 1 // avoid indenting root-level dirs
var subtractor int // tracks how much to subtract from expected indentation multiplier
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
for i, skipped := range skippedDirList[:currentDirIndex] {
if strings.HasPrefix(trimmed, dirList[i]+"/") {
if skipped {
subtractor++
// determine initial indentation multiplier based on "/" occurrences
indent := strings.Count(trimmedDirectory, "/") - 1 // subtract 1 to avoid indenting root-level directories
for i, skipped := range skippedDirList[:currentDirIndex] { // checks each skipped directory to determine if it is a parent to the current directory
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 {
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, trimmed
return indent, trimmedDirectory
}
// printFileEntry handles processing for printing file entries (determines color, adds aging indicator, wraps lines, and prints).
func printFileEntry(sb *strings.Builder, entry string, lastSlash int, charCounter *int, indent int, colorAlternator int8, agingTimestamp *int64) int8 {
colorCode := ""
if colorAlternator <= 0 {
// printFileEntry handles processing for printing file entries (determines color, wraps lines, and prints).
func printFileEntry(entry string, lastSlash, charCounter, indent int, colorAlternator int8) (int, int8) {
// determine color to print fileEntryName (alternate each time function is run)
var colorCode string
if colorAlternator > 0 {
colorCode = ""
} else {
colorCode = ansiAlternateEntryColor
}
colorAlternator = -colorAlternator
agingDot := ""
switch age.TranslateAgeTimestamp(agingTimestamp) {
case 1:
agingDot = back.AnsiGreen + "⁍" + back.AnsiReset
case 2:
agingDot = back.AnsiWarning + "⁍" + back.AnsiReset
case 3:
agingDot = back.AnsiError + "⁍" + back.AnsiReset
// trim the containing directory from the entry to determine fileEntryName
fileEntryName := entry[lastSlash:]
if charCounter == 0 { // indent first line of entries for each directory header
fmt.Print(strings.Repeat(" ", indent*2))
}
name := entry[lastSlash:]
if *charCounter == 0 {
writeIndent(sb, indent)
// determine whether to wrap to a new line (+1 is to account for trailing spaces)
charCounter += len(fileEntryName) + 1
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
inc := len(name) + 1
if agingDot != "" {
inc++
}
if *charCounter += inc; *charCounter+indent*2 >= width {
*charCounter = len(name) + 1
if agingDot != "" {
*charCounter++
}
sb.WriteString("\n")
writeIndent(sb, indent)
}
// print fileEntryName to screen
fmt.Printf("%s%s%s ", colorCode, fileEntryName, back.AnsiReset)
sb.WriteString(agingDot)
sb.WriteString(colorCode)
sb.WriteString(name)
sb.WriteString(back.AnsiReset)
sb.WriteString(" ")
return colorAlternator
return charCounter, colorAlternator
}
// EntryListGen generates and displays the full libmutton entry list.
func EntryListGen() {
_, dirList, err := synccommon.WalkEntryDir()
fileList, dirList, err := synccommon.WalkEntryDir()
if err != nil {
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
sb.WriteString("\n")
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
}
fmt.Print("\n"+ansiBlackOnWhite, len(fileList), " libmutton entries:"+back.AnsiReset)
// 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 {
charCounter, colorAlternator = 0, 1
skippedDirList[i] = i != 0
if entries := entriesByDir[directory]; len(entries) > 0 {
indent := printDirHeader(i)
for _, vanityPath := range entries {
lastSlash := strings.LastIndex(vanityPath, "/") + 1
colorAlternator = printFileEntry(&sb, vanityPath, lastSlash, &charCounter, indent, colorAlternator, entryMap[vanityPath].AgeTimestamp)
// reset formatting variables for new directory
charCounter = 0
colorAlternator = 1
// 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] {
continue
// fileList iteration
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 {
indent := printDirHeader(i)
writeIndent(&sb, indent)
sb.WriteString(back.AnsiWarning + "-empty directory-" + back.AnsiReset)
continue
if !containsFiles { // if the current directory contains no files...
if !containsSubdirectory { // nor does it contain any subdirectories...
if dirListLength > 1 { // and directories besides the root-level exist... display directory header and empty directory warning
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)
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")
fmt.Print(sb.String())
// print trailing new lines for proper spacing after entry list is complete
fmt.Print("\n\n")
os.Exit(0)
}
+37 -20
View File
@@ -2,6 +2,7 @@ package cli
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/glamour"
@@ -9,53 +10,59 @@ import (
"github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/crypt"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/syncclient"
)
const ansiShownPassword = "\033[38;5;10m"
// EntryReader prints the decrypted contents of a libmutton entry in a human-readable format.
func EntryReader(vanityPath string, decSlice []string, hideSecrets bool) {
fmt.Print("\n" + back.AnsiBold + vanityPath + back.AnsiReset + "\n\n")
func EntryReader(decryptedEntry []string, hideSecrets, syncEnabled bool) {
fmt.Println()
fieldLoop:
for field := range decSlice {
for field := range decryptedEntry {
switch field {
case 0:
// if the first field (password) is not empty, print it
if decSlice[0] != "" {
if decryptedEntry[0] != "" {
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 {
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:
// if the second field (username) is not empty, print it
if decSlice[1] != "" {
fmt.Print(ansiDirectoryHeader + "Username:" + back.AnsiReset + "\n" + decSlice[1] + "\n\n")
if decryptedEntry[1] != "" {
fmt.Print(ansiDirectoryHeader + "Username:" + back.AnsiReset + "\n" + decryptedEntry[1] + "\n\n")
}
case 2:
// if the third field (TOTP secret) is not empty, print it
if decSlice[2] != "" {
if decryptedEntry[2] != "" {
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 {
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:
// if the fourth field (url) is not empty, print it
if decSlice[3] != "" {
fmt.Print(ansiDirectoryHeader + "URL:" + back.AnsiReset + "\n" + decSlice[3] + "\n\n")
if decryptedEntry[3] != "" {
fmt.Print(ansiDirectoryHeader + "URL:" + back.AnsiReset + "\n" + decryptedEntry[3] + "\n\n")
}
case 4:
// print the notes header
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
r, _ := glamour.NewTermRenderer(glamour.WithStylesFromJSONBytes(glamourStyle()), glamour.WithWordWrap(width))
markdownNotesString, _ := r.Render(strings.Join(decSlice[4:], "\n"))
_ = r.Close()
r, _ := glamour.NewTermRenderer(glamour.WithStylesFromJSONBytes(glamourStyle()), glamour.WithPreservedNewLines(), glamour.WithWordWrap(width))
markdownNotesString, _ := r.Render(strings.Join(noteLines, "\n"))
// print the rendered Markdown notes
fmt.Print(markdownNotesString)
@@ -64,17 +71,27 @@ 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.
func EntryReaderDecrypt(realPath string, hideSecrets bool) {
_, err := back.TargetIsFile(realPath, true)
func EntryReaderDecrypt(targetLocation string, hideSecrets bool) {
_, err := back.TargetIsFile(targetLocation, true)
if err != nil { // if the location does not exist or is a directory...
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 {
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() {
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 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
url|-l Copy the URL 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:
password|-pw|<blank> Change the password 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
` + 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 3:" + back.AnsiReset + ` Various changes can be made to the configuration and stored entries from the "mutn tweak" menu
` + 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 2:" + back.AnsiReset + ` Type "mutn" (no arguments/options) to view a list of saved entries
` + 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 + " Using \"add\", \"edit\", or \"copy\" without specifying an option (field) will default to \"password\"\n\n")
os.Exit(0)
}
@@ -96,8 +95,7 @@ func HelpCopy() {
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
url|-l Copy the URL 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` + "\n\n")
note|-n Copy the first note line in an entry to your clipboard` + "\n\n")
os.Exit(0)
}
@@ -152,11 +150,11 @@ func Version() {
ansiVersionOutline + "<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\n" +
"\\" + ansiBlackOnWhite + " " + 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 + " Built with libmutton v" + global.LibmuttonVersion + " " + 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" +
"<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\n" + back.AnsiReset +
"\n For more information, see:\n\n" +
+12 -8
View File
@@ -2,16 +2,17 @@ package cli
import (
"fmt"
"strings"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/front"
"github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/go-boilerplate/security"
"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.
func inputPasswordGen() []byte {
func inputPasswordGen() string {
passLength := front.InputInt("Password length:", 1, -1)
fmt.Println()
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
// 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).
func writeEntryCLI(realPath string, decSlice []string, passwordIsNew bool, oldPassword []byte) {
if core.EntryIsNotEmpty(decSlice) {
if err := core.WriteEntry(realPath, decSlice, passwordIsNew, nil); err != nil {
// writeEntryCLI writes an entry to targetLocation and previews it (errors if no data is supplied).
func writeEntryCLI(targetLocation string, decryptedEntry []string, hideSecrets bool) {
if core.EntryIsNotEmpty(decryptedEntry) {
// 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)
}
// preview the entry
CopyMenu("", decSlice, oldPassword)
fmt.Println(back.AnsiBold + "\nEntry Preview:" + back.AnsiReset)
EntryReader(decryptedEntry, hideSecrets, true)
} else {
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:
- `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)
- 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
- 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
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)
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
#### Operating on Passwords