mirror of
https://github.com/rwinkhart/MUTN.git
synced 2026-08-28 12:56:30 -04:00
Compare commits
1
Commits
v0.4.0
..
v0.3.x-lts
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353b6a67de |
+3
-1
@@ -1,6 +1,8 @@
|
||||
/*.exe*
|
||||
/mutn
|
||||
/mutn.exe
|
||||
/MUTN
|
||||
/MUTN.exe
|
||||
/main
|
||||
/main.exe
|
||||
/version.go
|
||||
1output
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" )
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user