diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d2cedd98 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# PostgreSQL Connection Configuration +# These environment variables are automatically used by pgschema commands +# when the corresponding CLI flags are not provided + +# Database server host (default: localhost) +PGHOST=localhost + +# Database server port (default: 5432) +PGPORT=5432 + +# Database name +PGDATABASE=your_database_name + +# Database user name +PGUSER=your_username + +# Database password +PGPASSWORD=your_password_here + +# Application name for database connection (default: pgschema) +# This appears in pg_stat_activity and can help identify connections +PGAPPNAME=pgschema \ No newline at end of file diff --git a/cmd/apply/apply.go b/cmd/apply/apply.go index 6d9538f0..597b7d06 100644 --- a/cmd/apply/apply.go +++ b/cmd/apply/apply.go @@ -32,21 +32,23 @@ var ( applyApplicationName string ) + var ApplyCmd = &cobra.Command{ Use: "apply", Short: "Apply migration plan to update a database schema", Long: "Apply a migration plan to update a database schema. Either provide a desired state file (--file) to generate and apply a plan, or provide a pre-generated plan file (--plan) to execute directly.", RunE: RunApply, SilenceUsage: true, + PreRunE: util.PreRunEWithEnvVars(&applyDB, &applyUser), } func init() { // Target database connection flags - ApplyCmd.Flags().StringVar(&applyHost, "host", "localhost", "Database server host") - ApplyCmd.Flags().IntVar(&applyPort, "port", 5432, "Database server port") - ApplyCmd.Flags().StringVar(&applyDB, "db", "", "Database name (required)") - ApplyCmd.Flags().StringVar(&applyUser, "user", "", "Database user name (required)") - ApplyCmd.Flags().StringVar(&applyPassword, "password", "", "Database password (optional)") + ApplyCmd.Flags().StringVar(&applyHost, "host", util.GetEnvWithDefault("PGHOST", "localhost"), "Database server host (env: PGHOST)") + ApplyCmd.Flags().IntVar(&applyPort, "port", util.GetEnvIntWithDefault("PGPORT", 5432), "Database server port (env: PGPORT)") + ApplyCmd.Flags().StringVar(&applyDB, "db", "", "Database name (required) (env: PGDATABASE)") + ApplyCmd.Flags().StringVar(&applyUser, "user", "", "Database user name (required) (env: PGUSER)") + ApplyCmd.Flags().StringVar(&applyPassword, "password", "", "Database password (optional, can also use PGPASSWORD env var)") ApplyCmd.Flags().StringVar(&applySchema, "schema", "public", "Schema name") // Desired state schema file flag @@ -59,11 +61,7 @@ func init() { ApplyCmd.Flags().BoolVar(&applyAutoApprove, "auto-approve", false, "Apply changes without prompting for approval") ApplyCmd.Flags().BoolVar(&applyNoColor, "no-color", false, "Disable colored output") ApplyCmd.Flags().StringVar(&applyLockTimeout, "lock-timeout", "", "Maximum time to wait for database locks (e.g., 30s, 5m, 1h)") - ApplyCmd.Flags().StringVar(&applyApplicationName, "application-name", "pgschema", "Application name for database connection (visible in pg_stat_activity)") - - // Mark required flags - ApplyCmd.MarkFlagRequired("db") - ApplyCmd.MarkFlagRequired("user") + ApplyCmd.Flags().StringVar(&applyApplicationName, "application-name", util.GetEnvWithDefault("PGAPPNAME", "pgschema"), "Application name for database connection (visible in pg_stat_activity) (env: PGAPPNAME)") // Mark file and plan as mutually exclusive ApplyCmd.MarkFlagsMutuallyExclusive("file", "plan") diff --git a/cmd/dotenv_test.go b/cmd/dotenv_test.go new file mode 100644 index 00000000..ac55c0a2 --- /dev/null +++ b/cmd/dotenv_test.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/joho/godotenv" +) + +func TestDotenvLoading(t *testing.T) { + // Create a temporary directory for test + tmpDir := t.TempDir() + originalDir, _ := os.Getwd() + + // Change to temp directory + err := os.Chdir(tmpDir) + if err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Restore original directory after test + defer func() { + os.Chdir(originalDir) + }() + + // Test 1: Load .env file with PGPASSWORD + t.Run("LoadEnvFile", func(t *testing.T) { + // Clean environment first + os.Unsetenv("PGPASSWORD") + + // Create .env file + envContent := "PGPASSWORD=test_password_123\n" + err := os.WriteFile(".env", []byte(envContent), 0644) + if err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Load .env file + err = godotenv.Load() + if err != nil { + t.Fatalf("Failed to load .env file: %v", err) + } + + // Verify PGPASSWORD is set + password := os.Getenv("PGPASSWORD") + if password != "test_password_123" { + t.Errorf("Expected PGPASSWORD='test_password_123', got '%s'", password) + } + + // Cleanup + os.Remove(".env") + os.Unsetenv("PGPASSWORD") + }) + + // Test 2: Missing .env file should not cause errors + t.Run("MissingEnvFile", func(t *testing.T) { + // Clean environment first + os.Unsetenv("PGPASSWORD") + + // Ensure no .env file exists + os.Remove(".env") + + // Load .env file (should not error) + err := godotenv.Load() + if err == nil { + t.Error("Expected error when loading non-existent .env file, but got nil") + } + + // PGPASSWORD should be empty + password := os.Getenv("PGPASSWORD") + if password != "" { + t.Errorf("Expected PGPASSWORD to be empty, got '%s'", password) + } + }) + + // Test 3: Environment variable priority + t.Run("EnvVarPriority", func(t *testing.T) { + // Set PGPASSWORD in environment first + os.Setenv("PGPASSWORD", "env_password") + + // Create .env file with different password + envContent := "PGPASSWORD=dotenv_password\n" + err := os.WriteFile(".env", []byte(envContent), 0644) + if err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Load .env file - should NOT override existing env var + err = godotenv.Load() + if err != nil { + t.Fatalf("Failed to load .env file: %v", err) + } + + // Should still have the original environment value + password := os.Getenv("PGPASSWORD") + if password != "env_password" { + t.Errorf("Expected PGPASSWORD='env_password' (existing env var should take precedence), got '%s'", password) + } + + // Cleanup + os.Remove(".env") + os.Unsetenv("PGPASSWORD") + }) + + // Test 4: .env overrides when no existing env var + t.Run("DotenvOverridesWhenNoEnvVar", func(t *testing.T) { + // Clean environment first + os.Unsetenv("PGPASSWORD") + + // Create .env file + envContent := "PGPASSWORD=dotenv_only_password\n" + err := os.WriteFile(".env", []byte(envContent), 0644) + if err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Load .env file + err = godotenv.Load() + if err != nil { + t.Fatalf("Failed to load .env file: %v", err) + } + + // Should have the dotenv value + password := os.Getenv("PGPASSWORD") + if password != "dotenv_only_password" { + t.Errorf("Expected PGPASSWORD='dotenv_only_password', got '%s'", password) + } + + // Cleanup + os.Remove(".env") + os.Unsetenv("PGPASSWORD") + }) + + // Test 5: All PostgreSQL environment variables + t.Run("AllPostgreSQLEnvVars", func(t *testing.T) { + // Clean environment first + envVars := []string{"PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD", "PGAPPNAME"} + for _, envVar := range envVars { + os.Unsetenv(envVar) + } + + // Create .env file with all variables + envContent := `PGHOST=test.example.com +PGPORT=5433 +PGDATABASE=testdb +PGUSER=testuser +PGPASSWORD=testpass +PGAPPNAME=test-pgschema +` + err := os.WriteFile(".env", []byte(envContent), 0644) + if err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Load .env file + err = godotenv.Load() + if err != nil { + t.Fatalf("Failed to load .env file: %v", err) + } + + // Verify all variables are loaded + expectedValues := map[string]string{ + "PGHOST": "test.example.com", + "PGPORT": "5433", + "PGDATABASE": "testdb", + "PGUSER": "testuser", + "PGPASSWORD": "testpass", + "PGAPPNAME": "test-pgschema", + } + + for envVar, expected := range expectedValues { + actual := os.Getenv(envVar) + if actual != expected { + t.Errorf("Expected %s='%s', got '%s'", envVar, expected, actual) + } + } + + // Cleanup + os.Remove(".env") + for _, envVar := range envVars { + os.Unsetenv(envVar) + } + }) +} diff --git a/cmd/dump/dump.go b/cmd/dump/dump.go index fa701eaa..3f38aaf3 100644 --- a/cmd/dump/dump.go +++ b/cmd/dump/dump.go @@ -23,25 +23,25 @@ var ( file string ) + var DumpCmd = &cobra.Command{ Use: "dump", Short: "Dump database schema for a specific schema", Long: "Dump and output database schema information for a specific schema. Uses the --schema flag to target a particular schema (defaults to 'public').", RunE: runDump, SilenceUsage: true, + PreRunE: util.PreRunEWithEnvVars(&db, &user), } func init() { - DumpCmd.Flags().StringVar(&host, "host", "localhost", "Database server host") - DumpCmd.Flags().IntVar(&port, "port", 5432, "Database server port") - DumpCmd.Flags().StringVar(&db, "db", "", "Database name (required)") - DumpCmd.Flags().StringVar(&user, "user", "", "Database user name (required)") + DumpCmd.Flags().StringVar(&host, "host", util.GetEnvWithDefault("PGHOST", "localhost"), "Database server host (env: PGHOST)") + DumpCmd.Flags().IntVar(&port, "port", util.GetEnvIntWithDefault("PGPORT", 5432), "Database server port (env: PGPORT)") + DumpCmd.Flags().StringVar(&db, "db", "", "Database name (required) (env: PGDATABASE)") + DumpCmd.Flags().StringVar(&user, "user", "", "Database user name (required) (env: PGUSER)") DumpCmd.Flags().StringVar(&password, "password", "", "Database password (optional, can also use PGPASSWORD env var)") DumpCmd.Flags().StringVar(&schema, "schema", "public", "Schema name to dump (default: public)") DumpCmd.Flags().BoolVar(&multiFile, "multi-file", false, "Output schema to multiple files organized by object type") DumpCmd.Flags().StringVar(&file, "file", "", "Output file path (required when --multi-file is used)") - DumpCmd.MarkFlagRequired("db") - DumpCmd.MarkFlagRequired("user") } func runDump(cmd *cobra.Command, args []string) error { diff --git a/cmd/dump/dump_test.go b/cmd/dump/dump_test.go index 61c0b136..0db477fb 100644 --- a/cmd/dump/dump_test.go +++ b/cmd/dump/dump_test.go @@ -1,8 +1,10 @@ package dump import ( + "os" "testing" + "github.com/pgschema/pgschema/cmd/util" "github.com/spf13/cobra" ) @@ -74,3 +76,193 @@ func TestDumpCommand_ErrorHandling(t *testing.T) { t.Error("Expected error with unreachable database, but got nil") } } + +func TestDumpCommand_PasswordPriority(t *testing.T) { + // Store original values + originalHost := host + originalPort := port + originalDb := db + originalUser := user + originalPassword := password + + defer func() { + host = originalHost + port = originalPort + db = originalDb + user = originalUser + password = originalPassword + os.Unsetenv("PGPASSWORD") + }() + + t.Run("PasswordFromFlag", func(t *testing.T) { + // Clear environment variable + os.Unsetenv("PGPASSWORD") + + // Set flag values + host = "localhost" + port = 9999 // Use non-existent port to avoid actual connection + db = "test" + user = "test" + password = "flag_password" + + // The password resolution happens in runDump when it calls: + // finalPassword := password + // if finalPassword == "" { + // if envPassword := os.Getenv("PGPASSWORD"); envPassword != "" { + // finalPassword = envPassword + // } + // } + // We can't easily test this without refactoring, but we can test the flag is set + if password != "flag_password" { + t.Errorf("Expected password flag to be 'flag_password', got '%s'", password) + } + }) + + t.Run("PasswordFromEnvVar", func(t *testing.T) { + // Set environment variable + os.Setenv("PGPASSWORD", "env_password") + + // Clear flag + password = "" + + // Set other required values + host = "localhost" + port = 9999 + db = "test" + user = "test" + + // Verify environment variable is set + envPassword := os.Getenv("PGPASSWORD") + if envPassword != "env_password" { + t.Errorf("Expected PGPASSWORD env var to be 'env_password', got '%s'", envPassword) + } + + // Verify flag is empty (so env var should be used) + if password != "" { + t.Errorf("Expected password flag to be empty, got '%s'", password) + } + }) + + t.Run("FlagOverridesEnvVar", func(t *testing.T) { + // Set both environment variable and flag + os.Setenv("PGPASSWORD", "env_password") + password = "flag_password" + + // Set other required values + host = "localhost" + port = 9999 + db = "test" + user = "test" + + // Flag should take precedence + if password != "flag_password" { + t.Errorf("Expected password flag to be 'flag_password' (should override env var), got '%s'", password) + } + + // Environment variable should still be set + envPassword := os.Getenv("PGPASSWORD") + if envPassword != "env_password" { + t.Errorf("Expected PGPASSWORD env var to be 'env_password', got '%s'", envPassword) + } + }) + + t.Run("NoPasswordProvided", func(t *testing.T) { + // Clear both flag and environment variable + os.Unsetenv("PGPASSWORD") + password = "" + + // Set other required values + host = "localhost" + port = 9999 + db = "test" + user = "test" + + // Both should be empty + if password != "" { + t.Errorf("Expected password flag to be empty, got '%s'", password) + } + + envPassword := os.Getenv("PGPASSWORD") + if envPassword != "" { + t.Errorf("Expected PGPASSWORD env var to be empty, got '%s'", envPassword) + } + }) +} + +func TestDumpCommand_EnvironmentVariables(t *testing.T) { + // Store original values + originalHost := host + originalPort := port + originalDb := db + originalUser := user + + defer func() { + host = originalHost + port = originalPort + db = originalDb + user = originalUser + // Clean up environment variables + os.Unsetenv("PGHOST") + os.Unsetenv("PGPORT") + os.Unsetenv("PGDATABASE") + os.Unsetenv("PGUSER") + os.Unsetenv("PGAPPNAME") + }() + + t.Run("EnvironmentVariablesAsDefaults", func(t *testing.T) { + // Set environment variables + os.Setenv("PGHOST", "env-host") + os.Setenv("PGPORT", "9999") + os.Setenv("PGDATABASE", "env-db") + os.Setenv("PGUSER", "env-user") + + // Reinitialize the command flags to pick up env vars + // We can't easily do this without recreating the command, but we can test the helper functions + if util.GetEnvWithDefault("PGHOST", "localhost") != "env-host" { + t.Errorf("Expected PGHOST env var to be 'env-host', got '%s'", util.GetEnvWithDefault("PGHOST", "localhost")) + } + + if util.GetEnvIntWithDefault("PGPORT", 5432) != 9999 { + t.Errorf("Expected PGPORT env var to be 9999, got %d", util.GetEnvIntWithDefault("PGPORT", 5432)) + } + + if util.GetEnvWithDefault("PGDATABASE", "") != "env-db" { + t.Errorf("Expected PGDATABASE env var to be 'env-db', got '%s'", util.GetEnvWithDefault("PGDATABASE", "")) + } + + if util.GetEnvWithDefault("PGUSER", "") != "env-user" { + t.Errorf("Expected PGUSER env var to be 'env-user', got '%s'", util.GetEnvWithDefault("PGUSER", "")) + } + }) + + t.Run("EnvVarHelperFunctions", func(t *testing.T) { + // Test string helper + os.Setenv("TEST_STRING", "test-value") + if util.GetEnvWithDefault("TEST_STRING", "default") != "test-value" { + t.Errorf("Expected GetEnvWithDefault to return 'test-value', got '%s'", util.GetEnvWithDefault("TEST_STRING", "default")) + } + + // Test with missing env var + os.Unsetenv("MISSING_VAR") + if util.GetEnvWithDefault("MISSING_VAR", "default") != "default" { + t.Errorf("Expected GetEnvWithDefault to return 'default', got '%s'", util.GetEnvWithDefault("MISSING_VAR", "default")) + } + + // Test int helper + os.Setenv("TEST_INT", "12345") + if util.GetEnvIntWithDefault("TEST_INT", 0) != 12345 { + t.Errorf("Expected GetEnvIntWithDefault to return 12345, got %d", util.GetEnvIntWithDefault("TEST_INT", 0)) + } + + // Test int with invalid value (should return default) + os.Setenv("TEST_INVALID_INT", "not-a-number") + if util.GetEnvIntWithDefault("TEST_INVALID_INT", 999) != 999 { + t.Errorf("Expected GetEnvIntWithDefault to return default 999, got %d", util.GetEnvIntWithDefault("TEST_INVALID_INT", 999)) + } + + // Cleanup + os.Unsetenv("TEST_STRING") + os.Unsetenv("TEST_INT") + os.Unsetenv("TEST_INVALID_INT") + }) +} diff --git a/cmd/plan/plan.go b/cmd/plan/plan.go index c11b4910..3db6c6ea 100644 --- a/cmd/plan/plan.go +++ b/cmd/plan/plan.go @@ -28,21 +28,23 @@ var ( planNoColor bool ) + var PlanCmd = &cobra.Command{ Use: "plan", Short: "Generate migration plan for a specific schema", Long: "Generate a migration plan to apply a desired schema state to a target database schema. Compares the desired state (from --file) with the current state of a specific schema (specified by --schema, defaults to 'public').", RunE: runPlan, SilenceUsage: true, + PreRunE: util.PreRunEWithEnvVars(&planDB, &planUser), } func init() { // Target database connection flags - PlanCmd.Flags().StringVar(&planHost, "host", "localhost", "Database server host") - PlanCmd.Flags().IntVar(&planPort, "port", 5432, "Database server port") - PlanCmd.Flags().StringVar(&planDB, "db", "", "Database name (required)") - PlanCmd.Flags().StringVar(&planUser, "user", "", "Database user name (required)") - PlanCmd.Flags().StringVar(&planPassword, "password", "", "Database password (optional)") + PlanCmd.Flags().StringVar(&planHost, "host", util.GetEnvWithDefault("PGHOST", "localhost"), "Database server host (env: PGHOST)") + PlanCmd.Flags().IntVar(&planPort, "port", util.GetEnvIntWithDefault("PGPORT", 5432), "Database server port (env: PGPORT)") + PlanCmd.Flags().StringVar(&planDB, "db", "", "Database name (required) (env: PGDATABASE)") + PlanCmd.Flags().StringVar(&planUser, "user", "", "Database user name (required) (env: PGUSER)") + PlanCmd.Flags().StringVar(&planPassword, "password", "", "Database password (optional, can also use PGPASSWORD env var)") PlanCmd.Flags().StringVar(&planSchema, "schema", "public", "Schema name") // Desired state schema file flag @@ -54,9 +56,6 @@ func init() { PlanCmd.Flags().StringVar(&outputSQL, "output-sql", "", "Output SQL format to stdout or file path") PlanCmd.Flags().BoolVar(&planNoColor, "no-color", false, "Disable colored output") - // Mark required flags - PlanCmd.MarkFlagRequired("db") - PlanCmd.MarkFlagRequired("user") PlanCmd.MarkFlagRequired("file") } diff --git a/cmd/root.go b/cmd/root.go index afe715c6..731247a4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -83,6 +83,7 @@ func platform() string { return runtime.GOOS + "/" + runtime.GOARCH } + func Execute() { if err := RootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/cmd/util/env.go b/cmd/util/env.go new file mode 100644 index 00000000..010470cb --- /dev/null +++ b/cmd/util/env.go @@ -0,0 +1,51 @@ +package util + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" +) + +// GetEnvWithDefault returns the value of an environment variable or a default value if not set +func GetEnvWithDefault(envVar, defaultValue string) string { + if value := os.Getenv(envVar); value != "" { + return value + } + return defaultValue +} + +// GetEnvIntWithDefault returns the value of an environment variable as int or a default value if not set +func GetEnvIntWithDefault(envVar string, defaultValue int) int { + if value := os.Getenv(envVar); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +// PreRunEWithEnvVars creates a PreRunE function that validates required database connection parameters +// It checks environment variables if the corresponding flags weren't explicitly set +func PreRunEWithEnvVars(dbPtr, userPtr *string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + // Check if required values are available from environment variables + if GetEnvWithDefault("PGDATABASE", "") != "" && !cmd.Flags().Changed("db") { + *dbPtr = GetEnvWithDefault("PGDATABASE", "") + } + if GetEnvWithDefault("PGUSER", "") != "" && !cmd.Flags().Changed("user") { + *userPtr = GetEnvWithDefault("PGUSER", "") + } + + // Now validate that we have the required values + if *dbPtr == "" { + return fmt.Errorf("database name is required (use --db flag or PGDATABASE environment variable)") + } + if *userPtr == "" { + return fmt.Errorf("database user is required (use --user flag or PGUSER environment variable)") + } + + return nil + } +} \ No newline at end of file diff --git a/docs/cli/apply.mdx b/docs/cli/apply.mdx index 1059aa16..e68145c5 100644 --- a/docs/cli/apply.mdx +++ b/docs/cli/apply.mdx @@ -37,28 +37,39 @@ pgschema apply --host localhost --db myapp --user postgres --password mypassword ## Connection Options - Database server host + Database server host (env: PGHOST) - Database server port + Database server port (env: PGPORT) - Database name + Database name (required) (env: PGDATABASE) - Database user name + Database user name (required) (env: PGUSER) - Database password (can also use PGPASSWORD environment variable) + Database password (optional, can also use PGPASSWORD env var) - You can provide the password in two ways: + You can provide the password in multiple ways: - ```bash Environment Variable (Recommended) + ```bash .env File (Recommended) + # Create .env file with: + # PGHOST=localhost + # PGPORT=5432 + # PGDATABASE=myapp + # PGUSER=postgres + # PGPASSWORD=mypassword + + pgschema apply --file schema.sql + ``` + + ```bash Environment Variable PGPASSWORD=mypassword pgschema apply \ --host localhost \ --db myapp \ @@ -75,6 +86,8 @@ pgschema apply --host localhost --db myapp --user postgres --password mypassword --file schema.sql ``` + + See [dotenv (.env)](/cli/dotenv) for detailed configuration options. @@ -115,8 +128,8 @@ pgschema apply --host localhost --db myapp --user postgres --password mypassword - Application name for database connection (visible in pg_stat_activity) - + Application name for database connection (visible in pg_stat_activity) (env: PGAPPNAME) + See [PostgreSQL application_name documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-APPLICATION-NAME). diff --git a/docs/cli/dotenv.mdx b/docs/cli/dotenv.mdx new file mode 100644 index 00000000..63a688a9 --- /dev/null +++ b/docs/cli/dotenv.mdx @@ -0,0 +1,67 @@ +--- +title: "dotenv (.env)" +--- + +`pgschema` supports loading configuration from environment variables and dotenv `.env` files. + +## Overview + +pgschema automatically loads configuration from: +1. `.env` file in the current directory (if present) +2. System environment variables +3. Command-line flags (highest priority) + +The precedence order is: **CLI flags > environment variables > defaults** + +## Supported Environment Variables + +pgschema supports all standard PostgreSQL environment variables: + + + Database server host + + + + Database server port + + + + Database name (required for all commands) + + + + Database user name (required for all commands) + + + + Database password + + + + Application name visible in `pg_stat_activity` + + +## .env Setup + +Create a `.env` file in your project directory: + +```bash +# Database connection settings +PGHOST=localhost +PGPORT=5432 +PGDATABASE=myapp +PGUSER=postgres +PGPASSWORD=secretpassword + +# Optional: Custom application name +PGAPPNAME=pgschema +``` + +**Security Note:** Add `.env` to your `.gitignore` file to prevent committing sensitive credentials: + +```gitignore +# Environment files +.env +.env.local +.env.*.local +``` \ No newline at end of file diff --git a/docs/cli/dump.mdx b/docs/cli/dump.mdx index d1b96935..c73542fc 100644 --- a/docs/cli/dump.mdx +++ b/docs/cli/dump.mdx @@ -40,35 +40,46 @@ pgschema dump --host prod-host --db myapp --user postgres --schema public > curr # 3. Plan changes against staging pgschema plan --host staging-host --db myapp --user postgres --file current.sql -# 4. Apply changes if plan looks good +# 4. Apply changes if plan looks good pgschema apply --host staging-host --db myapp --user postgres --file current.sql ``` ## Connection Options - Database server host + Database server host (env: PGHOST) - Database server port + Database server port (env: PGPORT) - Database name + Database name (required) (env: PGDATABASE) - Database user name + Database user name (required) (env: PGUSER) - Database password (can also use PGPASSWORD environment variable) + Database password (optional, can also use PGPASSWORD env var) - You can provide the password in two ways: + You can provide the password in multiple ways: - ```bash Environment Variable (Recommended) + ```bash .env File (Recommended) + # Create .env file with: + # PGHOST=localhost + # PGPORT=5432 + # PGDATABASE=myapp + # PGUSER=postgres + # PGPASSWORD=mypassword + + pgschema dump + ``` + + ```bash Environment Variable PGPASSWORD=mypassword pgschema dump \ --host localhost \ --db myapp \ @@ -83,6 +94,8 @@ pgschema apply --host staging-host --db myapp --user postgres --file current.sql --password mypassword ``` + + See [dotenv (.env)](/cli/dotenv) for detailed configuration options. @@ -117,7 +130,7 @@ pgschema apply --host staging-host --db myapp --user postgres --file current.sql # Dump default schema (public) pgschema dump --host localhost --db myapp --user postgres -# Dump specific schema +# Dump specific schema pgschema dump --host localhost --db myapp --user postgres --schema analytics ``` diff --git a/docs/cli/plan.mdx b/docs/cli/plan.mdx index 6d7e947c..d8058cae 100644 --- a/docs/cli/plan.mdx +++ b/docs/cli/plan.mdx @@ -39,28 +39,39 @@ pgschema plan --host localhost --db myapp --user postgres --password mypassword ## Connection Options - Database server host + Database server host (env: PGHOST) - Database server port + Database server port (env: PGPORT) - Database name + Database name (required) (env: PGDATABASE) - Database user name + Database user name (required) (env: PGUSER) - Database password (can also use PGPASSWORD environment variable) + Database password (optional, can also use PGPASSWORD env var) - You can provide the password in two ways: + You can provide the password in multiple ways: - ```bash Environment Variable (Recommended) + ```bash .env File (Recommended) + # Create .env file with: + # PGHOST=localhost + # PGPORT=5432 + # PGDATABASE=myapp + # PGUSER=postgres + # PGPASSWORD=mypassword + + pgschema plan --file schema.sql + ``` + + ```bash Environment Variable PGPASSWORD=mypassword pgschema plan \ --host localhost \ --db myapp \ @@ -77,6 +88,8 @@ pgschema plan --host localhost --db myapp --user postgres --password mypassword --file schema.sql ``` + + See [dotenv (.env)](/cli/dotenv) for detailed configuration options. diff --git a/docs/docs.json b/docs/docs.json index 400f954a..0391b5cf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,7 +40,7 @@ }, { "group": "CLI Reference", - "pages": ["cli/dump", "cli/plan", "cli/apply"] + "pages": ["cli/dump", "cli/plan", "cli/apply", "cli/dotenv"] } ] }, diff --git a/go.mod b/go.mod index af3207e5..b4ddbf11 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect diff --git a/go.sum b/go.sum index 0605986e..dc067afc 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= diff --git a/main.go b/main.go index b1446a8d..74b6c82b 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,13 @@ package main import ( + "github.com/joho/godotenv" "github.com/pgschema/pgschema/cmd" ) func main() { + // Load .env file if it exists (silently ignore errors) + _ = godotenv.Load() + cmd.Execute() }