Skip to content
Kosh Docs

Architecture

Kosh internals — package layout, dependency injection, storage schema, and the data flow through a command.

This page covers how Kosh is put together internally: package responsibilities, how commands are wired, the storage schema, and where the boundaries are. It is aimed at contributors.

For the cryptography itself see Encryption Architecture; for ranking see Adaptive Search Algorithm.


Package Responsibility
cmd/ CLI surface — one file per subcommand, wired to Cobra
cmd/profile/ The profile parent command and its list/create/delete subcommands
internal/app app.Context — the dependency container passed to every command constructor
internal/config ~/.kosh/config.json load/save; active-profile resolution
internal/core Business logic behind VaultService and ProfileService
internal/crypto Thin wrappers around Go crypto primitives, plus secure file overwrite
internal/storage SQLite persistence: Store interface and its implementation
internal/model Plain data structs, encode/decode helpers, slog.LogValuer redaction
internal/search Scoring and ranking
internal/ui Terminal I/O — output, tables, prompts, interactive pickers, clipboard
internal/logger log/slog setup, gated on KOSH_DEBUG
internal/encoding Base64 helpers used at the model boundary
internal/constants Sentinel errors, user-facing strings, tuning constants

Commands are constructors, not package-level globals:

func NewCmdList(ctx *app.Context) *cobra.Command { … }

Each is registered in cmd/root.go:

rootCmd.AddCommand(
NewCmdUse(appCtx),
NewCmdInit(appCtx),
NewCmdList(appCtx),
NewCmdAdd(appCtx),
NewCmdGenerate(appCtx),
NewCmdGet(appCtx),
NewCmdSearch(appCtx),
NewCmdUpdate(appCtx),
NewCmdDelete(appCtx),
NewCmdCopy(appCtx),
profile.NewCmdProfile(appCtx),
)

app.Context carries Config, Store, Vault and Profile. It is populated in PersistentPreRun — load config → set the UI profile prefix → open the store → construct the services — and the store is closed in PersistentPostRun.

This replaced package-level globals and init() side effects, which is what makes commands testable.

core.VaultService and core.ProfileService are interfaces, implemented by KoshVault and KoshProfile. Commands depend on the interface, so tests can substitute fakes without touching SQLite or the filesystem.


Two rules govern every command that names a profile, and both are enforced in internal/core.

ResolveProfile returns the stored spelling

Section titled “ResolveProfile returns the stored spelling”
ResolveProfile(name string) (model.Profile, bool, error)

Lookup folds case, but a profile is always reported under the name it carries on disk. Callers must use the returned name for anything that then touches the file — on a case-sensitive filesystem work and Work are different paths, so opening a profile under the spelling the user typed rather than the stored one would silently create an empty vault beside the real one.

Case is folded on every platform, not only the ones whose filesystem does. Two profiles differing only in case can coexist on Linux but collide the moment the directory is copied to Windows or macOS, so they are refused everywhere. Where a directory carried over from a case-sensitive machine does hold both, an exact-spelling match wins; otherwise the first match is used.

LoadProfiles(filter, exactMatch) backs it: it folds both sides of the comparison, returns (nil, nil) when the profiles directory does not exist, and returns early on the first hit when exactMatch is set.

SanitizeProfileName(name string) (string, error)

Applied by kosh profile create and nowhere else. In order: fold accents to ASCII (encoding.RemoveAccent, via golang.org/x/text), trim, delete anything outside [a-zA-Z0-9\s_-], collapse whitespace runs to _, collapse repeated _ and -, trim _- from the ends, reject if empty, cap at 252 characters (255 less .db) and re-trim, then reject Windows reserved device names. Case is preserved throughout.

Ordering in profile create is load-bearing

Section titled “Ordering in profile create is load-bearing”

runCreate builds the vault before it touches any persistent state, and switches the active profile only once the vault exists. That order is not stylistic — it is the only defence available:

ui.ReadSecretWithConfirmation installs a SIGINT handler that calls os.Exit(130) (internal/ui/field.go). os.Exit runs no deferred functions, so no cleanup in the command layer can ever run when the user presses Ctrl+C at a password prompt. Rollback code, defer, error paths — none of it executes.

The earlier ordering switched the active profile before prompting, so an interrupt at the prompt left the config pointing at a profile whose vault had never been created. Because the store creates its file lazily, the next command would materialise it: a profile that existed, was active, and had no master password.

Persisting nothing before the prompt is the only reliable fix. Any future command that mutates state before prompting for input has the same bug.

Supporting changes: initVault(profile string) takes the profile name and builds its own config.Config for the store, because it runs before the profile is active; rollbackCreate(ctx, name) only deletes the orphaned vault file, logging cleanup problems rather than returning them.

A narrow window remains — an interrupt after the password is confirmed but before the vault finishes writing can still orphan a profile file. It is never the active profile and has no vault, so kosh profile delete removes it without a password.

DeleteProfile refuses any name that is not a local path (filepath.IsLocal) before it reaches the file-scrubbing code. Traversal names like ../../other are already unreachable — ResolveProfile only ever returns real directory entries — so this is defence in depth on the one destructive path in the package.


Kosh treats any unrecognised first argument as a search query. This is done by rewriting os.Args before Cobra ever parses them:

