Skip to content
154 changes: 154 additions & 0 deletions cmd/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package cmd

import (
"fmt"
"os"
"strings"

cobra "github.com/spf13/cobra"

config "github.com/inference-gateway/cli/config"
icons "github.com/inference-gateway/cli/internal/ui/styles/icons"
)

// envExampleFileName is the name of the .env.example file
const envExampleFileName = ".env.example"

// envVars is the list of all provider API environment variables.
// Empty-string entries produce blank-line separators in the output,
// grouping search-related keys separately from provider API keys.
var envVars = []string{
"GOOGLE_SEARCH_API_KEY",
"GOOGLE_SEARCH_ENGINE_ID",
"", // --- search providers ---
"DUCKDUCKGO_SEARCH_API_KEY",
"", // --- LLM providers ---
"ANTHROPIC_API_KEY",
"CLOUDFLARE_API_KEY",
"COHERE_API_KEY",
"GROQ_API_KEY",
"OLLAMA_API_KEY",
"OLLAMA_CLOUD_API_KEY",
"OPENAI_API_KEY",
"DEEPSEEK_API_KEY",
"GOOGLE_API_KEY",
"MISTRAL_API_KEY",
"MINIMAX_API_KEY",
"MOONSHOT_API_KEY",
}

// envExampleContent generates the content for .env.example
func envExampleContent() string {
var sb strings.Builder
sb.WriteString("# Inference Gateway Environment Variables\n")
sb.WriteString("# Copy this file to .env and fill in your API keys\n")
sb.WriteString("#\n")
sb.WriteString("# cp .env.example .env\n")
sb.WriteString("\n")

for _, v := range envVars {
if v == "" {
sb.WriteString("\n")
} else {
fmt.Fprintf(&sb, "%s=\n", v)
}
}

return sb.String()
}

var envCmd = &cobra.Command{
Use: "env",
Short: "Generate a .env.example file with provider API keys",
Long: `Generate a .env.example file in the current directory with all the
different provider API environment variables needed by the Inference Gateway.

This is a convenient shortcut so you don't need to remember which providers
are available or what environment variables to set.

After the file is created, copy it to .env and fill in your API keys:

cp .env.example .env

If .env.example already exists, this command will error. Use --overwrite to
replace it.`,
RunE: func(cmd *cobra.Command, args []string) error {
return createEnvExample(cmd)
},
}

func init() {
envCmd.Flags().Bool("overwrite", false, "Overwrite .env.example if it already exists")
rootCmd.AddCommand(envCmd)
}

// createEnvExample creates the .env.example file with provider API keys
func createEnvExample(cmd *cobra.Command) error {
overwrite, _ := cmd.Flags().GetBool("overwrite")

envExamplePath := envExampleFileName

if !overwrite {
if _, err := os.Stat(envExamplePath); err == nil {
return fmt.Errorf("%s already exists (use --overwrite to replace)", envExamplePath)
}
}

content := envExampleContent()
if err := os.WriteFile(envExamplePath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to create %s file: %w", envExampleFileName, err)
}

fmt.Printf("%s Successfully created %s\n", icons.CheckMarkStyle.Render(icons.CheckMark), envExamplePath)
fmt.Println("")
fmt.Println("Next steps:")
fmt.Println(" cp .env.example .env")
fmt.Println(" Edit .env and add your API keys")
fmt.Println("")

// Ensure .env is in .gitignore
if err := ensureEnvInGitignore(); err != nil {
return err
}

return nil
}

// ensureEnvInGitignore ensures that .env is listed in the root .gitignore file.
// If .gitignore doesn't exist, it creates one with .env entry.
// If .gitignore exists but doesn't contain .env, it appends it.
func ensureEnvInGitignore() error {
gitignorePath := config.GitignoreFileName
if _, err := os.Stat(gitignorePath); os.IsNotExist(err) {
gitignoreContent := "# Inference Gateway\n.env\n"
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644); err != nil {
return fmt.Errorf("failed to create .gitignore file: %w", err)
}
fmt.Printf("%s Created .gitignore with .env entry\n", icons.CheckMarkStyle.Render(icons.CheckMark))
return nil
}

data, err := os.ReadFile(gitignorePath)
if err != nil {
return fmt.Errorf("failed to read .gitignore: %w", err)
}
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == ".env" {
return nil
}
}

f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open .gitignore: %w", err)
}
defer func() {
_ = f.Close()
}()

if _, err := f.WriteString("\n# Inference Gateway\n.env\n"); err != nil {
return fmt.Errorf("failed to write to .gitignore: %w", err)
}
fmt.Printf("%s Added .env to .gitignore\n", icons.CheckMarkStyle.Render(icons.CheckMark))
return nil
}
228 changes: 228 additions & 0 deletions cmd/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package cmd

import (
"os"
"strings"
"testing"

cobra "github.com/spf13/cobra"
)

