diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-22 19:17:57 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-22 19:17:57 +0200 |
| commit | d629558394465b8956285edac324d67688ddd2c1 (patch) | |
| tree | f511cf1ea87d916c11e85243361c366ae9843cd4 | |
| parent | 48280e828bc4737a91ed556226f7fdcb52679f87 (diff) | |
Rename binary from geheim to foostore
- go.mod: module path codeberg.org/snonux/geheim → codeberg.org/snonux/foostore
- cmd/geheim/ → cmd/foostore/
- Magefile.go: binary/binaryName/mainPkg constants updated
- internal/config: config file path ~/.config/geheim.json → ~/.config/foostore.json
- All import paths and comments updated throughout
- Delete geheim.rb (the original Ruby implementation, superseded by this Go rewrite)
- CLAUDE.md rewritten to reflect the Go implementation, new binary name,
build system (mage), and current package architecture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | .serena/.gitignore | 1 | ||||
| -rw-r--r-- | .serena/project.yml | 117 | ||||
| -rw-r--r-- | CLAUDE.md | 50 | ||||
| -rw-r--r-- | Magefile.go | 6 | ||||
| -rw-r--r-- | cmd/foostore/main.go (renamed from cmd/geheim/main.go) | 8 | ||||
| -rwxr-xr-x | geheim.rb | 713 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | internal/cli/cli.go | 24 | ||||
| -rw-r--r-- | internal/config/config.go | 2 | ||||
| -rw-r--r-- | internal/config/config_test.go | 8 | ||||
| -rw-r--r-- | internal/crypto/crypto_test.go | 2 | ||||
| -rw-r--r-- | internal/git/git_test.go | 2 | ||||
| -rw-r--r-- | internal/shell/shell_test.go | 2 | ||||
| -rw-r--r-- | internal/store/data.go | 4 | ||||
| -rw-r--r-- | internal/store/data_test.go | 2 | ||||
| -rw-r--r-- | internal/store/index.go | 4 | ||||
| -rw-r--r-- | internal/store/index_test.go | 2 | ||||
| -rw-r--r-- | internal/store/store.go | 6 | ||||
| -rw-r--r-- | internal/store/store_test.go | 6 |
19 files changed, 188 insertions, 773 deletions
diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..38d1325 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,117 @@ +# the name by which the project can be referenced within Serena +project_name: "geheim" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- go + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# override of the corresponding setting in serena_config.yml, see the documentation there. +# If null or missing, the value from the global config is used. +symbol_info_budget: @@ -4,19 +4,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -`geheim.rb` is a single-file Ruby CLI for AES-256-CBC encryption of files and text. All secrets are stored in a Git repository with encrypted filenames (via SHA-256-hashed paths) and encrypted indices. The tool is designed for personal use on macOS, Linux, Android (Termux), and Windows. +`foostore` is a Go CLI for AES-256-CBC encryption of files and text. All secrets are stored in a Git repository with encrypted filenames (via SHA-256-hashed paths) and encrypted indices. The tool is designed for personal use on macOS, Linux, Android (Termux), and Windows. -## Running +## Building and running ```bash -ruby geheim.rb [command] [args] +mage # build (produces ./bin/foostore) +mage install # install to $GOPATH/bin (default ~/go/bin) +mage test # run all tests +mage vet # run go vet ``` -No build step. No gems beyond Ruby's standard library (openssl, readline, etc. are all stdlib). No Gemfile. +Or run directly after building: + +```bash +./bin/foostore [command] [args] +``` ## Testing -There is no test suite. Manual testing is done by running the script directly. +```bash +go test ./... +``` + +Table-driven unit tests exist for all internal packages. ## Fish shell integration @@ -26,7 +37,7 @@ There is no test suite. Manual testing is done by running the script directly. ## Configuration -Config is read from `~/.config/geheim.json` at startup (merged over defaults in `Config::DEFAULTS`). Key fields: +Config is read from `~/.config/foostore.json` at startup (merged over defaults). Key fields: - `data_dir`: Git repo where encrypted `.index` / `.data` file pairs are stored (default: `~/git/geheimlager`) - `key_file`: Path to the raw encryption key file (default: `~/.geheimlager.key`) - `export_dir`: Temporary directory for decrypted exports (default: `~/.geheimlagerexport`) @@ -37,29 +48,28 @@ The PIN (entered at startup or via `$PIN` env var) is used to derive the AES IV; ## Architecture -All code lives in `geheim.rb`. The class/module hierarchy: - ``` -Log (module) – formatted output: log/warn/prompt/fatal -Git (module) – git add/rm/commit/status/sync operations -Encryption (module) – AES-256-CBC encrypt/decrypt; reads PIN once into @@key/@@iv -Clipboard (module) – paste password field to OS clipboard (macOS/GNOME) -CommitFile – writes a file and git-adds it - GeheimData – one encrypted secret; encrypt/decrypt/export/reimport - Index – encrypted filename index; maps description → .data file via SHA256 hash -Geheim – main logic: fzf picker, search/add/import/rm/shred/walk_indexes -CLI – parses argv, runs the interactive shell loop (readline, vi mode) +cmd/foostore/main.go – thin entry point: -version flag, signal context, calls cli.Run +internal/version/ – Version constant +internal/config/ – load ~/.config/foostore.json, merge over defaults +internal/crypto/ – AES-256-CBC encrypt/decrypt (byte-identical to Ruby reference) +internal/git/ – git add/rm/commit/status/sync subprocess wrappers +internal/store/ – secret store: add/import/remove/search/export over .index+.data pairs +internal/clipboard/ – paste password field to OS clipboard (macOS/GNOME) +internal/shell/ – readline shell with vi mode, tab completion, history dedup +internal/cli/ – command dispatch, interactive shell loop ``` Data storage: every entry is a pair of files in `data_dir`: - `<sha256(dir)>/<sha256(name)>.index` – encrypted human-readable description/filename - `<sha256(dir)>/<sha256(name)>.data` – encrypted file content -Search (`walk_indexes`) decrypts every `.index` file and regex-matches against the description. `fzf` is used for interactive fuzzy selection. +Search (`WalkIndexes`) decrypts every `.index` file and regex-matches against the description. `fzf` is used for interactive fuzzy selection. ## Key design constraints -- Encryption key and IV are class-level (`@@key`, `@@iv`) — initialized once per process from the key file and PIN. +- Encryption key and IV are initialised once per process from the key file and PIN (`internal/crypto.Cipher`). - Commit messages are intentionally generic ("Changing stuff, not telling what in commit history") to avoid leaking metadata into git history. -- Binary vs text detection in `Index#binary?` is extension-based; known text extensions (`.txt`, `.README`, `.conf`, `.csv`, `.md`) are whitelisted. +- Binary vs text detection in `Index.IsBinary()` is extension-based; known text extensions (`.txt`, `.README`, `.conf`, `.csv`, `.md`) are whitelisted. - The `shred` command (GNU coreutils) is used when available; falls back to `rm -Pfv`. +- AES-256-CBC implementation is byte-identical to the original Ruby `geheim.rb` so existing encrypted databases remain readable. diff --git a/Magefile.go b/Magefile.go index 91631f0..571593b 100644 --- a/Magefile.go +++ b/Magefile.go @@ -16,9 +16,9 @@ import ( ) const ( - binary = "./bin/geheim" - binaryName = "geheim" - mainPkg = "./cmd/geheim" + binary = "./bin/foostore" + binaryName = "foostore" + mainPkg = "./cmd/foostore" ) // Default builds the binary so that a bare `mage` invocation is equivalent to `mage build`. diff --git a/cmd/geheim/main.go b/cmd/foostore/main.go index 1d5f6e6..dd0da3d 100644 --- a/cmd/geheim/main.go +++ b/cmd/foostore/main.go @@ -1,4 +1,4 @@ -// main is the thin entry point for the geheim binary. +// main is the thin entry point for the foostore binary. // It handles the -version flag, sets up a signal-cancellable context, // initialises the CLI, and exits with the code returned by Run. // All command logic lives in internal/cli. @@ -12,8 +12,8 @@ import ( "os/signal" "syscall" - "codeberg.org/snonux/geheim/internal/cli" - "codeberg.org/snonux/geheim/internal/version" + "codeberg.org/snonux/foostore/internal/cli" + "codeberg.org/snonux/foostore/internal/version" ) func main() { @@ -37,7 +37,7 @@ func main() { } // flag.Args() returns arguments after flags, so flag-aware invocations - // like `geheim -version` work while plain `geheim cat foo` still passes + // like `foostore -version` work while plain `foostore cat foo` still passes // all args through unchanged. os.Exit(c.Run(ctx, flag.Args())) } diff --git a/geheim.rb b/geheim.rb deleted file mode 100755 index 8c1b7ed..0000000 --- a/geheim.rb +++ /dev/null @@ -1,713 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'base64' -require 'digest' -require 'digest/sha2' -require 'fileutils' -require 'io/console' -require 'openssl' -require 'json' -require 'readline' - -VERSION = 'v0.3.1' - -# Configuration -class Config - CONFIG_PATH = File.join(Dir.home, '.config', 'geheim.json') - - DEFAULTS = { - data_dir: File.join(Dir.home, 'git', 'geheimlager'), - export_dir: File.join(Dir.home, '.geheimlagerexport'), - key_file: File.join(Dir.home, '.geheimlager.key'), - key_length: 32, - enc_alg: 'AES-256-CBC', - add_to_iv: 'Hello world', - edit_cmd: 'hx', - # edit_cmd: "nvim --cmd 'set noswapfile' --cmd 'set nobackup' --cmd 'set nowritebackup'", - gnome_clipboard_cmd: 'gpaste-client', - macos_clipboard_cmd: 'pbcopy', - sync_repos: %w[git1 git2] - }.freeze - - @@config = begin - DEFAULTS.merge(JSON.parse(File.read(CONFIG_PATH), symbolize_names: true)).freeze - rescue StandardError => e - puts "Unable to read #{CONFIG_PATH}, using defaults! #{e}" - DEFAULTS - end - - def self.method_missing(method_name, *args, &block) - return @@config[method_name] if @@config.key?(method_name) - - super - end - - def self.respond_to_missing?(method_name, include_private = false) - @@config.key?(method_name) || super - end -end - -# Logging capabilities -module Log - def log(message) - out message, '>' - end - - def warn(message) - out message, 'WARN' - end - - def prompt(message) - out message, '<', :nonl - end - - def fatal(message) - out message, 'FATAL' - exit 3 - end - - private - - def out(message, prefix, flag = :none) - message = message.to_s unless message.is_a?(String) - printer = flag == :nonl ? method(:print) : method(:puts) - message.split("\n").each { |line| printer.call("#{prefix} #{line}") } - end -end - -# Git versioning -module Git - include Log - - def git_add(file:) - Dir.chdir(File.dirname(file)) do - log `git add "#{File.basename(file)}"` - end - end - - def git_rm(file:) - Dir.chdir(File.dirname(file)) do - log `git rm "#{File.basename(file)}"` - end - end - - def git_status - Dir.chdir(Config.data_dir) do - log `git status` - end - end - - def git_commit - Dir.chdir(Config.data_dir) do - log `git commit -a -m 'Changing stuff, not telling what in commit history'` - end - end - - def git_reset - Dir.chdir(Config.data_dir) do - log `git reset --hard` - end - end - - def git_sync - log "Synchronising #{Config.data_dir}" - Dir.chdir(Config.data_dir) do - Config.sync_repos.each do |repo| - log `git pull #{repo} master` - log `git push #{repo} master` - end - log `git status` - end - end -end - -# Encryption functionality -module Encryption - include Log - @@key = nil - - def initialize - super() - return unless @@key.nil? - - pin = read_pin - # Set up initialization vector - iv = "#{pin * 2}#{Config.add_to_iv}#{pin * 2}" - @@iv = iv.byteslice(0, 16) - # ... and the encryption key! - @@key = enforce_key_length(File.read(Config.key_file), Config.key_length) - end - - def encrypt(plain:) - aes = OpenSSL::Cipher.new(Config.enc_alg) - aes.encrypt - aes.key = @@key - aes.iv = @@iv - - encrypted = aes.update(plain) - encrypted << aes.final - - encrypted - end - - def decrypt(encrypted:) - aes = OpenSSL::Cipher.new(Config.enc_alg) - aes.decrypt - aes.key = @@key - aes.iv = @@iv - - plain = aes.update(encrypted) - plain << aes.final - plain - end - - private - - def enforce_key_length(key, force_size) - new_key = key.dup - new_key += key while new_key.size < force_size - new_key[0, force_size] - end - - def read_pin - return ENV['PIN'] if ENV['PIN'] - - prompt 'PIN: ' - return $stdin.gets.chomp if `uname`.include?('Android') - - $stdin.noecho(&:gets).chomp - end -end - -# Comitting a file -class CommitFile - include Git - include Log - - def commit_content(file:, content:, force: false) - if File.exist?(file) && !force - warn "#{file} already exists. Use 'force' flag to overwrite." - return false - end - - dirname = File.dirname(file) - FileUtils.mkdir_p(dirname) unless Dir.exist?(dirname) - - log "Writing #{file}" - File.write(file, content) - git_add(file: file) - end -end - -# Clipboard support -module Clipboard - include Log - @clipboard_cmd = nil - - def initialize - super() - @clipboard_cmd = - ENV['UNAME'] == 'Darwin' ? Config.macos_clipboard_cmd : Config.gnome_clipboard_cmd - end - - def paste(data) - fatal "Can't paste to clipboard" if @clipboard_cmd.nil? - user, password, other = extract(data.to_s) - read, write = IO.pipe - pid = spawn(@clipboard_cmd, in: read) - read.close - write.write(password) - write.close - puts other - Process.detach(pid) - log "Pasted password for user '#{user}' to the clipboard" - end - - private - - def extract(data) - parts = data.match(/(?<User>\S+):(?<Password>\S+)/) - cleared_data = data.gsub(/(\S+):\S+/, '\1:CENSORED') - [parts['User'], parts['Password'], cleared_data] - end -end - -# Secret data store -class GeheimData < CommitFile - include Encryption - include Git - include Log - - attr_accessor :data, :exported_path - - def initialize(data_file:, data: nil) - super() - - @exported_path = nil - @data_path = "#{Config.data_dir}/#{data_file}" - @data = data.nil? ? decrypt(encrypted: File.read(@data_path)) : data - rescue StandardError => e - fatal e - end - - def to_s - "\t#{@data.gsub("\n", "\n\t")}\n" - end - - def rm - log "Deleting #{@data_path}" - git_rm(file: @data_path) - end - - def export(destination_file:) - destination_dir = "#{Config.export_dir}/#{File.dirname(destination_file)}" - unless File.directory?(destination_dir) - log "Creating #{destination_dir}" - FileUtils.mkdir_p(destination_dir) - end - - destination_path = "#{destination_dir}/#{File.basename(destination_file)}" - log "Exporting to #{destination_path}" - File.open(destination_path, 'w') { |fd| fd.write(@data) } - @exported_path = destination_path - end - - def reimport_after_export - @data = File.read(@exported_path) - commit(force: true) - end - - def commit(force: false) - commit_content(file: @data_path, content: encrypt(plain: @data), force: force) - end -end - -# Data store's encrypted index -class Index < CommitFile - attr_accessor :description, :data_file, :index_path - - include Encryption - include Log - - def initialize(index_file:, description: nil) - super() - @data_file = index_file.sub('.index', '.data') - @index_path = "#{Config.data_dir}/#{index_file}" - @hash = File.basename(index_file).sub('.index', '') - @description = description.nil? ? decrypt(encrypted: File.read(@index_path)) : description - end - - def binary? - if @description.include?('.txt') - false - elsif @description.include?('.README') - false - elsif @description.include?('.conf') - false - elsif @description.include?('.csv') - false - elsif @description.include?('.md') - false - else - @description.include?('.') - end - end - - def get_data(data: nil) - GeheimData.new(data_file: @data_file, data: data) - end - - def to_s - binary = binary? ? '(BINARY) ' : '' - "#{@description}; #{binary}...#{@hash[-11...-1]}\n" - end - - def <=>(other) - @description <=> other.description - end - - def rm - log "Deleting #{@index_path}" - git_rm(file: @index_path) - end - - def commit(force: false) - commit_content(file: @index_path, content: encrypt(plain: @description), force: force) - end -end - -# Secret store main class -class Geheim - include Clipboard - include Log - - def initialize - super() - unless File.directory?(Config.data_dir) - log "Creating #{Config.data_dir}" - FileUtils.mkdir_p(Config.data_dir) - end - @regex_cache = {} - end - - def fzf(flag = :none) - # Need to read an index first before opening the pipe to initialize - # the encryption PIN. - fzf = nil - walk_indexes do |index| - fzf = IO.popen('fzf', 'r+') if fzf.nil? - fzf.write(index) - end - fzf.close_write - match = fzf.read.chomp - log match unless flag == :silent - match.split(';').first - end - - def search(search_term: nil, action: :none) - ec = 1 - search_term = fzf(:silent) if search_term.nil? - indexes = [] - walk_indexes(search_term: search_term) do |index| - indexes << index - end - indexes.sort.each do |index| - print index - ec = 0 - case action - when :cat, :paste - if index.binary? - log 'Not displaying/pasting binary data!' - ec = 2 - elsif action == :paste - paste(index.get_data) - else - puts index.get_data - end - when :pathexport - index.get_data.export(destination_file: index.description) - when :export - destination_file = File.basename(index.description) - index.get_data.export(destination_file: destination_file) - when :open - destination_file = File.basename(index.description) - index.get_data.export(destination_file: destination_file) - shred_file(file: open_exported(file: destination_file), delay: 0) - when :edit - destination_file = File.basename(index.description) - data = index.get_data - data.export(destination_file: destination_file) - external_edit(file: destination_file) - data.reimport_after_export - end - index.description - end - ec - end - - def add(description:) - hash = hash_path(description) - - log 'Data: ' - data = $stdin.gets.chomp - index = Index.new(index_file: "#{hash}.index", description: description) - data = index.get_data(data: data) - - data.commit - index.commit - end - - def import(description: nil, action: nil, file: nil, dest_dir: nil, force: false) - src_path = file.gsub('//', '/').gsub(%r{^\./}, '') - dest_path = if dest_dir.nil? - src_path - elsif dest_dir.include?('.') - dest_dir - else - "#{dest_dir}/#{File.basename(file)}".gsub('//', '/') - end - - hash = hash_path(dest_path) - - fatal "#{file} does not exist!" unless File.exist?(src_path) - log "Importing #{src_path} -> #{dest_path}" - data = File.read(src_path) - shred_file(file: src_path) if action == :newtxt - description = dest_path if description.nil? - - index = Index.new(index_file: "#{hash}.index", description: description) - data = index.get_data(data: data) - - data.commit(force: force) - index.commit(force: force) - end - - def import_recursive(directory:, dest_dir: nil) - Dir.glob("#{directory}/**/*").each do |source_file| - next if File.directory?(source_file) - - file = source_file.sub("#{directory}/", '') - import(description: file, action: :import, file: source_file, dest_dir: dest_dir) - end - end - - def rm(search_term:) - indexes = [] - walk_indexes(search_term: search_term) do |index| - indexes << index - end - indexes.sort.each do |index| - loop do - log index - prompt 'You really want to delete this? (y/n): ' - case $stdin.gets.chomp - when 'y' - data = index.get_data - data.rm - index.rm - break - when 'n' - break - end - end - end - 0 - end - - def shred_all_exported - log 'Shredding all exported files' - ec = 0 - Dir.glob("#{Config.export_dir}/*").each do |file| - next unless File.file?(file) - - if (ec_ = shred_file(file: file)).positive? - ec = ec_ - end - end - ec - end - - private - - def shred_file(file:, delay: 0) - sleep(delay) if delay.positive? - `which shred` - if $?.success? - run_command("shred -vu #{file}") - else - run_command("rm -Pfv #{file}") - end - end - - def open_exported(file:) - file_path = "#{Config.export_dir}/#{file}" - - case ENV['UNAME'] - when 'Darwin' - run_command("open #{file_path}") - when 'Microsoft' - run_command("winopen #{file_path}") - when 'Linux' - run_command("evince #{file_path}") - else - # Termux (Android) - run_command("termux-open #{file_path}") - end - file_path - end - - def external_edit(file:) - file_path = "#{Config.export_dir}/#{file}" - edit_cmd = "#{Config.edit_cmd} #{file_path}" - log edit_cmd - system(edit_cmd) - file_path - end - - def run_command(cmd) - log "#{cmd}: #{`#{cmd}`}" - $?.exitstatus - end - - def walk_indexes(search_term: nil) - @regex_cache[search_term] = Regexp.new(/#{search_term}/) unless @regex_cache.key?(search_term) - regex = @regex_cache[search_term] - Dir.glob("#{Config.data_dir}/**/*.index").each do |index_file| - index = Index.new(index_file: index_file.sub(Config.data_dir, '')) - yield index if search_term.nil? || index.description.force_encoding('UTF-8').match(regex) - end - end - - def hash_path(path_string) - path = [] - path_string.gsub('//', '/').split('/').each do |part| - path << Digest::SHA256.hexdigest(part) - end - path.join('/') - end -end - -# Command line interface -class CLI - include Git - include Log - - COMMANDS = %w[ - ls search cat paste get add export pathexport open edit import - import_r rm sync status commit reset fullcommit shred version - commands help shell exit last - ].freeze - - SEARCH_ACTIONS = { - 'cat' => :cat, - 'paste' => :paste, - 'export' => :export, - 'pathexport' => :pathexport, - 'edit' => :edit, - 'open' => :open - }.freeze - - def initialize(interactive: false) - super() - @interactive = interactive - setup_readline if interactive - end - - def setup_readline - # Enable vi editing mode - Readline.vi_editing_mode - - # Set up tab completion - Readline.completion_proc = proc do |input| - # Get all available commands - completions = COMMANDS.dup - - # If PIN is set, also include entry names for completion - if ENV['PIN'] - begin - geheim = Geheim.new - geheim.walk_indexes do |index| - completions << index.description.split(';').first.strip - end - rescue StandardError - # Ignore errors during completion - end - end - - completions.grep(/^#{Regexp.escape(input)}/) - end - - # Set up completion append character - Readline.completion_append_character = ' ' - end - - def commands - puts COMMANDS - 0 - end - - def help - log <<~HELP - ls - SEARCHTERM - search SEARCHTERM - cat SEARCHTERM - get SEARCHTERM - add DESCRIPTION - export|pathexport|open|edit FILE - import FILE [DEST_DIRECTORY] [force] - import_r DIRECTORY [DEST_DIRECTORY] - rm SEARCHTERM - sync|status|commit|reset|fullcommit - shred - version - commands - help - shell - HELP - 0 - end - - def shell_loop(argv) - last_result = nil - ec = 0 - - loop do - if argv.empty? || @interactive - @interactive ||= true - setup_readline unless Readline.completion_proc - - input = Readline.readline('% ', true) - break if input.nil? # Handle Ctrl+D - - argv = input.strip.split - - # Don't add empty lines or duplicates to history - Readline::HISTORY.pop if argv.empty? || - (Readline::HISTORY.length > 1 && - Readline::HISTORY[-1] == Readline::HISTORY[-2]) - end - - geheim = Geheim.new - action = argv.first - search_term = argv.length < 2 ? last_result : argv[1] - - ec = case action - when 'ls' - geheim.search(search_term: '.') - when 'search' - geheim.search(search_term: search_term) - when *SEARCH_ACTIONS.keys - geheim.search(search_term: search_term, action: SEARCH_ACTIONS[action]) - when 'add' - geheim.add(description: search_term) - when 'import' - geheim.import(file: search_term, dest_dir: argv[2], force: !argv[3].nil?) - when 'import_r' - geheim.import_recursive(directory: search_term, dest_dir: argv[2]) - when 'rm' - geheim.rm(search_term: search_term) - when 'help' - help - when 'shell' - @interactive = true - log 'Switching to interactive mode' - when 'exit' - @interactive = false - log 'Good bye' - when 'status' - git_status - when 'commit' - git_commit - when 'reset' - git_reset - when 'sync' - git_sync - when 'fullcommit' - git_sync - git_commit - git_sync - when 'shred' - geheim.shred_all_exported - when 'version' - log "geheim #{VERSION}" - 0 - when 'commands' - commands - when 'last' - puts last_result - last_result - when nil - last_result = geheim.fzf - else - last_result = geheim.search(search_term: action) - end - break unless @interactive - end - - ec - end -end - -exit(CLI.new.shell_loop(ARGV)) @@ -1,4 +1,4 @@ -module codeberg.org/snonux/geheim +module codeberg.org/snonux/foostore go 1.24.0 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0ae4dde..fb76e90 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,7 +1,7 @@ // Package cli implements the command-line interface for geheim. // It mirrors the Ruby CLI class (geheim.rb lines 551-713): parsing argv, // dispatching commands, and running an optional interactive readline shell. -// Run() is the top-level entry point called by cmd/geheim/main.go. +// Run() is the top-level entry point called by cmd/foostore/main.go. package cli import ( @@ -15,13 +15,13 @@ import ( "runtime" "strings" - "codeberg.org/snonux/geheim/internal/clipboard" - "codeberg.org/snonux/geheim/internal/config" - "codeberg.org/snonux/geheim/internal/crypto" - "codeberg.org/snonux/geheim/internal/git" - "codeberg.org/snonux/geheim/internal/shell" - "codeberg.org/snonux/geheim/internal/store" - "codeberg.org/snonux/geheim/internal/version" + "codeberg.org/snonux/foostore/internal/clipboard" + "codeberg.org/snonux/foostore/internal/config" + "codeberg.org/snonux/foostore/internal/crypto" + "codeberg.org/snonux/foostore/internal/git" + "codeberg.org/snonux/foostore/internal/shell" + "codeberg.org/snonux/foostore/internal/store" + "codeberg.org/snonux/foostore/internal/version" ) // CommandList is the canonical list of supported commands, ordered to match @@ -60,7 +60,7 @@ type CLI struct { } // New initialises all runtime dependencies (config, PIN, cipher, store, git, -// clipboard, shell) and returns a ready-to-use CLI. cmd/geheim/main.go calls +// clipboard, shell) and returns a ready-to-use CLI. cmd/foostore/main.go calls // New with a signal-cancellable context so that long-running operations (fzf, // external editors) are interrupted cleanly on SIGINT/SIGTERM. func New(ctx context.Context) (*CLI, error) { @@ -70,7 +70,7 @@ func New(ctx context.Context) (*CLI, error) { // Run dispatches argv (typically os.Args[1:]) to the appropriate handler or // enters the interactive shell loop. Returns an exit code suitable for // os.Exit. The caller is responsible for calling sh.Close() when done; -// cmd/geheim/main.go does this via defer. +// cmd/foostore/main.go does this via defer. func (c *CLI) Run(ctx context.Context, argv []string) int { defer c.sh.Close() return c.run(ctx, argv) @@ -297,7 +297,7 @@ func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (in return 0, "", true case "version": - logMsg(fmt.Sprintf("geheim %s", version.Version)) + logMsg(fmt.Sprintf("foostore %s", version.Version)) return 0, "", true case "commands": @@ -315,7 +315,7 @@ func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (in // dispatch is called, so this branch only fires in one-shot mode where // switching to interactive mode is not meaningful. We print a notice and // exit cleanly rather than silently doing nothing. - logMsg("Use geheim without arguments to enter interactive mode") + logMsg("Use foostore without arguments to enter interactive mode") return 0, "", true case "exit": diff --git a/internal/config/config.go b/internal/config/config.go index f48a046..6354d83 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,7 @@ import ( ) // configPath is the location of the optional user config file. -const configPath = "~/.config/geheim.json" +const configPath = "~/.config/foostore.json" // Config holds all application-wide configuration values. // JSON field names use snake_case to match geheim.rb Config::DEFAULTS keys. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ae2d6c1..3f8ace9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -// writeUserConfig creates the ~/.config/geheim.json file inside the given +// writeUserConfig creates the ~/.config/foostore.json file inside the given // HOME directory (which must already exist). Used to exercise Load() directly. func writeUserConfig(t *testing.T, home, content string) { t.Helper() @@ -17,7 +17,7 @@ func writeUserConfig(t *testing.T, home, content string) { if err := os.MkdirAll(cfgDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } - path := filepath.Join(cfgDir, "geheim.json") + path := filepath.Join(cfgDir, "foostore.json") if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } @@ -167,7 +167,7 @@ func TestLoad_invalid_json(t *testing.T) { // TestLoad_missing_file_no_warning verifies that a missing config file does NOT // produce any output — absence is normal for a first-run or unconfigured install. func TestLoad_missing_file_no_warning(t *testing.T) { - dir := t.TempDir() // no geheim.json inside + dir := t.TempDir() // no foostore.json inside t.Setenv("HOME", dir) stderr := captureStderr(func() { _ = Load() }) @@ -188,7 +188,7 @@ func TestLoad_unreadable_file(t *testing.T) { writeUserConfig(t, dir, `{"edit_cmd":"nvim"}`) // Make the file unreadable. - cfgPath := filepath.Join(dir, ".config", "geheim.json") + cfgPath := filepath.Join(dir, ".config", "foostore.json") if err := os.Chmod(cfgPath, 0o000); err != nil { t.Fatalf("Chmod: %v", err) } diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index 556a12e..fb37d69 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -294,7 +294,7 @@ func TestEncryptGolden(t *testing.T) { wantHex: "6190f985f42374d24dd8e17b3b2d6057", }, { - name: "Hello world / pin=abcd1234 / 64x 'y'", + name: "Hello world / pin=abcd1234 / 64x 'y'", plaintext: []byte("Hello, world!"), pin: "abcd1234", // 64 bytes of 'y': key is already 2x the required 32 bytes so it gets truncated. diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 87d2ecf..fa1f8f7 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "codeberg.org/snonux/geheim/internal/git" + "codeberg.org/snonux/foostore/internal/git" ) // initRepo creates a temporary git repository with a minimal config so that diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index 19491e3..71eafa8 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "codeberg.org/snonux/geheim/internal/shell" + "codeberg.org/snonux/foostore/internal/shell" ) // isTTY returns true when stdin is connected to an actual terminal. diff --git a/internal/store/data.go b/internal/store/data.go index b9423f2..fb33bf0 100644 --- a/internal/store/data.go +++ b/internal/store/data.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - "codeberg.org/snonux/geheim/internal/crypto" - "codeberg.org/snonux/geheim/internal/git" + "codeberg.org/snonux/foostore/internal/crypto" + "codeberg.org/snonux/foostore/internal/git" ) // Data holds a decrypted secret blob and the paths used to persist it. diff --git a/internal/store/data_test.go b/internal/store/data_test.go index 88b80cd..42721af 100644 --- a/internal/store/data_test.go +++ b/internal/store/data_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "codeberg.org/snonux/geheim/internal/crypto" + "codeberg.org/snonux/foostore/internal/crypto" ) // --- helpers ----------------------------------------------------------------- diff --git a/internal/store/index.go b/internal/store/index.go index 9064971..923082f 100644 --- a/internal/store/index.go +++ b/internal/store/index.go @@ -11,8 +11,8 @@ import ( "path/filepath" "strings" - "codeberg.org/snonux/geheim/internal/crypto" - "codeberg.org/snonux/geheim/internal/git" + "codeberg.org/snonux/foostore/internal/crypto" + "codeberg.org/snonux/foostore/internal/git" ) // Index represents a decrypted .index file and its associated .data path. diff --git a/internal/store/index_test.go b/internal/store/index_test.go index a16d757..57aea8c 100644 --- a/internal/store/index_test.go +++ b/internal/store/index_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "codeberg.org/snonux/geheim/internal/crypto" + "codeberg.org/snonux/foostore/internal/crypto" ) // newTestIndexCipher is a local helper to avoid import cycle via store_test.go. diff --git a/internal/store/store.go b/internal/store/store.go index fe9f132..0dfa5e7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -19,9 +19,9 @@ import ( "sort" "strings" - "codeberg.org/snonux/geheim/internal/config" - "codeberg.org/snonux/geheim/internal/crypto" - "codeberg.org/snonux/geheim/internal/git" + "codeberg.org/snonux/foostore/internal/config" + "codeberg.org/snonux/foostore/internal/crypto" + "codeberg.org/snonux/foostore/internal/git" ) // Action describes what to do with each matching secret during a Search call. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 950c010..9670a01 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -13,9 +13,9 @@ import ( "strings" "testing" - "codeberg.org/snonux/geheim/internal/config" - "codeberg.org/snonux/geheim/internal/crypto" - "codeberg.org/snonux/geheim/internal/git" + "codeberg.org/snonux/foostore/internal/config" + "codeberg.org/snonux/foostore/internal/crypto" + "codeberg.org/snonux/foostore/internal/git" ) // --- test helpers ------------------------------------------------------------ |
