3febedd6ba
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
205 lines
8.1 KiB
Go
205 lines
8.1 KiB
Go
package launcher
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/common/config"
|
|
)
|
|
|
|
// findRepoRoot returns the nearest ancestor of startDir that contains both
|
|
// apps/backend and apps/web, also handling a start directory that *is*
|
|
// <repo>/apps (dev is commonly invoked from apps/). It returns an error, not
|
|
// a fallback path, when no such ancestor exists: silently running dev against
|
|
// the wrong tree would be worse than failing loudly.
|
|
func findRepoRoot(startDir string) (string, error) {
|
|
current := filepath.Clean(startDir)
|
|
for {
|
|
if filepath.Base(current) == "apps" &&
|
|
exists(filepath.Join(current, "backend")) &&
|
|
exists(filepath.Join(current, "web")) {
|
|
return filepath.Dir(current), nil
|
|
}
|
|
if exists(filepath.Join(current, "apps", "backend")) &&
|
|
exists(filepath.Join(current, "apps", "web")) {
|
|
return current, nil
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return "", fmt.Errorf(
|
|
"unable to locate repo root for dev (no ancestor of %s contains apps/backend and apps/web); run from the repository", startDir)
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
// isInsideKandevTask reports whether the current process looks like it was
|
|
// spawned inside a kandev-created task workspace. Two signals:
|
|
// 1. The parent kandev backend exports KANDEV_TASK_ID into every task shell.
|
|
// 2. Task worktrees live under ~/.kandev/tasks/.
|
|
//
|
|
// The path-prefix fallback is a defensive secondary signal for nested shells
|
|
// where KANDEV_TASK_ID was stripped. It is case-sensitive and does not
|
|
// resolve symlinks, so a realpath'd repoRoot may miss a symlinked HOME on
|
|
// macOS/Windows. KANDEV_TASK_ID remains the primary guarantee.
|
|
func isInsideKandevTask(repoRoot string) bool {
|
|
if os.Getenv("KANDEV_TASK_ID") != "" {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(repoRoot, kandevTasksDir()+string(os.PathSeparator))
|
|
}
|
|
|
|
// resolveDevBackendEnv computes the dev-mode backend env. Dev mode always
|
|
// roots kandev under <repo>/.kandev-dev so state is isolated from the user's
|
|
// production ~/.kandev and so `make clean-db` (which removes .kandev-dev/)
|
|
// matches what `make dev` writes.
|
|
//
|
|
// When invoked from inside a kandev task workspace, any KANDEV_DATABASE_PATH
|
|
// is assumed to be leaked from the parent backend and is ignored. In a normal
|
|
// shell, an explicit KANDEV_DATABASE_PATH is honored as an escape hatch.
|
|
type devDatabaseSource string
|
|
|
|
const (
|
|
devDatabaseDefault devDatabaseSource = "repo-local default"
|
|
devDatabaseTask devDatabaseSource = "task workspace"
|
|
devDatabaseEnvironment devDatabaseSource = "environment"
|
|
devDatabaseConfiguration devDatabaseSource = "configuration"
|
|
devDatabaseConfigHome devDatabaseSource = "configuration home"
|
|
)
|
|
|
|
// devDatabaseTarget is the one database decision shared by the launcher and
|
|
// its backend child. Keeping the provenance with the path prevents a backup
|
|
// from being performed against a different database than the child receives.
|
|
type devDatabaseTarget struct {
|
|
path string
|
|
homeDir string
|
|
source devDatabaseSource
|
|
extra []string
|
|
}
|
|
|
|
func resolveDevBackendEnv(repoRoot string, configs ...*config.Config) (dbPath string, extra []string) {
|
|
target := resolveDevDatabaseTarget(repoRoot, configs...)
|
|
return target.path, target.extra
|
|
}
|
|
|
|
func resolveDevDatabaseTarget(repoRoot string, configs ...*config.Config) devDatabaseTarget {
|
|
devHome := devKandevHome(repoRoot)
|
|
devDBPath := filepath.Join(devHome, "data", "kandev.db")
|
|
var startupConfig *config.Config
|
|
if len(configs) > 0 {
|
|
startupConfig = configs[0]
|
|
}
|
|
|
|
// Profile-selector only: the backend reads profiles.yaml at startup and
|
|
// applies the matching dev: values (mock agent, pprof, feature flags,
|
|
// etc.) to its own env. The launcher must not restate them — profiles.yaml
|
|
// at the repo root is the single source of truth.
|
|
baseExtra := []string{"KANDEV_DEBUG_DEV_MODE=true"}
|
|
homeDir := devStateHome(repoRoot, startupConfig)
|
|
|
|
if isInsideKandevTask(repoRoot) {
|
|
fmt.Println("[kandev] task workspace detected → using local dev state")
|
|
return newDevDatabaseTarget(devDBPath, devHome, devDatabaseTask, baseExtra)
|
|
}
|
|
if startupConfig == nil {
|
|
if override := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); override != "" {
|
|
return newDevDatabaseTarget(override, homeDir, devDatabaseEnvironment, baseExtra)
|
|
}
|
|
}
|
|
|
|
if startupConfig != nil && strings.TrimSpace(startupConfig.Database.Path) != "" {
|
|
switch startupConfig.SourceFor("database.path") {
|
|
case config.SourceEnvironment:
|
|
return newDevDatabaseTarget(startupConfig.Database.Path, homeDir, devDatabaseEnvironment, baseExtra)
|
|
case config.SourceConfiguration:
|
|
return newDevDatabaseTarget(startupConfig.Database.Path, homeDir, devDatabaseConfiguration, baseExtra)
|
|
}
|
|
}
|
|
if startupConfig != nil && startupConfig.SourceFor("homeDir") == config.SourceConfiguration {
|
|
path := filepath.Join(startupConfig.ResolvedDataDir(), "kandev.db")
|
|
return newDevDatabaseTarget(path, homeDir, devDatabaseConfigHome, baseExtra)
|
|
}
|
|
|
|
return newDevDatabaseTarget(devDBPath, homeDir, devDatabaseDefault, baseExtra)
|
|
}
|
|
|
|
func devStateHome(repoRoot string, startupConfig *config.Config) string {
|
|
if startupConfig != nil && startupConfig.SourceFor("homeDir") == config.SourceConfiguration {
|
|
return startupConfig.ResolvedHomeDir()
|
|
}
|
|
return devKandevHome(repoRoot)
|
|
}
|
|
|
|
func newDevDatabaseTarget(path, homeDir string, source devDatabaseSource, baseExtra []string) devDatabaseTarget {
|
|
extra := append([]string{}, baseExtra...)
|
|
extra = append(extra, "KANDEV_HOME_DIR="+homeDir, "KANDEV_DATABASE_PATH="+path)
|
|
return devDatabaseTarget{path: path, homeDir: homeDir, source: source, extra: extra}
|
|
}
|
|
|
|
// validateDevDatabaseTarget checks whether the resolved database target is
|
|
// safe to use in dev mode. Repo-local targets must live under
|
|
// .kandev-dev/data/ and must not traverse symlinks. Repo-local-default or
|
|
// task targets that land outside the repo-local state are rejected outright.
|
|
// External targets (environment or YAML) are allowed without symlink
|
|
// checking; they may be the user's explicit production database.
|
|
func validateDevDatabaseTarget(repoRoot string, target devDatabaseTarget) error {
|
|
path, err := filepath.Abs(target.path)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve dev database path %q: %w", target.path, err)
|
|
}
|
|
devHome, err := filepath.Abs(devKandevHome(repoRoot))
|
|
if err != nil {
|
|
return fmt.Errorf("resolve dev home directory: %w", err)
|
|
}
|
|
dataDir, err := filepath.Abs(filepath.Join(devKandevHome(repoRoot), "data"))
|
|
if err != nil {
|
|
return fmt.Errorf("resolve dev data directory: %w", err)
|
|
}
|
|
insideDataDir := pathWithinDirectory(dataDir, path)
|
|
insideDevHome := pathWithinDirectory(devHome, path)
|
|
if (insideDevHome && !insideDataDir) || (!insideDataDir && (target.source == devDatabaseDefault || target.source == devDatabaseTask)) {
|
|
return fmt.Errorf("refusing dev database path outside repo-local data directory: %s", path)
|
|
}
|
|
if insideDataDir {
|
|
if err := rejectSymlinkComponents(path); err != nil {
|
|
return fmt.Errorf("refusing dev database path %s: %w", path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pathWithinDirectory(root, path string) bool {
|
|
rel, err := filepath.Rel(root, path)
|
|
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
|
}
|
|
|
|
// normalizeDevDatabaseTarget sets the target path and homeDir to their
|
|
// absolute forms and updates the corresponding entries in extra so all
|
|
// consumers (backup, output, child environment, supervisor paths) resolve
|
|
// them consistently regardless of the launcher's CWD.
|
|
func normalizeDevDatabaseTarget(target devDatabaseTarget) (devDatabaseTarget, error) {
|
|
absPath, err := filepath.Abs(target.path)
|
|
if err != nil {
|
|
return devDatabaseTarget{}, fmt.Errorf("resolve database path: %w", err)
|
|
}
|
|
target.path = absPath
|
|
|
|
absHome, err := filepath.Abs(target.homeDir)
|
|
if err != nil {
|
|
return devDatabaseTarget{}, fmt.Errorf("resolve home directory: %w", err)
|
|
}
|
|
target.homeDir = absHome
|
|
|
|
for i, item := range target.extra {
|
|
switch {
|
|
case strings.HasPrefix(item, "KANDEV_DATABASE_PATH="):
|
|
target.extra[i] = "KANDEV_DATABASE_PATH=" + absPath
|
|
case strings.HasPrefix(item, "KANDEV_HOME_DIR="):
|
|
target.extra[i] = "KANDEV_HOME_DIR=" + absHome
|
|
}
|
|
}
|
|
return target, nil
|
|
}
|