func TestEnvExampleContent(t *testing.T) {
content := envExampleContent()

if !strings.Contains(content, "# Inference Gateway Environment Variables") {
t.Errorf("envExampleContent() should contain header")
}

if !strings.Contains(content, "cp .env.example .env") {
t.Errorf("envExampleContent() should contain cp hint")
}

expectedVars := []string{
"ANTHROPIC_API_KEY=",
"OPENAI_API_KEY=",
"DEEPSEEK_API_KEY=",
"GOOGLE_API_KEY=",
"GROQ_API_KEY=",
"MISTRAL_API_KEY=",
"CLOUDFLARE_API_KEY=",
"COHERE_API_KEY=",
"OLLAMA_API_KEY=",
"OLLAMA_CLOUD_API_KEY=",
"GOOGLE_SEARCH_API_KEY=",
"GOOGLE_SEARCH_ENGINE_ID=",
"DUCKDUCKGO_SEARCH_API_KEY=",
"MINIMAX_API_KEY=",
"MOONSHOT_API_KEY=",
}

for _, v := range expectedVars {
if !strings.Contains(content, v) {
t.Errorf("envExampleContent() should contain %s", v)
}
}
}

func TestCreateEnvExample(t *testing.T) {
tests := []struct {
name string
setupFiles map[string]string
overwrite bool
wantErr bool
errContains string
wantFileExists bool
}{
{
name: "creates .env.example when it doesn't exist",
setupFiles: nil,
overwrite: false,
wantErr: false,
wantFileExists: true,
},
{
name: "fails when .env.example already exists",
setupFiles: map[string]string{
".env.example": "existing content",
},
overwrite: false,
wantErr: true,
errContains: "already exists",
wantFileExists: true,
},
{
name: "overwrites when --overwrite is set",
setupFiles: map[string]string{
".env.example": "existing content",
},
overwrite: true,
wantErr: false,
wantFileExists: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "infer-env-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()

oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
defer func() { _ = os.Chdir(oldWd) }()

if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp dir: %v", err)
}

for path, content := range tt.setupFiles {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create setup file %s: %v", path, err)
}
}

cmd := &cobra.Command{}
cmd.Flags().Bool("overwrite", tt.overwrite, "")
_ = cmd.Flag("overwrite").Value.Set(func() string {
if tt.overwrite {
return "true"
}
return "false"
}())

err = createEnvExample(cmd)

if (err != nil) != tt.wantErr {
t.Errorf("createEnvExample() error = %v, wantErr %v", err, tt.wantErr)
return
}

if tt.wantErr && tt.errContains != "" && err != nil {
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("createEnvExample() error should contain %q, got %q", tt.errContains, err.Error())
}
}

if tt.wantFileExists {
if _, err := os.Stat(".env.example"); os.IsNotExist(err) {
t.Errorf("expected .env.example to exist, but it doesn't")
}
}
})
}
}

func TestCreateEnvExampleCreatesGitignore(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "infer-env-gitignore-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()

oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
defer func() { _ = os.Chdir(oldWd) }()

if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp dir: %v", err)
}

cmd := &cobra.Command{}
cmd.Flags().Bool("overwrite", false, "")
_ = cmd.Flag("overwrite").Value.Set("false")

err = createEnvExample(cmd)
if err != nil {
t.Fatalf("createEnvExample() error = %v", err)
}

gitignoreContent, err := os.ReadFile(".gitignore")
if err != nil {
t.Fatalf("expected .gitignore to be created, but got error: %v", err)
}

if !strings.Contains(string(gitignoreContent), ".env") {
t.Errorf(".gitignore should contain .env entry, got: %s", string(gitignoreContent))
}
}

func TestCreateEnvExampleWithExistingGitignore(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "infer-env-existing-gitignore-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()

oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
defer func() { _ = os.Chdir(oldWd) }()

if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to change to temp dir: %v", err)
}

if err := os.WriteFile(".gitignore", []byte("*.log\n"), 0644); err != nil {
t.Fatalf("failed to create .gitignore: %v", err)
}

cmd := &cobra.Command{}
cmd.Flags().Bool("overwrite", false, "")
_ = cmd.Flag("overwrite").Value.Set("false")

err = createEnvExample(cmd)
if err != nil {
t.Fatalf("createEnvExample() error = %v", err)
}

gitignoreContent, err := os.ReadFile(".gitignore")
if err != nil {
t.Fatalf("expected .gitignore to exist, but got error: %v", err)
}

if !strings.Contains(string(gitignoreContent), ".env") {
t.Errorf(".gitignore should contain .env entry, got: %s", string(gitignoreContent))
}
}

func TestEnvExampleContentFormat(t *testing.T) {
content := envExampleContent()

lines := strings.Split(content, "\n")
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.Contains(line, "=") {
t.Errorf("line %d should be in KEY= format, got: %q", i+1, line)
}
}
}
Loading