Skip to content

hulak migrate env — convert env/*.env to encrypted store #143

@xaaha

Description

@xaaha

Summary

Add a hulak migrate env command that converts existing env/*.env files into the encrypted .hulak/store.age format.

Background

After #127, hulak detects which storage backend to use:

  • .hulak/store.age exists → vault mode (primary)
  • only env/ exists → classic mode (legacy)

So users do not need to delete env/ to start using the vault — once store.age exists, it takes priority automatically. Migration is purely a one-time copy operation. Users can verify the migration, then delete env/ whenever they're comfortable.

Command

hulak migrate env
# ✓ Migrated global.env → store.age[global] (3 keys)
# ✓ Migrated staging.env → store.age[staging] (5 keys)
# ✓ Migrated prod.env → store.age[prod] (4 keys)
#
# No keypair found. Generating one...
# ✓ Identity stored at ~/.config/hulak/identity.txt
# ✓ Recipients file written to .hulak/recipients.txt
#
# ⚠ Save this recovery key somewhere safe (you won't see it again):
#   AGE-SECRET-KEY-1QF...
#   Anyone with this key can decrypt store.age.
#
# Note: env/ is untouched. The encrypted store now takes priority,
# but you can keep env/ around until you're confident in the migration.
# Delete it manually when ready: rm -rf env/

Flow

  1. Verify env/ directory exists
  2. Call vault.EnsureKeypair() — creates .hulak/, identity file, and recipients.txt if needed (see vault: multi-recipient encryption for team sharing #144)
  3. Scan env/ for *.env files
  4. For each file:
    1. Parse with existing envparser.LoadEnvVars() (preserves type inference)
    2. Derive environment name by stripping .env suffix
    3. Add as section in store
  5. If store.age already exists, merge with existing-store-wins precedence (don't overwrite secrets the user already set in the vault)
  6. Encrypt and write store.age via vault.WriteStore()
  7. Print summary + reminder about env/ cleanup
  8. Print recovery key warning only on first-run keypair generation

Edge cases

  • env/ doesn't exist → error: "No env/ directory found. Nothing to migrate."
  • store.age already has some environments → merge, existing store wins on conflicts (so re-running migrate is safe and won't clobber post-migration edits)
  • Empty .env files → create empty section in store
  • Files that aren't .env (e.g., .env.bak, .env.example) → skip with warning
  • Migration does NOT delete env/ — user does that manually after verification

Files to Create/Change

File Change
pkg/userFlags/subcommands.go Wire up migrate env subcommand
pkg/migration/envmigration.go NEW — migration logic

Relation to existing hulak migrate

Today hulak migrate handles Postman collection/environment import. This adds an env subcommand:

  • hulak migrate <env.json> <collection.json> — existing Postman migration (unchanged)
  • hulak migrate env — NEW: env/*.envstore.age migration

Tests

  • Migrate single .env file
  • Migrate multiple .env files
  • Merge with existing store.age (existing values win)
  • Skip non-.env files (e.g., .env.bak)
  • Empty env/ directory → still creates empty store + identity
  • No env/ directory → error
  • Re-running migrate is idempotent (no clobber)

Depends on

Blocks

Nothing — utility command.

Additional cases to cover (review pass)

  • $SYSTEM_VAR pass-through values: envparser supports $VAR references in .env values that are resolved against the host env at runtime. Migration must store the literal $VAR string in the encrypted store, not the resolved value, so the runtime template engine keeps doing the same pass-through after migration.
  • Partial pre-existing setup: identity file exists but .hulak/recipients.txt doesn't (e.g. project predates vault: multi-recipient encryption for team sharing #144). Migrate must bootstrap recipients.txt from key.pub before encrypting, not assume a clean slate.
  • Symlinked env/: follow the symlink and migrate the target. If the symlink is broken, error clearly rather than panicking.
  • env/ is a regular file, not a directory: graceful "expected env/ to be a directory" error.
  • stderr discipline: per project conventions, all migration progress (✓ Migrated..., recovery key warning, etc.) goes to stderr. stdout stays clean for piped consumers.
  • .env files containing only comments / blank lines: still create a section in the store (empty), don't skip silently — the user explicitly wants that environment to exist.
  • Duplicate keys within one .env file: last-wins (matches LoadEnvVars today), no warning needed.
  • Run when store.age already has a recipient set: do not regenerate the keypair; reuse the existing identity. The "Save this recovery key" warning prints only when EnsureKeypair actually generated new keys — gate on IdentityExists() snapshot taken before the call.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestepic: encryptionIssues that belong to the encryption epic

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions