diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e4788c9..bf534d6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.25.3' - name: Lint Check run: go vet ./... @@ -26,95 +26,77 @@ jobs: - name: Run tests run: go test -v ./... - # Create release - create-release: + # Create release and build binaries + release: needs: test runs-on: ubuntu-latest - outputs: - RELEASE_URL: ${{ steps.create-release.outputs.upload_url }} - VERSION: ${{ steps.get_version.outputs.VERSION }} + permissions: + contents: write steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for tags - name: Get Version id: get_version - run: echo "VERSION=$(cat VERSION)" >> $GITHUB_ENV - - - name: Verify latest release tag run: | - git fetch --tags - latest_tag=$(git describe --tags `git rev-list --tags --max-count=1`) - echo "Latest tag: $latest_tag" - echo "LATEST_TAG=$latest_tag" >> $GITHUB_ENV + VERSION=$(cat VERSION) + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" - - name: Check if new release is needed + - name: Check if release exists + id: check_release run: | - if [ "$LATEST_TAG" = "v$VERSION" ]; then - echo "No new release needed" - echo "SKIP_RELEASE=true" >> $GITHUB_ENV + git fetch --tags + if git rev-parse "v${{ steps.get_version.outputs.VERSION }}" >/dev/null 2>&1; then + echo "RELEASE_EXISTS=true" >> $GITHUB_OUTPUT + echo "Release v${{ steps.get_version.outputs.VERSION }} already exists" else - echo "SKIP_RELEASE=false" >> $GITHUB_ENV + echo "RELEASE_EXISTS=false" >> $GITHUB_OUTPUT + echo "Creating new release v${{ steps.get_version.outputs.VERSION }}" fi - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - - - name: Create Release - id: create-release - if: env.SKIP_RELEASE != 'true' - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - with: - tag_name: v${{ env.VERSION }} - release_name: Release v${{ env.VERSION }} - draft: false - prerelease: false - build: - needs: create-release - # Run only if a valid release url has been set - if: ${{ needs.create-release.outputs.RELEASE_URL }} != '' - strategy: - matrix: - os: ['windows', 'linux', 'darwin'] - arch: ['amd64', 'arm64'] - exclude: - - os: windows - arch: arm64 - - os: linux - arch: arm64 - - os: darwin - arch: amd64 - - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up Go - uses: actions/setup-go@v2 + if: steps.check_release.outputs.RELEASE_EXISTS == 'false' + uses: actions/setup-go@v5 with: - go-version: '1.21' # Use the Go version of your choice + go-version: '1.25.3' - - name: Create distribution directory - run: mkdir -p ${{ env.DIST_DIR }} - shell: bash - - - name: Build + - name: Build binaries + if: steps.check_release.outputs.RELEASE_EXISTS == 'false' run: | - GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -o ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-${{ matrix.os }}-${{ matrix.arch }} - env: - VERSION: ${{ needs.create-release.outputs.VERSION }} + mkdir -p ${{ env.DIST_DIR }} + + # Linux AMD64 + GOOS=linux GOARCH=amd64 go build -o ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-linux-amd64 + + # Linux ARM64 + GOOS=linux GOARCH=arm64 go build -o ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-linux-arm64 + + # macOS AMD64 (Intel) + GOOS=darwin GOARCH=amd64 go build -o ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-darwin-amd64 + + # macOS ARM64 (Apple Silicon) + GOOS=darwin GOARCH=arm64 go build -o ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-darwin-arm64 + + # Windows AMD64 + GOOS=windows GOARCH=amd64 go build -o ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-windows-amd64.exe - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + - name: Create Release + if: steps.check_release.outputs.RELEASE_EXISTS == 'false' + uses: softprops/action-gh-release@v2 with: - upload_url: ${{ needs.create-release.outputs.RELEASE_URL }} - asset_path: ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-${{ matrix.os }}-${{ matrix.arch }} - asset_name: ${{ env.APP_NAME }}-${{ matrix.os }}-${{ matrix.arch }} - asset_content_type: application/gzip + tag_name: v${{ steps.get_version.outputs.VERSION }} + name: Release v${{ steps.get_version.outputs.VERSION }} + draft: false + prerelease: false + files: | + ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-linux-amd64 + ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-linux-arm64 + ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-darwin-amd64 + ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-darwin-arm64 + ${{ env.DIST_DIR }}/${{ env.APP_NAME }}-windows-amd64.exe + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 23e69ec..d958c03 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.25.3' - name: Lint Check run: go vet ./... diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4898781 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "go.goroot": "${env:HOME}/.local/share/mise/installs/go/1.25.3", + "go.gopath": "${env:HOME}/go", + "go.toolsGopath": "${env:HOME}/.local/share/mise/installs/go/1.25.3", + "go.alternateTools": { + "go": "${env:HOME}/.local/share/mise/installs/go/1.25.3/bin/go", + "gopls": "${env:HOME}/.local/share/mise/installs/go/1.25.3/bin/gopls", + "dlv": "${env:HOME}/.local/share/mise/installs/go/1.25.3/bin/dlv" + } +} + diff --git a/VERSION b/VERSION index 41ba552..6c6aa7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.26 \ No newline at end of file +0.1.0 \ No newline at end of file diff --git a/cli/input.go b/cli/input.go index 6979050..eec5b27 100644 --- a/cli/input.go +++ b/cli/input.go @@ -5,9 +5,28 @@ import ( ) type CommandType struct { - Command string `arg:"positional,required"` - FromFile string `arg:"-f"` - Identifier string `arg:"-i"` + Command string `arg:"positional,required" help:"Command to execute: add, get, list, create, remove"` + FromFile string `arg:"-f" help:"Path to environment file"` + Identifier string `arg:"-i" help:"Unique identifier for the environment configuration"` + RestoreAs string `arg:"-r" help:"Filename to restore the environment file as (default: .env)"` +} + +func (CommandType) Description() string { + return `Environment Manager - Securely store and manage environment configurations + +Commands: + add Add an environment file with headers (requires -f) + get Retrieve and restore an environment configuration (requires -i) + list List all saved environment configurations + create Create environment configuration from a file without headers (requires -f, -i) + remove Remove an environment configuration (requires -i) + +Examples: + env-manager add -f .env.local + env-manager create -f secrets.txt -i production -r .env.prod + env-manager get -i production + env-manager list + env-manager remove -i production` } func (c *CommandType) validateCommand() { @@ -17,10 +36,10 @@ func (c *CommandType) validateCommand() { } } - panic("Invalid command. Valid commands are init and get") + panic("Invalid command. Valid commands are add, get, list, remove, and create") } -var validCommands [4]string = [4]string{"add", "get", "list", "remove"} +var validCommands [5]string = [5]string{"add", "get", "list", "remove", "create"} func ParseArgs() CommandType { var cmd CommandType diff --git a/go.mod b/go.mod index 66ccf0c..2a1aaec 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,7 @@ -module github.com/vertefra/env +module github.com/thinktwiceco/env-manager -go 1.21.4 +go 1.25.3 -require ( - github.com/alexflint/go-arg v1.4.3 // indirect - github.com/alexflint/go-scalar v1.1.0 // indirect -) +require github.com/alexflint/go-arg v1.6.0 + +require github.com/alexflint/go-scalar v1.2.0 // indirect diff --git a/go.sum b/go.sum index 5dd0ea8..5586a02 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,13 @@ -github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= -github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= -github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM= -github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo= +github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh index 4082fc6..9323fdd 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/bin/bash # Set the GitHub repository -REPO="vertefra/env-manager" +REPO="thinktwiceco/env-manager" # Get the OS and architecture for the system OS=$(uname -s | tr '[:upper:]' '[:lower:]') diff --git a/main.go b/main.go index 793e8df..33f5f70 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,10 @@ package main import ( "fmt" + "os" - "github.com/vertefra/env/cli" - "github.com/vertefra/env/manager" + "github.com/thinktwiceco/env-manager/cli" + "github.com/thinktwiceco/env-manager/manager" ) type ISecret interface { @@ -41,13 +42,34 @@ func main() { list() } + if c.Command == "create" { + if c.Identifier == "" { + panic("No identifier provided") + } + if c.FromFile == "" { + panic("No file path provided") + } + create(c.FromFile, c.Identifier, c.RestoreAs, &s) + } + + if c.Command == "remove" { + if c.Identifier == "" { + panic("No identifier provided") + } + remove(c.Identifier) + } + } // list retrieves all the environment files from the default environment folder // and prints "List" to the console. func list() { fmt.Println(">> Listing environment configurations...") - envFiles := manager.GetEnvFiles(&manager.DEFAULT_ENV_FOLDER) + envFiles, err := manager.GetEnvFiles(&manager.DEFAULT_ENV_FOLDER) + if err != nil { + fmt.Printf("Error getting environment files: %v\n", err) + return + } fmt.Printf("\n>> Found %d environment configurations\n", len(envFiles)) for _, e := range envFiles { fmt.Printf("\t> %s\n", e.Identifier()) @@ -58,7 +80,11 @@ func list() { // restores it using the provided secret, and prints "Get" to the console. func get(identifier string, s ISecret) { fmt.Printf("\n>> Getting environment configuration for %s...\n", identifier) - e := manager.GetEnvFile(identifier, &manager.DEFAULT_ENV_FOLDER) + e, err := manager.GetEnvFile(identifier, &manager.DEFAULT_ENV_FOLDER) + if err != nil { + fmt.Printf("Error getting environment file: %v\n", err) + return + } secret := s.GetSecret() manager.RestoreEnvFile(e, secret) fmt.Printf("\t> Environment configuration restored as %s", e.RestoreAs()) @@ -70,13 +96,90 @@ func get(identifier string, s ISecret) { func init_(filePath string, s ISecret) { fmt.Printf("\n>> Initializing environment configuration from %s...\n", filePath) if filePath != "" { - manager.CreateInitFolderIfNotExist(&manager.DEFAULT_ENV_FOLDER) - e := manager.ReadEnvFile(filePath, false) + f, err := manager.GetOrCreateFolder(&manager.DEFAULT_ENV_FOLDER) + if err != nil { + panic(err) + } + e := manager.ReadEnvFile(filePath) fmt.Println("\t> Saving environment configuration...") secret := s.GetSecret() + f.AddFileIdentifier(manager.EnvFilePath(filePath), manager.EnvFileIdentifier(e.Identifier())) manager.SaveEnvFile(e, secret, &manager.DEFAULT_ENV_FOLDER) fmt.Println("\t> Environment configuration saved") } else { panic("No file path provided") } } + +// create creates a new environment file from a source file without headers. +// It uses InitEnvFile to create the env file with the given identifier and restoreAs. +func create(filePath string, identifier string, restoreAs string, s ISecret) { + fmt.Printf("\n>> Creating environment configuration '%s' from %s...\n", identifier, filePath) + + f, err := manager.GetOrCreateFolder(&manager.DEFAULT_ENV_FOLDER) + if err != nil { + panic(err) + } + + // Set default restoreAs if not provided + if restoreAs == "" { + restoreAs = manager.DEFAULT_RESTORE_AS + } + + // Create new env file using InitEnvFile + e := manager.InitEnvFile(identifier, restoreAs) + + // Read file content + content, err := os.ReadFile(filePath) + if err != nil { + panic(fmt.Sprintf("Error reading file: %v", err)) + } + + // Set the content (this will add headers automatically) + e.SetContent(string(content)) + + fmt.Println("\t> Saving environment configuration...") + secret := s.GetSecret() + f.AddFileIdentifier(manager.EnvFilePath(filePath), manager.EnvFileIdentifier(identifier)) + manager.SaveEnvFile(e, secret, &manager.DEFAULT_ENV_FOLDER) + fmt.Printf("\t> Environment configuration '%s' saved\n", identifier) +} + +// remove deletes an environment configuration from the env-manager folder. +func remove(identifier string) { + fmt.Printf("\n>> Removing environment configuration '%s'...\n", identifier) + + f, err := manager.GetOrCreateFolder(&manager.DEFAULT_ENV_FOLDER) + if err != nil { + panic(err) + } + + // Find and remove the file from manifest + var filePathToRemove manager.EnvFilePath + for filePath, id := range f.GetIdentifiers() { + if string(id) == identifier { + filePathToRemove = filePath + break + } + } + + if filePathToRemove == "" { + fmt.Printf("Error: identifier '%s' not found\n", identifier) + return + } + + // Remove from manifest + err = f.EvictFileIdentifier(filePathToRemove) + if err != nil { + panic(fmt.Sprintf("Error removing from manifest: %v", err)) + } + + // Remove the actual encrypted file + filePath := fmt.Sprintf("%s/%s%s", manager.DEFAULT_ENV_FOLDER, manager.SAVED_PREFIX, identifier) + err = os.Remove(filePath) + if err != nil { + fmt.Printf("Warning: could not remove file %s: %v\n", filePath, err) + } + + fmt.Printf("\t> Environment configuration '%s' removed\n", identifier) +} diff --git a/manager/envFile.go b/manager/envFile.go index f6be594..d17f6a2 100644 --- a/manager/envFile.go +++ b/manager/envFile.go @@ -5,6 +5,7 @@ import ( "crypto/cipher" "crypto/rand" "encoding/hex" + "errors" "fmt" "io" "os" @@ -12,12 +13,10 @@ import ( ) type EnvFile struct { - header []string + header *Header fileContent string encrypted string - identifier string folderPath string // Where the encrypted file is saved - restoreAs string // Name of the decrypted file } func (e *EnvFile) RestoreAs() string { @@ -25,7 +24,7 @@ func (e *EnvFile) RestoreAs() string { } func (e *EnvFile) Identifier() string { - return e.identifier + return e.header.Identifier } func (e *EnvFile) IsEncrypted() bool { @@ -33,64 +32,25 @@ func (e *EnvFile) IsEncrypted() bool { } func (e *EnvFile) Headers() []string { - return e.header + return e.header.String() } func (e *EnvFile) readRestoreAs() { // search for #- restore-as: // in the header // If not found, use the default name - if len(e.header) == 0 { - e.readHeader() - } - for _, line := range e.header { - if strings.HasPrefix(line, RESTORE_AS_HEADER) { - r := strings.TrimPrefix(line, RESTORE_AS_HEADER) - e.restoreAs = strings.Trim(r, " ") - return - } - } - - if e.restoreAs == "" { - e.restoreAs = DEFAULT_RESTORE_AS + if e.header.RestoreAs == "" { + e.header.RestoreAs = DEFAULT_RESTORE_AS } } -func (e *EnvFile) readIdentifier() { - // Search for #- identifier: - // in the header - // If not found, exit with error - - for _, line := range e.header { - if strings.HasPrefix(line, IDENTIFIER_HEADER) { - i := strings.TrimPrefix(line, IDENTIFIER_HEADER) - e.identifier = strings.Trim(i, " ") - return - } - } - - if e.identifier == "" { - fmt.Println("Identifier not found") - os.Exit(1) - } -} - -func (e *EnvFile) readHeader() { - if e.fileContent == "" { - fmt.Println("File content is empty") - os.Exit(1) - } - - // Read first line - // If it starts with #-, it's a header - - lines := strings.Split(e.fileContent, "\n") - - for _, line := range lines { - if strings.HasPrefix(line, "#-") { - e.header = append(e.header, line) - } +func (e *EnvFile) readHeader() error { + h, err := InitHeader(e.fileContent) + if err != nil { + return err } + e.header = h + return nil } func (e *EnvFile) encrypt(key string) { @@ -136,71 +96,59 @@ func (e *EnvFile) decrypt(key string) { e.fileContent = string(ciphertext) } -// Utility function to get the list of files in the env-manager folder -func getEnvFileList(folderPath *string) []string { - files, err := os.ReadDir(*folderPath) - if err != nil { - panic(err) - } +/// Functions - var fileNames []string +func GetEnvFile(identifier string, folder *string) (*EnvFile, error) { + f, err := GetOrCreateFolder(folder) - for _, file := range files { - var name = file.Name() - // File gets saved as .env. - // split the name and keep the identifier - name = strings.TrimPrefix(name, SAVED_PREFIX) - fileNames = append(fileNames, name) + if err != nil { + return nil, err } - return fileNames -} - -/// Functions - -func GetEnvFile(identifier string, folder *string) *EnvFile { + allIdentifiers := f.GetIdentifiers() - allIdentifiers := getEnvFileList(folder) - - validIdentifier := false for _, id := range allIdentifiers { - if id == identifier { - validIdentifier = true - break + if string(id) == identifier { + return ReadEnvFile(fmt.Sprintf("%s/%s%s", f.FolderPath, SAVED_PREFIX, identifier)), nil } } - if !validIdentifier { - panic("Invalid identifier - " + identifier) - } - - filePath := fmt.Sprintf("%s/%s%s", *folder, SAVED_PREFIX, identifier) - e := ReadEnvFile(filePath, true) - return e + return nil, errors.New("invalid identifier - " + identifier) } -func GetEnvFiles(folder *string) []*EnvFile { - allIdentifiers := getEnvFileList(folder) +func GetEnvFiles(folder *string) ([]*EnvFile, error) { + f, err := GetOrCreateFolder(folder) + if err != nil { + return nil, err + } + + allIdentifiers := f.GetIdentifiers() var envFiles []*EnvFile for _, id := range allIdentifiers { filePath := fmt.Sprintf("%s/%s%s", *folder, SAVED_PREFIX, id) - e := ReadEnvFile(filePath, true) + e := ReadEnvFile(filePath) envFiles = append(envFiles, e) } - return envFiles + return envFiles, nil } func RestoreEnvFile(e *EnvFile, decryptSecret string) { e.decrypt(decryptSecret) + // Re-parse header from decrypted content to get correct restoreAs + h, err := InitHeader(e.fileContent) + if err == nil { + e.header = h + } + e.readRestoreAs() - fmt.Printf("Restoring file %s as %s\n", e.folderPath, e.restoreAs) + fmt.Printf("Restoring file %s as %s\n", e.folderPath, e.header.RestoreAs) - f, err := os.Create(e.restoreAs) + f, err := os.Create(e.header.RestoreAs) if err != nil { fmt.Println("Error creating file") @@ -230,7 +178,7 @@ func SaveEnvFile(e *EnvFile, encryptSecret string, folderPath *string) { e.folderPath = *folderPath } - filePath := fmt.Sprintf("%s/%s%s", e.folderPath, SAVED_PREFIX, e.identifier) + filePath := fmt.Sprintf("%s/%s%s", e.folderPath, SAVED_PREFIX, e.header.Identifier) fmt.Printf("Saving file: %s\n", filePath) f, err := os.Create(filePath) @@ -254,7 +202,23 @@ func SaveEnvFile(e *EnvFile, encryptSecret string, folderPath *string) { } } -func ReadEnvFile(filePath string, fromEncrypted bool) *EnvFile { +func InitEnvFile(identifier string, restoreAs string) *EnvFile { + h := &Header{ + Identifier: identifier, + RestoreAs: restoreAs, + } + return &EnvFile{ + header: h, + } +} + +func (e *EnvFile) SetContent(content string) { + // Add headers to the content so they're preserved when encrypting + headerContent := fmt.Sprintf("%s%s\n%s%s\n", IDENTIFIER_HEADER, e.header.Identifier, RESTORE_AS_HEADER, e.header.RestoreAs) + e.fileContent = headerContent + content +} + +func ReadEnvFile(filePath string) *EnvFile { fmt.Printf("Reading file: %s\n", filePath) f, err := os.Open(filePath) @@ -282,16 +246,26 @@ func ReadEnvFile(filePath string, fromEncrypted bool) *EnvFile { e := EnvFile{} // If filepath starts with the .env-manager folder // then the content is encrypted - if fromEncrypted { - // Identifier is . - sgmts := strings.Split(filePath, ".") - identifier := sgmts[len(sgmts)-1] + if strings.Contains(filePath, DEFAULT_ENV_FOLDER) || strings.Contains(filePath, SAVED_PREFIX) { e.encrypted = c - e.identifier = identifier + e.folderPath = filePath + // For encrypted files, extract identifier from filename + parts := strings.Split(filePath, "/") + filename := parts[len(parts)-1] + identifier := strings.TrimPrefix(filename, SAVED_PREFIX) + e.header = &Header{ + Identifier: identifier, + RestoreAs: DEFAULT_RESTORE_AS, + } } else { e.fileContent = c - e.readHeader() - e.readIdentifier() + h, err := InitHeader(c) + if err != nil { + fmt.Println("Error initializing header") + fmt.Println(err) + os.Exit(1) + } + e.header = h } return &e } diff --git a/manager/envFile_test.go b/manager/envFile_test.go index 9b6ea31..f266912 100644 --- a/manager/envFile_test.go +++ b/manager/envFile_test.go @@ -32,7 +32,7 @@ func deleteEnvFile(path string) { } func getEnvFileContent(identifier string, keyValuePairs ...string) string { - content := fmt.Sprintf("#- identifier: %s\n", identifier) + content := fmt.Sprintf("#- identifier: %s\n#- restore-as: %s\n", identifier, DEFAULT_RESTORE_AS) for _, pair := range keyValuePairs { content += fmt.Sprintf("%s\n", pair) @@ -55,14 +55,14 @@ func TestReadEnvFile(t *testing.T) { // folderPath := createTestFolder() - e := ReadEnvFile(ENV_FILE_PATH, false) + e := ReadEnvFile(ENV_FILE_PATH) - wantIdentifier := ENV_FILE_IDENTIFIER == e.identifier + wantIdentifier := ENV_FILE_IDENTIFIER == e.Identifier() wantContent := content == e.fileContent wantEncrypted := e.encrypted == "" if !wantIdentifier { - t.Errorf("ReadEnvFile() = %v, want %v", e.identifier, ENV_FILE_IDENTIFIER) + t.Errorf("ReadEnvFile() = %v, want %v", e.Identifier(), ENV_FILE_IDENTIFIER) } if !wantContent { @@ -93,29 +93,27 @@ func TestSaveAndReadEnvFile(t *testing.T) { // Simulate an init operation // Read the file given by the user (created as a fixture) - e := ReadEnvFile(ENV_FILE_PATH, false) - CreateInitFolderIfNotExist(&FOLDER_PATH) - // Inject custom folder path - SaveEnvFile(e, ENCRYPT_SECRET, &FOLDER_PATH) - - // Test List Env Files - envFiles := GetEnvFiles(&FOLDER_PATH) - - wantEnvFiles := len(envFiles) == 1 - - if !wantEnvFiles { - t.Errorf("GetEnvFiles() = %v, want %v", len(envFiles), 1) + e := ReadEnvFile(ENV_FILE_PATH) + f, err := GetOrCreateFolder(&FOLDER_PATH) + if err != nil { + t.Errorf("GetOrCreateFolder() = %v, want %v", err, nil) } + f.AddFileIdentifier(EnvFilePath(ENV_FILE_PATH), EnvFileIdentifier(ENV_FILE_IDENTIFIER)) + // Inject custom folder path + SaveEnvFile(e, ENCRYPT_SECRET, &f.FolderPath) // Read env file in the folder - e = ReadEnvFile(fmt.Sprintf("%s/%s%s", FOLDER_PATH, SAVED_PREFIX, ENV_FILE_IDENTIFIER), true) + e, err = GetEnvFile(ENV_FILE_IDENTIFIER, &f.FolderPath) + if err != nil { + t.Errorf("GetEnvFile() = %v, want %v", err, nil) + } println(e.encrypted) // File is read and encrypted, the identifier is unknown - wantIdentifier := e.identifier == ENV_FILE_IDENTIFIER + wantIdentifier := e.Identifier() == ENV_FILE_IDENTIFIER wantEncrypted := e.encrypted != "" if !wantIdentifier { - t.Errorf("SaveEnvFile() = %v, want %v", e.identifier, ENV_FILE_IDENTIFIER) + t.Errorf("SaveEnvFile() = %v, want %v", e.Identifier(), ENV_FILE_IDENTIFIER) } if !wantEncrypted { @@ -123,12 +121,15 @@ func TestSaveAndReadEnvFile(t *testing.T) { } // Simulate a get operation - toRestore := GetEnvFile(ENV_FILE_IDENTIFIER, &FOLDER_PATH) + toRestore, err := GetEnvFile(ENV_FILE_IDENTIFIER, &f.FolderPath) + if err != nil { + t.Errorf("GetEnvFile() = %v, want %v", err, nil) + } - wantRestoredIdentifier := toRestore.identifier == ENV_FILE_IDENTIFIER + wantRestoredIdentifier := toRestore.Identifier() == ENV_FILE_IDENTIFIER if !wantRestoredIdentifier { - t.Errorf("GetEnvFile() = %v, want %v", toRestore.identifier, ENV_FILE_IDENTIFIER) + t.Errorf("GetEnvFile() = %v, want %v", toRestore.Identifier(), ENV_FILE_IDENTIFIER) } wantRestoredEncrypted := toRestore.encrypted != "" @@ -162,3 +163,65 @@ func TestSaveAndReadEnvFile(t *testing.T) { t.Errorf("RestoreEnvFile() = %v, want %v", string(restoredContent), content) } } + +func TestInitEnvFile(t *testing.T) { + const ENV_FILE_IDENTIFIER = "production" + const ENV_FILE_RESTORE_AS = ".env.production" + const ENCRYPT_SECRET = "488c447d4919b142c80c82832cef7f18" + var FOLDER_PATH = ".env-manager-test-init" + + defer func() { + fmt.Println("Destroying test folder") + destroyTestFolder(&FOLDER_PATH) + deleteEnvFile(ENV_FILE_RESTORE_AS) + }() + + // Create env file using InitEnvFile + e := InitEnvFile(ENV_FILE_IDENTIFIER, ENV_FILE_RESTORE_AS) + + // Verify identifier and restoreAs are set correctly + if e.Identifier() != ENV_FILE_IDENTIFIER { + t.Errorf("InitEnvFile() identifier = %v, want %v", e.Identifier(), ENV_FILE_IDENTIFIER) + } + + if e.header.RestoreAs != ENV_FILE_RESTORE_AS { + t.Errorf("InitEnvFile() restoreAs = %v, want %v", e.header.RestoreAs, ENV_FILE_RESTORE_AS) + } + + // Set content (this will add headers automatically) + rawContent := "DB_HOST=localhost\nDB_PORT=5432\n" + e.SetContent(rawContent) + + // Create folder and save + f, err := GetOrCreateFolder(&FOLDER_PATH) + if err != nil { + t.Errorf("GetOrCreateFolder() = %v, want %v", err, nil) + } + + f.AddFileIdentifier(EnvFilePath("manual"), EnvFileIdentifier(ENV_FILE_IDENTIFIER)) + SaveEnvFile(e, ENCRYPT_SECRET, &f.FolderPath) + + // Read it back + e2, err := GetEnvFile(ENV_FILE_IDENTIFIER, &f.FolderPath) + if err != nil { + t.Errorf("GetEnvFile() = %v, want %v", err, nil) + } + + if e2.Identifier() != ENV_FILE_IDENTIFIER { + t.Errorf("Retrieved identifier = %v, want %v", e2.Identifier(), ENV_FILE_IDENTIFIER) + } + + // Restore and verify content + RestoreEnvFile(e2, ENCRYPT_SECRET) + + restoredContent, err := os.ReadFile(ENV_FILE_RESTORE_AS) + if err != nil { + t.Errorf("RestoreEnvFile() = %v, want %v", err, nil) + } + + // The restored file should contain the headers + raw content + expectedContent := fmt.Sprintf("#- identifier: %s\n#- restore-as: %s\n%s", ENV_FILE_IDENTIFIER, ENV_FILE_RESTORE_AS, rawContent) + if string(restoredContent) != expectedContent { + t.Errorf("Restored content = %v, want %v", string(restoredContent), expectedContent) + } +} diff --git a/manager/folder.go b/manager/folder.go index f84a02c..97370e4 100644 --- a/manager/folder.go +++ b/manager/folder.go @@ -1,11 +1,78 @@ package manager import ( + "encoding/json" "fmt" "os" ) -func CreateInitFolderIfNotExist(folderName *string) { +type EnvFilePath string +type EnvFileIdentifier string + +type Manifest struct { + Identifiers map[EnvFilePath]EnvFileIdentifier `json:"identifiers"` +} + +func (m *Manifest) Write(folderPath string) error { + manifestPath := fmt.Sprintf("%s/%s", folderPath, "manifest.json") + f, err := os.Create(manifestPath) + if err != nil { + return err + } + defer f.Close() + encoder := json.NewEncoder(f) + encoder.SetIndent("", " ") + return encoder.Encode(m) +} + +func (m *Manifest) Load(folderPath string) error { + manifestPath := fmt.Sprintf("%s/%s", folderPath, "manifest.json") + f, err := os.Open(manifestPath) + if err != nil { + return err + } + defer f.Close() + err = json.NewDecoder(f).Decode(m) + if err != nil { + return err + } + return nil +} + +func (m *Manifest) addFileIdentifier(filePath EnvFilePath, identifier EnvFileIdentifier, folderPath string) error { + if m.Identifiers == nil { + m.Identifiers = make(map[EnvFilePath]EnvFileIdentifier) + } + m.Identifiers[filePath] = identifier + return m.Write(folderPath) +} + +func (m *Manifest) EvictFileIdentifier(filePath EnvFilePath, folderPath string) error { + if m.Identifiers != nil { + delete(m.Identifiers, filePath) + return m.Write(folderPath) + } + return nil +} + +type Folder struct { + manifest *Manifest + FolderPath string +} + +func (f *Folder) AddFileIdentifier(filePath EnvFilePath, identifier EnvFileIdentifier) error { + return f.manifest.addFileIdentifier(filePath, identifier, f.FolderPath) +} + +func (f *Folder) EvictFileIdentifier(filePath EnvFilePath) error { + return f.manifest.EvictFileIdentifier(filePath, f.FolderPath) +} + +func (f *Folder) GetIdentifiers() map[EnvFilePath]EnvFileIdentifier { + return f.manifest.Identifiers +} + +func GetOrCreateFolder(folderName *string) (*Folder, error) { fmt.Println("Creating init folder... ") // Check if folder exists // If not, create it @@ -16,10 +83,21 @@ func CreateInitFolderIfNotExist(folderName *string) { if err != nil { fmt.Println("Error creating folder") fmt.Println(err) - os.Exit(1) + return nil, err } - fmt.Printf("Folder created: %s\n", *folderName) } else { fmt.Printf("Folder exists: %s\n", *folderName) } + + folder := &Folder{ + manifest: &Manifest{ + Identifiers: make(map[EnvFilePath]EnvFileIdentifier), + }, + FolderPath: *folderName, + } + + // Try to load existing manifest + folder.manifest.Load(folder.FolderPath) + + return folder, nil } diff --git a/manager/header.go b/manager/header.go new file mode 100644 index 0000000..603870e --- /dev/null +++ b/manager/header.go @@ -0,0 +1,50 @@ +package manager + +import ( + "errors" + "strings" +) + +type Header struct { + Identifier string + RestoreAs string +} + +func (h *Header) String() []string { + return []string{ + h.Identifier, + h.RestoreAs, + } +} + +func InitHeader(text string) (*Header, error) { + var identifier string = "" + var restoreAs string = "" + + lines := strings.Split(text, "\n") + + for _, line := range lines { + // Sanitize line + line = strings.Trim(line, " ") + + if strings.HasPrefix(line, IDENTIFIER_HEADER) { + identifier = strings.TrimPrefix(line, IDENTIFIER_HEADER) + } + if strings.HasPrefix(line, RESTORE_AS_HEADER) { + restoreAs = strings.TrimPrefix(line, RESTORE_AS_HEADER) + } + } + + if identifier == "" { + return nil, errors.New("invalid header: identifier not found") + } + + if restoreAs == "" { + return nil, errors.New("invalid header: restore-as not found") + } + + return &Header{ + Identifier: identifier, + RestoreAs: restoreAs, + }, nil +} diff --git a/manager/secret.go b/manager/secret.go index 72b1722..aa0f322 100644 --- a/manager/secret.go +++ b/manager/secret.go @@ -41,7 +41,7 @@ func getSecretFromFile() *string { } secret := string(fileContent) - secret = strings.Trim(secret, " ") + secret = strings.TrimSpace(secret) if secret == "" { return nil diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..43afba4 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.25.3" diff --git a/readme.md b/readme.md index 9e320b0..a18ad13 100644 --- a/readme.md +++ b/readme.md @@ -1,75 +1,69 @@ +![Static Badge](https://img.shields.io/badge/version-0.1.0-blue?style=flat&label=version&labelColor=darkblue&color=black) -# Env Manager ![Static Badge](https://img.shields.io/badge/version-0.26-blue?style=flat&label=version&labelColor=darkblue&color=black) +# Env Manager -Manages multiple `.env` files configurations for your project. -Configurations are stored encrypted. +Securely store and manage encrypted environment configurations. -## Install -Download and install installation script (Only tested on linux) +## Setup +**Install** (Linux only) ```bash - curl -sSL https://raw.githubusercontent.com/vertefra/env-manager/master/install.sh | bash +curl -sSL https://raw.githubusercontent.com/thinktwiceco/env-manager/master/install.sh | bash ``` -## Add a configuration -In order to store a new configuration file you need to add an `header` to your file. -Headers are at the top of the file and are metadata about the file. -The **identifier** identify the configuration when you want to restore it. - -_example_ -``` -#- identifier: LOCAL -#- restore-as: .env -STAGE=LOCAL -PEM_KEY=~/Downloads/MyKey.pem -SECRET_KEY=~/Downloads/MyKey.pem +**Create encryption key** +```bash +openssl rand -hex 16 > .secret ``` +> Secret can also be set via `ENV_MANAGER_SECRET` environment variable -Another `header` is `restore-as` which is the name of the file that will be restored. If not provided the file will be restored as `.env` - -## Secrets -In order to encrypt and decrypt the configurations you need to generate a secret. -Secret is read first from the environment variable `ENV_MANAGER_SECRET`. If no secret is found, it will try to look into a `.secret` file in the current directory. - -> Valid secret is a 16, 24 or 32 bytes long string. If Generating from `hexadecimal` consider that 2 characters are 1 byte. +## Commands -**Generate a secret in .secret file** +### `add` - Import file with headers +For files that already have env-manager headers: ```bash -openssl rand -hex 16 > .secret +env-manager add -f .env.production ``` -**Generate a secret in environment** +**File format:** ```bash -export ENV_MANAGER_SECRET=$(openssl rand -hex 16) +#- identifier: production +#- restore-as: .env +DATABASE_URL=postgres://localhost/db +API_KEY=secret123 ``` -## Usage - -**Add a new configuration** -``` -env-manager add -f .env +### `create` - Import plain file +For plain environment files without headers: +```bash +env-manager create -f secrets.txt -i production -r .env ``` -Where `.env` is the file you want to store and contains a valid `#- identifier: ` header - -This will add the current configuation present in your `.env` file to the manager enviroment. The configuration will be identified by the `#- identifier` header. - - -**Restore a configuration** - -``` -env-manager get -i
+### `get` - Restore configuration +```bash +env-manager get -i production ``` +Decrypts and restores the configuration file. -Where `
` is the identifier of the configuration you want to restore. - -**List all configurations** - -``` +### `list` - Show all configurations +```bash env-manager list ``` -Shows all the identifiers of the configurations stored in the manager. +### `remove` - Delete configuration +```bash +env-manager remove -i production +``` + +## How It Works +1. Files are encrypted using AES and stored in `.env-manager/` +2. A `manifest.json` tracks all configurations +3. Identifiers map to encrypted files for easy retrieval +4. On restore, files are decrypted and written with their original name +## Security +- ⚠️ **Keep `.secret` secure** - anyone with this key can decrypt your files +- ✅ Add `.secret` and `.env-manager/` to `.gitignore` +- ✅ Valid keys: 16, 24, or 32 bytes (32, 48, or 64 hex characters)