feat: adds --readonly and --pin flags, and displays Size column in list by default

This commit is contained in:
Lewis Wynne 2026-02-13 18:52:34 +00:00
parent e5b6dcd187
commit 5bcd3581dd
46 changed files with 711 additions and 177 deletions

View file

@ -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,

View file

@ -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
}

View file

@ -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)")

View file

@ -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)
}

View file

@ -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")

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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")
}

View 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")