if len(os.Args) == 1 {
os.Args = append(os.Args, DEFAULT_COMMAND) // "search"
} else {
firstArg := os.Args[1]
if !strings.HasPrefix(firstArg, "-") && !isKnownCommand(rootCmd, firstArg) {
// ["kosh", "launch_codes"] becomes ["kosh", "search", "launch_codes"]
os.Args = append(os.Args[:1], append([]string{DEFAULT_COMMAND}, os.Args[1:]...)...)
}
}

isKnownCommand checks registered commands and their aliases, plus Cobra builtins that do not appear in .Commands() (help, completion, __complete, __completeNoDesc).

This is also why a credential label may not be a subcommand name — the label would be swallowed by the dispatcher instead of reaching search.


Execute() runs the legacy-layout migration before anything else:

if err := migrateToProfileFS(); err != nil { … }

The migration is guarded so it is effectively idempotent:

  1. If ~/.kosh/profiles/default.db exists → return immediately.
  2. Else if ~/.kosh/kosh.db does not exist → return immediately.
  3. Else create ~/.kosh/profiles/ (0700) and os.Rename the legacy file to profiles/default.db.

Because step 1 precedes everything, a kosh.db restored from a backup later can never clobber an existing default profile.

The active profile is then read from ~/.kosh/config.json, which is created with {"active_profile":"default"} if absent.


Migrations live in an ordered migrations []string slice and are applied transactionally on every store init, tracked in a schema_migrations table. DDL was moved out of InitializeVault, which now only inserts the vault row.

The vault file is ~/.kosh/profiles/<name>.db. All crypto values are stored base64-encoded.

vault — one row per profile:

Column Contents
public_key Curve25519 public key
secret Curve25519 private key, encrypted
nonce Nonce for the above
salt Argon2id salt

credentials — one row per credential:

Column Contents
id Numeric handle used by update, delete, copy
label, user Plaintext metadata; unique together
ephemeral Per-credential ephemeral public key
nonce Per-credential nonce
secret Ciphertext
access_count Retrieval counter, feeds search ranking
accessed_at, updated_at, created_at Timestamps

schema_migrations — applied migration bookkeeping.

The connection is opened with PRAGMA secure_delete=ON.


The rule is: lower layers return errors without logging; Execute() logs once and prints once.

  • Storage translates driver errors into sentinels — constants.ErrCredentialNotFound instead of leaking sql.ErrNoRows upward.
  • Storage and vault errors are wrapped with %w and context as they propagate.
  • No layer below cmd/ calls into ui, and no layer below cmd/ logs an error it is returning.
  • Execute() emits a single slog.Debug("command failed", …) record and a single ui.Error(…) line, then exits 1.

All user-facing strings live in internal/constantserrors.go, messages.go, prompts.go — so wording is enforced project-wide rather than scattered across call sites.

  • Errors and messages are lowercase, unpunctuated and article-free; the UI supplies its own prefix.
  • Errors read subject-first: failed to save credential, profile does not exist.
  • Completed actions end in successfully: credential saved successfully.
  • Inline prompts end with ": "; block prompts (confirmations, option lists) do not.

File Contents
output.go Colours, glyphs, the profile prefix, Caution, PauseOutput, WithProfile
table.go Zero-dependency auto-sized table with an active-row pointer
time.go RelativeTime — two significant units (02d 04h ago)
field.go Prompts, password entry, typed confirmations, SIGINT handling
search.go The interactive credential/profile pickers
clipboard.go Single cross-platform clipboard path

PauseOutput() swaps the output writers to io.Discard and silences the logger, returning a restore function. It wraps raw-mode TUI sections so stray log lines cannot corrupt the display:

defer ui.PauseOutput()()

WithProfile(p) follows the same restore-function shape, temporarily overriding the (profile) prefix. kosh profile delete uses it so that output about the profile being deleted is labelled with that profile rather than the active one:

defer ui.WithProfile(target)()

The table renderer takes raw strings — embedding ANSI escapes in a cell would skew its column width calculation, which is done with utf8.RuneCountInString.


internal/logger is deliberately small: Setup() and Pause() around log/slog.

Setup() installs a text handler on stderr with AddSource: true. The level comes from KOSH_DEBUG via strconv.ParseBool; anything unset, unparseable or falsy sets a sentinel level above every real level, so nothing is emitted.

Pause() raises the level to that sentinel and returns a restore closure — the mechanism behind ui.PauseOutput().

Redaction is structural: the model types implement slog.LogValuer, so sensitive fields render as [REDACTED] in every record regardless of call site.


Kosh is deliberately thin, and builds without CGO:

Module Purpose
github.com/spf13/cobra CLI framework
golang.org/x/crypto Argon2id, XChaCha20-Poly1305, Curve25519
golang.org/x/term Raw-mode terminal handling for password entry
modernc.org/sqlite Pure-Go SQLite driver
golang.design/x/clipboard Cross-platform clipboard
golang.org/x/text Unicode normalization for accent folding in profile names

The pure-Go SQLite driver is what keeps the released binaries statically linked and free of system dependencies.


Terminal window
go test ./...

Current coverage is strongest in internal/search, internal/core and internal/crypto, with internal/ui/time_test.go covering relative-time formatting. The service interfaces in internal/core exist specifically so command-level tests can run against fakes.

internal/core/profile_service_test.go covers the profile service end to end: the sanitization table, case-insensitive resolution, exact-spelling preference, traversal names resolving to nothing, filter behaviour, and the missing-directory case.

internal/search/search_test.go is the largest suite — see test coverage for what it asserts.