Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ go run ./coolify docs llms

## Change default context
You can change the default context with `coolify context use <context_name>` or `coolify context set-default <context_name>`

## 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
Expand Down Expand Up @@ -490,7 +509,7 @@ Commands can use `private-key`, `private-keys`, `key`, or `keys` interchangeably

All commands support these global flags:

- `--context <name>` - Use a specific context instead of default
- `--context <name>` - Use a specific context instead of the repo-local or default one (see [Repo-local context](#repo-local-context))
- `--token <token>` - Override the authentication token
- `--format <format>` - Output format: `table` (default), `json`, or `pretty`
- `-s, --show-sensitive` - Show sensitive information (tokens, IPs, etc.)
Expand Down
18 changes: 17 additions & 1 deletion internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"os"

"github.com/spf13/cobra"

Expand All @@ -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 {
Expand All @@ -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

Expand Down
73 changes: 73 additions & 0 deletions internal/config/local.go
Original file line number Diff line number Diff line change
@@ -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
}
74 changes: 74 additions & 0 deletions internal/config/local_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}