diff --git a/README.md b/README.md index c7c423e..7188332 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,25 @@ go run ./coolify docs llms ## Change default context You can change the default context with `coolify context use ` or `coolify context set-default ` + +## Repo-local context + +Drop a `.coolify.json` (or `.coolifyrc`) at the root of a repo to pin every command run inside it to one context: + +```json +{ + "context": "production" +} +``` + +The CLI walks up from the working directory to find the nearest one. Resolution order: + +1. `--context` flag +2. repo-local `.coolify.json` / `.coolifyrc` +3. global default context + +The file only names a context that already exists in your global config — tokens stay in `~/.config/coolify/config.json` and never belong in the repo. Run with `--debug` to see which file the context came from. + ## Currently Supported Commands ### Update @@ -490,7 +509,7 @@ Commands can use `private-key`, `private-keys`, `key`, or `keys` interchangeably All commands support these global flags: -- `--context ` - Use a specific context instead of default +- `--context ` - Use a specific context instead of the repo-local or default one (see [Repo-local context](#repo-local-context)) - `--token ` - Override the authentication token - `--format ` - Output format: `table` (default), `json`, or `pretty` - `-s, --show-sensitive` - Show sensitive information (tokens, IPs, etc.) diff --git a/internal/cli/client.go b/internal/cli/client.go index 0f58b40..86f64ed 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "github.com/spf13/cobra" @@ -22,11 +23,22 @@ func GetAPIClient(cmd *cobra.Command) (*api.Client, error) { return nil, fmt.Errorf("failed to load config: %w", err) } + // Precedence: --context flag > repo-local config file > global default + localPath := "" + if contextName == "" { + contextName, localPath, err = config.LocalContext() + if err != nil { + return nil, err + } + } + var instance *config.Instance - // Use context if specified, otherwise use default if contextName != "" { instance, err = cfg.GetInstance(contextName) if err != nil { + if localPath != "" { + return nil, fmt.Errorf("context '%s' from %s not found in %s", contextName, localPath, config.Path()) + } return nil, fmt.Errorf("context '%s' not found: %w", contextName, err) } } else { @@ -36,6 +48,10 @@ func GetAPIClient(cmd *cobra.Command) (*api.Client, error) { } } + if debug && localPath != "" { + fmt.Fprintf(os.Stderr, "Using context '%s' from %s\n", contextName, localPath) + } + // Get FQDN from instance fqdn := instance.FQDN diff --git a/internal/config/local.go b/internal/config/local.go new file mode 100644 index 0000000..7f06032 --- /dev/null +++ b/internal/config/local.go @@ -0,0 +1,73 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// LocalFileNames are the repo-local config file names, checked in this order in +// each directory while walking up from the working directory. +var LocalFileNames = []string{".coolify.json", ".coolifyrc"} + +// LocalConfig is the repo-local config. It only points at a context that +// already exists in the global config - credentials stay global, never in the +// repo. +type LocalConfig struct { + Context string `json:"context"` + + // Path is the file this was read from (not serialized). + Path string `json:"-"` +} + +// FindLocal walks up from dir looking for a repo-local config file. Returns nil +// when none is found. +func FindLocal(dir string) (*LocalConfig, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + for { + for _, name := range LocalFileNames { + path := filepath.Join(dir, name) + if !fileExists(path) { + continue + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + var local LocalConfig + if err := json.Unmarshal(data, &local); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + local.Path = path + return &local, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return nil, nil + } + dir = parent + } +} + +// LocalContext returns the context name declared by the nearest repo-local +// config file, plus the file it came from. Both are empty when there is none. +func LocalContext() (name, path string, err error) { + cwd, err := os.Getwd() + if err != nil { + return "", "", err + } + + local, err := FindLocal(cwd) + if err != nil || local == nil { + return "", "", err + } + return local.Context, local.Path, nil +} diff --git a/internal/config/local_test.go b/internal/config/local_test.go new file mode 100644 index 0000000..d8fa376 --- /dev/null +++ b/internal/config/local_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindLocal(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "a", "b") + if err := os.MkdirAll(nested, 0o750); err != nil { + t.Fatal(err) + } + + // No file anywhere up the tree (TempDir parents have none). + local, err := FindLocal(nested) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if local != nil { + t.Fatalf("expected no local config, got %+v", local) + } + + // Found by walking up from a nested dir. + rootFile := filepath.Join(root, ".coolify.json") + if err := os.WriteFile(rootFile, []byte(`{"context":"production"}`), 0o600); err != nil { + t.Fatal(err) + } + local, err = FindLocal(nested) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if local == nil || local.Context != "production" || local.Path != rootFile { + t.Fatalf("got %+v, want context=production path=%s", local, rootFile) + } + + // Nearest file wins. + nestedFile := filepath.Join(nested, ".coolifyrc") + if err := os.WriteFile(nestedFile, []byte(`{"context":"staging"}`), 0o600); err != nil { + t.Fatal(err) + } + local, err = FindLocal(nested) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if local.Context != "staging" || local.Path != nestedFile { + t.Fatalf("got %+v, want context=staging path=%s", local, nestedFile) + } + + // Malformed JSON is an error, not a silent fallback. + if err := os.WriteFile(nestedFile, []byte("context = production"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := FindLocal(nested); err == nil { + t.Fatal("expected parse error for malformed local config") + } +} + +func TestLocalContext(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".coolify.json"), []byte(`{"context":"prod"}`), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + + name, path, err := LocalContext() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "prod" || path == "" { + t.Fatalf("got name=%q path=%q, want name=prod and a path", name, path) + } +}