feat: adds --readonly and --pin flags, and displays Size column in list by default
This commit is contained in:
parent
e5b6dcd187
commit
5bcd3581dd
46 changed files with 711 additions and 177 deletions
|
|
@ -104,14 +104,14 @@ func defaultConfig() Config {
|
|||
AlwaysPromptOverwrite: false,
|
||||
},
|
||||
Store: StoreConfig{
|
||||
DefaultStoreName: "default",
|
||||
DefaultStoreName: "store",
|
||||
AlwaysPromptDelete: true,
|
||||
AlwaysPromptOverwrite: true,
|
||||
},
|
||||
List: ListConfig{
|
||||
AlwaysShowAllStores: true,
|
||||
DefaultListFormat: "table",
|
||||
DefaultColumns: "key,store,value,ttl",
|
||||
DefaultColumns: "meta,size,ttl,store,key,value",
|
||||
},
|
||||
Git: GitConfig{
|
||||
AutoFetch: false,
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ func TestConfigFieldsStringField(t *testing.T) {
|
|||
if f.Kind != reflect.String {
|
||||
t.Errorf("store.default_store_name Kind = %v, want String", f.Kind)
|
||||
}
|
||||
if f.Value != "default" {
|
||||
t.Errorf("store.default_store_name Value = %v, want 'default'", f.Value)
|
||||
if f.Value != "store" {
|
||||
t.Errorf("store.default_store_name Value = %v, want 'store'", f.Value)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ func del(cmd *cobra.Command, args []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
var removedNames []string
|
||||
for _, dbName := range storeOrder {
|
||||
st := byStore[dbName]
|
||||
|
|
@ -123,6 +125,9 @@ func del(cmd *cobra.Command, args []string) error {
|
|||
if idx < 0 {
|
||||
return fmt.Errorf("cannot remove '%s': no such key", t.full)
|
||||
}
|
||||
if entries[idx].ReadOnly && !force {
|
||||
return fmt.Errorf("cannot remove '%s': key is read-only", t.full)
|
||||
}
|
||||
entries = append(entries[:idx], entries[idx+1:]...)
|
||||
removedNames = append(removedNames, t.display)
|
||||
}
|
||||
|
|
@ -137,6 +142,7 @@ func del(cmd *cobra.Command, args []string) error {
|
|||
func init() {
|
||||
delCmd.Flags().BoolP("interactive", "i", false, "prompt yes/no for each deletion")
|
||||
delCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts")
|
||||
delCmd.Flags().Bool("force", false, "bypass read-only protection")
|
||||
delCmd.Flags().StringSliceP("key", "k", nil, "delete keys matching glob pattern (repeatable)")
|
||||
delCmd.Flags().StringSliceP("store", "s", nil, "target stores matching glob pattern (repeatable)")
|
||||
delCmd.Flags().StringSliceP("value", "v", nil, "delete entries matching value glob pattern (repeatable)")
|
||||
|
|
|
|||
42
cmd/edit.go
42
cmd/edit.go
|
|
@ -48,10 +48,21 @@ func edit(cmd *cobra.Command, args []string) error {
|
|||
encryptFlag, _ := cmd.Flags().GetBool("encrypt")
|
||||
decryptFlag, _ := cmd.Flags().GetBool("decrypt")
|
||||
preserveNewline, _ := cmd.Flags().GetBool("preserve-newline")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
readonlyFlag, _ := cmd.Flags().GetBool("readonly")
|
||||
writableFlag, _ := cmd.Flags().GetBool("writable")
|
||||
pinFlag, _ := cmd.Flags().GetBool("pin")
|
||||
unpinFlag, _ := cmd.Flags().GetBool("unpin")
|
||||
|
||||
if encryptFlag && decryptFlag {
|
||||
return fmt.Errorf("cannot edit '%s': --encrypt and --decrypt are mutually exclusive", args[0])
|
||||
}
|
||||
if readonlyFlag && writableFlag {
|
||||
return fmt.Errorf("cannot edit '%s': --readonly and --writable are mutually exclusive", args[0])
|
||||
}
|
||||
if pinFlag && unpinFlag {
|
||||
return fmt.Errorf("cannot edit '%s': --pin and --unpin are mutually exclusive", args[0])
|
||||
}
|
||||
|
||||
// Load identity
|
||||
var identity *age.X25519Identity
|
||||
|
|
@ -87,6 +98,9 @@ func edit(cmd *cobra.Command, args []string) error {
|
|||
original = nil
|
||||
} else {
|
||||
entry = &entries[idx]
|
||||
if entry.ReadOnly && !force {
|
||||
return fmt.Errorf("cannot edit '%s': key is read-only", args[0])
|
||||
}
|
||||
if entry.Locked {
|
||||
return fmt.Errorf("cannot edit '%s': secret is locked (identity file missing)", args[0])
|
||||
}
|
||||
|
|
@ -149,7 +163,7 @@ func edit(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
// Check for no-op
|
||||
noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag
|
||||
noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag && !readonlyFlag && !writableFlag && !pinFlag && !unpinFlag
|
||||
if bytes.Equal(original, newValue) && noMetaFlags {
|
||||
infof("no changes to '%s'", spec.Display())
|
||||
return nil
|
||||
|
|
@ -164,9 +178,11 @@ func edit(cmd *cobra.Command, args []string) error {
|
|||
// Build or update entry
|
||||
if creating {
|
||||
newEntry := Entry{
|
||||
Key: spec.Key,
|
||||
Value: newValue,
|
||||
Secret: encryptFlag,
|
||||
Key: spec.Key,
|
||||
Value: newValue,
|
||||
Secret: encryptFlag,
|
||||
ReadOnly: readonlyFlag,
|
||||
Pinned: pinFlag,
|
||||
}
|
||||
if ttlStr != "" {
|
||||
expiresAt, err := parseTTLString(ttlStr)
|
||||
|
|
@ -199,6 +215,19 @@ func edit(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
entry.Secret = false
|
||||
}
|
||||
|
||||
if readonlyFlag {
|
||||
entry.ReadOnly = true
|
||||
}
|
||||
if writableFlag {
|
||||
entry.ReadOnly = false
|
||||
}
|
||||
if pinFlag {
|
||||
entry.Pinned = true
|
||||
}
|
||||
if unpinFlag {
|
||||
entry.Pinned = false
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeStoreFile(p, entries, recipients); err != nil {
|
||||
|
|
@ -219,5 +248,10 @@ func init() {
|
|||
editCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest")
|
||||
editCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)")
|
||||
editCmd.Flags().Bool("preserve-newline", false, "keep trailing newlines added by the editor")
|
||||
editCmd.Flags().Bool("force", false, "bypass read-only protection")
|
||||
editCmd.Flags().Bool("readonly", false, "mark the key as read-only")
|
||||
editCmd.Flags().Bool("writable", false, "clear the read-only flag")
|
||||
editCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)")
|
||||
editCmd.Flags().Bool("unpin", false, "unpin the key")
|
||||
rootCmd.AddCommand(editCmd)
|
||||
}
|
||||
|
|
|
|||
223
cmd/list.go
223
cmd/list.go
|
|
@ -67,6 +67,8 @@ var columnNames = map[string]columnKind{
|
|||
"key": columnKey,
|
||||
"store": columnStore,
|
||||
"value": columnValue,
|
||||
"meta": columnMeta,
|
||||
"size": columnSize,
|
||||
"ttl": columnTTL,
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ func validListColumns(v string) error {
|
|||
for _, raw := range strings.Split(v, ",") {
|
||||
tok := strings.TrimSpace(raw)
|
||||
if _, ok := columnNames[tok]; !ok {
|
||||
return fmt.Errorf("must be a comma-separated list of 'key', 'store', 'value', 'ttl' (got '%s')", tok)
|
||||
return fmt.Errorf("must be a comma-separated list of 'key', 'store', 'value', 'meta', 'size', 'ttl' (got '%s')", tok)
|
||||
}
|
||||
if seen[tok] {
|
||||
return fmt.Errorf("duplicate column '%s'", tok)
|
||||
|
|
@ -103,7 +105,10 @@ var (
|
|||
listBase64 bool
|
||||
listCount bool
|
||||
listNoKeys bool
|
||||
listNoStore bool
|
||||
listNoValues bool
|
||||
listNoMeta bool
|
||||
listNoSize bool
|
||||
listNoTTL bool
|
||||
listFull bool
|
||||
listAll bool
|
||||
|
|
@ -120,6 +125,8 @@ const (
|
|||
columnValue
|
||||
columnTTL
|
||||
columnStore
|
||||
columnMeta
|
||||
columnSize
|
||||
)
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
|
|
@ -131,10 +138,9 @@ By default, list shows entries from every store. Pass a store name as a
|
|||
positional argument to narrow to a single store, or use --store/-s with a
|
||||
glob pattern to filter by store name.
|
||||
|
||||
The Store column is always shown so entries can be distinguished across
|
||||
stores. Use --key/-k and --value/-v to filter by key or value glob, and
|
||||
--store/-s to filter by store name. All filters are repeatable and OR'd
|
||||
within the same flag.`,
|
||||
Use --key/-k and --value/-v to filter by key or value glob, and --store/-s
|
||||
to filter by store name. All filters are repeatable and OR'd within the
|
||||
same flag.`,
|
||||
Aliases: []string{"ls"},
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: list,
|
||||
|
|
@ -178,19 +184,35 @@ func list(cmd *cobra.Command, args []string) error {
|
|||
targetDB = "@" + dbName
|
||||
}
|
||||
|
||||
if listNoKeys && listNoValues && listNoTTL {
|
||||
return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys, --no-values, or --no-ttl")
|
||||
columns := parseColumns(config.List.DefaultColumns)
|
||||
|
||||
// Each --no-X flag: if explicitly true, remove the column;
|
||||
// if explicitly false (--no-X=false), add the column if missing.
|
||||
type colToggle struct {
|
||||
flag string
|
||||
kind columnKind
|
||||
}
|
||||
for _, ct := range []colToggle{
|
||||
{"no-keys", columnKey},
|
||||
{"no-store", columnStore},
|
||||
{"no-values", columnValue},
|
||||
{"no-meta", columnMeta},
|
||||
{"no-size", columnSize},
|
||||
{"no-ttl", columnTTL},
|
||||
} {
|
||||
if !cmd.Flags().Changed(ct.flag) {
|
||||
continue
|
||||
}
|
||||
val, _ := cmd.Flags().GetBool(ct.flag)
|
||||
if val {
|
||||
columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == ct.kind })
|
||||
} else if !slices.Contains(columns, ct.kind) {
|
||||
columns = append(columns, ct.kind)
|
||||
}
|
||||
}
|
||||
|
||||
columns := parseColumns(config.List.DefaultColumns)
|
||||
if listNoKeys {
|
||||
columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnKey })
|
||||
}
|
||||
if listNoValues {
|
||||
columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnValue })
|
||||
}
|
||||
if listNoTTL {
|
||||
columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnTTL })
|
||||
if len(columns) == 0 {
|
||||
return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable some --no-* flags")
|
||||
}
|
||||
|
||||
keyPatterns, err := cmd.Flags().GetStringSlice("key")
|
||||
|
|
@ -271,6 +293,17 @@ func list(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Stable sort: pinned entries first, preserving alphabetical order within each group
|
||||
slices.SortStableFunc(filtered, func(a, b Entry) int {
|
||||
if a.Pinned && !b.Pinned {
|
||||
return -1
|
||||
}
|
||||
if !a.Pinned && b.Pinned {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
if listCount {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), len(filtered))
|
||||
return nil
|
||||
|
|
@ -330,7 +363,7 @@ func list(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
// Table-based formats
|
||||
showValues := !listNoValues
|
||||
showValues := slices.Contains(columns, columnValue)
|
||||
tw := table.NewWriter()
|
||||
tw.SetOutputMirror(output)
|
||||
tw.SetStyle(table.StyleDefault)
|
||||
|
|
@ -384,16 +417,32 @@ func list(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
case columnStore:
|
||||
if tty {
|
||||
row = append(row, dimStyle.Sprint(e.StoreName))
|
||||
row = append(row, text.Colors{text.Bold, text.FgYellow}.Sprint(e.StoreName))
|
||||
} else {
|
||||
row = append(row, e.StoreName)
|
||||
}
|
||||
case columnMeta:
|
||||
if tty {
|
||||
row = append(row, colorizeMeta(e))
|
||||
} else {
|
||||
row = append(row, entryMetaString(e))
|
||||
}
|
||||
case columnSize:
|
||||
sizeStr := formatSize(len(e.Value))
|
||||
if tty {
|
||||
if len(e.Value) >= 1000 {
|
||||
sizeStr = text.Colors{text.Bold, text.FgGreen}.Sprint(sizeStr)
|
||||
} else {
|
||||
sizeStr = text.FgGreen.Sprint(sizeStr)
|
||||
}
|
||||
}
|
||||
row = append(row, sizeStr)
|
||||
case columnTTL:
|
||||
ttlStr := formatExpiry(e.ExpiresAt)
|
||||
if tty && e.ExpiresAt == 0 {
|
||||
ttlStr = dimStyle.Sprint(ttlStr)
|
||||
}
|
||||
row = append(row, ttlStr)
|
||||
if tty && e.ExpiresAt == 0 {
|
||||
ttlStr = dimStyle.Sprint(ttlStr)
|
||||
}
|
||||
row = append(row, ttlStr)
|
||||
}
|
||||
}
|
||||
tw.AppendRow(row)
|
||||
|
|
@ -484,6 +533,10 @@ func headerRow(columns []columnKind, tty bool) table.Row {
|
|||
row = append(row, h("Store"))
|
||||
case columnValue:
|
||||
row = append(row, h("Value"))
|
||||
case columnMeta:
|
||||
row = append(row, h("Meta"))
|
||||
case columnSize:
|
||||
row = append(row, h("Size"))
|
||||
case columnTTL:
|
||||
row = append(row, h("TTL"))
|
||||
}
|
||||
|
|
@ -494,12 +547,13 @@ func headerRow(columns []columnKind, tty bool) table.Row {
|
|||
const (
|
||||
keyColumnWidthCap = 30
|
||||
storeColumnWidthCap = 20
|
||||
sizeColumnWidthCap = 10
|
||||
ttlColumnWidthCap = 20
|
||||
)
|
||||
|
||||
// columnLayout holds the resolved max widths for each column kind.
|
||||
type columnLayout struct {
|
||||
key, store, value, ttl int
|
||||
key, store, value, meta, size, ttl int
|
||||
}
|
||||
|
||||
// computeLayout derives column widths from the terminal size and actual
|
||||
|
|
@ -509,7 +563,16 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
|
|||
var lay columnLayout
|
||||
termWidth := detectTerminalWidth(out)
|
||||
|
||||
// Scan entries for actual max key/store/TTL content widths.
|
||||
// Meta column is always exactly 4 chars wide (ewtp).
|
||||
lay.meta = 4
|
||||
|
||||
// Ensure columns are at least as wide as their headers.
|
||||
lay.key = len("Key")
|
||||
lay.store = len("Store")
|
||||
lay.size = len("Size")
|
||||
lay.ttl = len("TTL")
|
||||
|
||||
// Scan entries for actual max key/store/size/TTL content widths.
|
||||
for _, e := range entries {
|
||||
if w := utf8.RuneCountInString(e.Key); w > lay.key {
|
||||
lay.key = w
|
||||
|
|
@ -517,6 +580,9 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
|
|||
if w := utf8.RuneCountInString(e.StoreName); w > lay.store {
|
||||
lay.store = w
|
||||
}
|
||||
if w := utf8.RuneCountInString(formatSize(len(e.Value))); w > lay.size {
|
||||
lay.size = w
|
||||
}
|
||||
if w := utf8.RuneCountInString(formatExpiry(e.ExpiresAt)); w > lay.ttl {
|
||||
lay.ttl = w
|
||||
}
|
||||
|
|
@ -527,6 +593,9 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
|
|||
if lay.store > storeColumnWidthCap {
|
||||
lay.store = storeColumnWidthCap
|
||||
}
|
||||
if lay.size > sizeColumnWidthCap {
|
||||
lay.size = sizeColumnWidthCap
|
||||
}
|
||||
if lay.ttl > ttlColumnWidthCap {
|
||||
lay.ttl = ttlColumnWidthCap
|
||||
}
|
||||
|
|
@ -541,7 +610,7 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
|
|||
return lay
|
||||
}
|
||||
|
||||
// Give the value column whatever is left after key and TTL.
|
||||
// Give the value column whatever is left after fixed-width columns.
|
||||
lay.value = available
|
||||
for _, col := range columns {
|
||||
switch col {
|
||||
|
|
@ -549,6 +618,10 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
|
|||
lay.value -= lay.key
|
||||
case columnStore:
|
||||
lay.value -= lay.store
|
||||
case columnMeta:
|
||||
lay.value -= lay.meta
|
||||
case columnSize:
|
||||
lay.value -= lay.size
|
||||
case columnTTL:
|
||||
lay.value -= lay.ttl
|
||||
}
|
||||
|
|
@ -568,31 +641,40 @@ func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay
|
|||
|
||||
var configs []table.ColumnConfig
|
||||
for i, col := range columns {
|
||||
var maxW int
|
||||
var enforcer func(string, int) string
|
||||
cc := table.ColumnConfig{Number: i + 1}
|
||||
switch col {
|
||||
case columnKey:
|
||||
maxW = lay.key
|
||||
enforcer = text.Trim
|
||||
cc.WidthMax = lay.key
|
||||
cc.WidthMaxEnforcer = text.Trim
|
||||
case columnStore:
|
||||
maxW = lay.store
|
||||
enforcer = text.Trim
|
||||
cc.WidthMax = lay.store
|
||||
cc.WidthMaxEnforcer = text.Trim
|
||||
cc.Align = text.AlignRight
|
||||
cc.AlignHeader = text.AlignRight
|
||||
case columnValue:
|
||||
maxW = lay.value
|
||||
cc.WidthMax = lay.value
|
||||
if full {
|
||||
enforcer = text.WrapText
|
||||
cc.WidthMaxEnforcer = text.WrapText
|
||||
}
|
||||
// When !full, values are already pre-truncated by
|
||||
// summariseValue — no enforcer needed.
|
||||
case columnMeta:
|
||||
cc.WidthMax = lay.meta
|
||||
cc.WidthMaxEnforcer = text.Trim
|
||||
cc.Align = text.AlignRight
|
||||
cc.AlignHeader = text.AlignRight
|
||||
case columnSize:
|
||||
cc.WidthMax = lay.size
|
||||
cc.WidthMaxEnforcer = text.Trim
|
||||
cc.Align = text.AlignRight
|
||||
cc.AlignHeader = text.AlignRight
|
||||
case columnTTL:
|
||||
maxW = lay.ttl
|
||||
enforcer = text.Trim
|
||||
cc.WidthMax = lay.ttl
|
||||
cc.WidthMaxEnforcer = text.Trim
|
||||
cc.Align = text.AlignRight
|
||||
cc.AlignHeader = text.AlignRight
|
||||
}
|
||||
configs = append(configs, table.ColumnConfig{
|
||||
Number: i + 1,
|
||||
WidthMax: maxW,
|
||||
WidthMaxEnforcer: enforcer,
|
||||
})
|
||||
configs = append(configs, cc)
|
||||
}
|
||||
tw.SetColumnConfigs(configs)
|
||||
}
|
||||
|
|
@ -615,6 +697,64 @@ func detectTerminalWidth(out io.Writer) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
// entryMetaString returns a 4-char flag string: (e)ncrypted (w)ritable (t)tl (p)inned.
|
||||
func entryMetaString(e Entry) string {
|
||||
var b [4]byte
|
||||
if e.Secret {
|
||||
b[0] = 'e'
|
||||
} else {
|
||||
b[0] = '-'
|
||||
}
|
||||
if !e.ReadOnly {
|
||||
b[1] = 'w'
|
||||
} else {
|
||||
b[1] = '-'
|
||||
}
|
||||
if e.ExpiresAt > 0 {
|
||||
b[2] = 't'
|
||||
} else {
|
||||
b[2] = '-'
|
||||
}
|
||||
if e.Pinned {
|
||||
b[3] = 'p'
|
||||
} else {
|
||||
b[3] = '-'
|
||||
}
|
||||
return string(b[:])
|
||||
}
|
||||
|
||||
// colorizeMeta returns a colorized meta string for TTY display.
|
||||
// e=bold+yellow, w=bold+red, t=bold+green, p=bold+yellow, unset=dim.
|
||||
func colorizeMeta(e Entry) string {
|
||||
dim := text.Colors{text.Faint}
|
||||
yellow := text.Colors{text.Bold, text.FgYellow}
|
||||
red := text.Colors{text.Bold, text.FgRed}
|
||||
green := text.Colors{text.Bold, text.FgGreen}
|
||||
|
||||
var b strings.Builder
|
||||
if e.Secret {
|
||||
b.WriteString(yellow.Sprint("e"))
|
||||
} else {
|
||||
b.WriteString(dim.Sprint("-"))
|
||||
}
|
||||
if !e.ReadOnly {
|
||||
b.WriteString(red.Sprint("w"))
|
||||
} else {
|
||||
b.WriteString(dim.Sprint("-"))
|
||||
}
|
||||
if e.ExpiresAt > 0 {
|
||||
b.WriteString(green.Sprint("t"))
|
||||
} else {
|
||||
b.WriteString(dim.Sprint("-"))
|
||||
}
|
||||
if e.Pinned {
|
||||
b.WriteString(yellow.Sprint("p"))
|
||||
} else {
|
||||
b.WriteString(dim.Sprint("-"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderTable(tw table.Writer) {
|
||||
switch listFormat.String() {
|
||||
case "tsv":
|
||||
|
|
@ -635,7 +775,10 @@ func init() {
|
|||
listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64")
|
||||
listCmd.Flags().BoolVarP(&listCount, "count", "c", false, "print only the count of matching entries")
|
||||
listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column")
|
||||
listCmd.Flags().BoolVar(&listNoStore, "no-store", false, "suppress the store column")
|
||||
listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column")
|
||||
listCmd.Flags().BoolVar(&listNoMeta, "no-meta", false, "suppress the meta column")
|
||||
listCmd.Flags().BoolVar(&listNoSize, "no-size", false, "suppress the size column")
|
||||
listCmd.Flags().BoolVar(&listNoTTL, "no-ttl", false, "suppress the TTL column")
|
||||
listCmd.Flags().BoolVarP(&listFull, "full", "f", false, "show full values without truncation")
|
||||
listCmd.Flags().BoolVar(&listNoHeader, "no-header", false, "suppress the header row")
|
||||
|
|
|
|||
70
cmd/meta.go
70
cmd/meta.go
|
|
@ -2,6 +2,7 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -9,13 +10,10 @@ import (
|
|||
var metaCmd = &cobra.Command{
|
||||
Use: "meta KEY[@STORE]",
|
||||
Short: "View or modify metadata for a key",
|
||||
Long: `View or modify metadata (TTL, encryption) for a key without changing its value.
|
||||
Long: `View or modify metadata (TTL, encryption, read-only, pinned) for a key
|
||||
without changing its value.
|
||||
|
||||
With no flags, displays the key's current metadata. Use flags to modify:
|
||||
--ttl DURATION Set expiry (e.g. 30m, 2h)
|
||||
--ttl never Remove expiry
|
||||
--encrypt Encrypt the value at rest
|
||||
--decrypt Decrypt the value (store as plaintext)`,
|
||||
With no flags, displays the key's current metadata. Pass flags to modify.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: meta,
|
||||
SilenceUsage: true,
|
||||
|
|
@ -52,23 +50,46 @@ func meta(cmd *cobra.Command, args []string) error {
|
|||
ttlStr, _ := cmd.Flags().GetString("ttl")
|
||||
encryptFlag, _ := cmd.Flags().GetBool("encrypt")
|
||||
decryptFlag, _ := cmd.Flags().GetBool("decrypt")
|
||||
readonlyFlag, _ := cmd.Flags().GetBool("readonly")
|
||||
writableFlag, _ := cmd.Flags().GetBool("writable")
|
||||
pinFlag, _ := cmd.Flags().GetBool("pin")
|
||||
unpinFlag, _ := cmd.Flags().GetBool("unpin")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
if encryptFlag && decryptFlag {
|
||||
return fmt.Errorf("cannot meta '%s': --encrypt and --decrypt are mutually exclusive", args[0])
|
||||
}
|
||||
if readonlyFlag && writableFlag {
|
||||
return fmt.Errorf("cannot meta '%s': --readonly and --writable are mutually exclusive", args[0])
|
||||
}
|
||||
if pinFlag && unpinFlag {
|
||||
return fmt.Errorf("cannot meta '%s': --pin and --unpin are mutually exclusive", args[0])
|
||||
}
|
||||
|
||||
// View mode: no flags set
|
||||
if ttlStr == "" && !encryptFlag && !decryptFlag {
|
||||
isModify := ttlStr != "" || encryptFlag || decryptFlag || readonlyFlag || writableFlag || pinFlag || unpinFlag
|
||||
if !isModify {
|
||||
expiresStr := "never"
|
||||
if entry.ExpiresAt > 0 {
|
||||
expiresStr = formatExpiry(entry.ExpiresAt)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " key: %s\n", spec.Full())
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " secret: %v\n", entry.Secret)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " writable: %v\n", !entry.ReadOnly)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " pinned: %v\n", entry.Pinned)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " expires: %s\n", expiresStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read-only enforcement: --readonly and --writable always work without --force,
|
||||
// but other modifications on a read-only key require --force.
|
||||
if entry.ReadOnly && !force && !readonlyFlag && !writableFlag {
|
||||
onlyPinChange := !encryptFlag && !decryptFlag && ttlStr == "" && (pinFlag || unpinFlag)
|
||||
if !onlyPinChange {
|
||||
return fmt.Errorf("cannot meta '%s': key is read-only", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Modification mode — may need identity for encrypt
|
||||
if encryptFlag {
|
||||
identity, err = ensureIdentity()
|
||||
|
|
@ -81,12 +102,19 @@ func meta(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("cannot meta '%s': %v", args[0], err)
|
||||
}
|
||||
|
||||
var changes []string
|
||||
|
||||
if ttlStr != "" {
|
||||
expiresAt, err := parseTTLString(ttlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot meta '%s': %v", args[0], err)
|
||||
}
|
||||
entry.ExpiresAt = expiresAt
|
||||
if expiresAt == 0 {
|
||||
changes = append(changes, "cleared ttl")
|
||||
} else {
|
||||
changes = append(changes, "set ttl to "+ttlStr)
|
||||
}
|
||||
}
|
||||
|
||||
if encryptFlag {
|
||||
|
|
@ -97,6 +125,7 @@ func meta(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0])
|
||||
}
|
||||
entry.Secret = true
|
||||
changes = append(changes, "encrypted")
|
||||
}
|
||||
|
||||
if decryptFlag {
|
||||
|
|
@ -107,18 +136,43 @@ func meta(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0])
|
||||
}
|
||||
entry.Secret = false
|
||||
changes = append(changes, "decrypted")
|
||||
}
|
||||
|
||||
if readonlyFlag {
|
||||
entry.ReadOnly = true
|
||||
changes = append(changes, "made readonly")
|
||||
}
|
||||
if writableFlag {
|
||||
entry.ReadOnly = false
|
||||
changes = append(changes, "made writable")
|
||||
}
|
||||
if pinFlag {
|
||||
entry.Pinned = true
|
||||
changes = append(changes, "pinned")
|
||||
}
|
||||
if unpinFlag {
|
||||
entry.Pinned = false
|
||||
changes = append(changes, "unpinned")
|
||||
}
|
||||
|
||||
if err := writeStoreFile(p, entries, recipients); err != nil {
|
||||
return fmt.Errorf("cannot meta '%s': %v", args[0], err)
|
||||
}
|
||||
|
||||
return autoSync("meta " + spec.Display())
|
||||
summary := strings.Join(changes, ", ")
|
||||
okf("%s %s", summary, spec.Display())
|
||||
return autoSync(summary + " " + spec.Display())
|
||||
}
|
||||
|
||||
func init() {
|
||||
metaCmd.Flags().String("ttl", "", "set expiry (e.g. 30m, 2h) or 'never' to clear")
|
||||
metaCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest")
|
||||
metaCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)")
|
||||
metaCmd.Flags().Bool("readonly", false, "mark the key as read-only")
|
||||
metaCmd.Flags().Bool("writable", false, "clear the read-only flag")
|
||||
metaCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)")
|
||||
metaCmd.Flags().Bool("unpin", false, "unpin the key")
|
||||
metaCmd.Flags().Bool("force", false, "bypass read-only protection for metadata changes")
|
||||
rootCmd.AddCommand(metaCmd)
|
||||
}
|
||||
|
|
|
|||
16
cmd/mv.go
16
cmd/mv.go
|
|
@ -71,6 +71,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
promptOverwrite := !yes && (interactive || config.Key.AlwaysPromptOverwrite)
|
||||
|
||||
identity, _ := loadIdentity()
|
||||
|
|
@ -103,6 +104,11 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
|
|||
}
|
||||
srcEntry := srcEntries[srcIdx]
|
||||
|
||||
// Block moving a read-only source (move removes the source)
|
||||
if !keepSource && srcEntry.ReadOnly && !force {
|
||||
return fmt.Errorf("cannot move '%s': key is read-only", fromSpec.Key)
|
||||
}
|
||||
|
||||
sameStore := fromSpec.DB == toSpec.DB
|
||||
|
||||
// Check destination for overwrite prompt
|
||||
|
|
@ -121,6 +127,10 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
|
|||
|
||||
dstIdx := findEntry(dstEntries, toSpec.Key)
|
||||
|
||||
if dstIdx >= 0 && dstEntries[dstIdx].ReadOnly && !force {
|
||||
return fmt.Errorf("cannot overwrite '%s': key is read-only", toSpec.Key)
|
||||
}
|
||||
|
||||
if safe && dstIdx >= 0 {
|
||||
infof("skipped '%s': already exists", toSpec.Display())
|
||||
return nil
|
||||
|
|
@ -137,13 +147,15 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Write destination entry — preserve secret status
|
||||
// Write destination entry — preserve metadata
|
||||
newEntry := Entry{
|
||||
Key: toSpec.Key,
|
||||
Value: srcEntry.Value,
|
||||
ExpiresAt: srcEntry.ExpiresAt,
|
||||
Secret: srcEntry.Secret,
|
||||
Locked: srcEntry.Locked,
|
||||
ReadOnly: srcEntry.ReadOnly,
|
||||
Pinned: srcEntry.Pinned,
|
||||
}
|
||||
|
||||
if sameStore {
|
||||
|
|
@ -197,9 +209,11 @@ func init() {
|
|||
mvCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination")
|
||||
mvCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts")
|
||||
mvCmd.Flags().Bool("safe", false, "do not overwrite if the destination already exists")
|
||||
mvCmd.Flags().Bool("force", false, "bypass read-only protection")
|
||||
rootCmd.AddCommand(mvCmd)
|
||||
cpCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination")
|
||||
cpCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts")
|
||||
cpCmd.Flags().Bool("safe", false, "do not overwrite if the destination already exists")
|
||||
cpCmd.Flags().Bool("force", false, "bypass read-only protection")
|
||||
rootCmd.AddCommand(cpCmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ type Entry struct {
|
|||
ExpiresAt uint64 // Unix timestamp; 0 = never expires
|
||||
Secret bool // encrypted on disk
|
||||
Locked bool // secret but no identity available to decrypt
|
||||
ReadOnly bool // cannot be modified without --force
|
||||
Pinned bool // sorts to top in list output
|
||||
StoreName string // populated by list --all
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +54,8 @@ type jsonEntry struct {
|
|||
Value string `json:"value"`
|
||||
Encoding string `json:"encoding,omitempty"`
|
||||
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||
ReadOnly *bool `json:"readonly,omitempty"`
|
||||
Pinned *bool `json:"pinned,omitempty"`
|
||||
Store string `json:"store,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +153,8 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error)
|
|||
if je.ExpiresAt != nil {
|
||||
expiresAt = uint64(*je.ExpiresAt)
|
||||
}
|
||||
readOnly := je.ReadOnly != nil && *je.ReadOnly
|
||||
pinned := je.Pinned != nil && *je.Pinned
|
||||
|
||||
if je.Encoding == "secret" {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(je.Value)
|
||||
|
|
@ -156,14 +162,14 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error)
|
|||
return Entry{}, fmt.Errorf("decode secret for '%s': %w", je.Key, err)
|
||||
}
|
||||
if identity == nil {
|
||||
return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true}, nil
|
||||
return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true, ReadOnly: readOnly, Pinned: pinned}, nil
|
||||
}
|
||||
plaintext, err := decrypt(ciphertext, identity)
|
||||
if err != nil {
|
||||
warnf("cannot decrypt '%s': %v", je.Key, err)
|
||||
return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true}, nil
|
||||
return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true, ReadOnly: readOnly, Pinned: pinned}, nil
|
||||
}
|
||||
return Entry{Key: je.Key, Value: plaintext, ExpiresAt: expiresAt, Secret: true}, nil
|
||||
return Entry{Key: je.Key, Value: plaintext, ExpiresAt: expiresAt, Secret: true, ReadOnly: readOnly, Pinned: pinned}, nil
|
||||
}
|
||||
|
||||
var value []byte
|
||||
|
|
@ -179,7 +185,7 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error)
|
|||
default:
|
||||
return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key)
|
||||
}
|
||||
return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt}, nil
|
||||
return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt, ReadOnly: readOnly, Pinned: pinned}, nil
|
||||
}
|
||||
|
||||
func encodeJsonEntry(e Entry, recipients []age.Recipient) (jsonEntry, error) {
|
||||
|
|
@ -188,6 +194,14 @@ func encodeJsonEntry(e Entry, recipients []age.Recipient) (jsonEntry, error) {
|
|||
ts := int64(e.ExpiresAt)
|
||||
je.ExpiresAt = &ts
|
||||
}
|
||||
if e.ReadOnly {
|
||||
t := true
|
||||
je.ReadOnly = &t
|
||||
}
|
||||
if e.Pinned {
|
||||
t := true
|
||||
je.Pinned = &t
|
||||
}
|
||||
|
||||
if e.Secret && e.Locked {
|
||||
// Passthrough: Value holds raw ciphertext, re-encode as-is
|
||||
|
|
|
|||
20
cmd/set.go
20
cmd/set.go
|
|
@ -133,8 +133,14 @@ func set(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("cannot set '%s': %v", args[0], err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
idx := findEntry(entries, spec.Key)
|
||||
|
||||
if idx >= 0 && entries[idx].ReadOnly && !force {
|
||||
return fmt.Errorf("cannot set '%s': key is read-only", args[0])
|
||||
}
|
||||
|
||||
if safe && idx >= 0 {
|
||||
infof("skipped '%s': already exists", spec.Display())
|
||||
return nil
|
||||
|
|
@ -157,10 +163,15 @@ func set(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
pinFlag, _ := cmd.Flags().GetBool("pin")
|
||||
readonlyFlag, _ := cmd.Flags().GetBool("readonly")
|
||||
|
||||
entry := Entry{
|
||||
Key: spec.Key,
|
||||
Value: value,
|
||||
Secret: secret,
|
||||
Key: spec.Key,
|
||||
Value: value,
|
||||
Secret: secret,
|
||||
ReadOnly: readonlyFlag,
|
||||
Pinned: pinFlag,
|
||||
}
|
||||
if ttl != 0 {
|
||||
entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix())
|
||||
|
|
@ -185,5 +196,8 @@ func init() {
|
|||
setCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting an existing key")
|
||||
setCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest using age")
|
||||
setCmd.Flags().Bool("safe", false, "do not overwrite if the key already exists")
|
||||
setCmd.Flags().Bool("force", false, "bypass read-only protection")
|
||||
setCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)")
|
||||
setCmd.Flags().Bool("readonly", false, "mark the key as read-only")
|
||||
setCmd.Flags().StringP("file", "f", "", "read value from a file")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,14 +262,14 @@ func validateDBName(name string) error {
|
|||
|
||||
func formatExpiry(expiresAt uint64) string {
|
||||
if expiresAt == 0 {
|
||||
return "none"
|
||||
return "-"
|
||||
}
|
||||
expiry := time.Unix(int64(expiresAt), 0).UTC()
|
||||
remaining := time.Until(expiry)
|
||||
if remaining <= 0 {
|
||||
return "expired"
|
||||
}
|
||||
return fmt.Sprintf("in %s", remaining.Round(time.Second))
|
||||
return remaining.Round(time.Second).String()
|
||||
}
|
||||
|
||||
// parseTTLString parses a TTL string that is either a duration (e.g. "30m", "2h")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue