fix(launcher): isolate dev database resolution from ambient environment (#2910)
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>
This commit is contained in:
@@ -120,10 +120,6 @@ type devLaunchConfig struct {
|
||||
// spawned: repo root, dev ports, and the dev backend env. It returns a
|
||||
// nonzero exit code (having printed the error) when any step fails.
|
||||
func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfig, int) {
|
||||
startupConfig, exitCode := devStartupConfig(configs...)
|
||||
if exitCode != 0 {
|
||||
return devLaunchConfig{}, exitCode
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
@@ -134,6 +130,10 @@ func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfi
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return devLaunchConfig{}, 2
|
||||
}
|
||||
startupConfig, exitCode := devStartupConfig(repoRoot, configs...)
|
||||
if exitCode != 0 {
|
||||
return devLaunchConfig{}, exitCode
|
||||
}
|
||||
backendPort, err := resolvePorts(opts, startupConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
@@ -155,25 +155,30 @@ func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfi
|
||||
return devLaunchConfig{}, 1
|
||||
}
|
||||
ports.BackendURL = endpoints.accessURL
|
||||
dbPath, extra := resolveDevBackendEnv(repoRoot, startupConfig)
|
||||
homeDir := devKandevHome(repoRoot)
|
||||
if !isInsideKandevTask(repoRoot) && configSourceIsExplicit(startupConfig, "homeDir") {
|
||||
homeDir = startupConfig.ResolvedHomeDir()
|
||||
database := resolveDevDatabaseTarget(repoRoot, startupConfig)
|
||||
if err := validateDevDatabaseTarget(repoRoot, database); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return devLaunchConfig{}, 1
|
||||
}
|
||||
database, err = normalizeDevDatabaseTarget(database)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] unable to normalize dev database target: "+err.Error())
|
||||
return devLaunchConfig{}, 1
|
||||
}
|
||||
return devLaunchConfig{
|
||||
repoRoot: repoRoot,
|
||||
ports: ports,
|
||||
dbPath: dbPath,
|
||||
extra: extra,
|
||||
dbPath: database.path,
|
||||
extra: database.extra,
|
||||
logLevel: resolveLogLevelForConfig(opts, startupConfig),
|
||||
debug: opts.Debug,
|
||||
homeDir: homeDir,
|
||||
homeDir: database.homeDir,
|
||||
startup: startupConfig,
|
||||
endpoints: endpoints,
|
||||
}, 0
|
||||
}
|
||||
|
||||
func devStartupConfig(configs ...*config.Config) (*config.Config, int) {
|
||||
func devStartupConfig(repoRoot string, configs ...*config.Config) (*config.Config, int) {
|
||||
if len(configs) > 0 {
|
||||
return configs[0], 0
|
||||
}
|
||||
@@ -184,7 +189,7 @@ func devStartupConfig(configs ...*config.Config) (*config.Config, int) {
|
||||
return nil, 1
|
||||
}
|
||||
}
|
||||
startupConfig, err := loadDevBootstrapConfig()
|
||||
startupConfig, err := loadDevBootstrapConfig(repoRoot)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
return nil, 1
|
||||
@@ -192,24 +197,45 @@ func devStartupConfig(configs ...*config.Config) (*config.Config, int) {
|
||||
return startupConfig, 0
|
||||
}
|
||||
|
||||
func loadDevBootstrapConfig() (*config.Config, error) {
|
||||
func loadDevBootstrapConfig(repoRoot string) (*config.Config, error) {
|
||||
// Temporarily remove ambient KANDEV_HOME_DIR so a working-directory or
|
||||
// system YAML homeDir is not silently overridden by the parent process's
|
||||
// homeDir (which in dev mode is the parent backend's value, not an
|
||||
// explicit instruction). This must happen before the fast-path check
|
||||
// below because devStartupConfig sets KANDEV_DEBUG_DEV_MODE=true before
|
||||
// calling us.
|
||||
prevHome, homePresent := os.LookupEnv("KANDEV_HOME_DIR")
|
||||
if homePresent {
|
||||
if err := os.Unsetenv("KANDEV_HOME_DIR"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Setenv("KANDEV_HOME_DIR", prevHome) // best effort restore
|
||||
}()
|
||||
}
|
||||
|
||||
if os.Getenv("KANDEV_E2E_MOCK") != "" || os.Getenv("KANDEV_DEBUG_DEV_MODE") != "" {
|
||||
return loadBootstrapConfig()
|
||||
return loadBootstrapConfigWithHome(devKandevHome(repoRoot))
|
||||
}
|
||||
|
||||
const selector = "KANDEV_DEBUG_DEV_MODE"
|
||||
previous, present := os.LookupEnv(selector)
|
||||
prevMode, modePresent := os.LookupEnv(selector)
|
||||
if err := os.Setenv(selector, "true"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := loadBootstrapConfig()
|
||||
if present {
|
||||
if restoreErr := os.Setenv(selector, previous); err == nil {
|
||||
cfg, err := loadBootstrapConfigWithHome(devKandevHome(repoRoot))
|
||||
if modePresent {
|
||||
if restoreErr := os.Setenv(selector, prevMode); err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
} else if restoreErr := os.Unsetenv(selector); err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
if homePresent {
|
||||
if restoreErr := os.Setenv("KANDEV_HOME_DIR", prevHome); err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ func itoa(n int) string { return strconv.Itoa(n) }
|
||||
var errTestHealth = errors.New("test health failure")
|
||||
|
||||
func TestRunDevBuildVersionAndLaunchesBackendThenWebAndOpensBrowser(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
@@ -130,7 +131,7 @@ func TestRunDevBuildVersionAndLaunchesBackendThenWebAndOpensBrowser(t *testing.T
|
||||
"KANDEV_WEB_INTERNAL_URL=http://localhost:" + itoa(backendCfg.Ports.WebPort),
|
||||
"KANDEV_DEBUG_DEV_MODE=true",
|
||||
"KANDEV_HOME_DIR=" + filepath.Join(repo, ".kandev-dev"),
|
||||
"KANDEV_DATABASE_PATH=",
|
||||
"KANDEV_DATABASE_PATH=" + filepath.Join(repo, ".kandev-dev", "data", "kandev.db"),
|
||||
"KANDEV_BACKEND_PID_FILE=" + filepath.Join(repo, ".kandev-dev", "supervisor", "backend.pid"),
|
||||
} {
|
||||
if !strings.Contains(backendEnv, expected) {
|
||||
@@ -228,6 +229,7 @@ func TestRunDevUsesConfiguredHealthTimeoutForBackendAndWeb(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunDevHeadlessSkipsBrowser(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
@@ -282,9 +284,19 @@ func TestRunDevHeadlessSkipsBrowser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunDevAbortsOnBackupFailureBeforeLaunch(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
ambientHome := t.TempDir()
|
||||
sentinel := filepath.Join(ambientHome, "data", "kandev.db")
|
||||
if err := os.MkdirAll(filepath.Dir(sentinel), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sentinel, []byte("must not be backed up"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("KANDEV_HOME_DIR", ambientHome)
|
||||
// A path without a .kandev-dev segment is treated as production; making it
|
||||
// a directory forces the copy to fail so the abort path runs.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
@@ -309,9 +321,13 @@ func TestRunDevAbortsOnBackupFailureBeforeLaunch(t *testing.T) {
|
||||
if launched {
|
||||
t.Fatal("backend launched despite a failed production-db backup")
|
||||
}
|
||||
if got, err := os.ReadFile(sentinel); err != nil || string(got) != "must not be backed up" {
|
||||
t.Fatalf("ambient-home sentinel changed or became unreadable: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDevShutsDownTreeOnBackendHealthFailure(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
@@ -434,6 +450,7 @@ func TestRunDevShutsDownTreeOnBackendReadinessFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunDevShutsDownTreeWhenWebChildExits(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
@@ -496,6 +513,7 @@ func TestRunDevShutsDownTreeWhenWebChildExits(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunDevFailsWhenRepoRootNotFound(t *testing.T) {
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
code := runDev(context.Background(), Options{Command: CommandDev}, BuildInfo{})
|
||||
|
||||
@@ -59,11 +59,32 @@ func isInsideKandevTask(repoRoot string) bool {
|
||||
// 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.
|
||||
//
|
||||
// The returned dbPath is display-only; the backend derives its own DB path
|
||||
// from KANDEV_HOME_DIR via resolveDatabasePath(). Both resolve to the same
|
||||
// location.
|
||||
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
|
||||
@@ -76,32 +97,108 @@ func resolveDevBackendEnv(repoRoot string, configs ...*config.Config) (dbPath st
|
||||
// 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 devDBPath, append(baseExtra,
|
||||
"KANDEV_HOME_DIR="+devHome,
|
||||
// Clear a parent-leaked DB path so the backend uses the
|
||||
// HomeDir-derived default.
|
||||
"KANDEV_DATABASE_PATH=",
|
||||
)
|
||||
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 && startupConfig.SourceFor("database.path") == config.SourceConfiguration &&
|
||||
strings.TrimSpace(startupConfig.Database.Path) != "" {
|
||||
return startupConfig.Database.Path, 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 ||
|
||||
startupConfig.SourceFor("homeDir") == config.SourceEnvironment) {
|
||||
return filepath.Join(startupConfig.ResolvedDataDir(), "kandev.db"), baseExtra
|
||||
if startupConfig != nil && startupConfig.SourceFor("homeDir") == config.SourceConfiguration {
|
||||
path := filepath.Join(startupConfig.ResolvedDataDir(), "kandev.db")
|
||||
return newDevDatabaseTarget(path, homeDir, devDatabaseConfigHome, baseExtra)
|
||||
}
|
||||
|
||||
if override := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); override != "" {
|
||||
return override, append(baseExtra, "KANDEV_DATABASE_PATH="+override)
|
||||
}
|
||||
|
||||
return devDBPath, append(baseExtra,
|
||||
"KANDEV_HOME_DIR="+devHome,
|
||||
"KANDEV_DATABASE_PATH=",
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ func TestResolveDevBackendEnvDefaultUsesRepoLocalHome(t *testing.T) {
|
||||
if env["KANDEV_HOME_DIR"] != wantHome {
|
||||
t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome)
|
||||
}
|
||||
if env["KANDEV_DATABASE_PATH"] != "" {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty (derive from home)", env["KANDEV_DATABASE_PATH"])
|
||||
if env["KANDEV_DATABASE_PATH"] != wantDB {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want %q", env["KANDEV_DATABASE_PATH"], wantDB)
|
||||
}
|
||||
if env["KANDEV_DEBUG_DEV_MODE"] != "true" {
|
||||
t.Fatalf("KANDEV_DEBUG_DEV_MODE = %q, want true", env["KANDEV_DEBUG_DEV_MODE"])
|
||||
@@ -146,8 +146,8 @@ func TestResolveDevBackendEnvHonorsExplicitDatabasePath(t *testing.T) {
|
||||
if env["KANDEV_DATABASE_PATH"] != "/custom/kandev.db" {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q", env["KANDEV_DATABASE_PATH"])
|
||||
}
|
||||
if _, ok := env["KANDEV_HOME_DIR"]; ok {
|
||||
t.Fatal("explicit override must not set KANDEV_HOME_DIR")
|
||||
if want := filepath.Join(repo, ".kandev-dev"); env["KANDEV_HOME_DIR"] != want {
|
||||
t.Fatalf("KANDEV_HOME_DIR = %q, want repo-local dev home %q", env["KANDEV_HOME_DIR"], want)
|
||||
}
|
||||
if env["KANDEV_DEBUG_DEV_MODE"] != "true" {
|
||||
t.Fatalf("KANDEV_DEBUG_DEV_MODE = %q, want true", env["KANDEV_DEBUG_DEV_MODE"])
|
||||
@@ -169,12 +169,12 @@ func TestResolveDevBackendEnvIgnoresWhitespaceOnlyOverride(t *testing.T) {
|
||||
if env["KANDEV_HOME_DIR"] != wantHome {
|
||||
t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome)
|
||||
}
|
||||
if env["KANDEV_DATABASE_PATH"] != "" {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty", env["KANDEV_DATABASE_PATH"])
|
||||
if env["KANDEV_DATABASE_PATH"] != filepath.Join(wantHome, "data", "kandev.db") {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want repo-local database", env["KANDEV_DATABASE_PATH"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevBackendEnvHonorsEnvironmentConfiguredHome(t *testing.T) {
|
||||
func TestResolveDevBackendEnvIgnoresAmbientConfiguredHome(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
customHome := filepath.Join(t.TempDir(), "kandev")
|
||||
t.Setenv("KANDEV_HOME_DIR", customHome)
|
||||
@@ -188,11 +188,149 @@ func TestResolveDevBackendEnvHonorsEnvironmentConfiguredHome(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
dbPath, extra := resolveDevBackendEnv(repo, cfg)
|
||||
if want := filepath.Join(customHome, "data", "kandev.db"); dbPath != want {
|
||||
if want := filepath.Join(repo, ".kandev-dev", "data", "kandev.db"); dbPath != want {
|
||||
t.Fatalf("dbPath = %q, want %q", dbPath, want)
|
||||
}
|
||||
if env := devEnvToMap(extra); env["KANDEV_HOME_DIR"] != "" {
|
||||
t.Fatalf("dev extra environment overwrote configured home: %q", env["KANDEV_HOME_DIR"])
|
||||
if env := devEnvToMap(extra); env["KANDEV_HOME_DIR"] != filepath.Join(repo, ".kandev-dev") {
|
||||
t.Fatalf("KANDEV_HOME_DIR = %q, want repo-local dev home", env["KANDEV_HOME_DIR"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevBackendEnvHonorsConfiguredHomeDataDirectory(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
configuredHome := filepath.Join(t.TempDir(), "configured-kandev")
|
||||
cfg := &commonconfig.Config{
|
||||
HomeDir: configuredHome,
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"homeDir": commonconfig.SourceConfiguration,
|
||||
}},
|
||||
}
|
||||
|
||||
dbPath, extra := resolveDevBackendEnv(repo, cfg)
|
||||
want := filepath.Join(configuredHome, "data", "kandev.db")
|
||||
if dbPath != want {
|
||||
t.Fatalf("dbPath = %q, want configured home data path %q", dbPath, want)
|
||||
}
|
||||
if got := devEnvToMap(extra)["KANDEV_DATABASE_PATH"]; got != want {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevLaunchConfigIgnoresAmbientHomeForSupervisorState(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
t.Chdir(repo)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
ambientHome := filepath.Join(t.TempDir(), "kandev")
|
||||
cfg := &commonconfig.Config{
|
||||
HomeDir: ambientHome,
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"homeDir": commonconfig.SourceEnvironment,
|
||||
}},
|
||||
}
|
||||
|
||||
launch, code := devLaunchConfigFor(Options{Command: CommandDev}, cfg)
|
||||
if code != 0 {
|
||||
t.Fatalf("devLaunchConfigFor() = %d, want 0", code)
|
||||
}
|
||||
if want := filepath.Join(repo, ".kandev-dev"); launch.homeDir != want {
|
||||
t.Fatalf("supervisor home = %q, want repo-local %q", launch.homeDir, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevDatabaseTargetPreservesYamlHomeOverAmbientEnv(t *testing.T) {
|
||||
// GIVEN a working-directory YAML with an explicit homeDir AND an ambient
|
||||
// KANDEV_HOME_DIR that differs, WHEN the config is loaded through
|
||||
// devStartupConfig AND resolved through resolveDevDatabaseTarget,
|
||||
// THEN the YAML homeDir is preserved rather than silently discarded.
|
||||
repo := makeRepoTree(t)
|
||||
clearLauncherConfigurationEnvironment(t)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
configuredHome := filepath.Join(t.TempDir(), "configured-kandev")
|
||||
ambientHome := filepath.Join(t.TempDir(), "ambient-home")
|
||||
|
||||
// Place a config.yaml in the working directory with an explicit homeDir.
|
||||
t.Chdir(repo)
|
||||
configPath := filepath.Join(repo, "config.yaml")
|
||||
writeLauncherConfig(t, configPath, "homeDir: "+configuredHome+"\n")
|
||||
|
||||
// Set ambient KANDEV_HOME_DIR to a different value. Without the fix,
|
||||
// loadDevBootstrapConfig would pick up this env and the homeDir source
|
||||
// would become SourceEnvironment, causing devStateHome to fall back.
|
||||
t.Setenv("KANDEV_HOME_DIR", ambientHome)
|
||||
|
||||
cfg, exitCode := devStartupConfig(repo)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("devStartupConfig() = %d, want 0", exitCode)
|
||||
}
|
||||
if cfg.SourceFor("homeDir") != commonconfig.SourceConfiguration {
|
||||
t.Fatalf("homeDir source = %q, want SourceConfiguration (YAML); ambient KANDEV_HOME_DIR must not override YAML during dev bootstrap", cfg.SourceFor("homeDir"))
|
||||
}
|
||||
if got := cfg.ResolvedHomeDir(); got != configuredHome {
|
||||
t.Fatalf("resolved homeDir = %q, want YAML value %q", got, configuredHome)
|
||||
}
|
||||
|
||||
// Now resolve the database target with this config.
|
||||
target := resolveDevDatabaseTarget(repo, cfg)
|
||||
if target.homeDir != configuredHome {
|
||||
t.Fatalf("target homeDir = %q, want YAML-configured home %q", target.homeDir, configuredHome)
|
||||
}
|
||||
wantDB := filepath.Join(configuredHome, "data", "kandev.db")
|
||||
if target.path != wantDB {
|
||||
t.Fatalf("target database path = %q, want configured home data path %q", target.path, wantDB)
|
||||
}
|
||||
if target.source != devDatabaseConfigHome {
|
||||
t.Fatalf("target source = %q, want devDatabaseConfigHome", target.source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevBackendEnvHonorsEnvironmentDatabaseBeforeAmbientHome(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
ambientHome := filepath.Join(t.TempDir(), "ambient-home")
|
||||
explicitDB := filepath.Join(t.TempDir(), "explicit.db")
|
||||
cfg := &commonconfig.Config{
|
||||
HomeDir: ambientHome,
|
||||
Database: commonconfig.DatabaseConfig{Path: explicitDB},
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"homeDir": commonconfig.SourceEnvironment,
|
||||
"database.path": commonconfig.SourceEnvironment,
|
||||
}},
|
||||
}
|
||||
|
||||
dbPath, extra := resolveDevBackendEnv(repo, cfg)
|
||||
if dbPath != explicitDB {
|
||||
t.Fatalf("dbPath = %q, want explicit environment database %q", dbPath, explicitDB)
|
||||
}
|
||||
if env := devEnvToMap(extra); env["KANDEV_DATABASE_PATH"] != explicitDB {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want %q", env["KANDEV_DATABASE_PATH"], explicitDB)
|
||||
} else if want := filepath.Join(repo, ".kandev-dev"); env["KANDEV_HOME_DIR"] != want {
|
||||
t.Fatalf("KANDEV_HOME_DIR = %q, want repo-local dev home %q", env["KANDEV_HOME_DIR"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevBackendEnvPinsConfiguredHomeForConfiguredDatabase(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
t.Setenv("KANDEV_TASK_ID", "")
|
||||
t.Setenv("KANDEV_HOME_DIR", filepath.Join(t.TempDir(), "ambient-home"))
|
||||
configuredHome := filepath.Join(t.TempDir(), "configured-home")
|
||||
explicitDB := filepath.Join(t.TempDir(), "configured.db")
|
||||
cfg := &commonconfig.Config{
|
||||
HomeDir: configuredHome,
|
||||
Database: commonconfig.DatabaseConfig{Path: explicitDB},
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"homeDir": commonconfig.SourceConfiguration,
|
||||
"database.path": commonconfig.SourceConfiguration,
|
||||
}},
|
||||
}
|
||||
|
||||
dbPath, extra := resolveDevBackendEnv(repo, cfg)
|
||||
if dbPath != explicitDB {
|
||||
t.Fatalf("dbPath = %q, want %q", dbPath, explicitDB)
|
||||
}
|
||||
env := devEnvToMap(extra)
|
||||
if env["KANDEV_HOME_DIR"] != configuredHome {
|
||||
t.Fatalf("KANDEV_HOME_DIR = %q, want configured dev home %q", env["KANDEV_HOME_DIR"], configuredHome)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +351,286 @@ func TestResolveDevBackendEnvClearsLeakedPathInTaskWorkspace(t *testing.T) {
|
||||
if env["KANDEV_HOME_DIR"] != wantHome {
|
||||
t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome)
|
||||
}
|
||||
if env["KANDEV_DATABASE_PATH"] != "" {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty (parent-leaked path cleared)", env["KANDEV_DATABASE_PATH"])
|
||||
if env["KANDEV_DATABASE_PATH"] != filepath.Join(wantHome, "data", "kandev.db") {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want repo-local database", env["KANDEV_DATABASE_PATH"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevBackendEnvTaskWorkspaceIgnoresSelectedDatabase(t *testing.T) {
|
||||
root := makeRepoTree(t)
|
||||
t.Setenv("KANDEV_TASK_ID", "task-123")
|
||||
cfg := &commonconfig.Config{
|
||||
Database: commonconfig.DatabaseConfig{Path: filepath.Join(t.TempDir(), "parent.db")},
|
||||
Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{
|
||||
"database.path": commonconfig.SourceConfiguration,
|
||||
}},
|
||||
}
|
||||
|
||||
dbPath, extra := resolveDevBackendEnv(root, cfg)
|
||||
want := filepath.Join(root, ".kandev-dev", "data", "kandev.db")
|
||||
if dbPath != want {
|
||||
t.Fatalf("dbPath = %q, want task-local %q", dbPath, want)
|
||||
}
|
||||
if got := devEnvToMap(extra)["KANDEV_DATABASE_PATH"]; got != want {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH = %q, want task-local database", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDevDatabaseTargetRejectsRepoLocalSymlinkEscape(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
devHome := devKandevHome(repo)
|
||||
if err := os.Symlink(t.TempDir(), devHome); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := devDatabaseTarget{path: filepath.Join(devHome, "data", "kandev.db"), source: devDatabaseDefault}
|
||||
if err := validateDevDatabaseTarget(repo, target); err == nil {
|
||||
t.Fatal("validateDevDatabaseTarget accepted a symlinked repo-local database path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDevDatabaseTargetRejectsExplicitRepoLocalSymlinkEscape(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
devHome := devKandevHome(repo)
|
||||
dataDir := filepath.Join(devHome, "data")
|
||||
if err := os.MkdirAll(devHome, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(t.TempDir(), dataDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := devDatabaseTarget{path: filepath.Join(dataDir, "kandev.db"), source: devDatabaseEnvironment}
|
||||
if err := validateDevDatabaseTarget(repo, target); err == nil {
|
||||
t.Fatal("validateDevDatabaseTarget accepted an explicit database path through a repo-local symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDevDatabaseTargetRejectsPathOutsideDataDir(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
devHome := devKandevHome(repo)
|
||||
dataDir := filepath.Join(devHome, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
source devDatabaseSource
|
||||
}{
|
||||
{
|
||||
name: "repo-local default sibling of data",
|
||||
path: filepath.Join(devHome, "kandev.db"),
|
||||
source: devDatabaseDefault,
|
||||
},
|
||||
{
|
||||
name: "repo-local default under supervisor",
|
||||
path: filepath.Join(devHome, "supervisor", "kandev.db"),
|
||||
source: devDatabaseDefault,
|
||||
},
|
||||
{
|
||||
name: "task source sibling of data",
|
||||
path: filepath.Join(devHome, "kandev.db"),
|
||||
source: devDatabaseTask,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := devDatabaseTarget{path: tt.path, source: tt.source}
|
||||
if err := validateDevDatabaseTarget(repo, target); err == nil {
|
||||
t.Fatal("validateDevDatabaseTarget accepted a database path outside .kandev-dev/data")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDevDatabaseTargetRejectsExplicitRepoLocalPathOutsideDataDir(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
devHome := devKandevHome(repo)
|
||||
|
||||
for _, source := range []devDatabaseSource{devDatabaseEnvironment, devDatabaseConfiguration} {
|
||||
t.Run(string(source), func(t *testing.T) {
|
||||
target := devDatabaseTarget{
|
||||
path: filepath.Join(devHome, "supervisor", "kandev.db"),
|
||||
source: source,
|
||||
}
|
||||
if err := validateDevDatabaseTarget(repo, target); err == nil {
|
||||
t.Fatal("validateDevDatabaseTarget accepted an explicit repo-local path outside .kandev-dev/data")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDevDatabaseTargetAcceptsPathInsideDataDir(t *testing.T) {
|
||||
repo := makeRepoTree(t)
|
||||
devHome := devKandevHome(repo)
|
||||
dataDir := filepath.Join(devHome, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
source devDatabaseSource
|
||||
}{
|
||||
{
|
||||
name: "repo-local default inside data",
|
||||
path: filepath.Join(dataDir, "kandev.db"),
|
||||
source: devDatabaseDefault,
|
||||
},
|
||||
{
|
||||
name: "task source inside data",
|
||||
path: filepath.Join(dataDir, "kandev.db"),
|
||||
source: devDatabaseTask,
|
||||
},
|
||||
{
|
||||
name: "environment source inside data",
|
||||
path: filepath.Join(dataDir, "kandev.db"),
|
||||
source: devDatabaseEnvironment,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := devDatabaseTarget{path: tt.path, source: tt.source}
|
||||
if err := validateDevDatabaseTarget(repo, target); err != nil {
|
||||
t.Fatalf("validateDevDatabaseTarget rejected valid database path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDevDatabaseTargetMakesPathAbsolute(t *testing.T) {
|
||||
relPath := filepath.Join(".kandev-dev", "data", "kandev.db")
|
||||
relHome := ".kandev-dev"
|
||||
target := devDatabaseTarget{
|
||||
path: relPath,
|
||||
homeDir: relHome,
|
||||
source: devDatabaseDefault,
|
||||
extra: []string{"KANDEV_DEBUG_DEV_MODE=true", "KANDEV_HOME_DIR=" + relHome, "KANDEV_DATABASE_PATH=" + relPath},
|
||||
}
|
||||
|
||||
normalized, err := normalizeDevDatabaseTarget(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPath, err := filepath.Abs(relPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized.path != wantPath {
|
||||
t.Fatalf("normalized path = %q, want absolute %q", normalized.path, wantPath)
|
||||
}
|
||||
wantHome, err := filepath.Abs(relHome)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized.homeDir != wantHome {
|
||||
t.Fatalf("normalized homeDir = %q, want absolute %q", normalized.homeDir, wantHome)
|
||||
}
|
||||
|
||||
foundPath := false
|
||||
foundHome := false
|
||||
for _, item := range normalized.extra {
|
||||
switch {
|
||||
case strings.HasPrefix(item, "KANDEV_DATABASE_PATH="):
|
||||
val := item[len("KANDEV_DATABASE_PATH="):]
|
||||
if val != wantPath {
|
||||
t.Fatalf("KANDEV_DATABASE_PATH in extra = %q, want %q", val, wantPath)
|
||||
}
|
||||
foundPath = true
|
||||
case strings.HasPrefix(item, "KANDEV_HOME_DIR="):
|
||||
val := item[len("KANDEV_HOME_DIR="):]
|
||||
if val != wantHome {
|
||||
t.Fatalf("KANDEV_HOME_DIR in extra = %q, want %q", val, wantHome)
|
||||
}
|
||||
foundHome = true
|
||||
}
|
||||
}
|
||||
if !foundPath {
|
||||
t.Fatal("KANDEV_DATABASE_PATH not found in normalized extra")
|
||||
}
|
||||
if !foundHome {
|
||||
t.Fatal("KANDEV_HOME_DIR not found in normalized extra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDevDatabaseTargetNestedCwdRelativeExplicit(t *testing.T) {
|
||||
// Simulate invoking dev from a nested directory like <repo>/apps with a
|
||||
// relative explicit database path and relative YAML homeDir. The
|
||||
// launcher's CWD is <repo>/apps, but the backend's CWD will be <repo>.
|
||||
// filepath.Abs resolves the path relative to CWD; normalization must
|
||||
// produce the CWD-absolute form so the child does not resolve it
|
||||
// differently.
|
||||
repo := makeRepoTree(t)
|
||||
nestedCWD := filepath.Join(repo, "apps")
|
||||
if err := os.MkdirAll(nestedCWD, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativePath := "../.kandev-dev/data/kandev.db"
|
||||
relativeHome := "../.kandev-dev"
|
||||
|
||||
// Change to the nested CWD so filepath.Abs resolves relative to it.
|
||||
t.Chdir(nestedCWD)
|
||||
|
||||
wantPath, err := filepath.Abs(relativePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wantPath != filepath.Join(repo, ".kandev-dev", "data", "kandev.db") {
|
||||
t.Fatalf("abs path = %q, want %q", wantPath, filepath.Join(repo, ".kandev-dev", "data", "kandev.db"))
|
||||
}
|
||||
wantHome, err := filepath.Abs(relativeHome)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wantHome != filepath.Join(repo, ".kandev-dev") {
|
||||
t.Fatalf("abs home = %q, want %q", wantHome, filepath.Join(repo, ".kandev-dev"))
|
||||
}
|
||||
|
||||
target := devDatabaseTarget{
|
||||
path: relativePath,
|
||||
homeDir: relativeHome,
|
||||
source: devDatabaseEnvironment,
|
||||
extra: []string{"KANDEV_DEBUG_DEV_MODE=true", "KANDEV_HOME_DIR=" + relativeHome, "KANDEV_DATABASE_PATH=" + relativePath},
|
||||
}
|
||||
|
||||
normalized, err := normalizeDevDatabaseTarget(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized.path != wantPath {
|
||||
t.Fatalf("normalized path = %q, want %q", normalized.path, wantPath)
|
||||
}
|
||||
if normalized.homeDir != wantHome {
|
||||
t.Fatalf("normalized homeDir = %q, want %q", normalized.homeDir, wantHome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDevDatabaseTargetRejectsUnavailableWorkingDirectory(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
t.Chdir(workingDir)
|
||||
absolutePath := filepath.Join(workingDir, "absolute.db")
|
||||
if err := os.Remove(workingDir); err != nil {
|
||||
t.Skipf("executor does not allow removing the current directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "relative database path", path: "relative.db"},
|
||||
{name: "absolute database path", path: absolutePath},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
normalized, err := normalizeDevDatabaseTarget(devDatabaseTarget{
|
||||
path: tt.path,
|
||||
homeDir: "relative-home",
|
||||
extra: []string{"KANDEV_DATABASE_PATH=" + tt.path, "KANDEV_HOME_DIR=relative-home"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("normalizeDevDatabaseTarget returned %+v after absolute-path resolution failed", normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,15 +103,14 @@ func TestBackendLogPathForDevConfigUsesEffectiveBackendHome(t *testing.T) {
|
||||
t.Fatalf("default dev backend log = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
databasePath := filepath.Join(t.TempDir(), "kandev.db")
|
||||
t.Setenv("KANDEV_DATABASE_PATH", databasePath)
|
||||
overrideExtra := devExtraForTest(t, repo)
|
||||
got = backendLogPathForDevConfig(devLaunchConfig{startup: nil, extra: overrideExtra})
|
||||
want = filepath.Join(home, ".kandev", "logs", "backend-logs.log")
|
||||
want = filepath.Join(repo, ".kandev-dev", "logs", "backend-logs.log")
|
||||
if got != want {
|
||||
t.Fatalf("explicit database dev backend log = %q, want %q", got, want)
|
||||
t.Fatalf("explicit database dev backend log = %q, want repo-local %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ owners:
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLATFORM-GO-DEV-LAUNCHER-001.1:** Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the repo root living under `~/.kandev/tasks/` — any inherited `KANDEV_DATABASE_PATH` is assumed to be leaked from the parent backend and is cleared, so the backend derives its DB from `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace.
|
||||
- **AC-PLATFORM-GO-DEV-LAUNCHER-001.1:** Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the repo root living under `~/.kandev/tasks/` — the launcher replaces any inherited `KANDEV_DATABASE_PATH` with the task-local path and passes it with `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace.
|
||||
- **AC-PLATFORM-GO-DEV-LAUNCHER-001.2:** Otherwise an explicit `KANDEV_DATABASE_PATH` is honored as an escape hatch.
|
||||
- **AC-PLATFORM-GO-DEV-LAUNCHER-001.3:** Otherwise the DB is `<repoRoot>/.kandev-dev/data/kandev.db`.
|
||||
- **AC-PLATFORM-GO-DEV-LAUNCHER-001.4:** **GIVEN** a host with non-loopback LAN and Tailscale IPv4 addresses, **WHEN** the `dev`, `start`, or `run` launcher prints its startup banner, **THEN** it prints one `network:` URL for each address using the backend port.
|
||||
@@ -89,15 +89,32 @@ Dev mode roots Kandev at `<repoRoot>/.kandev-dev` so `make dev` never mutates th
|
||||
for a parent containing both `apps/backend` and `apps/web`; failing to find one is a
|
||||
usage error, not a silent fallback.
|
||||
|
||||
Dev resolves one database target, with provenance, before any backup, output,
|
||||
supervisor setup, or backend launch. That exact target is passed explicitly to
|
||||
the backend child so these consumers cannot resolve different databases. The
|
||||
launcher also pins the child's `KANDEV_HOME_DIR` to the selected dev-state home
|
||||
(repo-local by default, or explicit YAML `homeDir`) so ambient parent state
|
||||
cannot diverge from supervisor state when an explicit database is selected.
|
||||
|
||||
`KANDEV_DATABASE_PATH` resolution keeps its current three-way rule:
|
||||
|
||||
- Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the
|
||||
repo root living under `~/.kandev/tasks/` — any inherited `KANDEV_DATABASE_PATH` is
|
||||
assumed to be leaked from the parent backend and is cleared, so the backend derives
|
||||
its DB from `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace.
|
||||
- Otherwise an explicit `KANDEV_DATABASE_PATH` is honored as an escape hatch.
|
||||
repo root living under `~/.kandev/tasks/` — the launcher replaces any inherited
|
||||
`KANDEV_DATABASE_PATH` with the task-local path and passes it with `KANDEV_HOME_DIR`.
|
||||
The launcher prints that it detected a task workspace.
|
||||
- Otherwise an explicit `KANDEV_DATABASE_PATH`, whether selected through the
|
||||
environment or YAML, is honored as an escape hatch. It takes precedence over
|
||||
an ambient `KANDEV_HOME_DIR`.
|
||||
- Otherwise the DB is `<repoRoot>/.kandev-dev/data/kandev.db`.
|
||||
|
||||
An ambient `KANDEV_HOME_DIR` alone is parent-process context, not an
|
||||
authorization to redirect dev state: plain dev keeps its database, bootstrap
|
||||
home, and supervisor state repo-local. A working-directory or system YAML
|
||||
`homeDir` remains explicit configuration. Repo-local targets are rejected if
|
||||
they leave `.kandev-dev/data` or traverse a symlink; external targets require
|
||||
explicit database or YAML-home provenance and fail closed before the launcher
|
||||
opens or copies a database.
|
||||
|
||||
Dev mode always sets `KANDEV_DEBUG_DEV_MODE=true`, which is the profile selector; the
|
||||
concrete dev defaults stay in `profiles.yaml`. The launcher must not restate them.
|
||||
|
||||
@@ -228,6 +245,7 @@ them: building and serving the web app (Vite), the published npm shim, and repo
|
||||
| Repo root not found from CWD | Exit 2 with a message telling the user to run from the repo. |
|
||||
| Explicit backend or web port occupied | Exit 1 naming the port and its source (flag vs. env). No browser is opened. |
|
||||
| Production DB backup fails | Exit 1 before the backend starts; the message names the DB path. |
|
||||
| Repo-local DB path escapes `.kandev-dev/data` or a symlink | Exit 1 before any database access or backend start; the message names the path. |
|
||||
| Backend fails health within 600s | Print the captured backend output, shut down the tree, exit 1. |
|
||||
| Vite never becomes ready | Shut down the tree, exit 1. |
|
||||
| Either child exits | Shut down the tree; exit with that child's code, or 0 if it was signalled. |
|
||||
@@ -238,7 +256,7 @@ them: building and serving the web app (Vite), the published npm shim, and repo
|
||||
## Persistence guarantees
|
||||
|
||||
- `.kandev-dev/` is the only state `make dev` writes, unless the user explicitly sets
|
||||
`KANDEV_DATABASE_PATH` outside a task workspace.
|
||||
`KANDEV_DATABASE_PATH` or a YAML `homeDir` outside a task workspace.
|
||||
- `dev-prod-db-*.db` snapshots are retained five deep in `~/.kandev/data/backups/` and
|
||||
never prune other backup families.
|
||||
- The supervisor manifest and control socket live under
|
||||
|
||||
Reference in New Issue
Block a user