diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c8415ae3..c5ce9386 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -12,7 +12,7 @@ jobs: name: Integration Tests runs-on: ubuntu-latest environment: TEST - timeout-minutes: 15 + timeout-minutes: 20 env: # These will be available to all steps in this job CELOSCAN_API_KEY: ${{ secrets.CELOSCAN_API_KEY }} DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} @@ -49,7 +49,7 @@ jobs: name: Integration Tests (macOS) runs-on: macos-latest environment: TEST - timeout-minutes: 15 + timeout-minutes: 20 env: # These will be available to all steps in this job CELOSCAN_API_KEY: ${{ secrets.CELOSCAN_API_KEY }} DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} diff --git a/CLAUDE.md b/CLAUDE.md index e9154912..676d3b69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,8 +268,38 @@ The registry (`.treb/deployments.json`) is a comprehensive JSON structure tracki ## Configuration -### foundry.toml Sender Profiles +### treb.toml Sender Configuration (Recommended) ```toml +# treb.toml — Treb sender configuration +# Each [ns.] section defines a namespace with sender configs. +# The optional 'profile' field maps to a foundry.toml profile (defaults to namespace name). + +[ns.default] +profile = "default" + +[ns.default.senders.deployer] +type = "private_key" +private_key = "${DEPLOYER_PRIVATE_KEY}" + +[ns.production] +profile = "production" + +# Production namespace with Safe multisig sender +[ns.production.senders.safe] +type = "safe" +safe = "0x32CB58b145d3f7e28c45cE4B2Cc31fa94248b23F" +signer = "proposer" + +# Hardware wallet proposer +[ns.production.senders.proposer] +type = "ledger" +derivation_path = "${PROD_PROPOSER_DERIVATION_PATH}" +``` + +### foundry.toml Sender Profiles (Legacy) +```toml +# NOTE: This format is deprecated. Run `treb migrate-config` to migrate to treb.toml. + # Default profile with private key sender [profile.default.treb.senders.deployer] type = "private_key" @@ -354,7 +384,8 @@ treb run DeployCounter --dry-run - `src/internal/`: Internal utilities and registry contracts ### Configuration Files -- `foundry.toml`: Foundry configuration with sender profiles and RPC endpoints +- `treb.toml`: Treb sender configuration with namespace-based sender profiles (preferred) +- `foundry.toml`: Foundry configuration with RPC endpoints (legacy sender profiles deprecated) - `.treb/deployments.json`: Deployment registry tracking all deployments - `.treb/transactions.json`: Transaction registry tracking script executions - `.treb/config.json`: Local project configuration (namespace, network) diff --git a/Makefile b/Makefile index aa9c6a38..c170065f 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ setup-integration-test: # Run integration tests integration-test: setup-integration-test @echo "šŸ”— Running integration tests..." - @gotestsum --format=testname --no-summary=output --rerun-fails --rerun-fails-max-failures=5 --packages=./test/integration -- -v -timeout=10m + @gotestsum --format=testname --no-summary=output --rerun-fails --rerun-fails-max-failures=5 --packages=./test/integration -- -v -timeout=15m # Clean build artifacts clean: diff --git a/internal/adapters/forge/forge.go b/internal/adapters/forge/forge.go index b9a3c5ef..ef81461c 100644 --- a/internal/adapters/forge/forge.go +++ b/internal/adapters/forge/forge.go @@ -44,7 +44,7 @@ func (f *ForgeAdapter) Build() error { output, err := cmd.CombinedOutput() duration := time.Since(start) - + if err != nil { f.log.Error("forge build failed", "error", err, "output", string(output), "duration", duration) // Only print error details if build actually failed @@ -305,7 +305,7 @@ func (f *ForgeAdapter) buildEnv(config usecase.RunScriptConfig) []string { maps.Copy(env, config.Parameters) // Profile - env["FOUNDRY_PROFILE"] = config.Namespace + env["FOUNDRY_PROFILE"] = config.FoundryProfile env["NAMESPACE"] = config.Namespace env["NETWORK"] = config.Network.Name env["DRYRUN"] = strconv.FormatBool(config.DryRun || config.Debug || config.DebugJSON) diff --git a/internal/adapters/forge/forge_test.go b/internal/adapters/forge/forge_test.go index 580c1565..7a3201b7 100644 --- a/internal/adapters/forge/forge_test.go +++ b/internal/adapters/forge/forge_test.go @@ -22,9 +22,10 @@ func baseRunScriptConfig() usecase.RunScriptConfig { ChainID: 11155111, RPCURL: "https://sepolia.example.com", }, - Namespace: "default", - Script: &models.Contract{Name: "DeployCounter", Path: "script/deploy/DeployCounter.s.sol"}, - Parameters: map[string]string{"LABEL": "v1"}, + Namespace: "default", + FoundryProfile: "default", + Script: &models.Contract{Name: "DeployCounter", Path: "script/deploy/DeployCounter.s.sol"}, + Parameters: map[string]string{"LABEL": "v1"}, SenderScriptConfig: config.SenderScriptConfig{ EncodedConfig: "encoded", }, diff --git a/internal/adapters/repository/contracts/repository.go b/internal/adapters/repository/contracts/repository.go index f2ffcf60..f04e95b7 100644 --- a/internal/adapters/repository/contracts/repository.go +++ b/internal/adapters/repository/contracts/repository.go @@ -45,7 +45,7 @@ func NewRepository(projectRoot string, log *slog.Logger) *Repository { func (i *Repository) Index() error { start := time.Now() i.log.Debug("starting contract indexing") - + i.mu.Lock() defer i.mu.Unlock() @@ -106,7 +106,7 @@ func (i *Repository) Index() error { func (i *Repository) runForgeBuild() error { start := time.Now() i.log.Debug("running forge build for contract indexing", "dir", i.projectRoot) - + // Check if we need to rebuild by looking for a cache indicator // For now, always build without --force to improve performance // The --force flag was causing significant slowdowns @@ -115,7 +115,7 @@ func (i *Repository) runForgeBuild() error { output, err := cmd.CombinedOutput() duration := time.Since(start) - + if err != nil { i.log.Error("forge build failed", "error", err, "duration", duration) return fmt.Errorf("forge build failed: %w\nOutput: %s", err, string(output)) diff --git a/internal/cli/config.go b/internal/cli/config.go index 89dd8246..2b0c58ed 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -124,6 +124,9 @@ func showConfig(cmd *cobra.Command) error { return err } + // Enrich result with config source from runtime config + result.ConfigSource = app.Config.ConfigSource + // Render result renderer := render.NewConfigRenderer(cmd.OutOrStdout()) return renderer.RenderConfig(result) diff --git a/internal/cli/migrate_config.go b/internal/cli/migrate_config.go new file mode 100644 index 00000000..b6f33d41 --- /dev/null +++ b/internal/cli/migrate_config.go @@ -0,0 +1,300 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + domainconfig "github.com/trebuchet-org/treb-cli/internal/domain/config" +) + +// NewMigrateConfigCmd creates the migrate-config command +func NewMigrateConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate-config", + Short: "Migrate treb config from foundry.toml to treb.toml", + Long: `Migrate treb sender configuration from foundry.toml [profile.*.treb.*] sections +into a dedicated treb.toml file with [ns.] structure. + +This command will: +1. Read all [profile.*.treb.*] sections from foundry.toml +2. Convert each profile to a [ns.] namespace in treb.toml +3. Show a preview of the generated treb.toml +4. Ask for confirmation before writing + +Examples: + # Interactive migration (shows preview, asks for confirmation) + treb migrate-config + + # Non-interactive migration (writes without prompts) + treb migrate-config --non-interactive`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app, err := getApp(cmd) + if err != nil { + return err + } + + return runMigrateConfig(app.Config) + }, + } + + return cmd +} + +// runMigrateConfig performs the config migration from foundry.toml to treb.toml. +func runMigrateConfig(cfg *domainconfig.RuntimeConfig) error { + // Check if there's any treb config in foundry.toml to migrate + profiles := extractTrebProfiles(cfg.FoundryConfig) + if len(profiles) == 0 { + fmt.Println("No treb config found in foundry.toml — nothing to migrate.") + return nil + } + + // Generate treb.toml content + content := generateTrebToml(profiles) + + trebTomlPath := filepath.Join(cfg.ProjectRoot, "treb.toml") + foundryTomlPath := filepath.Join(cfg.ProjectRoot, "foundry.toml") + + // Check if treb.toml already exists + if _, err := os.Stat(trebTomlPath); err == nil { + if cfg.NonInteractive { + // In non-interactive mode, overwrite + fmt.Fprintln(os.Stderr, "Warning: treb.toml already exists and will be overwritten.") + } else { + yellow := color.New(color.FgYellow) + yellow.Fprintln(os.Stderr, "Warning: treb.toml already exists.") + if !confirmPrompt("Overwrite existing treb.toml?") { + fmt.Println("Migration cancelled.") + return nil + } + } + } + + // Show preview + if !cfg.NonInteractive { + fmt.Println("Generated treb.toml:") + fmt.Println() + fmt.Println(content) + + if !confirmPrompt("Write this to treb.toml?") { + fmt.Println("Migration cancelled.") + return nil + } + } + + // Write treb.toml + if err := os.WriteFile(trebTomlPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write treb.toml: %w", err) + } + + green := color.New(color.FgGreen, color.Bold) + green.Printf("āœ“ treb.toml written successfully\n") + + // Offer to clean up foundry.toml + cleanedUp := false + if !cfg.NonInteractive { + fmt.Println() + if confirmPrompt("Remove [profile.*.treb.*] sections from foundry.toml?") { + if err := cleanupFoundryToml(foundryTomlPath); err != nil { + return fmt.Errorf("failed to clean up foundry.toml: %w", err) + } + green.Printf("āœ“ foundry.toml cleaned up\n") + cleanedUp = true + } else { + fmt.Println("Skipped foundry.toml cleanup — you can remove [profile.*.treb.*] sections manually.") + } + } + + // Print next steps + fmt.Println() + fmt.Println("Next steps:") + fmt.Println(" 1. Review the generated treb.toml") + if !cleanedUp { + fmt.Println(" 2. Remove [profile.*.treb.*] sections from foundry.toml") + fmt.Println(" 3. Run `treb config show` to verify your config is loaded correctly") + } else { + fmt.Println(" 2. Run `treb config show` to verify your config is loaded correctly") + } + + return nil +} + +// cleanupFoundryToml reads foundry.toml and removes [profile.*.treb.*] sections. +func cleanupFoundryToml(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + cleaned := removeTrebFromFoundryToml(string(data)) + return os.WriteFile(path, []byte(cleaned), 0644) +} + +// trebProfile holds a foundry profile name and its treb sender config. +type trebProfile struct { + Name string + Senders map[string]domainconfig.SenderConfig +} + +// extractTrebProfiles extracts foundry profiles that have treb sender configs. +func extractTrebProfiles(fc *domainconfig.FoundryConfig) []trebProfile { + if fc == nil { + return nil + } + + var profiles []trebProfile + for name, profile := range fc.Profile { + if profile.Treb != nil && len(profile.Treb.Senders) > 0 { + profiles = append(profiles, trebProfile{ + Name: name, + Senders: profile.Treb.Senders, + }) + } + } + + // Sort by name for deterministic output (default first) + sort.Slice(profiles, func(i, j int) bool { + if profiles[i].Name == "default" { + return true + } + if profiles[j].Name == "default" { + return false + } + return profiles[i].Name < profiles[j].Name + }) + + return profiles +} + +// generateTrebToml generates well-formatted treb.toml content from foundry profiles. +func generateTrebToml(profiles []trebProfile) string { + var b strings.Builder + + b.WriteString("# treb.toml — Treb sender configuration\n") + b.WriteString("#\n") + b.WriteString("# Each [ns.] section defines a namespace with sender configs.\n") + b.WriteString("# The optional 'profile' field maps to a foundry.toml profile (defaults to namespace name).\n") + b.WriteString("#\n") + b.WriteString("# Migrated from foundry.toml [profile.*.treb.*] sections.\n") + + for i, p := range profiles { + if i > 0 { + b.WriteString("\n") + } + b.WriteString("\n") + + // Namespace header + fmt.Fprintf(&b, "[ns.%s]\n", p.Name) + // Always set profile explicitly so it's clear where it maps + fmt.Fprintf(&b, "profile = %q\n", p.Name) + + // Sort sender names for deterministic output + senderNames := make([]string, 0, len(p.Senders)) + for name := range p.Senders { + senderNames = append(senderNames, name) + } + sort.Strings(senderNames) + + for _, senderName := range senderNames { + sender := p.Senders[senderName] + b.WriteString("\n") + fmt.Fprintf(&b, "[ns.%s.senders.%s]\n", p.Name, senderName) + writeSenderConfig(&b, sender) + } + } + + return b.String() +} + +// writeSenderConfig writes a sender config's fields to the builder. +func writeSenderConfig(b *strings.Builder, s domainconfig.SenderConfig) { + fmt.Fprintf(b, "type = %q\n", string(s.Type)) + + if s.Address != "" { + fmt.Fprintf(b, "address = %q\n", s.Address) + } + if s.PrivateKey != "" { + fmt.Fprintf(b, "private_key = %q\n", s.PrivateKey) + } + if s.Safe != "" { + fmt.Fprintf(b, "safe = %q\n", s.Safe) + } + if s.Signer != "" { + fmt.Fprintf(b, "signer = %q\n", s.Signer) + } + if s.DerivationPath != "" { + fmt.Fprintf(b, "derivation_path = %q\n", s.DerivationPath) + } + if s.Governor != "" { + fmt.Fprintf(b, "governor = %q\n", s.Governor) + } + if s.Timelock != "" { + fmt.Fprintf(b, "timelock = %q\n", s.Timelock) + } + if s.Proposer != "" { + fmt.Fprintf(b, "proposer = %q\n", s.Proposer) + } +} + +// trebSectionHeaderRe matches [profile..treb] and [profile..treb.senders.] headers. +var trebSectionHeaderRe = regexp.MustCompile(`^\[profile\.[^]]+\.treb(?:\.[^]]+)?\]\s*$`) + +// anySectionHeaderRe matches any TOML section header like [something] or [a.b.c]. +var anySectionHeaderRe = regexp.MustCompile(`^\[.+\]\s*$`) + +// removeTrebFromFoundryToml removes [profile.*.treb.*] sections from foundry.toml content +// using a line-based approach to preserve user formatting and comments. +func removeTrebFromFoundryToml(content string) string { + lines := strings.Split(content, "\n") + var result []string + inTrebSection := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if anySectionHeaderRe.MatchString(trimmed) { + if trebSectionHeaderRe.MatchString(trimmed) { + inTrebSection = true + continue + } + inTrebSection = false + } + + if inTrebSection { + // Skip key-value lines, comments, and blank lines within treb sections + continue + } + + result = append(result, line) + } + + // Clean up excess trailing blank lines (collapse to single trailing newline) + output := strings.Join(result, "\n") + for strings.HasSuffix(output, "\n\n") { + output = strings.TrimSuffix(output, "\n") + } + if !strings.HasSuffix(output, "\n") { + output += "\n" + } + + return output +} + +// confirmPrompt asks the user a yes/no question and returns their choice. +func confirmPrompt(label string) bool { + prompt := promptui.Prompt{ + Label: label, + IsConfirm: true, + } + + _, err := prompt.Run() + return err == nil +} diff --git a/internal/cli/migrate_config_test.go b/internal/cli/migrate_config_test.go new file mode 100644 index 00000000..a518ac7e --- /dev/null +++ b/internal/cli/migrate_config_test.go @@ -0,0 +1,530 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/trebuchet-org/treb-cli/internal/domain/config" +) + +func TestExtractTrebProfiles(t *testing.T) { + t.Run("nil foundry config returns nil", func(t *testing.T) { + profiles := extractTrebProfiles(nil) + assert.Nil(t, profiles) + }) + + t.Run("no treb config in any profile", func(t *testing.T) { + fc := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": {SrcPath: "src"}, + }, + } + profiles := extractTrebProfiles(fc) + assert.Empty(t, profiles) + }) + + t.Run("extracts profiles with treb senders", func(t *testing.T) { + fc := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "${PK}"}, + }, + }, + }, + "staging": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypeLedger, DerivationPath: "m/44'/60'/0'/0/0"}, + }, + }, + }, + "other": {SrcPath: "src"}, // no treb config + }, + } + profiles := extractTrebProfiles(fc) + require.Len(t, profiles, 2) + // default should come first + assert.Equal(t, "default", profiles[0].Name) + assert.Equal(t, "staging", profiles[1].Name) + }) + + t.Run("skips profiles with nil senders", func(t *testing.T) { + fc := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{}, + }, + }, + }, + } + profiles := extractTrebProfiles(fc) + assert.Empty(t, profiles) + }) + + t.Run("sort order: default first then alphabetical", func(t *testing.T) { + fc := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "production": {Treb: &config.TrebConfig{Senders: map[string]config.SenderConfig{"a": {Type: "private_key"}}}}, + "default": {Treb: &config.TrebConfig{Senders: map[string]config.SenderConfig{"b": {Type: "private_key"}}}}, + "beta": {Treb: &config.TrebConfig{Senders: map[string]config.SenderConfig{"c": {Type: "private_key"}}}}, + }, + } + profiles := extractTrebProfiles(fc) + require.Len(t, profiles, 3) + assert.Equal(t, "default", profiles[0].Name) + assert.Equal(t, "beta", profiles[1].Name) + assert.Equal(t, "production", profiles[2].Name) + }) +} + +func TestGenerateTrebToml(t *testing.T) { + t.Run("single profile with private key sender", func(t *testing.T) { + profiles := []trebProfile{ + { + Name: "default", + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "${DEPLOYER_PRIVATE_KEY}"}, + }, + }, + } + content := generateTrebToml(profiles) + assert.Contains(t, content, "[ns.default]") + assert.Contains(t, content, `profile = "default"`) + assert.Contains(t, content, "[ns.default.senders.deployer]") + assert.Contains(t, content, `type = "private_key"`) + assert.Contains(t, content, `private_key = "${DEPLOYER_PRIVATE_KEY}"`) + }) + + t.Run("multiple profiles with different sender types", func(t *testing.T) { + profiles := []trebProfile{ + { + Name: "default", + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "${PK}"}, + }, + }, + { + Name: "production", + Senders: map[string]config.SenderConfig{ + "safe": {Type: config.SenderTypeSafe, Safe: "0xABC", Signer: "proposer"}, + "proposer": {Type: config.SenderTypeLedger, DerivationPath: "m/44'/60'/0'/0/0"}, + }, + }, + } + content := generateTrebToml(profiles) + + assert.Contains(t, content, "[ns.default]") + assert.Contains(t, content, "[ns.production]") + assert.Contains(t, content, `profile = "production"`) + assert.Contains(t, content, "[ns.production.senders.proposer]") + assert.Contains(t, content, `type = "ledger"`) + assert.Contains(t, content, `derivation_path = "m/44'/60'/0'/0/0"`) + assert.Contains(t, content, "[ns.production.senders.safe]") + assert.Contains(t, content, `type = "safe"`) + assert.Contains(t, content, `safe = "0xABC"`) + assert.Contains(t, content, `signer = "proposer"`) + }) + + t.Run("includes header comments", func(t *testing.T) { + profiles := []trebProfile{ + {Name: "default", Senders: map[string]config.SenderConfig{"d": {Type: "private_key"}}}, + } + content := generateTrebToml(profiles) + assert.Contains(t, content, "# treb.toml") + assert.Contains(t, content, "Migrated from foundry.toml") + }) + + t.Run("all sender fields rendered when set", func(t *testing.T) { + profiles := []trebProfile{ + { + Name: "default", + Senders: map[string]config.SenderConfig{ + "full": { + Type: config.SenderTypeOZGovernor, + Address: "0x123", + Governor: "0xGOV", + Timelock: "0xTL", + Proposer: "0xPROP", + PrivateKey: "${PK}", + Safe: "0xSAFE", + Signer: "signer1", + DerivationPath: "m/44", + }, + }, + }, + } + content := generateTrebToml(profiles) + assert.Contains(t, content, `address = "0x123"`) + assert.Contains(t, content, `governor = "0xGOV"`) + assert.Contains(t, content, `timelock = "0xTL"`) + assert.Contains(t, content, `proposer = "0xPROP"`) + assert.Contains(t, content, `private_key = "${PK}"`) + assert.Contains(t, content, `safe = "0xSAFE"`) + assert.Contains(t, content, `signer = "signer1"`) + assert.Contains(t, content, `derivation_path = "m/44"`) + }) + + t.Run("omits empty sender fields", func(t *testing.T) { + profiles := []trebProfile{ + { + Name: "default", + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "${PK}"}, + }, + }, + } + content := generateTrebToml(profiles) + assert.NotContains(t, content, "address =") + assert.NotContains(t, content, "safe =") + assert.NotContains(t, content, "signer =") + assert.NotContains(t, content, "derivation_path =") + assert.NotContains(t, content, "governor =") + assert.NotContains(t, content, "timelock =") + assert.NotContains(t, content, "proposer =") + }) +} + +func TestRunMigrateConfig(t *testing.T) { + t.Run("no treb config prints message and exits", func(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.RuntimeConfig{ + ProjectRoot: tmpDir, + FoundryConfig: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": {SrcPath: "src"}, + }, + }, + NonInteractive: true, + } + + err := runMigrateConfig(cfg) + require.NoError(t, err) + + // treb.toml should NOT be written + _, err = os.Stat(filepath.Join(tmpDir, "treb.toml")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("non-interactive writes treb.toml without prompts", func(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.RuntimeConfig{ + ProjectRoot: tmpDir, + FoundryConfig: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "${PK}"}, + }, + }, + }, + }, + }, + NonInteractive: true, + } + + err := runMigrateConfig(cfg) + require.NoError(t, err) + + // treb.toml should be written + data, err := os.ReadFile(filepath.Join(tmpDir, "treb.toml")) + require.NoError(t, err) + assert.Contains(t, string(data), "[ns.default]") + assert.Contains(t, string(data), `type = "private_key"`) + }) + + t.Run("non-interactive overwrites existing treb.toml", func(t *testing.T) { + tmpDir := t.TempDir() + existingPath := filepath.Join(tmpDir, "treb.toml") + require.NoError(t, os.WriteFile(existingPath, []byte("old content"), 0644)) + + cfg := &config.RuntimeConfig{ + ProjectRoot: tmpDir, + FoundryConfig: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "${PK}"}, + }, + }, + }, + }, + }, + NonInteractive: true, + } + + err := runMigrateConfig(cfg) + require.NoError(t, err) + + // Should be overwritten with new content + data, err := os.ReadFile(existingPath) + require.NoError(t, err) + assert.Contains(t, string(data), "[ns.default]") + assert.NotContains(t, string(data), "old content") + }) + + t.Run("multiple profiles converted correctly", func(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.RuntimeConfig{ + ProjectRoot: tmpDir, + FoundryConfig: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "${PK}"}, + }, + }, + }, + "production": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "safe": {Type: config.SenderTypeSafe, Safe: "0xABC", Signer: "proposer"}, + "proposer": {Type: config.SenderTypeLedger, DerivationPath: "m/44'/60'/0'/0/0"}, + }, + }, + }, + }, + }, + NonInteractive: true, + } + + err := runMigrateConfig(cfg) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(tmpDir, "treb.toml")) + require.NoError(t, err) + content := string(data) + + assert.Contains(t, content, "[ns.default]") + assert.Contains(t, content, "[ns.production]") + assert.Contains(t, content, "[ns.production.senders.safe]") + assert.Contains(t, content, "[ns.production.senders.proposer]") + }) + + t.Run("non-interactive does not modify foundry.toml", func(t *testing.T) { + tmpDir := t.TempDir() + foundryContent := `[profile.default] +src = "src" + +[profile.default.treb.senders.deployer] +type = "private_key" +private_key = "0xkey" +` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "foundry.toml"), []byte(foundryContent), 0644)) + + cfg := &config.RuntimeConfig{ + ProjectRoot: tmpDir, + FoundryConfig: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey, PrivateKey: "0xkey"}, + }, + }, + }, + }, + }, + NonInteractive: true, + } + + err := runMigrateConfig(cfg) + require.NoError(t, err) + + // foundry.toml should be untouched + data, err := os.ReadFile(filepath.Join(tmpDir, "foundry.toml")) + require.NoError(t, err) + assert.Equal(t, foundryContent, string(data)) + }) +} + +func TestRemoveTrebFromFoundryToml(t *testing.T) { + t.Run("removes single treb section", func(t *testing.T) { + input := `[profile.default] +src = "src" + +[profile.default.treb.senders.deployer] +type = "private_key" +private_key = "0xkey" + +[rpc_endpoints] +anvil = "http://localhost:8545" +` + result := removeTrebFromFoundryToml(input) + assert.Contains(t, result, `[profile.default]`) + assert.Contains(t, result, `src = "src"`) + assert.Contains(t, result, `[rpc_endpoints]`) + assert.Contains(t, result, `anvil = "http://localhost:8545"`) + assert.NotContains(t, result, `[profile.default.treb.senders.deployer]`) + assert.NotContains(t, result, `private_key`) + }) + + t.Run("removes multiple treb sections across profiles", func(t *testing.T) { + input := `[profile.default] +src = "src" + +[profile.default.treb.senders.anvil] +type = "private_key" +private_key = "0xkey" + +[profile.default.treb.senders.governor] +type = "oz_governor" +governor = "0xGOV" + +[profile.live.treb.senders.safe0] +type = "safe" +safe = "0xSAFE" +signer = "signer0" + +[rpc_endpoints] +anvil = "http://localhost:8545" +` + result := removeTrebFromFoundryToml(input) + assert.Contains(t, result, `[profile.default]`) + assert.Contains(t, result, `[rpc_endpoints]`) + assert.NotContains(t, result, `[profile.default.treb.senders.anvil]`) + assert.NotContains(t, result, `[profile.default.treb.senders.governor]`) + assert.NotContains(t, result, `[profile.live.treb.senders.safe0]`) + assert.NotContains(t, result, `oz_governor`) + }) + + t.Run("preserves comments outside treb sections", func(t *testing.T) { + input := `# Main config +[profile.default] +src = "src" + +[profile.default.treb.senders.deployer] +type = "private_key" +# private key comment +private_key = "0xkey" + +[rpc_endpoints] +# RPC config +anvil = "http://localhost:8545" +` + result := removeTrebFromFoundryToml(input) + assert.Contains(t, result, "# Main config") + assert.Contains(t, result, "# RPC config") + assert.NotContains(t, result, "private key comment") + assert.NotContains(t, result, "private_key") + }) + + t.Run("handles treb section at end of file", func(t *testing.T) { + input := `[profile.default] +src = "src" + +[profile.default.treb.senders.deployer] +type = "private_key" +private_key = "0xkey" +` + result := removeTrebFromFoundryToml(input) + assert.Contains(t, result, `[profile.default]`) + assert.Contains(t, result, `src = "src"`) + assert.NotContains(t, result, "private_key") + }) + + t.Run("no treb sections returns content unchanged", func(t *testing.T) { + input := `[profile.default] +src = "src" + +[rpc_endpoints] +anvil = "http://localhost:8545" +` + result := removeTrebFromFoundryToml(input) + assert.Equal(t, input, result) + }) + + t.Run("preserves exact foundry.toml content from test fixture", func(t *testing.T) { + input := `[profile.default] +src = "src" +out = "out" +libs = ["lib"] +test = "test" +script = "script" +optimizer_runs = 0 +fs_permissions = [{ access = "read-write", path = "./" }] +bytecode_hash = "none" +cbor_metadata = false + +[lint] +lint_on_build = false + +[rpc_endpoints] +celo-sepolia = "https://forno.celo-sepolia.celo-testnet.org" +base-sepolia = "https://sepolia.base.org" +polygon = "https://polygon-bor-rpc.publicnode.com" +celo = "https://forno.celo.org" +anvil-31337 = "http://localhost:8545" +anvil-31338 = "http://localhost:9545" + +[etherscan] +sepolia = { key = "${ETHERSCAN_API_KEY}" } +celo-sepolia = { key = "${ETHERSCAN_API_KEY}", chain = 11142220 } +celo = { key = "${ETHERSCAN_API_KEY}", chain = 42220 } + +[profile.default.treb.senders.anvil] +type = "private_key" # anvil user 0 +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +# Governor sender configuration - addresses are set after governance deployment +[profile.default.treb.senders.governor] +type = "oz_governor" +governor = "${GOVERNOR_ADDRESS}" +timelock = "${TIMELOCK_ADDRESS}" +proposer = "anvil" + +[profile.live.treb.senders.safe0] +type = "safe" +safe = "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F" +signer = "signer0" + +[profile.live.treb.senders.safe1] +type = "safe" +safe = "0x8dcD47D7aC5FEBC1E49a532644D21cd9D9dd97b2" +signer = "signer0" + +[profile.live.treb.senders.signer0] +type = "private_key" +private_key="${BASE_SEPOLIA_SIGNER0_PK}" +` + result := removeTrebFromFoundryToml(input) + + // Should preserve all non-treb sections + assert.Contains(t, result, `[profile.default]`) + assert.Contains(t, result, `src = "src"`) + assert.Contains(t, result, `[lint]`) + assert.Contains(t, result, `[rpc_endpoints]`) + assert.Contains(t, result, `[etherscan]`) + + // Should remove all treb sections + assert.NotContains(t, result, `[profile.default.treb`) + assert.NotContains(t, result, `[profile.live.treb`) + assert.NotContains(t, result, "private_key") + assert.NotContains(t, result, "oz_governor") + assert.NotContains(t, result, "GOVERNOR_ADDRESS") + }) + + t.Run("removes bare treb section header", func(t *testing.T) { + input := `[profile.default] +src = "src" + +[profile.default.treb] +some_key = "value" + +[rpc_endpoints] +anvil = "http://localhost:8545" +` + result := removeTrebFromFoundryToml(input) + assert.NotContains(t, result, "[profile.default.treb]") + assert.NotContains(t, result, "some_key") + assert.Contains(t, result, "[rpc_endpoints]") + }) +} diff --git a/internal/cli/render/config.go b/internal/cli/render/config.go index f4200ca8..228966f5 100644 --- a/internal/cli/render/config.go +++ b/internal/cli/render/config.go @@ -57,7 +57,15 @@ func (r *ConfigRenderer) RenderConfig(result *usecase.ShowConfigResult) error { fmt.Fprintf(r.out, "Network: %s\n", "(not set)") } - fmt.Fprintf(r.out, "\nšŸ“ config file: %s\n", getRelativePath(result.ConfigPath)) + // Show config source + switch result.ConfigSource { + case "treb.toml": + fmt.Fprintf(r.out, "\nšŸ“¦ Config source: treb.toml\n") + case "foundry.toml": + fmt.Fprintf(r.out, "\nšŸ“¦ Config source: foundry.toml (legacy)\n") + } + + fmt.Fprintf(r.out, "šŸ“ config file: %s\n", getRelativePath(result.ConfigPath)) return nil } diff --git a/internal/cli/render/init.go b/internal/cli/render/init.go index 709a5c05..c03c0653 100644 --- a/internal/cli/render/init.go +++ b/internal/cli/render/init.go @@ -61,8 +61,8 @@ func (r *InitRenderer) printSuccessMessage(result *usecase.InitProjectResult) { fmt.Println(" • Set API keys for contract verification") fmt.Println("") - fmt.Println("2. Configure deployment environments in foundry.toml:") - fmt.Println(" • Add [profile.staging.deployer] and [profile.production.deployer] sections") + fmt.Println("2. Configure deployment environments in treb.toml:") + fmt.Println(" • Add [ns..senders.] sections for each environment") fmt.Println(" • See documentation for Safe multisig and hardware wallet support") fmt.Println("") diff --git a/internal/cli/root.go b/internal/cli/root.go index 4ec14a88..1f6346f5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,10 +3,13 @@ package cli import ( "context" "fmt" + "os" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/trebuchet-org/treb-cli/internal/app" "github.com/trebuchet-org/treb-cli/internal/config" + domainconfig "github.com/trebuchet-org/treb-cli/internal/domain/config" ) // contextKey is the type for context keys @@ -49,6 +52,9 @@ smart contract deployments using CreateX factory contracts.`, return fmt.Errorf("failed to initialize app: %w", err) } + // Show deprecation warning for legacy foundry.toml config + showLegacyConfigWarning(cmd, app.Config) + // Store app in context ctx := context.WithValue(cmd.Context(), appKey, app) @@ -147,6 +153,10 @@ smart contract deployments using CreateX factory contracts.`, configCmd.GroupID = "management" rootCmd.AddCommand(configCmd) + migrateConfigCmd := NewMigrateConfigCmd() + migrateConfigCmd.GroupID = "management" + rootCmd.AddCommand(migrateConfigCmd) + // Version command versionCmd := NewVersionCmd() rootCmd.AddCommand(versionCmd) @@ -167,6 +177,53 @@ func isForkActiveForCurrentNetwork(ctx context.Context, a *app.App) (bool, strin return state.IsForkActive(a.Config.Network.Name), a.Config.Network.Name } +// suppressedCommands are commands that should not show the deprecation warning +var suppressedCommands = map[string]bool{ + "version": true, + "help": true, + "completion": true, + "init": true, + "migrate-config": true, +} + +// showLegacyConfigWarning prints a deprecation warning to stderr when +// sender config is detected in foundry.toml and treb.toml does not exist. +func showLegacyConfigWarning(cmd *cobra.Command, cfg *domainconfig.RuntimeConfig) { + if !shouldShowDeprecationWarning(cmd.Name(), cfg) { + return + } + yellow := color.New(color.FgYellow) + _, _ = yellow.Fprintln(os.Stderr, "Warning: treb config detected in foundry.toml — this is deprecated.") + _, _ = yellow.Fprintln(os.Stderr, "Run `treb migrate-config` to move your config to treb.toml.") +} + +// shouldShowDeprecationWarning determines if the deprecation warning should be shown. +func shouldShowDeprecationWarning(cmdName string, cfg *domainconfig.RuntimeConfig) bool { + if suppressedCommands[cmdName] { + return false + } + if cfg.JSON { + return false + } + if cfg.ConfigSource != "foundry.toml" { + return false + } + return hasLegacyTrebConfig(cfg.FoundryConfig) +} + +// hasLegacyTrebConfig returns true if any foundry.toml profile has treb sender config. +func hasLegacyTrebConfig(fc *domainconfig.FoundryConfig) bool { + if fc == nil { + return false + } + for _, profile := range fc.Profile { + if profile.Treb != nil { + return true + } + } + return false +} + // getApp retrieves the app instance from the command context func getApp(cmd *cobra.Command) (*app.App, error) { appInstance := cmd.Context().Value(appKey) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 00000000..89af0045 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,195 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/trebuchet-org/treb-cli/internal/domain/config" +) + +func TestShouldShowDeprecationWarning(t *testing.T) { + legacyFoundryConfig := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey}, + }, + }, + }, + }, + } + + noTrebFoundryConfig := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": {}, + }, + } + + tests := []struct { + name string + cmdName string + cfg *config.RuntimeConfig + expected bool + }{ + { + name: "shows warning when legacy config detected", + cmdName: "list", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + FoundryConfig: legacyFoundryConfig, + }, + expected: true, + }, + { + name: "suppressed when treb.toml exists", + cmdName: "list", + cfg: &config.RuntimeConfig{ + ConfigSource: "treb.toml", + FoundryConfig: legacyFoundryConfig, + }, + expected: false, + }, + { + name: "suppressed for version command", + cmdName: "version", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + FoundryConfig: legacyFoundryConfig, + }, + expected: false, + }, + { + name: "suppressed for help command", + cmdName: "help", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + FoundryConfig: legacyFoundryConfig, + }, + expected: false, + }, + { + name: "suppressed for completion command", + cmdName: "completion", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + FoundryConfig: legacyFoundryConfig, + }, + expected: false, + }, + { + name: "suppressed for init command", + cmdName: "init", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + FoundryConfig: legacyFoundryConfig, + }, + expected: false, + }, + { + name: "suppressed for migrate-config command", + cmdName: "migrate-config", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + FoundryConfig: legacyFoundryConfig, + }, + expected: false, + }, + { + name: "suppressed when json flag is set", + cmdName: "list", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + JSON: true, + FoundryConfig: legacyFoundryConfig, + }, + expected: false, + }, + { + name: "not shown when foundry.toml has no treb config", + cmdName: "list", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + FoundryConfig: noTrebFoundryConfig, + }, + expected: false, + }, + { + name: "not shown when foundry config is nil", + cmdName: "list", + cfg: &config.RuntimeConfig{ + ConfigSource: "foundry.toml", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := shouldShowDeprecationWarning(tt.cmdName, tt.cfg) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasLegacyTrebConfig(t *testing.T) { + tests := []struct { + name string + fc *config.FoundryConfig + expected bool + }{ + { + name: "nil foundry config", + fc: nil, + expected: false, + }, + { + name: "no treb config in profiles", + fc: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": {}, + }, + }, + expected: false, + }, + { + name: "treb config in default profile", + fc: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: config.SenderTypePrivateKey}, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "treb config in non-default profile", + fc: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": {}, + "production": {Treb: &config.TrebConfig{}}, + }, + }, + expected: true, + }, + { + name: "empty profiles map", + fc: &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasLegacyTrebConfig(tt.fc) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/cli/verify.go b/internal/cli/verify.go index ae7f1194..4a0268fe 100644 --- a/internal/cli/verify.go +++ b/internal/cli/verify.go @@ -86,7 +86,7 @@ Examples: filter := domain.DeploymentFilter{ Namespace: namespace, } - + // Get network info from app config if available if app.Config.Network != nil { filter.ChainID = app.Config.Network.ChainID diff --git a/internal/config/provider.go b/internal/config/provider.go index ff182ed2..673e5f05 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -37,51 +37,28 @@ func Provider(v *viper.Viper) (*config.RuntimeConfig, error) { DryRun: v.GetBool("dry_run"), } - // Load foundry config + // Load foundry config (always needed for network resolution etc.) foundryConfig, err := loadFoundryConfig(projectRoot) if err != nil { return nil, fmt.Errorf("failed to load foundry config: %w", err) } cfg.FoundryConfig = foundryConfig - // Load profile-specific treb config with merging strategy - // Start with default profile if it exists - var mergedTrebConfig *config.TrebConfig - if defaultProfile, ok := foundryConfig.Profile["default"]; ok { - // Create a copy of default config - mergedTrebConfig = &config.TrebConfig{ - Senders: make(map[string]config.SenderConfig), - } - if defaultProfile.Treb != nil && defaultProfile.Treb.Senders != nil { - for k, v := range defaultProfile.Treb.Senders { - mergedTrebConfig.Senders[k] = v - } - } + // Try treb.toml first; fall back to foundry.toml profiles + trebFileConfig, err := loadTrebConfig(projectRoot) + if err != nil { + return nil, fmt.Errorf("failed to load treb config: %w", err) } - // If requesting a specific profile, merge it with default - if cfg.Namespace != "default" { - if profile, ok := foundryConfig.Profile[cfg.Namespace]; ok { - if mergedTrebConfig == nil { - // No default, use profile directly - mergedTrebConfig = profile.Treb - } else { - // Merge profile with default - if profile.Treb != nil { - // Override senders - complete replacement per key - if profile.Treb.Senders != nil { - for k, v := range profile.Treb.Senders { - mergedTrebConfig.Senders[k] = v - } - } - } - } - } - // If profile doesn't exist but we have default, use default + if trebFileConfig != nil { + cfg.ConfigSource = "treb.toml" + cfg.TrebConfig, cfg.FoundryProfile = mergeTrebFileConfig(trebFileConfig, cfg.Namespace) + } else { + cfg.ConfigSource = "foundry.toml" + cfg.FoundryProfile = cfg.Namespace + cfg.TrebConfig = mergeFoundryTrebConfig(foundryConfig, cfg.Namespace) } - cfg.TrebConfig = mergedTrebConfig - if os.Getenv("TREB_DEBUG") != "" { fmt.Printf("DEBUG: Loaded TrebConfig for profile %s\n", cfg.Namespace) if cfg.TrebConfig != nil && cfg.TrebConfig.Senders != nil { @@ -165,6 +142,72 @@ func SetupViper(projectRoot string, cmd *cobra.Command) *viper.Viper { return v } +// mergeTrebFileConfig builds a merged TrebConfig from treb.toml namespaces. +// It starts with ns.default senders, then overlays ns. senders. +// Returns the merged TrebConfig and the resolved Foundry profile name. +func mergeTrebFileConfig(trebFile *config.TrebFileConfig, namespace string) (*config.TrebConfig, string) { + merged := &config.TrebConfig{ + Senders: make(map[string]config.SenderConfig), + } + + // Start with default namespace senders + if defaultNs, ok := trebFile.Ns["default"]; ok { + for k, v := range defaultNs.Senders { + merged.Senders[k] = v + } + } + + // Resolve foundry profile: default to namespace name + foundryProfile := namespace + + // Overlay active namespace senders (if not "default") + if namespace != "default" { + if activeNs, ok := trebFile.Ns[namespace]; ok { + for k, v := range activeNs.Senders { + merged.Senders[k] = v + } + foundryProfile = activeNs.Profile + } + } else if defaultNs, ok := trebFile.Ns["default"]; ok { + foundryProfile = defaultNs.Profile + } + + return merged, foundryProfile +} + +// mergeFoundryTrebConfig builds a merged TrebConfig from foundry.toml profiles. +// This preserves the legacy behavior: start with profile.default.treb, overlay profile..treb. +func mergeFoundryTrebConfig(foundryConfig *config.FoundryConfig, namespace string) *config.TrebConfig { + var merged *config.TrebConfig + + // Start with default profile if it exists + if defaultProfile, ok := foundryConfig.Profile["default"]; ok { + merged = &config.TrebConfig{ + Senders: make(map[string]config.SenderConfig), + } + if defaultProfile.Treb != nil && defaultProfile.Treb.Senders != nil { + for k, v := range defaultProfile.Treb.Senders { + merged.Senders[k] = v + } + } + } + + // If requesting a specific profile, merge it with default + if namespace != "default" { + if profile, ok := foundryConfig.Profile[namespace]; ok { + if merged == nil { + merged = profile.Treb + } else if profile.Treb != nil && profile.Treb.Senders != nil { + for k, v := range profile.Treb.Senders { + merged.Senders[k] = v + } + } + } + } + + return merged +} + // ProvideNetworkResolver creates a NetworkResolver for Wire dependency injection func ProvideNetworkResolver(cfg *config.RuntimeConfig) *NetworkResolver { return NewNetworkResolver(cfg.ProjectRoot, cfg.FoundryConfig) diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go new file mode 100644 index 00000000..c9d89393 --- /dev/null +++ b/internal/config/provider_test.go @@ -0,0 +1,300 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/trebuchet-org/treb-cli/internal/domain/config" +) + +func TestMergeTrebFileConfig(t *testing.T) { + t.Run("default namespace only", func(t *testing.T) { + trebFile := &config.TrebFileConfig{ + Ns: map[string]config.NamespaceConfig{ + "default": { + Profile: "default", + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "private_key", PrivateKey: "0x1234"}, + }, + }, + }, + } + + merged, profile := mergeTrebFileConfig(trebFile, "default") + + require.NotNil(t, merged) + assert.Equal(t, "default", profile) + assert.Len(t, merged.Senders, 1) + assert.Equal(t, config.SenderType("private_key"), merged.Senders["deployer"].Type) + }) + + t.Run("active namespace overlays default senders", func(t *testing.T) { + trebFile := &config.TrebFileConfig{ + Ns: map[string]config.NamespaceConfig{ + "default": { + Profile: "default", + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "private_key", PrivateKey: "0x1234"}, + "backup": {Type: "private_key", PrivateKey: "0x5678"}, + }, + }, + "live": { + Profile: "production", + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "ledger", DerivationPath: "m/44'/60'/0'/0/0"}, + }, + }, + }, + } + + merged, profile := mergeTrebFileConfig(trebFile, "live") + + require.NotNil(t, merged) + assert.Equal(t, "production", profile) + assert.Len(t, merged.Senders, 2) + // deployer overridden by live namespace + assert.Equal(t, config.SenderType("ledger"), merged.Senders["deployer"].Type) + // backup inherited from default + assert.Equal(t, config.SenderType("private_key"), merged.Senders["backup"].Type) + }) + + t.Run("profile defaults to namespace name", func(t *testing.T) { + trebFile := &config.TrebFileConfig{ + Ns: map[string]config.NamespaceConfig{ + "staging": { + Profile: "staging", // Set by loadTrebConfig default logic + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "private_key", PrivateKey: "0x1234"}, + }, + }, + }, + } + + _, profile := mergeTrebFileConfig(trebFile, "staging") + assert.Equal(t, "staging", profile) + }) + + t.Run("namespace not in config uses default senders and namespace as profile", func(t *testing.T) { + trebFile := &config.TrebFileConfig{ + Ns: map[string]config.NamespaceConfig{ + "default": { + Profile: "default", + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "private_key", PrivateKey: "0x1234"}, + }, + }, + }, + } + + merged, profile := mergeTrebFileConfig(trebFile, "unknown") + + require.NotNil(t, merged) + assert.Equal(t, "unknown", profile) + assert.Len(t, merged.Senders, 1) + assert.Equal(t, config.SenderType("private_key"), merged.Senders["deployer"].Type) + }) + + t.Run("empty config returns empty senders", func(t *testing.T) { + trebFile := &config.TrebFileConfig{ + Ns: map[string]config.NamespaceConfig{}, + } + + merged, profile := mergeTrebFileConfig(trebFile, "default") + require.NotNil(t, merged) + assert.Equal(t, "default", profile) + assert.Empty(t, merged.Senders) + }) +} + +func TestMergeFoundryTrebConfig(t *testing.T) { + t.Run("default profile only", func(t *testing.T) { + foundryConfig := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "private_key", PrivateKey: "0x1234"}, + }, + }, + }, + }, + } + + merged := mergeFoundryTrebConfig(foundryConfig, "default") + require.NotNil(t, merged) + assert.Len(t, merged.Senders, 1) + assert.Equal(t, config.SenderType("private_key"), merged.Senders["deployer"].Type) + }) + + t.Run("specific profile merges with default", func(t *testing.T) { + foundryConfig := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "private_key", PrivateKey: "0x1234"}, + "backup": {Type: "private_key", PrivateKey: "0x5678"}, + }, + }, + }, + "production": { + Treb: &config.TrebConfig{ + Senders: map[string]config.SenderConfig{ + "deployer": {Type: "ledger", DerivationPath: "m/44'/60'/0'/0/0"}, + }, + }, + }, + }, + } + + merged := mergeFoundryTrebConfig(foundryConfig, "production") + require.NotNil(t, merged) + assert.Len(t, merged.Senders, 2) + assert.Equal(t, config.SenderType("ledger"), merged.Senders["deployer"].Type) + assert.Equal(t, config.SenderType("private_key"), merged.Senders["backup"].Type) + }) + + t.Run("no treb config returns nil", func(t *testing.T) { + foundryConfig := &config.FoundryConfig{ + Profile: map[string]config.ProfileConfig{ + "default": {}, + }, + } + + merged := mergeFoundryTrebConfig(foundryConfig, "default") + // Should have an empty senders map since default profile exists but Treb is nil + require.NotNil(t, merged) + assert.Empty(t, merged.Senders) + }) +} + +func TestProviderConfigSource(t *testing.T) { + t.Run("uses treb.toml when present", func(t *testing.T) { + dir := t.TempDir() + + // Write minimal foundry.toml (always required) + foundryToml := `[profile.default] +src = "src" +` + err := os.WriteFile(filepath.Join(dir, "foundry.toml"), []byte(foundryToml), 0644) + require.NoError(t, err) + + // Write treb.toml + trebToml := ` +[ns.default.senders.deployer] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +` + err = os.WriteFile(filepath.Join(dir, "treb.toml"), []byte(trebToml), 0644) + require.NoError(t, err) + + v := viper.New() + v.Set("project_root", dir) + v.Set("namespace", "default") + + cfg, err := Provider(v) + require.NoError(t, err) + + assert.Equal(t, "treb.toml", cfg.ConfigSource) + assert.Equal(t, "default", cfg.FoundryProfile) + require.NotNil(t, cfg.TrebConfig) + assert.Equal(t, config.SenderType("private_key"), cfg.TrebConfig.Senders["deployer"].Type) + }) + + t.Run("falls back to foundry.toml when treb.toml absent", func(t *testing.T) { + dir := t.TempDir() + + // Write foundry.toml with treb sender config + foundryToml := `[profile.default] +src = "src" + +[profile.default.treb.senders.deployer] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +` + err := os.WriteFile(filepath.Join(dir, "foundry.toml"), []byte(foundryToml), 0644) + require.NoError(t, err) + + v := viper.New() + v.Set("project_root", dir) + v.Set("namespace", "default") + + cfg, err := Provider(v) + require.NoError(t, err) + + assert.Equal(t, "foundry.toml", cfg.ConfigSource) + assert.Equal(t, "default", cfg.FoundryProfile) + require.NotNil(t, cfg.TrebConfig) + assert.Equal(t, config.SenderType("private_key"), cfg.TrebConfig.Senders["deployer"].Type) + }) + + t.Run("treb.toml profile field sets FoundryProfile", func(t *testing.T) { + dir := t.TempDir() + + foundryToml := `[profile.default] +src = "src" + +[profile.production] +src = "src" +` + err := os.WriteFile(filepath.Join(dir, "foundry.toml"), []byte(foundryToml), 0644) + require.NoError(t, err) + + trebToml := ` +[ns.default.senders.deployer] +type = "private_key" +private_key = "0x1234" + +[ns.live] +profile = "production" + +[ns.live.senders.deployer] +type = "ledger" +derivation_path = "m/44'/60'/0'/0/0" +` + err = os.WriteFile(filepath.Join(dir, "treb.toml"), []byte(trebToml), 0644) + require.NoError(t, err) + + v := viper.New() + v.Set("project_root", dir) + v.Set("namespace", "live") + + cfg, err := Provider(v) + require.NoError(t, err) + + assert.Equal(t, "treb.toml", cfg.ConfigSource) + assert.Equal(t, "production", cfg.FoundryProfile, "should resolve profile from treb.toml") + assert.Equal(t, config.SenderType("ledger"), cfg.TrebConfig.Senders["deployer"].Type) + }) + + t.Run("foundry.toml fallback sets FoundryProfile to namespace", func(t *testing.T) { + dir := t.TempDir() + + foundryToml := `[profile.default] +src = "src" + +[profile.staging] +src = "src" + +[profile.staging.treb.senders.deployer] +type = "private_key" +private_key = "0x1234" +` + err := os.WriteFile(filepath.Join(dir, "foundry.toml"), []byte(foundryToml), 0644) + require.NoError(t, err) + + v := viper.New() + v.Set("project_root", dir) + v.Set("namespace", "staging") + + cfg, err := Provider(v) + require.NoError(t, err) + + assert.Equal(t, "foundry.toml", cfg.ConfigSource) + assert.Equal(t, "staging", cfg.FoundryProfile, "FoundryProfile should equal namespace in legacy mode") + }) +} diff --git a/internal/config/trebfile.go b/internal/config/trebfile.go new file mode 100644 index 00000000..117f44cc --- /dev/null +++ b/internal/config/trebfile.go @@ -0,0 +1,51 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + "github.com/trebuchet-org/treb-cli/internal/domain/config" +) + +// loadTrebConfig loads and parses treb.toml if it exists. +// Returns (nil, nil) when treb.toml does not exist. +func loadTrebConfig(projectRoot string) (*config.TrebFileConfig, error) { + trebPath := filepath.Join(projectRoot, "treb.toml") + + if _, err := os.Stat(trebPath); os.IsNotExist(err) { + return nil, nil + } + + var cfg config.TrebFileConfig + if _, err := toml.DecodeFile(trebPath, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse treb.toml: %w", err) + } + + // Default profile to namespace name when omitted + for nsName, nsCfg := range cfg.Ns { + if nsCfg.Profile == "" { + nsCfg.Profile = nsName + cfg.Ns[nsName] = nsCfg + } + } + + // Expand environment variables in all sender config string fields + for nsName, nsCfg := range cfg.Ns { + for senderName, sender := range nsCfg.Senders { + sender.PrivateKey = os.ExpandEnv(sender.PrivateKey) + sender.Safe = os.ExpandEnv(sender.Safe) + sender.Address = os.ExpandEnv(sender.Address) + sender.Signer = os.ExpandEnv(sender.Signer) + sender.DerivationPath = os.ExpandEnv(sender.DerivationPath) + sender.Governor = os.ExpandEnv(sender.Governor) + sender.Timelock = os.ExpandEnv(sender.Timelock) + sender.Proposer = os.ExpandEnv(sender.Proposer) + nsCfg.Senders[senderName] = sender + } + cfg.Ns[nsName] = nsCfg + } + + return &cfg, nil +} diff --git a/internal/config/trebfile_test.go b/internal/config/trebfile_test.go new file mode 100644 index 00000000..7f416746 --- /dev/null +++ b/internal/config/trebfile_test.go @@ -0,0 +1,172 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/trebuchet-org/treb-cli/internal/domain/config" +) + +func TestLoadTrebConfig(t *testing.T) { + t.Run("parses valid treb.toml", func(t *testing.T) { + dir := t.TempDir() + trebToml := ` +[ns.default.senders.deployer] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +[ns.live] +profile = "production" + +[ns.live.senders.safe0] +type = "safe" +safe = "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F" +signer = "deployer" +` + err := os.WriteFile(filepath.Join(dir, "treb.toml"), []byte(trebToml), 0644) + require.NoError(t, err) + + cfg, err := loadTrebConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Check default namespace + defaultNs, ok := cfg.Ns["default"] + require.True(t, ok) + assert.Equal(t, config.SenderType("private_key"), defaultNs.Senders["deployer"].Type) + assert.Equal(t, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", defaultNs.Senders["deployer"].PrivateKey) + + // Check live namespace + liveNs, ok := cfg.Ns["live"] + require.True(t, ok) + assert.Equal(t, "production", liveNs.Profile) + assert.Equal(t, config.SenderType("safe"), liveNs.Senders["safe0"].Type) + assert.Equal(t, "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F", liveNs.Senders["safe0"].Safe) + assert.Equal(t, "deployer", liveNs.Senders["safe0"].Signer) + }) + + t.Run("profile defaults to namespace name", func(t *testing.T) { + dir := t.TempDir() + trebToml := ` +[ns.staging.senders.deployer] +type = "private_key" +private_key = "0x1234" +` + err := os.WriteFile(filepath.Join(dir, "treb.toml"), []byte(trebToml), 0644) + require.NoError(t, err) + + cfg, err := loadTrebConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + stagingNs := cfg.Ns["staging"] + assert.Equal(t, "staging", stagingNs.Profile, "profile should default to namespace name") + }) + + t.Run("explicit profile overrides default", func(t *testing.T) { + dir := t.TempDir() + trebToml := ` +[ns.live] +profile = "production" + +[ns.live.senders.deployer] +type = "private_key" +private_key = "0x1234" +` + err := os.WriteFile(filepath.Join(dir, "treb.toml"), []byte(trebToml), 0644) + require.NoError(t, err) + + cfg, err := loadTrebConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + liveNs := cfg.Ns["live"] + assert.Equal(t, "production", liveNs.Profile, "explicit profile should be preserved") + }) + + t.Run("expands environment variables", func(t *testing.T) { + dir := t.TempDir() + trebToml := ` +[ns.default.senders.deployer] +type = "private_key" +private_key = "${TEST_TREB_PK}" + +[ns.default.senders.safe0] +type = "safe" +safe = "${TEST_TREB_SAFE}" +signer = "deployer" +` + err := os.WriteFile(filepath.Join(dir, "treb.toml"), []byte(trebToml), 0644) + require.NoError(t, err) + + t.Setenv("TEST_TREB_PK", "0xdeadbeef") + t.Setenv("TEST_TREB_SAFE", "0x1111111111111111111111111111111111111111") + + cfg, err := loadTrebConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + deployer := cfg.Ns["default"].Senders["deployer"] + assert.Equal(t, "0xdeadbeef", deployer.PrivateKey) + + safe0 := cfg.Ns["default"].Senders["safe0"] + assert.Equal(t, "0x1111111111111111111111111111111111111111", safe0.Safe) + }) + + t.Run("missing file returns nil", func(t *testing.T) { + dir := t.TempDir() + + cfg, err := loadTrebConfig(dir) + assert.NoError(t, err) + assert.Nil(t, cfg) + }) + + t.Run("invalid TOML returns error", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "treb.toml"), []byte("invalid [[ toml"), 0644) + require.NoError(t, err) + + cfg, err := loadTrebConfig(dir) + assert.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "failed to parse treb.toml") + }) + + t.Run("all sender config string fields are expanded", func(t *testing.T) { + dir := t.TempDir() + trebToml := ` +[ns.default.senders.hw] +type = "ledger" +address = "${TEST_HW_ADDR}" +derivation_path = "${TEST_HW_PATH}" + +[ns.default.senders.gov] +type = "oz_governor" +governor = "${TEST_GOV}" +timelock = "${TEST_TIMELOCK}" +proposer = "hw" +` + err := os.WriteFile(filepath.Join(dir, "treb.toml"), []byte(trebToml), 0644) + require.NoError(t, err) + + t.Setenv("TEST_HW_ADDR", "0x2222222222222222222222222222222222222222") + t.Setenv("TEST_HW_PATH", "m/44'/60'/0'/0/0") + t.Setenv("TEST_GOV", "0x3333333333333333333333333333333333333333") + t.Setenv("TEST_TIMELOCK", "0x4444444444444444444444444444444444444444") + + cfg, err := loadTrebConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + hw := cfg.Ns["default"].Senders["hw"] + assert.Equal(t, "0x2222222222222222222222222222222222222222", hw.Address) + assert.Equal(t, "m/44'/60'/0'/0/0", hw.DerivationPath) + + gov := cfg.Ns["default"].Senders["gov"] + assert.Equal(t, "0x3333333333333333333333333333333333333333", gov.Governor) + assert.Equal(t, "0x4444444444444444444444444444444444444444", gov.Timelock) + }) +} diff --git a/internal/domain/config/runtime.go b/internal/domain/config/runtime.go index c9dd3f01..6a002747 100644 --- a/internal/domain/config/runtime.go +++ b/internal/domain/config/runtime.go @@ -24,6 +24,10 @@ type RuntimeConfig struct { // Command-specific settings (only populated for relevant commands) DryRun bool + // Config source tracking + FoundryProfile string // Foundry profile name to use (from treb.toml or namespace) + ConfigSource string // "treb.toml" or "foundry.toml" + // Resolved configurations FoundryConfig *FoundryConfig TrebConfig *TrebConfig // Profile-specific treb config diff --git a/internal/domain/config/trebfile.go b/internal/domain/config/trebfile.go new file mode 100644 index 00000000..2161c854 --- /dev/null +++ b/internal/domain/config/trebfile.go @@ -0,0 +1,12 @@ +package config + +// TrebFileConfig represents the full treb.toml configuration file +type TrebFileConfig struct { + Ns map[string]NamespaceConfig `toml:"ns"` +} + +// NamespaceConfig represents a [ns.] section in treb.toml +type NamespaceConfig struct { + Profile string `toml:"profile,omitempty"` + Senders map[string]SenderConfig `toml:"senders"` +} diff --git a/internal/usecase/init_project.go b/internal/usecase/init_project.go index b4e27222..c197eebb 100644 --- a/internal/usecase/init_project.go +++ b/internal/usecase/init_project.go @@ -25,6 +25,7 @@ type InitProjectResult struct { FoundryProjectValid bool TrebSolInstalled bool RegistryCreated bool + TrebTomlCreated bool EnvExampleCreated bool AlreadyInitialized bool Steps []InitStep @@ -79,6 +80,13 @@ func (i *InitProject) Execute(ctx context.Context) (*InitProjectResult, error) { } } + // Create treb.toml + step = i.createTrebToml(ctx) + result.Steps = append(result.Steps, step) + if step.Success { + result.TrebTomlCreated = true + } + // Create example environment step = i.createExampleEnvironment(ctx) result.Steps = append(result.Steps, step) @@ -177,6 +185,53 @@ func (i *InitProject) createRegistry(ctx context.Context) InitStep { } } +func (i *InitProject) createTrebToml(ctx context.Context) InitStep { + // Only create treb.toml if it doesn't exist + exists, err := i.fileWriter.FileExists(ctx, "treb.toml") + if err != nil { + return InitStep{ + Name: "Create treb.toml", + Success: false, + Error: fmt.Errorf("failed to check treb.toml: %w", err), + } + } + + if exists { + return InitStep{ + Name: "Create treb.toml", + Success: true, + Message: "treb.toml already exists", + } + } + + trebToml := `# treb.toml — Treb sender configuration +# +# Each [ns.] section defines a namespace with sender configs. +# The optional 'profile' field maps to a foundry.toml profile (defaults to namespace name). + +[ns.default] +profile = "default" + +[ns.default.senders.deployer] +type = "private_key" +private_key = "${DEPLOYER_PRIVATE_KEY}" +` + + if err := i.fileWriter.WriteScript(ctx, "treb.toml", trebToml); err != nil { + return InitStep{ + Name: "Create treb.toml", + Success: false, + Error: fmt.Errorf("failed to create treb.toml: %w", err), + } + } + + return InitStep{ + Name: "Create treb.toml", + Success: true, + Message: "Created treb.toml with default sender config", + } +} + func (i *InitProject) createExampleEnvironment(ctx context.Context) InitStep { // Only create .env.example if it doesn't exist exists, err := i.fileWriter.FileExists(ctx, ".env.example") diff --git a/internal/usecase/list_deployments.go b/internal/usecase/list_deployments.go index 59736543..564198df 100644 --- a/internal/usecase/list_deployments.go +++ b/internal/usecase/list_deployments.go @@ -194,19 +194,19 @@ func calculateSummary(deployments []*models.Deployment) DeploymentSummary { func (uc *ListDeployments) findNetworkName(ctx context.Context, chainID uint64) string { // Get all available network names networkNames := uc.networkResolver.GetNetworks(ctx) - + // Try to resolve each network to find matching chain ID for _, name := range networkNames { network, err := uc.networkResolver.ResolveNetwork(ctx, name) if err != nil { continue } - + if network.ChainID == chainID { return name } } - + // Return empty string if no network found return "" } diff --git a/internal/usecase/ports.go b/internal/usecase/ports.go index ad3b939a..8c357c87 100644 --- a/internal/usecase/ports.go +++ b/internal/usecase/ports.go @@ -241,6 +241,7 @@ type RunScriptConfig struct { Script *models.Contract Network *config.Network Namespace string + FoundryProfile string // Foundry profile to set via FOUNDRY_PROFILE env var Parameters map[string]string // Includes resolved parameters and sender configs DryRun bool Debug bool diff --git a/internal/usecase/run_script.go b/internal/usecase/run_script.go index fc561194..cf03e9ec 100644 --- a/internal/usecase/run_script.go +++ b/internal/usecase/run_script.go @@ -175,6 +175,7 @@ func (uc *RunScript) Run(ctx context.Context, params RunScriptParams) (*RunScrip runScriptConfig := RunScriptConfig{ Network: uc.config.Network, Namespace: uc.config.Namespace, + FoundryProfile: uc.config.FoundryProfile, Script: script, Parameters: resolvedParams, Libraries: libraryStrings, diff --git a/internal/usecase/show_config.go b/internal/usecase/show_config.go index ee4319bc..c328fdfb 100644 --- a/internal/usecase/show_config.go +++ b/internal/usecase/show_config.go @@ -8,9 +8,10 @@ import ( // ShowConfigResult contains the result of showing configuration type ShowConfigResult struct { - Config *config.LocalConfig - ConfigPath string - Exists bool + Config *config.LocalConfig + ConfigPath string + Exists bool + ConfigSource string // "treb.toml" or "foundry.toml" } // ShowConfig is a use case for showing configuration diff --git a/internal/usecase/verify_deployment_test.go b/internal/usecase/verify_deployment_test.go index cfa58cf8..cbe64a2d 100644 --- a/internal/usecase/verify_deployment_test.go +++ b/internal/usecase/verify_deployment_test.go @@ -20,9 +20,9 @@ func TestVerifyDeployment_UsesDeploymentResolver(t *testing.T) { networkResolver NetworkResolver deploymentResolver DeploymentResolver ) - + var progress ProgressSink - + uc := NewVerifyDeployment(repo, contractVerifier, networkResolver, deploymentResolver, progress) assert.NotNil(t, uc) }) @@ -38,18 +38,18 @@ func TestVerifyDeployment_UsesDeploymentResolver(t *testing.T) { } uc := NewVerifyDeployment(nil, nil, nil, mockResolver, NopProgress{}) - + // Test that the filter parameters are properly passed to the query filter := domain.DeploymentFilter{ ChainID: 31337, Namespace: "production", } - + _, err := uc.VerifySpecific(context.Background(), "Counter:v2", filter, VerifyOptions{}) - + // Error is expected since mock returns not found assert.Error(t, err) - + // Verify the deployment resolver was called with correct query assert.Equal(t, "Counter:v2", capturedQuery.Reference) assert.Equal(t, uint64(31337), capturedQuery.ChainID) @@ -67,4 +67,4 @@ func (m *mockDeploymentResolver) ResolveDeployment(ctx context.Context, query do return m.resolveFunc(ctx, query) } return nil, domain.ErrNotFound -} \ No newline at end of file +} diff --git a/scripts/ralph/CLAUDE.md b/scripts/ralph/CLAUDE.md new file mode 100644 index 00000000..f95bb927 --- /dev/null +++ b/scripts/ralph/CLAUDE.md @@ -0,0 +1,104 @@ +# Ralph Agent Instructions + +You are an autonomous coding agent working on a software project. + +## Your Task + +1. Read the PRD at `prd.json` (in the same directory as this file) +2. Read the progress log at `progress.txt` (check Codebase Patterns section first) +3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main. +4. Pick the **highest priority** user story where `passes: false` +5. Implement that single user story +6. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires) +7. Update CLAUDE.md files if you discover reusable patterns (see below) +8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]` +9. Update the PRD to set `passes: true` for the completed story +10. Append your progress to `progress.txt` + +## Progress Report Format + +APPEND to progress.txt (never replace, always append): +``` +## [Date/Time] - [Story ID] +- What was implemented +- Files changed +- **Learnings for future iterations:** + - Patterns discovered (e.g., "this codebase uses X for Y") + - Gotchas encountered (e.g., "don't forget to update Z when changing W") + - Useful context (e.g., "the evaluation panel is in component X") +--- +``` + +The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better. + +## Consolidate Patterns + +If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings: + +``` +## Codebase Patterns +- Example: Use `sql` template for aggregations +- Example: Always use `IF NOT EXISTS` for migrations +- Example: Export types from actions.ts for UI components +``` + +Only add patterns that are **general and reusable**, not story-specific details. + +## Update CLAUDE.md Files + +Before committing, check if any edited files have learnings worth preserving in nearby CLAUDE.md files: + +1. **Identify directories with edited files** - Look at which directories you modified +2. **Check for existing CLAUDE.md** - Look for CLAUDE.md in those directories or parent directories +3. **Add valuable learnings** - If you discovered something future developers/agents should know: + - API patterns or conventions specific to that module + - Gotchas or non-obvious requirements + - Dependencies between files + - Testing approaches for that area + - Configuration or environment requirements + +**Examples of good CLAUDE.md additions:** +- "When modifying X, also update Y to keep them in sync" +- "This module uses pattern Z for all API calls" +- "Tests require the dev server running on PORT 3000" +- "Field names must match the template exactly" + +**Do NOT add:** +- Story-specific implementation details +- Temporary debugging notes +- Information already in progress.txt + +Only update CLAUDE.md if you have **genuinely reusable knowledge** that would help future work in that directory. + +## Quality Requirements + +- ALL commits must pass your project's quality checks (typecheck, lint, test) +- Do NOT commit broken code +- Keep changes focused and minimal +- Follow existing code patterns + +## Browser Testing (If Available) + +For any story that changes UI, verify it works in the browser if you have browser testing tools configured (e.g., via MCP): + +1. Navigate to the relevant page +2. Verify the UI changes work as expected +3. Take a screenshot if helpful for the progress log + +If no browser tools are available, note in your progress report that manual browser verification is needed. + +## Stop Condition + +After completing a user story, check if ALL stories have `passes: true`. + +If ALL stories are complete and passing, reply with: +COMPLETE + +If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story). + +## Important + +- Work on ONE story per iteration +- Commit frequently +- Keep CI green +- Read the Codebase Patterns section in progress.txt before starting diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index 810245a7..7d21bcbf 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -1,365 +1,3 @@ # Ralph Progress Log -Started: Fri Feb 27 04:32:59 PM CET 2026 - -## Codebase Patterns -- Domain models live in `internal/domain/` with `json:"camelCase"` tags -- Port interfaces are defined in `internal/usecase/ports.go` with `ctx context.Context` as first param -- File-based adapters in `internal/adapters/fs/` follow `NewAdapter(cfg *config.RuntimeConfig)` constructor pattern -- Use `var _ usecase.Interface = (*Adapter)(nil)` for compile-time type assertion -- Error wrapping: `fmt.Errorf("context: %w", err)` -- JSON files use `json.MarshalIndent(v, "", " ")` for pretty-printing -- Tests use `testify/assert` and `testify/require` -- Wire DI sets in `internal/adapters/providers.go` organized by domain (FSSet, AnvilSet, etc.) -- LocalConfig at `.treb/config.local.json`, fork state at `.treb/priv/fork-state.json` -- Anvil manager uses `makeRPCCallWithResponse` for JSON-RPC calls; use `httptest.NewServer` for unit testing RPC methods -- Extract testable helper functions (e.g. `buildAnvilArgs`) from methods that interact with OS processes for pure unit tests -- `setFilePaths` respects pre-set PidFile/LogFile paths; fork instances should set name to "fork-" for `/tmp/treb-fork-.pid` naming -- Lint requires all error return values to be checked, even in test helpers -- Fork snapshot dir structure: `.treb/priv/fork//snapshots//` - registry files copied here -- Use `//nolint:gosec` for internally-constructed paths that trigger G703 taint analysis -- Run integration tests isolated because they take a long time -- `deployments.json` is a flat map keyed by deployment ID (e.g. `default/31337/Counter`), not nested under a `deployments` key -- To add new dependencies to use cases: update constructor, wire_gen.go (manual edit), and wire.go -- LocalConfig supports dot-notation keys (e.g. `fork.setup`) - add to `ValidConfigKeys()`, `IsValidConfigKey()`, and switch cases in set/remove use cases -- Forge cheatcodes (vm.deal, vm.store) don't persist on fork nodes via `forge script --broadcast`; use actual transactions or Anvil RPC methods for setup scripts -- Integration test framework prepends `=== cmd N: [...] ===\n` before stdout; use `extractJSONArray()` helper to parse JSON from framework-wrapped output -- `loadDeploymentIDs` in usecase package is reusable for any fork vs. baseline deployment comparison -- Integration tests that need to kill processes and run commands after use `PostTest` with empty `TestCmds` and manual `Treb()` calls -- treb-sol contracts use `pragma solidity ^0.8.0`, inherit from forge-std's CommonBase/Script/Test, and Safe contracts are available via `safe-smart-account/` remapping -- Safe storage layout: slot 0=singleton, slot 1=modules, slot 2=owners mapping (linked list), slot 3=ownerCount, slot 4=threshold - ---- - -## 2026-02-27 - US-001 -- Implemented ForkState domain model (`internal/domain/fork.go`) with ForkEntry and SnapshotEntry -- Added ForkStateStore interface to `internal/usecase/ports.go` -- Implemented ForkStateStoreAdapter in `internal/adapters/fs/fork_state_store.go` -- Files changed: `internal/domain/fork.go`, `internal/domain/fork_test.go`, `internal/adapters/fs/fork_state_store.go`, `internal/adapters/fs/fork_state_store_test.go`, `internal/usecase/ports.go` -- **Learnings for future iterations:** - - GetActiveFork returns `*ForkEntry` (not error) since nil is sufficient for "not found" - - ForkState.Forks map must be initialized on Load even from empty JSON to avoid nil map panics - - The `.treb/priv/` directory is for private/local state not committed to git ---- - -## 2026-02-27 - US-002 -- Extended AnvilInstance domain model with optional ForkURL field -- Added `--fork-url` arg to anvil command builder when ForkURL is set -- Extracted `buildAnvilArgs` helper function for testability -- Added `TakeSnapshot` method (evm_snapshot JSON-RPC) and `RevertSnapshot` method (evm_revert JSON-RPC) to anvil manager -- Updated `AnvilManager` interface in ports.go with new snapshot methods -- Updated `setFilePaths` to preserve pre-set PidFile/LogFile paths -- Fork instances use name "fork-" → /tmp/treb-fork-.pid naming -- Files changed: `internal/domain/anvil.go`, `internal/adapters/anvil/manager.go`, `internal/adapters/anvil/manager_test.go`, `internal/usecase/ports.go` -- **Learnings for future iterations:** - - `httptest.NewServer` is effective for unit testing JSON-RPC methods without needing a real anvil - - Anvil's `evm_revert` returns a boolean (true on success), while `evm_snapshot` returns a hex string snapshot ID - - The `setFilePaths` method is called by many manager methods; allowing pre-set paths lets callers control file locations for fork instances - - Always check error returns in test code - linter enforces this even in httptest handlers ---- - -## 2026-02-27 - US-003 -- Created ForkFileManager interface in `internal/usecase/ports.go` with BackupFiles, RestoreFiles, CleanupForkDir methods -- Implemented ForkFileManagerAdapter in `internal/adapters/fs/fork_file_manager.go` -- Backs up/restores 5 registry files: deployments.json, transactions.json, safe-txs.json, registry.json, addressbook.json -- Snapshot directory structure: `.treb/priv/fork//snapshots//` -- Files changed: `internal/usecase/ports.go`, `internal/adapters/fs/fork_file_manager.go`, `internal/adapters/fs/fork_file_manager_test.go` -- **Learnings for future iterations:** - - gosec taint analysis (G703) flags `os.OpenFile` with paths from parameters even when internally constructed; use `//nolint:gosec` with justification - - Missing source files are handled gracefully with `os.IsNotExist` checks - skip instead of error - - `os.RemoveAll` on non-existent paths does not error (unlike `os.Remove`), so CleanupForkDir naturally handles non-existent directories - - Pre-existing lint issues in verifier.go (G702 gosec) - not related to fork mode work ---- - -## 2026-02-27 - US-004 -- Implemented DetectEnvVar, GenerateEnvVarName, LoadRawRPCEndpoint, LoadRawRPCEndpoints, and MigrateRPCEndpoint functions -- DetectEnvVar uses regex to match `${VAR_NAME}` patterns, returning the var name and whether it's an env var -- GenerateEnvVarName converts network names to convention: uppercase, dashes/dots to underscores, append `_RPC_URL` -- LoadRawRPCEndpoint reads foundry.toml with BurntSushi/toml without calling os.ExpandEnv, preserving raw `${VAR}` references -- MigrateRPCEndpoint does text-level find/replace in foundry.toml and appends to .env with proper newline handling -- Files changed: `internal/config/rpc_env.go`, `internal/config/rpc_env_test.go` -- **Learnings for future iterations:** - - TOML decoding does NOT expand `${VAR}` - it's `os.ExpandEnv()` in `loadFoundryConfig` that does the expansion. So reading raw values just means decoding TOML without the ExpandEnv call. - - MigrateRPCEndpoint does string-level replacement in foundry.toml rather than TOML serialization to preserve formatting, comments, and ordering - - The `.env` append logic must handle: file not existing, file with no trailing newline, and duplicate env var detection - - The `//nolint:gosec` directive is needed for `os.ReadFile`/`os.WriteFile`/`os.OpenFile` with internally-constructed paths to avoid G703 taint analysis warnings ---- - -## 2026-02-27 - US-005 -- Implemented `treb fork enter ` CLI command as cobra command group with subcommand -- Created EnterFork use case in `internal/usecase/enter_fork.go` with full fork lifecycle: - - Validates no active fork for network, finds available port, starts anvil with --fork-url - - Waits for health check, backs up registry files to snapshot 0, takes EVM snapshot - - Saves fork state, ensures `.treb/priv/` in .gitignore -- Created CLI command in `internal/cli/fork.go` handling RPC env var detection/migration flow -- Created renderer in `internal/cli/render/fork.go` for status output -- Wired DI: added ForkSet to providers.go, updated app.go/wire.go/wire_gen.go -- Registered fork command group in root.go -- Integration tests: fork_enter_success and fork_enter_already_active both pass -- Files changed: `internal/usecase/enter_fork.go`, `internal/cli/fork.go`, `internal/cli/render/fork.go`, `internal/adapters/providers.go`, `internal/app/app.go`, `internal/app/wire.go`, `internal/app/wire_gen.go`, `internal/cli/root.go`, `test/integration/fork_test.go`, `test/testdata/project/.gitignore` -- **Learnings for future iterations:** - - Integration test framework uses go-toml marshal which produces single-quoted strings — can't rely on original TOML formatting. Use `PreSetup` functions for env var migration setup. - - PID/log files should be project-scoped (`.treb/priv/`) not global (`/tmp/`) for parallel test isolation - - `SkipGolden: true` is needed for tests with dynamic output (PIDs, ports, URLs) - - The `TestRunCommand/run_with_debug` test is pre-existing flaky due to non-deterministic terminal escape sequences from solc output - - NetworkResolver must be exported from App struct for CLI commands that need to resolve network config - - RestoreFiles must remove files not present in the snapshot backup (they were created during fork mode) ---- - -## 2026-02-27 - US-006 -- Implemented `treb fork exit [network]` CLI command with `--all` flag -- Created ExitFork use case in `internal/usecase/exit_fork.go`: - - Stops anvil fork process via anvil manager (handles already-dead processes) - - Restores all .treb/ registry files from initial backup (snapshot 0) - - Removes fork directory .treb/priv/fork// - - Removes fork entry from state; deletes fork-state.json if no more active forks - - `--all` flag iterates all active forks and exits each -- Added fork exit CLI command in `internal/cli/fork.go` with network argument (optional, falls back to configured network) -- Added RenderExit method in `internal/cli/render/fork.go` -- Wired DI: added ExitFork use case to app.go, wire.go, wire_gen.go -- Fixed RestoreFiles in `internal/adapters/fs/fork_file_manager.go`: files not present in snapshot backup are now removed from .treb/ (they were created during fork mode) -- Updated corresponding unit test `TestForkFileManager_RestoreRemovesFilesNotInBackup` -- Integration tests: fork_exit_restores_state, fork_exit_after_deploy_restores_registry, fork_exit_no_active_fork -- Files changed: `internal/usecase/exit_fork.go`, `internal/cli/fork.go`, `internal/cli/render/fork.go`, `internal/app/app.go`, `internal/app/wire.go`, `internal/app/wire_gen.go`, `internal/adapters/fs/fork_file_manager.go`, `internal/adapters/fs/fork_file_manager_test.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - RestoreFiles must handle files created during fork mode: if a file doesn't exist in the snapshot backup, it should be removed from .treb/ (not left in place) - - Anvil manager's Stop method is safe to call even if the process is already dead (returns nil) - - When network arg is optional in a CLI command, use `cobra.MaximumNArgs(1)` and fall back to `app.Config.Network.Name` - - `portFromURL` helper extracts port from `http://host:port` format for building AnvilInstance from ForkEntry ---- - -## 2026-02-27 - US-007 -- Implemented RPC override in forge adapter for fork mode -- Added `ForkEnvOverrides map[string]string` field to `RunScriptConfig` in `internal/usecase/ports.go` -- Updated `buildEnv()` in `internal/adapters/forge/forge.go` to merge fork env overrides into subprocess env -- Added `ForkStateStore` dependency to `RunScript` use case in `internal/usecase/run_script.go` -- RunScript.Run() loads fork state and populates ForkEnvOverrides when fork is active for the current network -- Updated DI wiring in `internal/app/wire_gen.go` to pass ForkStateStoreAdapter to NewRunScript -- Unit tests for buildEnv: with fork override, without fork override, nil overrides, different network override -- Integration tests: fork_run_deploys_to_fork_anvil (verifies eth_getCode on fork URL + NOT on regular anvil), run_without_fork_deploys_to_regular_anvil -- Files changed: `internal/adapters/forge/forge.go`, `internal/adapters/forge/forge_test.go`, `internal/app/wire_gen.go`, `internal/usecase/ports.go`, `internal/usecase/run_script.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - The `deployments.json` file is a flat map keyed by deployment ID (e.g. `default/31337/Counter`), NOT nested under a `deployments` key - - Fork env override works by setting the env var that foundry.toml's `${VAR}` references in the forge subprocess env only - this is transparent to the Solidity scripts - - The RunScript use case silently ignores fork state load errors (file doesn't exist when not in fork mode) - this is correct since most runs don't use fork mode - - Integration tests for fork mode are slow (~7s per test due to forge compilation) - use `SkipGolden: true` for tests with dynamic output ---- - -## 2026-02-27 - US-008 -- Added pre-run EVM and file snapshot logic to RunScript use case (`internal/usecase/run_script.go`) -- When fork mode is active, before forge execution: takes EVM snapshot, backs up registry files to next snapshot index, records snapshot in fork state -- Added `AnvilManager` and `ForkFileManager` as new dependencies to RunScript use case -- Updated DI wiring in `internal/app/wire_gen.go` to pass manager and forkFileManagerAdapter to NewRunScript -- `takePreRunSnapshot` method: loads fork state, determines next index from snapshot count, builds AnvilInstance from ForkEntry, takes EVM snapshot, backs up files, saves updated state -- Integration tests: fork_run_creates_pre_run_snapshot (single run → snapshot 1 with 2 stack entries), fork_multiple_runs_create_multiple_snapshots (two runs → snapshots 1 and 2 with 3 stack entries) -- Files changed: `internal/usecase/run_script.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - The `forkEnvOverrides != nil` check is a clean proxy for "fork mode is active" since it's only populated when a fork is found for the current network - - `portFromURL` helper from exit_fork.go is reusable within the usecase package for building AnvilInstance from ForkEntry - - Snapshot index is simply `len(fork.Snapshots)` since the stack grows monotonically (0 = initial, 1 = first run, etc.) - - Pre-existing lint issue in verifier.go (G702 gosec) is unrelated to fork mode work - all prior stories had same issue ---- - -## 2026-02-27 - US-009 -- Implemented `treb fork revert [network]` CLI command with `--all` flag -- Created RevertFork use case in `internal/usecase/revert_fork.go`: - - Single revert: pops top snapshot, calls evm_revert, restores files from popped snapshot, removes snapshot dir - - Revert all (`--all`): reverts EVM to initial snapshot (index 0), restores files from snapshot 0, removes all non-initial snapshot dirs - - Errors if only initial snapshot remains ("nothing to revert") -- Added CLI command in `internal/cli/fork.go` with network argument (optional, falls back to configured network) -- Added RenderRevert method in `internal/cli/render/fork.go` -- Wired DI: added RevertFork use case to app.go, wire.go, wire_gen.go -- Integration tests: fork_revert_restores_last_run, fork_revert_partial_keeps_earlier_deploy, fork_revert_all_restores_initial_state, fork_revert_nothing_to_revert - all pass -- Files changed: `internal/usecase/revert_fork.go`, `internal/cli/fork.go`, `internal/cli/render/fork.go`, `internal/app/app.go`, `internal/app/wire.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - Anvil's `evm_revert` to snapshot 0 also restores to initial state; the stack model works naturally since older snapshots remain valid after reverting a newer one - - For `revert --all`, revert EVM to the initial snapshot ID (index 0), not to each snapshot in reverse order - - `os.RemoveAll` is safe for cleaning up snapshot directories (no error on non-existent paths) - - The `portFromURL` helper in exit_fork.go is reusable across all fork use cases for building AnvilInstance from ForkEntry ---- - -## 2026-02-27 - US-010 -- Added `fork.setup` configuration key to LocalConfig model (`internal/domain/config/local.go`) -- Updated SetConfig and RemoveConfig use cases to handle `fork.setup` key -- Updated CLI config command help text to list `fork.setup` as available key -- Extended EnterFork use case with `executeSetupFork` method that: - - Loads fork.setup from LocalConfig - - Skips silently if not configured or script file doesn't exist - - Executes the setup script via ForgeScriptRunner with fork env overrides - - On failure: returns error so caller stops anvil and cleans up state -- Moved initial EVM snapshot and file backup AFTER setup script execution (so snapshot captures setup state) -- Added `SetupScriptRan` field to EnterForkResult for renderer output -- Updated DI wiring: added LocalConfigRepository and ForgeScriptRunner to NewEnterFork -- Created test SetupFork scripts: working (ETH transfer) and failing (revert) -- Integration tests: fork_enter_with_setup_script, fork_enter_with_failing_setup_script, fork_enter_without_setup_config - all pass -- Files changed: `internal/domain/config/local.go`, `internal/usecase/enter_fork.go`, `internal/usecase/set_config.go`, `internal/usecase/remove_config.go`, `internal/cli/config.go`, `internal/cli/render/fork.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go`, `test/testdata/project/script/setup/SetupFork.s.sol`, `test/testdata/project/script/setup/SetupForkFailing.s.sol` -- **Learnings for future iterations:** - - Forge cheatcodes (vm.deal, vm.store) do NOT persist state on the actual node when using `forge script --broadcast`. They only affect the simulation. For setup scripts that need persistent state changes on a fork, use actual transactions (vm.startBroadcast + transfer/call) or Anvil RPC methods. - - LocalConfig uses `json:"forkSetup,omitempty"` to avoid polluting the JSON when not set, while the config key is `fork.setup` (dot-notation) - - The `executeSetupFork` method builds a minimal `RunScriptConfig` without going through full parameter/sender resolution - the setup script is treated as a plain forge script, not a treb deployment script - - EnterForkResult.SetupScriptRan flag allows the renderer to conditionally show "Setup: executed successfully" ---- - -## 2026-02-27 - US-011 -- Implemented `treb fork status` CLI command showing all active forks -- Created ForkStatus use case in `internal/usecase/fork_status.go`: - - Loads fork state and iterates all active forks - - Health-checks each fork's anvil (PID alive + RPC responsive) via anvil manager - - Counts fork-added deployments by comparing current deployments.json against snapshot 0 backup - - Indicates which fork is the currently configured network -- Added RenderStatus method to ForkRenderer in `internal/cli/render/fork.go`: - - Table output: network name, chain ID, fork URL, anvil PID, health status, uptime, snapshot count, fork deploy count - - Shows "(current)" marker for the configured network - - "No active forks" message when no forks exist -- Added `formatDuration` helper for human-readable uptime display -- Wired DI: added ForkStatus to app.go, wire.go, wire_gen.go -- Integration tests: fork_status_shows_active_fork, fork_status_after_deploy, fork_status_no_active_forks - all pass -- Files changed: `internal/usecase/fork_status.go`, `internal/cli/fork.go`, `internal/cli/render/fork.go`, `internal/app/app.go`, `internal/app/wire.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - `loadDeploymentIDs` is a reusable utility for comparing deployment sets across JSON files (current vs. backup) - - The ForkStatus use case reads deployments.json directly from disk rather than going through the DeploymentRepository - this is simpler and avoids coupling with the full repository infrastructure - - `portFromURL` helper in exit_fork.go continues to be reusable across fork use cases for building AnvilInstance from ForkEntry - - The `time.Since(entry.EnteredAt)` approach for uptime means the displayed value is a snapshot at render time, not a live counter ---- - -## 2026-02-27 - US-012 -- Implemented `treb fork history [network]` CLI command showing snapshot history for a fork -- Created ForkHistory use case in `internal/usecase/fork_history.go`: - - Loads fork state, resolves network (optional arg, falls back to configured network) - - Builds history entries from snapshot stack with index, command, timestamp, current/initial markers - - Errors if no active fork for the specified network -- Added `newForkHistoryCmd` in `internal/cli/fork.go` with optional network argument -- Added `RenderHistory` method in `internal/cli/render/fork.go`: - - Shows "Fork History: " header - - Entries marked with → for current (top of stack), [0] as "initial", others show command - - Timestamps formatted as "2006-01-02 15:04:05" -- Wired DI: added ForkHistory to app.go, wire.go, wire_gen.go -- Integration tests: fork_history_shows_entries (3 entries after 2 runs), fork_history_after_revert (only initial after revert), fork_history_no_active_fork (error case) -- Files changed: `internal/usecase/fork_history.go`, `internal/cli/fork.go`, `internal/cli/render/fork.go`, `internal/app/app.go`, `internal/app/wire.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - ForkHistory is one of the simplest use cases - only depends on ForkStateStore (no anvil manager needed since it's read-only) - - The `buildHistoryEntries` helper converts domain SnapshotEntry to display-friendly ForkHistoryEntry with pre-formatted timestamps - - The → marker for the current (most recent) entry helps users identify what `fork revert` would undo ---- - -## 2026-02-27 - US-013 -- Added fork awareness to `treb list` command with `[fork]` indicator, `--fork` and `--no-fork` filters, and `--json` output with `fork` field -- Extended ListDeployments use case with ForkStateStore dependency to compute fork deployment IDs by comparing current deployments.json against snapshot 0 backup -- Added `ForkOnly` and `NoFork` params to `ListDeploymentsParams` with `filterByFork` function -- Added `ForkDeploymentIDs map[string]bool` to `DeploymentListResult` -- Updated deployments renderer to show `[fork]` indicator in yellow after deployment name -- Added `--json` flag to list command with `listJSONEntry` struct including `fork` field (omitempty) -- Updated DI wiring to pass `forkStateStoreAdapter` to `NewListDeployments` -- Updated existing `list_json_output` integration test to expect success (no longer an error) -- Added `extractJSONArray` helper for parsing JSON from framework-wrapped output -- Files changed: `internal/usecase/list_deployments.go`, `internal/usecase/ports.go`, `internal/cli/list.go`, `internal/cli/render/deployments.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go`, `test/integration/list_test.go` -- **Learnings for future iterations:** - - The integration test framework prepends `=== cmd N: [args...] ===\n` before each command's stdout - JSON parsing must skip this header (use `extractJSONArray` helper) - - `loadDeploymentIDs` from `fork_status.go` is reusable for any fork vs. baseline comparison - it's a package-level function in the usecase package - - The `--json` flag was added as a local bool on the list command (not via viper/global config) to avoid interference with the global `--json` flag binding - - Fork deployment detection uses the same pattern as `countForkDeployments` in ForkStatus: compare current deployments.json keys against snapshot 0 backup keys - - `forkDeploymentIDs` is nil when no fork is active, serving as a clean "not applicable" signal - nil means no fork indicators shown, empty map means fork active but no new deployments ---- - -## 2026-02-27 - US-014 -- Added fork awareness to `treb show` command with `[fork]` indicator in output header -- Extended ShowDeployment use case with ForkStateStore dependency for fork detection -- Created `ShowDeploymentResult` struct wrapping Deployment + IsForkDeployment flag -- Added `--no-fork` flag that errors if the resolved deployment was added during fork mode -- Updated `RenderDeployment` in `internal/cli/render/deployment.go` to accept and render `isForkDeployment` indicator -- JSON output includes `fork: true` field when deployment is fork-added -- `isForkDeployment` method reuses `loadDeploymentIDs` for comparing deployment against snapshot 0 backup -- Updated DI wiring to pass `forkStateStoreAdapter` to `NewShowDeployment` -- Integration tests: fork_show_displays_fork_indicator, fork_show_no_fork_indicator_for_pre_fork_deploy -- Files changed: `internal/usecase/show_deployment.go`, `internal/cli/show.go`, `internal/cli/render/deployment.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - The `ShowDeployment` use case's return type changed from `*models.Deployment` to `*ShowDeploymentResult` - similar pattern to ListDeployments which returns `*DeploymentListResult` with fork metadata - - `isForkDeployment` method follows the exact same pattern as `computeForkDeploymentIDs` in ListDeployments but checks a single deployment ID instead of computing the full set - - The `RenderDeployment` function uses `color.New(...).Fprint` (not Fprintf) for the header to avoid ANSI codes wrapping the `[fork]` indicator which uses a different color style - - Pre-existing gosec lint issue in verifier.go (G702) continues to be the only lint failure - not related to fork mode ---- - -## 2026-02-27 - US-015 -- Implemented anvil crash detection in RunScript use case: pre-run health check before fork mode runs -- Created RestartFork use case in `internal/usecase/restart_fork.go`: - - Stops dead anvil process, restores files from initial backup (snapshot 0) - - Starts fresh fork from original RPC, re-runs SetupFork if configured - - Takes new initial EVM snapshot, updates fork state with new entry -- Added `treb fork restart [network]` CLI command in `internal/cli/fork.go` -- Added `RenderRestart` method in `internal/cli/render/fork.go` -- Wired DI: added RestartFork to app.go, wire.go, wire_gen.go -- `checkForkHealth` method on RunScript: checks PID alive + RPC responsive via GetStatus, returns actionable error with suggested commands -- `treb fork status` already shows 'dead' for crashed forks (implemented in US-011) -- `treb fork exit` already handles dead anvil gracefully (implemented in US-006) -- Integration tests: fork_run_detects_crashed_anvil, fork_status_shows_dead_for_crashed_anvil, fork_exit_cleans_up_crashed_anvil, fork_restart_after_crash_restores_state -- Files changed: `internal/usecase/run_script.go`, `internal/usecase/restart_fork.go`, `internal/cli/fork.go`, `internal/cli/render/fork.go`, `internal/app/app.go`, `internal/app/wire.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - RestartFork reuses the same dependencies as EnterFork (localConfig, forgeRunner for SetupFork) - it's essentially "exit + enter" in a single use case - - `killForkAnvil` test helper uses SIGKILL (not SIGTERM) to simulate a true crash - SIGTERM would be caught by anvil - - Integration tests that kill processes and then run more commands need `PostTest` pattern (empty TestCmds, manual Treb() calls in PostTest) since the test framework runs TestCmds before PostTest - - The `checkForkHealth` method reloads fork state from disk (not cached) to get the latest fork entry - this is correct since fork state may have been modified by other commands - - `getAvailablePort()` in enter_fork.go is reusable by restart_fork.go since it's a package-level function ---- - -## 2026-02-27 - US-016 -- Added fork mode command guards for fork-incompatible operations -- `treb verify` and `treb sync` are hard-blocked in fork mode with error messages -- `treb prune` and `treb register` print informational notes about fork state but proceed normally -- `treb reset`, `treb tag` operate normally (no changes needed) -- Added `ForkStateStore` to App struct for CLI-level fork state access -- Created `isForkActiveForCurrentNetwork` helper in `internal/cli/root.go` -- Integration tests: fork_verify_blocked, fork_sync_blocked -- Files changed: `internal/app/app.go`, `internal/app/wire_gen.go`, `internal/cli/root.go`, `internal/cli/verify.go`, `internal/cli/sync.go`, `internal/cli/prune.go`, `internal/cli/register.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - Go linter enforces lowercase error strings (ST1005 staticcheck rule), even when PRD specifies capitalized messages - - Adding a field to the App struct also requires updating NewApp constructor signature, wire_gen.go NewApp call, and the App body initialization - - The `isForkActiveForCurrentNetwork` helper provides a clean CLI-level pattern for fork state checks without needing to inject ForkStateStore into every use case - - Fork mode checks are best placed at the CLI layer (command RunE) rather than use case layer, since the behavior varies per command (hard block vs. info note vs. no change) ---- - -## 2026-02-27 - US-017 -- Implemented `treb fork diff [network]` CLI command with `--json` flag -- Created DiffFork use case in `internal/usecase/diff_fork.go`: - - Compares current deployments.json against initial backup at snapshot 0 - - Reports new deployments (IDs in current but not backup), modified deployments (same ID, different data), and new transaction count - - Errors if no active fork for the specified network -- Added `RenderDiff` method to ForkRenderer in `internal/cli/render/fork.go`: - - Shows "Fork Diff: " header - - Lists new deployments with `+` prefix, modified with `~` prefix - - Shows new transaction count - - "No changes since fork entered." when no diff -- Added `--json` flag for JSON output using `json.MarshalIndent` -- Added `newForkDiffCmd` in `internal/cli/fork.go` with optional network argument -- Wired DI: added DiffFork to app.go, wire.go, wire_gen.go -- `loadRawDeployments` reads raw JSON for byte-level comparison of modified entries -- Reused `loadDeploymentIDs` from fork_status.go for transaction diff -- Integration tests: fork_diff_shows_new_deployments (2 deploys), fork_diff_no_changes (no runs), fork_diff_after_revert_shows_no_changes (deploy + revert), fork_diff_no_active_fork (error case) - all pass -- Files changed: `internal/usecase/diff_fork.go`, `internal/cli/fork.go`, `internal/cli/render/fork.go`, `internal/app/app.go`, `internal/app/wire.go`, `internal/app/wire_gen.go`, `test/integration/fork_test.go` -- **Learnings for future iterations:** - - DiffFork is a read-only use case needing only ForkStateStore (no anvil manager) - similar simplicity to ForkHistory - - `loadRawDeployments` returns `map[string]json.RawMessage` for byte-level comparison, while `loadDeploymentIDs` returns `map[string]bool` for set operations - - The `--json` flag on subcommands is best handled as a local bool flag passed through the RunE closure, avoiding conflicts with global flags - - Modified deployments are detected by comparing raw JSON bytes (string comparison of json.RawMessage), which catches any field-level change without needing to define which fields matter ---- - -## 2026-02-27 - US-018 -- Created ForkHelper.sol contract in `treb-sol/src/ForkHelper.sol` with three main utility functions -- `convertSafeToSingleOwner(address safe, address newOwner)`: rewrites Safe storage via vm.store to set single owner, ownerCount=1, threshold=1, by clearing existing linked list and setting up new one -- `dealNativeToken(address to, uint256 amount)`: wrapper around vm.deal for readability -- `dealERC20(address token, address to, uint256 amount)`: auto-detects balanceOf mapping slot by probing slots 0-9 with distinctive test values, then writes desired amount -- Abstract contract inheriting CommonBase for vm access, designed for diamond inheritance with TrebScript -- Comprehensive NatSpec documentation on all functions and the contract itself -- Created ForkHelper.t.sol test suite in `treb-sol/test/ForkHelper.t.sol` with 13 tests: - - Safe conversion: single owner, multiple owners (3→1), replace with existing owner, revert on zero address, revert on sentinel - - Native token: set balance, replace existing, zero amount - - ERC20: slot 0 detection, slot 1 detection, zero amount, large amount, multiple recipients -- Created MockERC20Slot0 and MockERC20Slot1 test contracts to verify auto-detection across different storage layouts -- All 114 treb-sol tests pass (13 new + 101 existing), forge build succeeds, Go lint passes, Go unit tests pass -- Files changed: `treb-sol/src/ForkHelper.sol`, `treb-sol/test/ForkHelper.t.sol` -- **Learnings for future iterations:** - - Safe storage layout (from SafeStorage.sol): slot 0=singleton, slot 1=modules mapping, slot 2=owners mapping, slot 3=ownerCount, slot 4=threshold - - Safe owners are a linked list in a mapping: sentinel(0x1) → owner1 → owner2 → ... → sentinel. Must clear all old entries before setting new ones. - - Solidity mapping storage slot: `keccak256(abi.encode(key, baseSlot))` for `mapping(address => T)` at `baseSlot` - - Use CommonBase from forge-std for vm access in abstract contracts - avoids naming conflicts in diamond inheritance with Script/Test - - ERC20 balanceOf slot detection via probing: write distinctive value, check balanceOf(), restore if mismatch. Works for any mapping-based implementation. - - ForkHelperHarness pattern (inherits both ForkHelper and Test) needed because ForkHelper's functions are `internal` +Started: Fri Feb 27 05:55:05 PM CET 2026 --- diff --git a/scripts/ralph/ralph.sh b/scripts/ralph/ralph.sh new file mode 100755 index 00000000..baff052a --- /dev/null +++ b/scripts/ralph/ralph.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Ralph Wiggum - Long-running AI agent loop +# Usage: ./ralph.sh [--tool amp|claude] [max_iterations] + +set -e + +# Parse arguments +TOOL="amp" # Default to amp for backwards compatibility +MAX_ITERATIONS=10 + +while [[ $# -gt 0 ]]; do + case $1 in + --tool) + TOOL="$2" + shift 2 + ;; + --tool=*) + TOOL="${1#*=}" + shift + ;; + *) + # Assume it's max_iterations if it's a number + if [[ "$1" =~ ^[0-9]+$ ]]; then + MAX_ITERATIONS="$1" + fi + shift + ;; + esac +done + +# Validate tool choice +if [[ "$TOOL" != "amp" && "$TOOL" != "claude" ]]; then + echo "Error: Invalid tool '$TOOL'. Must be 'amp' or 'claude'." + exit 1 +fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRD_FILE="$SCRIPT_DIR/prd.json" +PROGRESS_FILE="$SCRIPT_DIR/progress.txt" +ARCHIVE_DIR="$SCRIPT_DIR/archive" +LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch" + +# Archive previous run if branch changed +if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then + CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "") + LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "") + + if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then + # Archive the previous run + DATE=$(date +%Y-%m-%d) + # Strip "ralph/" prefix from branch name for folder + FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||') + ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME" + + echo "Archiving previous run: $LAST_BRANCH" + mkdir -p "$ARCHIVE_FOLDER" + [ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/" + [ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/" + echo " Archived to: $ARCHIVE_FOLDER" + + # Reset progress file for new run + echo "# Ralph Progress Log" > "$PROGRESS_FILE" + echo "Started: $(date)" >> "$PROGRESS_FILE" + echo "---" >> "$PROGRESS_FILE" + fi +fi + +# Track current branch +if [ -f "$PRD_FILE" ]; then + CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "") + if [ -n "$CURRENT_BRANCH" ]; then + echo "$CURRENT_BRANCH" > "$LAST_BRANCH_FILE" + fi +fi + +# Initialize progress file if it doesn't exist +if [ ! -f "$PROGRESS_FILE" ]; then + echo "# Ralph Progress Log" > "$PROGRESS_FILE" + echo "Started: $(date)" >> "$PROGRESS_FILE" + echo "---" >> "$PROGRESS_FILE" +fi + +echo "Starting Ralph - Tool: $TOOL - Max iterations: $MAX_ITERATIONS" + +for i in $(seq 1 $MAX_ITERATIONS); do + echo "" + echo "===============================================================" + echo " Ralph Iteration $i of $MAX_ITERATIONS ($TOOL)" + echo "===============================================================" + + # Run the selected tool with the ralph prompt + if [[ "$TOOL" == "amp" ]]; then + OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true + else + # Claude Code: use --dangerously-skip-permissions for autonomous operation, --print for output + OUTPUT=$(claude --dangerously-skip-permissions --print < "$SCRIPT_DIR/CLAUDE.md" 2>&1 | tee /dev/stderr) || true + fi + + # Check for completion signal + if echo "$OUTPUT" | grep -q "COMPLETE"; then + echo "" + echo "Ralph completed all tasks!" + echo "Completed at iteration $i of $MAX_ITERATIONS" + exit 0 + fi + + echo "Iteration $i complete. Continuing..." + sleep 2 +done + +echo "" +echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks." +echo "Check $PROGRESS_FILE for status." +exit 1 diff --git a/tasks/prd-separate-config.md b/tasks/prd-separate-config.md new file mode 100644 index 00000000..51d9c736 --- /dev/null +++ b/tasks/prd-separate-config.md @@ -0,0 +1,177 @@ +# PRD: Separate Treb Config (`treb.toml`) + +## Introduction + +Move treb-specific sender configuration out of `foundry.toml` into a dedicated `treb.toml` file that lives alongside it. This decouples treb's config from Foundry's, making it clearer what belongs to each tool, and introduces namespaces as a first-class treb concept that can optionally map to a foundry profile. Backwards compatibility is maintained — treb continues to read the old `foundry.toml` locations with a deprecation warning — and a `treb migrate-config` command helps users transition interactively. + +## Goals + +- Introduce `treb.toml` as the canonical location for treb sender configuration +- Decouple treb namespaces from foundry profiles (namespace can map to a different profile name) +- Maintain full backwards compatibility with existing `[profile.*.treb.*]` config in `foundry.toml` +- Show a clear deprecation warning when legacy config is detected +- Provide an interactive migration command to move config from `foundry.toml` to `treb.toml` + +## User Stories + +### US-001: Define treb.toml schema and parser +**Description:** As a developer, I need a TOML parser for `treb.toml` so the CLI can read sender configs from the new file. + +**Acceptance Criteria:** +- [ ] New domain type `TrebFileConfig` representing the full `treb.toml` schema +- [ ] Parser reads `treb.toml` from project root (same directory as `foundry.toml`) +- [ ] Supports namespace sections: `[ns..senders.]` with same sender fields as today +- [ ] Supports optional `profile` field per namespace: `[ns.]` `profile = ""` +- [ ] When `profile` is omitted, defaults to the namespace name (preserving current behavior) +- [ ] `[ns.default.senders.*]` is the base config, merged with namespace-specific overrides (same merge semantics as today) +- [ ] Environment variable expansion (`${VAR}`) works in all string fields +- [ ] Unit tests cover parsing, profile mapping, merging, and env var expansion +- [ ] `make lint` passes + +### US-002: Integrate treb.toml into config resolution +**Description:** As a developer, I need the config provider to prefer `treb.toml` over `foundry.toml` so users get the new behavior when the file exists. + +**Acceptance Criteria:** +- [ ] `config.Provider` checks for `treb.toml` first; if present, loads sender config from it +- [ ] If `treb.toml` exists, `foundry.toml` `[profile.*.treb.*]` sections are ignored entirely +- [ ] If `treb.toml` does not exist, falls back to current `foundry.toml` parsing (no behavior change) +- [ ] `RuntimeConfig.TrebConfig` is populated identically regardless of source +- [ ] The resolved foundry profile name comes from `treb.toml`'s `[ns.]` `profile` field when available +- [ ] `FOUNDRY_PROFILE` env var set during forge execution uses the resolved profile name +- [ ] Unit tests cover both paths (treb.toml present vs absent) +- [ ] Integration tests pass with both config styles +- [ ] `make lint` passes + +### US-003: Detect legacy config and show deprecation warning +**Description:** As a user with config in `foundry.toml`, I want to see a clear warning telling me to migrate so I know my setup is outdated. + +**Acceptance Criteria:** +- [ ] On every command run (except `version`, `help`, `completion`, `init`, `migrate-config`), check if `foundry.toml` contains `[profile.*.treb.*]` sections +- [ ] If legacy config detected and no `treb.toml` exists, print a yellow warning to stderr: + ``` + Warning: treb config detected in foundry.toml — this is deprecated. + Run `treb migrate-config` to move your config to treb.toml. + ``` +- [ ] Warning does not block command execution +- [ ] Warning is suppressed when `--json` flag is set (machine-readable output) +- [ ] Warning is suppressed when `treb.toml` already exists (even if foundry.toml still has stale treb sections) +- [ ] Unit test verifies warning is emitted under correct conditions +- [ ] `make lint` passes + +### US-004: Implement `treb migrate-config` command +**Description:** As a user, I want to run `treb migrate-config` to interactively move my sender config from `foundry.toml` to `treb.toml`. + +**Acceptance Criteria:** +- [ ] New `migrate-config` CLI command registered under the root command +- [ ] Reads all `[profile.*.treb.*]` sections from `foundry.toml` +- [ ] Maps each foundry profile to a `[ns.]` section in `treb.toml`: + - Profile name becomes namespace name + - `profile` field is set to the foundry profile name (explicit even when they match) + - Sender configs are copied verbatim +- [ ] Shows the user a preview of the generated `treb.toml` content +- [ ] Asks user to confirm writing `treb.toml` +- [ ] Asks user whether to remove the `[profile.*.treb.*]` sections from `foundry.toml` + - If yes, removes only the `treb` sub-tables (preserves all other profile config) + - If no, leaves `foundry.toml` untouched and informs user they can clean up manually +- [ ] If `treb.toml` already exists, warns and asks whether to overwrite +- [ ] Supports `--non-interactive` flag: writes `treb.toml` without prompts, does NOT modify `foundry.toml` +- [ ] Prints success message with next steps +- [ ] Integration test covers the full migration flow +- [ ] `make lint` passes + +### US-005: Update `treb init` to generate `treb.toml` +**Description:** As a new user running `treb init`, I want the scaffolded project to use `treb.toml` from the start. + +**Acceptance Criteria:** +- [ ] `treb init` generates a `treb.toml` with a `[ns.default.senders.deployer]` section (private_key type with env var placeholder) +- [ ] `treb init` no longer adds `[profile.default.treb.*]` sections to `foundry.toml` +- [ ] Generated `treb.toml` includes comments explaining the structure +- [ ] Existing integration tests for `init` updated to expect `treb.toml` +- [ ] `make lint` passes + +### US-006: Update documentation and test fixtures +**Description:** As a developer/user, I need docs and test data to reflect the new config format. + +**Acceptance Criteria:** +- [ ] Test fixture `test/testdata/project/` updated with a `treb.toml` (and foundry.toml treb sections removed) +- [ ] CLAUDE.md config examples updated to show `treb.toml` format +- [ ] `treb config show` displays which config source is active (`treb.toml` vs `foundry.toml (legacy)`) +- [ ] `make unit-test` passes +- [ ] `make integration-test` passes + +## Functional Requirements + +- FR-1: The system must parse `treb.toml` from the project root using the `[ns.]` section structure +- FR-2: Each namespace section supports an optional `profile` field mapping to a foundry profile (defaults to namespace name) +- FR-3: Sender configs under `[ns..senders.]` use the identical schema as today's `SenderConfig` +- FR-4: When `treb.toml` exists, it is the sole source of treb sender config; `foundry.toml` treb sections are ignored +- FR-5: When `treb.toml` does not exist, the system falls back to reading `[profile.*.treb.*]` from `foundry.toml` (full backwards compat) +- FR-6: A deprecation warning is printed to stderr on every command when legacy config is detected without a `treb.toml` +- FR-7: `treb migrate-config` interactively converts `foundry.toml` treb sections to `treb.toml` format +- FR-8: `treb migrate-config` optionally removes migrated sections from `foundry.toml` upon user confirmation +- FR-9: `treb init` generates `treb.toml` instead of adding treb config to `foundry.toml` +- FR-10: `treb config show` indicates the active config source +- FR-11: Sender config merging (default namespace + active namespace) works identically to today's profile merging + +## Non-Goals + +- No changes to `.treb/config.local.json` — it remains the store for ephemeral local state (namespace, network) +- No migration of `[rpc_endpoints]` or `[etherscan]` out of `foundry.toml` — those are foundry-native +- No new sender types or sender config schema changes +- No forced migration — users can stay on `foundry.toml` indefinitely (with warnings) +- No changes to the Solidity library (`treb-sol/`) + +## Technical Considerations + +- **TOML library:** Reuse existing `github.com/BurntSushi/toml` for parsing `treb.toml` +- **TOML writing:** For `migrate-config`, use `toml.Marshal` or template-based generation to produce well-formatted, commented output. The `BurntSushi/toml` library supports encoding. +- **Foundry.toml modification:** Removing `[profile.*.treb.*]` sections from `foundry.toml` requires careful TOML manipulation to avoid corrupting the file. Consider using a TOML-aware edit (parse, remove keys, re-encode) or a simpler approach (read lines, identify and remove treb sections). A TOML round-trip (decode + re-encode) may reorder keys — a line-based approach may be safer for preserving user formatting. +- **Config detection:** To detect legacy config, check if any `FoundryConfig.Profile[*].Treb` is non-nil after parsing +- **Profile field default:** When `[ns.]` omits `profile`, the resolved profile name equals the namespace name — this preserves the current `namespace == FOUNDRY_PROFILE` behavior without requiring explicit config + +### Example `treb.toml` + +```toml +# Treb deployment configuration +# Docs: https://github.com/trebuchet-org/treb-cli + +# Default namespace — senders here are inherited by all namespaces +[ns.default] +profile = "default" # foundry profile to use + +[ns.default.senders.anvil] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +[ns.default.senders.governor] +type = "oz_governor" +governor = "${GOVERNOR_ADDRESS}" +timelock = "${TIMELOCK_ADDRESS}" +proposer = "anvil" + +# Production namespace — inherits default senders, can override +[ns.live] +profile = "live" # maps to [profile.live] in foundry.toml + +[ns.live.senders.safe0] +type = "safe" +safe = "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F" +signer = "signer0" + +[ns.live.senders.signer0] +type = "private_key" +private_key = "${BASE_SEPOLIA_SIGNER0_PK}" +``` + +## Success Metrics + +- All existing integration tests pass without modification (backwards compat) +- New integration tests cover both `treb.toml` and legacy `foundry.toml` paths +- `treb migrate-config` correctly converts the test fixture's `foundry.toml` to a valid `treb.toml` +- No user-facing behavior changes for users who haven't migrated (aside from the warning) + +## Open Questions + +- Should `treb.toml` support top-level settings beyond namespaces in the future (e.g., default timeout, registry path)? For now we scope to senders only, but the `[ns.*]` prefix leaves room for top-level keys later. +- Should the deprecation warning include a version target for removal of legacy support, or keep it open-ended? +- Should `treb migrate-config` handle edge cases where the user has both `treb.toml` and legacy config (merge vs overwrite)? diff --git a/test/helpers/test_context.go b/test/helpers/test_context.go index ae56c25a..f99f4b0f 100644 --- a/test/helpers/test_context.go +++ b/test/helpers/test_context.go @@ -344,6 +344,34 @@ func (ctx *TestContext) Clean() error { os.MkdirAll(dirPath, 0755) } + // Restore treb.toml from fixture (tests may modify or remove it via PreSetup) + fixtureTrebToml := filepath.Join(GetFixtureDir(), "treb.toml") + workTrebToml := filepath.Join(ctx.WorkDir, "treb.toml") + if _, err := os.Stat(fixtureTrebToml); err == nil { + copyFile(fixtureTrebToml, workTrebToml) + } else { + os.Remove(workTrebToml) + } + + // Restore foundry.toml from fixture and re-apply port config + // (tests may modify foundry.toml via PreSetup, e.g., migrate-config tests) + fixtureFoundryToml := filepath.Join(GetFixtureDir(), "foundry.toml") + workFoundryToml := filepath.Join(ctx.WorkDir, "foundry.toml") + copyFile(fixtureFoundryToml, workFoundryToml) + var port1, port2 int + for name, node := range ctx.AnvilNodes { + p := 0 + fmt.Sscanf(node.Port, "%d", &p) + if strings.Contains(name, "31337") { + port1 = p + } else { + port2 = p + } + } + if port1 > 0 && port2 > 0 { + updateFoundryConfig(workFoundryToml, port1, port2) + } + // Clean generated scripts except .gitkeep scriptDir := filepath.Join(ctx.WorkDir, "script", "deploy") if entries, err := os.ReadDir(scriptDir); err == nil { @@ -414,6 +442,12 @@ func createLightweightWorkspace(src, dst string) error { return fmt.Errorf("failed to copy foundry.toml: %w", err) } + case "treb.toml": + // Copy treb.toml (tests may modify it via PreSetup) + if err := copyFile(srcPath, dstPath); err != nil { + return fmt.Errorf("failed to copy treb.toml: %w", err) + } + case "script": // Copy script directory (for test isolation) if err := copyDir(srcPath, dstPath); err != nil { diff --git a/test/integration/init_test.go b/test/integration/init_test.go index 36c5c0e3..fc87594c 100644 --- a/test/integration/init_test.go +++ b/test/integration/init_test.go @@ -1,28 +1,60 @@ package integration import ( + "os" + "path/filepath" "testing" + + "github.com/trebuchet-org/treb-cli/test/helpers" ) func TestInitCommand(t *testing.T) { tests := []IntegrationTest{ { Name: "init_new_project", + PreSetup: func(t *testing.T, ctx *helpers.TestContext) { + // Remove fixture treb.toml so init creates a fresh one + os.Remove(filepath.Join(ctx.WorkDir, "treb.toml")) + }, TestCmds: [][]string{ {"init"}, }, + OutputArtifacts: append(DefaultOutputArtifacs, "treb.toml"), }, { Name: "init_existing_project", + PreSetup: func(t *testing.T, ctx *helpers.TestContext) { + // Remove fixture treb.toml so first init creates a fresh one + os.Remove(filepath.Join(ctx.WorkDir, "treb.toml")) + }, SetupCmds: [][]string{ {"init"}, // First init }, TestCmds: [][]string{ {"init"}, // Should handle gracefully when already initialized }, + OutputArtifacts: append(DefaultOutputArtifacs, "treb.toml"), }, { Name: "init_and_deploy", + PreSetup: func(t *testing.T, ctx *helpers.TestContext) { + // Pre-create treb.toml with anvil sender matching the test fixture's + // foundry.toml config so that init skips treb.toml generation and + // subsequent run commands find the expected sender. + trebToml := `# treb.toml — Treb sender configuration + +[ns.default] +profile = "default" + +[ns.default.senders.anvil] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +` + err := os.WriteFile(filepath.Join(ctx.WorkDir, "treb.toml"), []byte(trebToml), 0644) + if err != nil { + t.Fatalf("Failed to create treb.toml: %v", err) + } + }, TestCmds: [][]string{ {"init"}, {"gen", "deploy", "src/Counter.sol:Counter"}, diff --git a/test/integration/migrate_config_test.go b/test/integration/migrate_config_test.go new file mode 100644 index 00000000..ac1eb2b5 --- /dev/null +++ b/test/integration/migrate_config_test.go @@ -0,0 +1,185 @@ +package integration + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/trebuchet-org/treb-cli/test/helpers" +) + +// readFixtureFoundryTomlWithTreb returns a foundry.toml content that includes +// legacy [profile.*.treb.*] sections, for testing the migrate-config command. +func readFixtureFoundryTomlWithTreb(t *testing.T) []byte { + t.Helper() + content := `[profile.default] +src = "src" +out = "out" +libs = ["lib"] +test = "test" +script = "script" +optimizer_runs = 0 +fs_permissions = [{ access = "read-write", path = "./" }] +bytecode_hash = "none" +cbor_metadata = false + +[lint] +lint_on_build = false + +[rpc_endpoints] +celo-sepolia = "https://forno.celo-sepolia.celo-testnet.org" +base-sepolia = "https://sepolia.base.org" +polygon = "https://polygon-bor-rpc.publicnode.com" +celo = "https://forno.celo.org" +anvil-31337 = "http://localhost:8545" +anvil-31338 = "http://localhost:9545" + +[etherscan] +sepolia = { key = "${ETHERSCAN_API_KEY}" } +celo-sepolia = { key = "${ETHERSCAN_API_KEY}", chain = 11142220 } +celo = { key = "${ETHERSCAN_API_KEY}", chain = 42220 } + +[profile.default.treb.senders.anvil] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +[profile.default.treb.senders.governor] +type = "oz_governor" +governor = "${GOVERNOR_ADDRESS}" +timelock = "${TIMELOCK_ADDRESS}" +proposer = "anvil" + +[profile.live.treb.senders.safe0] +type = "safe" +safe = "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F" +signer = "signer0" + +[profile.live.treb.senders.safe1] +type = "safe" +safe = "0x8dcD47D7aC5FEBC1E49a532644D21cd9D9dd97b2" +signer = "signer0" + +[profile.live.treb.senders.signer0] +type = "private_key" +private_key="${BASE_SEPOLIA_SIGNER0_PK}" +` + return []byte(content) +} + +func TestMigrateConfigCommand(t *testing.T) { + tests := []IntegrationTest{ + { + Name: "migrate_config_creates_treb_toml", + PreSetup: func(t *testing.T, ctx *helpers.TestContext) { + // Remove the treb.toml from fixture so migrate-config can create it + os.Remove(filepath.Join(ctx.WorkDir, "treb.toml")) + // Write foundry.toml with legacy treb sections for migration + foundryContent := readFixtureFoundryTomlWithTreb(t) + err := os.WriteFile(filepath.Join(ctx.WorkDir, "foundry.toml"), foundryContent, 0644) + require.NoError(t, err) + }, + TestCmds: [][]string{ + {"migrate-config"}, + }, + OutputArtifacts: []string{"treb.toml"}, + PostTest: func(t *testing.T, ctx *helpers.TestContext, output string) { + // Verify treb.toml was created with expected content + data, err := os.ReadFile(filepath.Join(ctx.WorkDir, "treb.toml")) + require.NoError(t, err) + content := string(data) + + assert.Contains(t, content, "[ns.default]") + assert.Contains(t, content, `profile = "default"`) + assert.Contains(t, content, "[ns.default.senders.anvil]") + assert.Contains(t, content, `type = "private_key"`) + + // Verify foundry.toml is untouched (non-interactive does NOT modify it) + foundryData, err := os.ReadFile(filepath.Join(ctx.WorkDir, "foundry.toml")) + require.NoError(t, err) + foundryContent := string(foundryData) + assert.Contains(t, foundryContent, "[profile.default.treb.senders.anvil]") + }, + }, + { + Name: "migrate_config_no_config_to_migrate", + PreSetup: func(t *testing.T, ctx *helpers.TestContext) { + // Remove treb.toml from fixture + os.Remove(filepath.Join(ctx.WorkDir, "treb.toml")) + // Replace foundry.toml with one that has no treb sections + foundryContent := `[profile.default] +src = "src" +out = "out" + +[rpc_endpoints] +anvil-31337 = "http://localhost:8545" +` + err := os.WriteFile(filepath.Join(ctx.WorkDir, "foundry.toml"), []byte(foundryContent), 0644) + require.NoError(t, err) + }, + TestCmds: [][]string{ + {"migrate-config"}, + }, + SkipGolden: true, + PostTest: func(t *testing.T, ctx *helpers.TestContext, output string) { + assert.Contains(t, output, "nothing to migrate") + + // treb.toml should not exist + _, err := os.Stat(filepath.Join(ctx.WorkDir, "treb.toml")) + assert.True(t, os.IsNotExist(err)) + }, + }, + { + Name: "migrate_config_overwrites_existing_treb_toml", + PreSetup: func(t *testing.T, ctx *helpers.TestContext) { + // Write foundry.toml with legacy treb sections for migration + foundryContent := readFixtureFoundryTomlWithTreb(t) + err := os.WriteFile(filepath.Join(ctx.WorkDir, "foundry.toml"), foundryContent, 0644) + require.NoError(t, err) + // Create an existing treb.toml with old content + err = os.WriteFile(filepath.Join(ctx.WorkDir, "treb.toml"), []byte("# old content\n"), 0644) + require.NoError(t, err) + }, + TestCmds: [][]string{ + {"migrate-config"}, + }, + SkipGolden: true, + PostTest: func(t *testing.T, ctx *helpers.TestContext, output string) { + // Should warn about overwrite + assert.Contains(t, output, "already exists") + + // treb.toml should have new content + data, err := os.ReadFile(filepath.Join(ctx.WorkDir, "treb.toml")) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "[ns.default]") + assert.NotContains(t, content, "old content") + }, + }, + { + Name: "migrate_config_then_commands_work", + PreSetup: func(t *testing.T, ctx *helpers.TestContext) { + // Remove treb.toml so migrate-config can create it + os.Remove(filepath.Join(ctx.WorkDir, "treb.toml")) + // Write foundry.toml with legacy treb sections for migration + foundryContent := readFixtureFoundryTomlWithTreb(t) + err := os.WriteFile(filepath.Join(ctx.WorkDir, "foundry.toml"), foundryContent, 0644) + require.NoError(t, err) + }, + TestCmds: [][]string{ + {"migrate-config"}, + {"config"}, + }, + SkipGolden: true, + PostTest: func(t *testing.T, ctx *helpers.TestContext, output string) { + // treb.toml should exist and be valid + data, err := os.ReadFile(filepath.Join(ctx.WorkDir, "treb.toml")) + require.NoError(t, err) + assert.Contains(t, string(data), "[ns.default]") + }, + }, + } + + RunIntegrationTests(t, tests) +} diff --git a/test/testdata/golden/integration/TestConfigCommand/config_remove_namespace/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_remove_namespace/commands.golden index af82e370..87412b5c 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_remove_namespace/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_remove_namespace/commands.golden @@ -8,4 +8,5 @@ Namespace: default Network: (not set) +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_remove_network/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_remove_network/commands.golden index 50f2b442..c0dbf412 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_remove_network/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_remove_network/commands.golden @@ -8,4 +8,5 @@ Namespace: default Network: (not set) +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_remove_with_ns_alias/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_remove_with_ns_alias/commands.golden index 14467c37..7f578682 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_remove_with_ns_alias/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_remove_with_ns_alias/commands.golden @@ -8,4 +8,5 @@ Namespace: default Network: (not set) +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_set_both_namespace_and_network/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_set_both_namespace_and_network/commands.golden index 649a2876..2b30fc90 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_set_both_namespace_and_network/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_set_both_namespace_and_network/commands.golden @@ -13,4 +13,5 @@ Namespace: staging Network: polygon +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_set_namespace/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_set_namespace/commands.golden index f38333fb..a66ac54b 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_set_namespace/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_set_namespace/commands.golden @@ -8,4 +8,5 @@ Namespace: production Network: (not set) +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_set_namespace_with_ns_alias/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_set_namespace_with_ns_alias/commands.golden index c44ea403..73b90c47 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_set_namespace_with_ns_alias/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_set_namespace_with_ns_alias/commands.golden @@ -8,4 +8,5 @@ Namespace: testing Network: (not set) +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_set_network/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_set_network/commands.golden index d3fe1795..9fbffb4d 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_set_network/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_set_network/commands.golden @@ -8,4 +8,5 @@ Namespace: default Network: celo +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_show_with_existing_config/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_show_with_existing_config/commands.golden index 089d9dad..70c59948 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_show_with_existing_config/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_show_with_existing_config/commands.golden @@ -3,4 +3,5 @@ Namespace: staging Network: polygon +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_show_with_network_not_set/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_show_with_network_not_set/commands.golden index 3d8dcc5c..5824eb6a 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_show_with_network_not_set/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_show_with_network_not_set/commands.golden @@ -3,4 +3,5 @@ Namespace: default Network: (not set) +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestConfigCommand/config_update_existing_value/commands.golden b/test/testdata/golden/integration/TestConfigCommand/config_update_existing_value/commands.golden index a08b0a86..8e5ba706 100644 --- a/test/testdata/golden/integration/TestConfigCommand/config_update_existing_value/commands.golden +++ b/test/testdata/golden/integration/TestConfigCommand/config_update_existing_value/commands.golden @@ -13,4 +13,5 @@ Namespace: production Network: polygon +šŸ“¦ Config source: treb.toml šŸ“ config file: .treb/config.local.json diff --git a/test/testdata/golden/integration/TestInitCommand/init_and_deploy/commands.golden b/test/testdata/golden/integration/TestInitCommand/init_and_deploy/commands.golden index 70a4a8dd..4374fb89 100644 --- a/test/testdata/golden/integration/TestInitCommand/init_and_deploy/commands.golden +++ b/test/testdata/golden/integration/TestInitCommand/init_and_deploy/commands.golden @@ -2,6 +2,7 @@ āœ… Valid Foundry project detected āœ… treb-sol library found āœ… Created v2 registry structure in .treb/ +āœ… treb.toml already exists āœ… Created .env.example šŸŽ‰ treb initialized successfully! @@ -12,8 +13,8 @@ • Set RPC URLs for networks you'll deploy to • Set API keys for contract verification -2. Configure deployment environments in foundry.toml: - • Add [profile.staging.deployer] and [profile.production.deployer] sections +2. Configure deployment environments in treb.toml: + • Add [ns..senders.] sections for each environment • See documentation for Safe multisig and hardware wallet support 3. Generate your first deployment script: diff --git a/test/testdata/golden/integration/TestInitCommand/init_existing_project/commands.golden b/test/testdata/golden/integration/TestInitCommand/init_existing_project/commands.golden index c10001dc..43b73553 100644 --- a/test/testdata/golden/integration/TestInitCommand/init_existing_project/commands.golden +++ b/test/testdata/golden/integration/TestInitCommand/init_existing_project/commands.golden @@ -2,6 +2,7 @@ āœ… Valid Foundry project detected āœ… treb-sol library found āœ… Registry files already exist in .treb/ +āœ… treb.toml already exists āœ… .env.example already exists āš ļø treb was already initialized in this project @@ -12,8 +13,8 @@ • Set RPC URLs for networks you'll deploy to • Set API keys for contract verification -2. Configure deployment environments in foundry.toml: - • Add [profile.staging.deployer] and [profile.production.deployer] sections +2. Configure deployment environments in treb.toml: + • Add [ns..senders.] sections for each environment • See documentation for Safe multisig and hardware wallet support 3. Generate your first deployment script: diff --git a/test/testdata/golden/integration/TestInitCommand/init_existing_project/treb.toml.golden b/test/testdata/golden/integration/TestInitCommand/init_existing_project/treb.toml.golden new file mode 100644 index 00000000..be71b8a1 --- /dev/null +++ b/test/testdata/golden/integration/TestInitCommand/init_existing_project/treb.toml.golden @@ -0,0 +1,11 @@ +# treb.toml — Treb sender configuration +# +# Each [ns.] section defines a namespace with sender configs. +# The optional 'profile' field maps to a foundry.toml profile (defaults to namespace name). + +[ns.default] +profile = "default" + +[ns.default.senders.deployer] +type = "private_key" +private_key = "${DEPLOYER_PRIVATE_KEY}" diff --git a/test/testdata/golden/integration/TestInitCommand/init_new_project/commands.golden b/test/testdata/golden/integration/TestInitCommand/init_new_project/commands.golden index 325cf872..bcfb3e6a 100644 --- a/test/testdata/golden/integration/TestInitCommand/init_new_project/commands.golden +++ b/test/testdata/golden/integration/TestInitCommand/init_new_project/commands.golden @@ -2,6 +2,7 @@ āœ… Valid Foundry project detected āœ… treb-sol library found āœ… Created v2 registry structure in .treb/ +āœ… Created treb.toml with default sender config āœ… Created .env.example šŸŽ‰ treb initialized successfully! @@ -12,8 +13,8 @@ • Set RPC URLs for networks you'll deploy to • Set API keys for contract verification -2. Configure deployment environments in foundry.toml: - • Add [profile.staging.deployer] and [profile.production.deployer] sections +2. Configure deployment environments in treb.toml: + • Add [ns..senders.] sections for each environment • See documentation for Safe multisig and hardware wallet support 3. Generate your first deployment script: diff --git a/test/testdata/golden/integration/TestInitCommand/init_new_project/treb.toml.golden b/test/testdata/golden/integration/TestInitCommand/init_new_project/treb.toml.golden new file mode 100644 index 00000000..be71b8a1 --- /dev/null +++ b/test/testdata/golden/integration/TestInitCommand/init_new_project/treb.toml.golden @@ -0,0 +1,11 @@ +# treb.toml — Treb sender configuration +# +# Each [ns.] section defines a namespace with sender configs. +# The optional 'profile' field maps to a foundry.toml profile (defaults to namespace name). + +[ns.default] +profile = "default" + +[ns.default.senders.deployer] +type = "private_key" +private_key = "${DEPLOYER_PRIVATE_KEY}" diff --git a/test/testdata/golden/integration/TestMigrateConfigCommand/migrate_config_creates_treb_toml/commands.golden b/test/testdata/golden/integration/TestMigrateConfigCommand/migrate_config_creates_treb_toml/commands.golden new file mode 100644 index 00000000..9631e8d4 --- /dev/null +++ b/test/testdata/golden/integration/TestMigrateConfigCommand/migrate_config_creates_treb_toml/commands.golden @@ -0,0 +1,7 @@ +=== cmd 0: [migrate-config] === +āœ“ treb.toml written successfully + +Next steps: + 1. Review the generated treb.toml + 2. Remove [profile.*.treb.*] sections from foundry.toml + 3. Run `treb config show` to verify your config is loaded correctly diff --git a/test/testdata/golden/integration/TestMigrateConfigCommand/migrate_config_creates_treb_toml/treb.toml.golden b/test/testdata/golden/integration/TestMigrateConfigCommand/migrate_config_creates_treb_toml/treb.toml.golden new file mode 100644 index 00000000..6329ae10 --- /dev/null +++ b/test/testdata/golden/integration/TestMigrateConfigCommand/migrate_config_creates_treb_toml/treb.toml.golden @@ -0,0 +1,34 @@ +# treb.toml — Treb sender configuration +# +# Each [ns.] section defines a namespace with sender configs. +# The optional 'profile' field maps to a foundry.toml profile (defaults to namespace name). +# +# Migrated from foundry.toml [profile.*.treb.*] sections. + +[ns.default] +profile = "default" + +[ns.default.senders.anvil] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +[ns.default.senders.governor] +type = "oz_governor" +proposer = "anvil" + + +[ns.live] +profile = "live" + +[ns.live.senders.safe0] +type = "safe" +safe = "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F" +signer = "signer0" + +[ns.live.senders.safe1] +type = "safe" +safe = "0x8dcD47D7aC5FEBC1E49a532644D21cd9D9dd97b2" +signer = "signer0" + +[ns.live.senders.signer0] +type = "private_key" diff --git a/test/testdata/project/foundry.toml b/test/testdata/project/foundry.toml index dcd12b82..472bddca 100644 --- a/test/testdata/project/foundry.toml +++ b/test/testdata/project/foundry.toml @@ -24,31 +24,3 @@ anvil-31338 = "http://localhost:9545" sepolia = { key = "${ETHERSCAN_API_KEY}" } celo-sepolia = { key = "${ETHERSCAN_API_KEY}", chain = 11142220 } celo = { key = "${ETHERSCAN_API_KEY}", chain = 42220 } - -[profile.default.treb.senders.anvil] -type = "private_key" # anvil user 0 -private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - -# Governor sender configuration - addresses are set after governance deployment -[profile.default.treb.senders.governor] -type = "oz_governor" -governor = "${GOVERNOR_ADDRESS}" -timelock = "${TIMELOCK_ADDRESS}" -proposer = "anvil" - -[profile.live.treb.senders.safe0] -type = "safe" -safe = "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F" -signer = "signer0" - -[profile.live.treb.senders.safe1] -type = "safe" -safe = "0x8dcD47D7aC5FEBC1E49a532644D21cd9D9dd97b2" -signer = "signer0" - -[profile.live.treb.senders.signer0] -type = "private_key" -private_key="${BASE_SEPOLIA_SIGNER0_PK}" - - - diff --git a/test/testdata/project/treb.toml b/test/testdata/project/treb.toml new file mode 100644 index 00000000..15708b35 --- /dev/null +++ b/test/testdata/project/treb.toml @@ -0,0 +1,34 @@ +# treb.toml — Treb sender configuration +# +# Each [ns.] section defines a namespace with sender configs. +# The optional 'profile' field maps to a foundry.toml profile (defaults to namespace name). + +[ns.default] +profile = "default" + +[ns.default.senders.anvil] +type = "private_key" +private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +[ns.default.senders.governor] +type = "oz_governor" +governor = "${GOVERNOR_ADDRESS}" +timelock = "${TIMELOCK_ADDRESS}" +proposer = "anvil" + +[ns.live] +profile = "live" + +[ns.live.senders.safe0] +type = "safe" +safe = "0x3D33783D1fd1B6D849d299aD2E711f844fC16d2F" +signer = "signer0" + +[ns.live.senders.safe1] +type = "safe" +safe = "0x8dcD47D7aC5FEBC1E49a532644D21cd9D9dd97b2" +signer = "signer0" + +[ns.live.senders.signer0] +type = "private_key" +private_key = "${BASE_SEPOLIA_SIGNER0_PK}"