From c81fb77f72bbf9b06f81074b4236a758f7750511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 5 Mar 2026 20:31:17 +0000 Subject: [PATCH] ci: improve docker image and KANDEV_DATA_DIR env var (#370) --- .github/workflows/release.yml | 1 + Dockerfile | 20 +++++--- apps/backend/cmd/kandev/agents.go | 1 + apps/backend/cmd/kandev/gateway.go | 3 +- apps/backend/cmd/kandev/main.go | 3 +- apps/backend/cmd/kandev/storage.go | 8 +--- .../agent/handlers/shell_handlers_test.go | 2 +- .../agent/lifecycle/executor_docker_test.go | 8 ++-- .../internal/agent/lifecycle/manager.go | 3 +- .../agent/lifecycle/manager_events_test.go | 2 +- .../internal/agent/lifecycle/manager_test.go | 2 +- .../agent/lifecycle/session_history.go | 10 ++-- apps/backend/internal/common/config/config.go | 30 ++++++++++-- .../internal/common/config/config_test.go | 47 +++++++++++++++++++ .../internal/lsp/installer/registry.go | 18 +++++-- .../internal/lsp/installer/registry_test.go | 6 +-- apps/backend/internal/persistence/provider.go | 3 +- apps/backend/internal/repoclone/clone.go | 9 ++-- .../task/handlers/process_handlers_test.go | 2 +- apps/backend/internal/worktree/config.go | 10 ++-- apps/backend/internal/worktree/provider.go | 6 ++- docker-entrypoint.sh | 9 ++++ docs/db.md | 6 ++- docs/docker.md | 4 +- docs/k8s.md | 4 +- 25 files changed, 158 insertions(+), 59 deletions(-) create mode 100644 apps/backend/internal/common/config/config_test.go create mode 100644 docker-entrypoint.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc9e96fdb7..5a1ab444a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha + type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} - uses: docker/setup-buildx-action@v3 diff --git a/Dockerfile b/Dockerfile index e4a1917bef..b918fbe5d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,9 +60,13 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends \ git \ ca-certificates \ + gosu \ tini && \ rm -rf /var/lib/apt/lists/* +# Create kandev user (uid 1000) — not tied to base image's built-in node user +RUN groupadd -r kandev && useradd -r -g kandev -u 1000 -m kandev + # Create app directory structure matching what the CLI expects: # /app/apps/backend/bin/kandev # /app/apps/web/.next/standalone/web/server.js @@ -92,20 +96,22 @@ VOLUME ["/data"] # Environment defaults for containerized operation ENV KANDEV_NO_BROWSER=1 \ - KANDEV_DATABASE_PATH=/data/kandev.db \ - KANDEV_WORKTREE_BASEPATH=/data/worktrees \ + KANDEV_DATA_DIR=/data \ KANDEV_DOCKER_ENABLED=false \ HOSTNAME=0.0.0.0 \ NODE_ENV=production -# Run as non-root -RUN chown -R node:node /app /data -USER node +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Set build-time ownership of /app and /data +RUN chown -R kandev:kandev /app /data WORKDIR /app EXPOSE 8080 3000 -# tini as PID 1 for signal handling; CLI manages backend + web processes -ENTRYPOINT ["tini", "--"] +# tini as PID 1 for signal handling; entrypoint handles privilege drop +ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] CMD ["kandev", "start", "--backend-port", "8080", "--web-port", "3000", "--verbose"] diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index ed705e015e..e6dbce7882 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -95,6 +95,7 @@ func provideLifecycleManager( profileResolver, mcpService, lifecycle.ExecutorFallbackWarn, + cfg.ResolvedDataDir(), log, ) diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 068a7f1fa3..2ab56c2b65 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -79,6 +79,7 @@ func provideGateway( notificationRepo notificationstore.Repository, taskRepo *sqliterepo.Repository, githubSvc *github.Service, + dataDir string, ) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, error) { gateway, err := gateways.Provide(log) if err != nil { @@ -89,7 +90,7 @@ func provideGateway( scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc} if lifecycleMgr != nil { gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc) - gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(log)) + gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(dataDir, log)) gateway.SetVscodeProxy(lifecycleMgr) } diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 4ee17b40e2..22526960e7 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -285,7 +285,7 @@ func startAgentInfrastructure( // ============================================ repoCloner := repoclone.NewCloner(repoclone.Config{ BasePath: cfg.RepoClone.BasePath, - }, repoclone.DetectGitProtocol(), log) + }, repoclone.DetectGitProtocol(), cfg.ResolvedDataDir(), log) log.Info("Repository cloner configured", zap.String("base_path", cfg.RepoClone.BasePath)) @@ -341,6 +341,7 @@ func startGatewayAndServe( ctx, log, eventBus, services.Task, services.User, orchestratorSvc, lifecycleMgr, agentRegistry, repos.Notification, repos.Task, services.GitHub, + cfg.ResolvedDataDir(), ) if err != nil { log.Error("Failed to initialize WebSocket gateway", zap.Error(err)) diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index 4c55d4af41..dbbf075fc5 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -2,8 +2,6 @@ package main import ( "fmt" - "os" - "path/filepath" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/common/config" @@ -89,11 +87,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep cleanups = append(cleanups, cleanup) // Initialize master key and secrets store - kandevDir := os.Getenv("KANDEV_DATA_DIR") - if kandevDir == "" { - kandevDir = filepath.Join(os.Getenv("HOME"), ".kandev") - } - masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir) + masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir()) if err != nil { return nil, nil, nil, fmt.Errorf("master key: %w", err) } diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go index 77f6ea3362..cb8c9a9a2b 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers_test.go +++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go @@ -184,7 +184,7 @@ func newTestManager() *lifecycle.Manager { credsMgr := &MockCredentialsManager{} profileResolver := &MockProfileResolver{} // Pass nil for runtime - tests don't need them - return lifecycle.NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, lifecycle.ExecutorFallbackWarn, log) + return lifecycle.NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, lifecycle.ExecutorFallbackWarn, "", log) } func TestWsShellStatus_NoInstanceFound(t *testing.T) { diff --git a/apps/backend/internal/agent/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/lifecycle/executor_docker_test.go index fc5ff9b1a7..5af094b72b 100644 --- a/apps/backend/internal/agent/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker_test.go @@ -269,7 +269,7 @@ func TestDockerExecutor_Close_AfterFailedInit(t *testing.T) { func TestDockerClientProvider_NilRegistry(t *testing.T) { log := newTestDockerLogger() - mgr := NewManager(nil, nil, nil, nil, nil, nil, ExecutorFallbackWarn, log) + mgr := NewManager(nil, nil, nil, nil, nil, nil, ExecutorFallbackWarn, "", log) provider := mgr.DockerClientProvider() if provider == nil { @@ -284,7 +284,7 @@ func TestDockerClientProvider_NoDockerExecutor(t *testing.T) { log := newTestDockerLogger() registry := NewExecutorRegistry(log) registry.Register(&MockExecutor{name: executor.NameStandalone}) - mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, log) + mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log) provider := mgr.DockerClientProvider() if client := provider(); client != nil { @@ -298,7 +298,7 @@ func TestDockerClientProvider_WithDockerExecutor(t *testing.T) { dockerExec := NewDockerExecutor(config.DockerConfig{}, log) dockerExec.newClientFunc = failingClientFactory("docker unavailable") registry.Register(dockerExec) - mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, log) + mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log) provider := mgr.DockerClientProvider() if client := provider(); client != nil { @@ -311,7 +311,7 @@ func TestDockerClientProvider_WithWorkingDocker(t *testing.T) { registry := NewExecutorRegistry(log) dockerExec := NewDockerExecutor(config.DockerConfig{}, log) registry.Register(dockerExec) - mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, log) + mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log) provider := mgr.DockerClientProvider() client := provider() diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index d076bbfaa0..9b9237af5c 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -86,6 +86,7 @@ func NewManager( profileResolver ProfileResolver, mcpProvider McpConfigProvider, fallbackPolicy ExecutorFallbackPolicy, + dataDir string, log *logger.Logger, ) *Manager { componentLogger := log.WithFields(zap.String("component", "lifecycle-manager")) @@ -106,7 +107,7 @@ func NewManager( executionStore := NewExecutionStore() // Initialize session history manager for fork_session pattern (context injection) - historyManager, err := NewSessionHistoryManager("", log) + historyManager, err := NewSessionHistoryManager("", dataDir, log) if err != nil { log.Warn("failed to create session history manager, context injection disabled", zap.Error(err)) } diff --git a/apps/backend/internal/agent/lifecycle/manager_events_test.go b/apps/backend/internal/agent/lifecycle/manager_events_test.go index 8940190cad..36e76d800c 100644 --- a/apps/backend/internal/agent/lifecycle/manager_events_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_events_test.go @@ -66,7 +66,7 @@ func createTestManagerWithTracking() (*Manager, *MockEventBusWithTracking) { eventBus := &MockEventBusWithTracking{} credsMgr := &MockCredentialsManager{} profileResolver := &MockProfileResolver{} - mgr := NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, log) + mgr := NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, "", log) return mgr, eventBus } diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index b409d1b363..d76670ff49 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -208,7 +208,7 @@ func newTestManager() *Manager { credsMgr := &MockCredentialsManager{} profileResolver := &MockProfileResolver{} // Pass nil for runtime - tests don't need them - return NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, log) + return NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, "", log) } func TestNewManager(t *testing.T) { diff --git a/apps/backend/internal/agent/lifecycle/session_history.go b/apps/backend/internal/agent/lifecycle/session_history.go index 99f0b74e90..0b9725579d 100644 --- a/apps/backend/internal/agent/lifecycle/session_history.go +++ b/apps/backend/internal/agent/lifecycle/session_history.go @@ -49,14 +49,10 @@ type HistoryEntry struct { // NewSessionHistoryManager creates a new SessionHistoryManager. // The baseDir is the directory where session history files will be stored. -// If baseDir is empty, defaults to ~/.kandev/sessions/ -func NewSessionHistoryManager(baseDir string, log *logger.Logger) (*SessionHistoryManager, error) { +// If baseDir is empty, it defaults to dataDir+"/sessions". +func NewSessionHistoryManager(baseDir string, dataDir string, log *logger.Logger) (*SessionHistoryManager, error) { if baseDir == "" { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get home directory: %w", err) - } - baseDir = filepath.Join(homeDir, ".kandev", "sessions") + baseDir = filepath.Join(dataDir, "sessions") } // Ensure directory exists diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index ad38e11b23..292c824241 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -15,6 +15,7 @@ import ( // Config holds all configuration sections for Kandev. type Config struct { + DataDir string `mapstructure:"dataDir"` Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` NATS NATSConfig `mapstructure:"nats"` @@ -28,6 +29,25 @@ type Config struct { RepoClone RepoCloneConfig `mapstructure:"repoClone"` } +// ResolvedDataDir returns the base data directory for Kandev. +// Config.DataDir is populated by Viper from the config file or KANDEV_DATA_DIR env var. +// If empty, falls back to ~/.kandev. +func (c *Config) ResolvedDataDir() string { + if c.DataDir != "" { + if strings.HasPrefix(c.DataDir, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, c.DataDir[2:]) + } + } + return c.DataDir + } + home, err := os.UserHomeDir() + if err != nil { + return ".kandev" + } + return filepath.Join(home, ".kandev") +} + // ServerConfig holds HTTP server configuration. type ServerConfig struct { Host string `mapstructure:"host"` @@ -176,9 +196,12 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.readTimeout", 30) v.SetDefault("server.writeTimeout", 30) + // DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev + v.SetDefault("dataDir", "") + // Database defaults v.SetDefault("database.driver", "sqlite") - v.SetDefault("database.path", "./kandev.db") + v.SetDefault("database.path", "") v.SetDefault("database.host", "localhost") v.SetDefault("database.port", 5432) v.SetDefault("database.user", "kandev") @@ -227,12 +250,12 @@ func setDefaults(v *viper.Viper) { // Worktree defaults v.SetDefault("worktree.enabled", true) - v.SetDefault("worktree.basePath", "~/.kandev/worktrees") + v.SetDefault("worktree.basePath", "") v.SetDefault("worktree.defaultBranch", "main") v.SetDefault("worktree.cleanupOnRemove", true) // RepoClone defaults - v.SetDefault("repoClone.basePath", "~/.kandev/repos") + v.SetDefault("repoClone.basePath", "") } // DefaultDockerHost returns the platform-appropriate Docker socket path. @@ -285,6 +308,7 @@ func LoadWithPath(configPath string) (*Config, error) { _ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST") _ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT") _ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL") + _ = v.BindEnv("dataDir", "KANDEV_DATA_DIR") _ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL") _ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE") diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go new file mode 100644 index 0000000000..7a2456d21b --- /dev/null +++ b/apps/backend/internal/common/config/config_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolvedDataDir_ExplicitConfig(t *testing.T) { + cfg := &Config{DataDir: "/custom/data"} + if got := cfg.ResolvedDataDir(); got != "/custom/data" { + t.Errorf("ResolvedDataDir() = %q, want %q", got, "/custom/data") + } +} + +func TestResolvedDataDir_FallbackToHome(t *testing.T) { + cfg := &Config{} + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot determine home directory") + } + want := filepath.Join(home, ".kandev") + if got := cfg.ResolvedDataDir(); got != want { + t.Errorf("ResolvedDataDir() = %q, want %q", got, want) + } +} + +func TestResolvedDataDir_ConfigTakesPrecedence(t *testing.T) { + t.Setenv("KANDEV_DATA_DIR", "/env/data") + // Even with env set, explicit Config.DataDir wins + cfg := &Config{DataDir: "/explicit/data"} + if got := cfg.ResolvedDataDir(); got != "/explicit/data" { + t.Errorf("ResolvedDataDir() = %q, want %q", got, "/explicit/data") + } +} + +func TestResolvedDataDir_TildeExpansion(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot determine home directory") + } + cfg := &Config{DataDir: "~/kandev"} + want := filepath.Join(home, "kandev") + if got := cfg.ResolvedDataDir(); got != want { + t.Errorf("ResolvedDataDir() = %q, want %q", got, want) + } +} diff --git a/apps/backend/internal/lsp/installer/registry.go b/apps/backend/internal/lsp/installer/registry.go index 938389d99d..5ad208a09f 100644 --- a/apps/backend/internal/lsp/installer/registry.go +++ b/apps/backend/internal/lsp/installer/registry.go @@ -68,13 +68,21 @@ type Registry struct { } // NewRegistry creates a new installer registry. -func NewRegistry(log *logger.Logger) *Registry { - home, err := os.UserHomeDir() - if err != nil { - home = "." +// If dataDir is non-empty, LSP binaries are stored under dataDir+"/lsp-servers". +// Otherwise falls back to ~/.kandev/lsp-servers. +func NewRegistry(dataDir string, log *logger.Logger) *Registry { + var binDir string + if dataDir != "" { + binDir = filepath.Join(dataDir, "lsp-servers") + } else { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + binDir = filepath.Join(home, DefaultBinDir) } return &Registry{ - binDir: filepath.Join(home, DefaultBinDir), + binDir: binDir, logger: log.WithFields(zap.String("component", "lsp-installer")), } } diff --git a/apps/backend/internal/lsp/installer/registry_test.go b/apps/backend/internal/lsp/installer/registry_test.go index bffd7e282d..15c228e2b9 100644 --- a/apps/backend/internal/lsp/installer/registry_test.go +++ b/apps/backend/internal/lsp/installer/registry_test.go @@ -106,7 +106,7 @@ func TestBinaryName(t *testing.T) { } func TestStrategyFor(t *testing.T) { - r := NewRegistry(testLogger()) + r := NewRegistry("", testLogger()) // Supported languages should return a strategy for _, lang := range []string{"typescript", "go", "rust", "python"} { @@ -133,7 +133,7 @@ func TestStrategyFor(t *testing.T) { func TestBinaryPath_InPATH(t *testing.T) { // "ls" should always be in PATH - r := NewRegistry(testLogger()) + r := NewRegistry("", testLogger()) // Override the languages map temporarily — we can't do that directly, // so we test that BinaryPath returns an error for a language whose binary @@ -217,7 +217,7 @@ func TestBinaryPath_NotFound(t *testing.T) { } func TestBinaryPath_UnsupportedLanguage(t *testing.T) { - r := NewRegistry(testLogger()) + r := NewRegistry("", testLogger()) _, err := r.BinaryPath("java") if err == nil { t.Error("BinaryPath(\"java\") should return error for unsupported language") diff --git a/apps/backend/internal/persistence/provider.go b/apps/backend/internal/persistence/provider.go index cafe4d50d3..2d5d7c9804 100644 --- a/apps/backend/internal/persistence/provider.go +++ b/apps/backend/internal/persistence/provider.go @@ -2,6 +2,7 @@ package persistence import ( "fmt" + "path/filepath" "github.com/jmoiron/sqlx" "go.uber.org/zap" @@ -34,7 +35,7 @@ func Provide(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, er func provideSQLite(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, error) { dbPath := cfg.Database.Path if dbPath == "" { - dbPath = "./kandev.db" + dbPath = filepath.Join(cfg.ResolvedDataDir(), "kandev.db") } // Writer: single connection, owns WAL/journal_mode setup. diff --git a/apps/backend/internal/repoclone/clone.go b/apps/backend/internal/repoclone/clone.go index 2aee5bb4ae..04e3c1e2fc 100644 --- a/apps/backend/internal/repoclone/clone.go +++ b/apps/backend/internal/repoclone/clone.go @@ -33,10 +33,11 @@ type Cloner struct { repoMus sync.Map } -// NewCloner creates a new Cloner with the given config and git protocol. -func NewCloner(cfg Config, protocol string, log *logger.Logger) *Cloner { - if cfg.BasePath == "" { - cfg.BasePath = "~/.kandev/repos" +// NewCloner creates a new Cloner with the given config, git protocol, and data directory. +// If cfg.BasePath is empty, it defaults to dataDir+"/repos". +func NewCloner(cfg Config, protocol string, dataDir string, log *logger.Logger) *Cloner { + if cfg.BasePath == "" && dataDir != "" { + cfg.BasePath = filepath.Join(dataDir, "repos") } return &Cloner{config: cfg, protocol: protocol, logger: log} } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index a45b1df780..86539ee028 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -462,7 +462,7 @@ func getExecutionStore(t *testing.T, mgr *lifecycle.Manager) *lifecycle.Executio func newLifecycleManager(t *testing.T, log *logger.Logger) *lifecycle.Manager { t.Helper() reg := registry.NewRegistry(log) - return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackWarn, log) + return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackWarn, "", log) } func newAgentctlClient(t *testing.T, serverURL string, log *logger.Logger) *agentctlclient.Client { diff --git a/apps/backend/internal/worktree/config.go b/apps/backend/internal/worktree/config.go index eb775f6d9c..f70905a479 100644 --- a/apps/backend/internal/worktree/config.go +++ b/apps/backend/internal/worktree/config.go @@ -33,12 +33,16 @@ func (c *Config) Validate() error { if c.BranchPrefix == "" { c.BranchPrefix = DefaultBranchPrefix } - if c.BasePath == "" { - c.BasePath = "~/.kandev/worktrees" - } return nil } +// SetDataDirFallback sets the BasePath from the data directory if not already configured. +func (c *Config) SetDataDirFallback(dataDir string) { + if c.BasePath == "" && dataDir != "" { + c.BasePath = filepath.Join(dataDir, "worktrees") + } +} + // ExpandedBasePath returns the base path with ~ expanded to the user's home directory. func (c *Config) ExpandedBasePath() (string, error) { path := c.BasePath diff --git a/apps/backend/internal/worktree/provider.go b/apps/backend/internal/worktree/provider.go index 534850ffbf..d48a0ea7e0 100644 --- a/apps/backend/internal/worktree/provider.go +++ b/apps/backend/internal/worktree/provider.go @@ -13,11 +13,13 @@ func Provide(writer, reader *sqlx.DB, cfg *config.Config, log *logger.Logger) (* if err != nil { return nil, nil, err } - manager, err := NewManager(Config{ + wtCfg := Config{ Enabled: cfg.Worktree.Enabled, BasePath: cfg.Worktree.BasePath, BranchPrefix: "kandev/", - }, store, log) + } + wtCfg.SetDataDirFallback(cfg.ResolvedDataDir()) + manager, err := NewManager(wtCfg, store, log) if err != nil { return nil, nil, err } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000000..3f4634b679 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +if [ "$(id -u)" = '0' ]; then + chown -R kandev:kandev /data + exec gosu kandev "$@" +fi + +exec "$@" diff --git a/docs/db.md b/docs/db.md index 20c73950b1..c8bd548cbd 100644 --- a/docs/db.md +++ b/docs/db.md @@ -10,15 +10,17 @@ SQLite requires no external services and works out of the box. | Variable | Default | Description | |----------|---------|-------------| +| `KANDEV_DATA_DIR` | `~/.kandev` | Base directory for all data (DB, worktrees, sessions, etc.) | | `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver | -| `KANDEV_DATABASE_PATH` | `./kandev.db` | Path to the SQLite database file | +| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | Path to the SQLite database file (override) | ### Config file (`config.yaml`) ```yaml +dataDir: "" # empty = resolve from KANDEV_DATA_DIR or ~/.kandev database: driver: sqlite - path: ./kandev.db + path: "" # empty = $dataDir/kandev.db ``` No additional setup is needed - the database file is created automatically on first run. diff --git a/docs/docker.md b/docs/docker.md index df97133636..0f9ffc94ba 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -63,12 +63,12 @@ docker run -p 8080:8080 -p 3000:3000 \ | Variable | Default | Description | |----------|---------|-------------| +| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) | | `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) | -| `KANDEV_DATABASE_PATH` | `/data/kandev.db` | SQLite database file path | +| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) | | `KANDEV_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | | `KANDEV_LOGGING_FORMAT` | `text` | Log format: `text` or `json` | | `KANDEV_DOCKER_ENABLED` | `false` | Enable Docker runtime for agents (see below) | -| `KANDEV_WORKTREE_BASEPATH` | `/data/worktrees` | Git worktree storage directory | ### PostgreSQL diff --git a/docs/k8s.md b/docs/k8s.md index db7346382f..0efb91d922 100644 --- a/docs/k8s.md +++ b/docs/k8s.md @@ -81,12 +81,12 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper). | Env Var | Default | Description | |---------|---------|-------------| | `KANDEV_SERVER_PORT` | `8080` | Backend API port | +| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) | | `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) | -| `KANDEV_DATABASE_PATH` | `/data/kandev.db` | SQLite database file path | +| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) | | `KANDEV_DOCKER_ENABLED` | `false` | Enable Docker runtime for agents (requires DinD) | | `KANDEV_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | | `KANDEV_LOGGING_FORMAT` | auto | Log format: `json` (auto-detected in K8s) or `text` | -| `KANDEV_WORKTREE_BASEPATH` | `/data/worktrees` | Git worktree storage directory | ### PostgreSQL Settings (when `KANDEV_DATABASE_DRIVER=postgres`)