feat: select the jobs file itself in Settings
The Jobs directory row named a folder and assumed the file inside it was called jobs.json. It is now a Jobs file row: Browse opens a file picker filtered to .json, the field stays editable so a file that does not exist yet can be typed, and the job list can live under any name. Config.JobsDir/jobs_dir becomes Config.JobsFile/jobs_file, holding the whole path; Paths.JobsDir is derived from it so saves still create the folder. An older gosentry.json is migrated on load by joining its jobs_dir with jobs.json — the exact file that version used — and the retired key is dropped when the config is rewritten. The default clears before unmarshalling, or a file that omits jobs_file and a file that sets it would be indistinguishable and the migration would never run. Saving used to write the current job list over whatever was at the new path, which made switching to an existing jobs file impossible: its contents were destroyed. An existing file now wins. Its jobs are loaded, normalized, and adopted, with runtimes, schedule cache, next-run times and log-seeded statistics rebuilt around them by adoptJobsLocked — the same helper NewService now uses, so construction and adoption cannot drift. A path with no file behind it still receives the current jobs, which is how the file is renamed or relocated. The new file is read before anything is written, so an unparsable one leaves both the config and the jobs untouched. Adoption drops every runtime, and a run finishing afterwards would write its result onto whichever job inherited its ID, so the switch is refused while a job is running. Unrelated settings still save during a run. Because the replacement happens without a prompt, the Service emits JobsLoaded with the path and count, and History carries the receipt. A path that names only a folder (trailing separator, a dot, or two dots) is rejected with a validation error instead of failing later with an opaque OS error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+12
-7
@@ -9,9 +9,11 @@ const (
|
||||
// The config file stays beside the executable so the portable build behaves
|
||||
// predictably: moving the program folder moves its settings with it.
|
||||
ConfigFileName = "gosentry.json"
|
||||
// Jobs are kept in a separate JSON file because the user can choose a
|
||||
// different jobs directory, while application settings remain local to the
|
||||
// installed/copied program.
|
||||
// Jobs are kept in a separate JSON file because the user can point the
|
||||
// configuration at any jobs file they like, while application settings
|
||||
// remain local to the installed/copied program. This is only the default
|
||||
// name, used before the config is read and when an older config that named
|
||||
// just a directory is migrated.
|
||||
JobsFileName = "jobs.json"
|
||||
|
||||
)
|
||||
@@ -23,10 +25,13 @@ type Paths struct {
|
||||
ExecutablePath string
|
||||
AppDir string
|
||||
ConfigPath string
|
||||
JobsDir string
|
||||
JobsPath string
|
||||
LogsDir string
|
||||
DesktopIcon string
|
||||
// JobsDir is the directory containing JobsPath. It is derived from the
|
||||
// configured jobs file, never configured on its own, and exists so writers
|
||||
// can create the folder before saving.
|
||||
JobsDir string
|
||||
JobsPath string
|
||||
LogsDir string
|
||||
DesktopIcon string
|
||||
}
|
||||
|
||||
func ResolvePaths() (Paths, error) {
|
||||
|
||||
+52
-28
@@ -78,14 +78,25 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
// Clearing the default first keeps "the file sets jobs_file" distinguishable
|
||||
// from "the file omits it", which the jobs_dir migration below depends on.
|
||||
// The fallbacks restore a value in either case.
|
||||
config.JobsFile = ""
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.JobsDir) == "" {
|
||||
// A config written before the setting named a file carries jobs_dir instead
|
||||
// of jobs_file. Keep its meaning by appending the fixed name that version
|
||||
// used, then drop the old key so the file is rewritten in the current shape.
|
||||
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
|
||||
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
|
||||
}
|
||||
config.JobsDir = ""
|
||||
if strings.TrimSpace(config.JobsFile) == "" {
|
||||
// Empty paths are treated as missing values rather than intentional root
|
||||
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||
config.JobsDir = "."
|
||||
config.JobsFile = JobsFileName
|
||||
}
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
config.LogsDir = "logs"
|
||||
@@ -112,24 +123,39 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
// Seed harmless sample jobs so a new user can immediately see scheduled
|
||||
// and manual execution without inventing a command.
|
||||
jobs := defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
// LoadJobsFile reads and normalizes the job definitions at path. The bool
|
||||
// reports whether the file was there: a missing file is not an error but the
|
||||
// answer to "is this file already a jobs file?", which is what the Settings tab
|
||||
// needs when the user points the application at a different jobs file.
|
||||
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
var file domain.JobsFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
normalizeJobs(file.Jobs)
|
||||
return file.Jobs, true, nil
|
||||
}
|
||||
|
||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
jobs, found, err := LoadJobsFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Jobs, nil
|
||||
if found {
|
||||
return jobs, nil
|
||||
}
|
||||
// Seed harmless sample jobs so a new user can immediately see scheduled
|
||||
// and manual execution without inventing a command.
|
||||
jobs = defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
func normalizeJobs(jobs []domain.Job) {
|
||||
@@ -162,28 +188,26 @@ func normalizeJobs(jobs []domain.Job) {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveJobsDir(appDir string, jobsDir string) string {
|
||||
return ResolveConfiguredDir(appDir, jobsDir)
|
||||
}
|
||||
|
||||
// ResolveConfiguredDir turns a directory from the config into the absolute
|
||||
// path the application will actually use. It is exported so callers outside
|
||||
// storage — the settings tab, which opens the configured logs folder — apply
|
||||
// the same rule to a path the user has typed but not yet saved.
|
||||
func ResolveConfiguredDir(appDir string, dir string) string {
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir
|
||||
// ResolveConfiguredPath turns a file or directory path from the config into the
|
||||
// absolute path the application will actually use. It is exported so callers
|
||||
// outside storage — the settings tab, which opens the configured logs folder —
|
||||
// apply the same rule to a path the user has typed but not yet saved.
|
||||
func ResolveConfiguredPath(appDir string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
// Relative paths are resolved against the executable directory, not the
|
||||
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||
// Explorer launches, and terminal launches consistent.
|
||||
return filepath.Clean(filepath.Join(appDir, dir))
|
||||
return filepath.Clean(filepath.Join(appDir, path))
|
||||
}
|
||||
|
||||
func (s *Store) applyConfigPaths() {
|
||||
s.Paths.JobsDir = ResolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
||||
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
||||
s.Paths.LogsDir = ResolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
||||
// The jobs file is configured as a whole path; its directory is derived so
|
||||
// SaveJobs can create the folder when the user points at a new location.
|
||||
s.Paths.JobsPath = ResolveConfiguredPath(s.Paths.AppDir, s.Config.JobsFile)
|
||||
s.Paths.JobsDir = filepath.Dir(s.Paths.JobsPath)
|
||||
s.Paths.LogsDir = ResolveConfiguredPath(s.Paths.AppDir, s.Config.LogsDir)
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
|
||||
+107
-5
@@ -77,7 +77,7 @@ func TestConfigRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
want := domain.Config{
|
||||
JobsDir: "/custom/jobs",
|
||||
JobsFile: "/custom/jobs/team.json",
|
||||
LogsDir: "/custom/logs",
|
||||
MaxLogFiles: 50,
|
||||
MaxLogAgeDays: 14,
|
||||
@@ -94,8 +94,8 @@ func TestConfigRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got.JobsDir != want.JobsDir {
|
||||
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
|
||||
if got.JobsFile != want.JobsFile {
|
||||
t.Errorf("JobsFile: got %q, want %q", got.JobsFile, want.JobsFile)
|
||||
}
|
||||
if got.LogsDir != want.LogsDir {
|
||||
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
||||
@@ -156,8 +156,8 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.JobsDir != "." {
|
||||
t.Errorf("default JobsDir = %q, want '.'", got.JobsDir)
|
||||
if got.JobsFile != "jobs.json" {
|
||||
t.Errorf("default JobsFile = %q, want 'jobs.json'", got.JobsFile)
|
||||
}
|
||||
if got.LogsDir != "logs" {
|
||||
t.Errorf("default LogsDir = %q, want 'logs'", got.LogsDir)
|
||||
@@ -207,6 +207,108 @@ func TestLoadOrCreateConfigKeepsZeroTimeoutOnReload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreateConfigMigratesJobsDir covers a gosentry.json written before
|
||||
// the setting named a file: the old jobs_dir keeps pointing at the same jobs
|
||||
// file, and the retired key is dropped so it is not written back.
|
||||
func TestLoadOrCreateConfigMigratesJobsDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
AppDir: dir,
|
||||
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||
}
|
||||
legacy := map[string]any{
|
||||
"jobs_dir": filepath.Join(dir, "shared"),
|
||||
"logs_dir": "logs",
|
||||
"max_log_files": 100,
|
||||
"max_log_age_days": 30,
|
||||
}
|
||||
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := loadOrCreateConfig(paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dir, "shared", JobsFileName)
|
||||
if got.JobsFile != want {
|
||||
t.Errorf("migrated JobsFile: got %q, want %q", got.JobsFile, want)
|
||||
}
|
||||
if got.JobsDir != "" {
|
||||
t.Errorf("legacy JobsDir should be cleared, got %q", got.JobsDir)
|
||||
}
|
||||
|
||||
// The migrated config must not carry the retired key once it is saved.
|
||||
store := &Store{Paths: paths, Config: got}
|
||||
if err := store.SaveConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(paths.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "jobs_dir") {
|
||||
t.Errorf("saved config should not contain jobs_dir:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadJobsFileReportsMissingWithoutCreating covers the loader the Settings
|
||||
// tab uses to decide between adopting a jobs file and writing the current jobs
|
||||
// to it: a missing file is reported as "not found" rather than an error, and —
|
||||
// unlike the startup path — is not seeded with sample jobs.
|
||||
func TestLoadJobsFileReportsMissingWithoutCreating(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "nothing-here.json")
|
||||
|
||||
jobs, found, err := LoadJobsFile(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("missing file should not be an error: %v", err)
|
||||
}
|
||||
if found || jobs != nil {
|
||||
t.Errorf("missing file: got found=%v jobs=%+v, want false/nil", found, jobs)
|
||||
}
|
||||
if _, err := os.Stat(missing); !os.IsNotExist(err) {
|
||||
t.Error("LoadJobsFile must not create the file it was asked about")
|
||||
}
|
||||
|
||||
// An existing file comes back normalized, so a hand-written jobs file gains
|
||||
// its IDs and defaults before the application adopts it.
|
||||
path := filepath.Join(dir, "hand-written.json")
|
||||
if err := writeJSON(path, domain.JobsFile{Jobs: []domain.Job{{Name: "No ID"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs, found, err = LoadJobsFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || len(jobs) != 1 {
|
||||
t.Fatalf("existing file: got found=%v jobs=%+v, want true and one job", found, jobs)
|
||||
}
|
||||
if jobs[0].ID != 1 || jobs[0].Schedule == "" || jobs[0].Command == "" {
|
||||
t.Errorf("loaded job should be normalized, got %+v", jobs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyConfigPathsDerivesJobsDir checks that the jobs file drives both
|
||||
// resolved paths: relative values resolve against the program folder, and the
|
||||
// containing directory comes from the file name the user chose.
|
||||
func TestApplyConfigPathsDerivesJobsDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := &Store{
|
||||
Paths: Paths{AppDir: dir},
|
||||
Config: domain.Config{JobsFile: filepath.Join("shared", "team.json"), LogsDir: "logs"},
|
||||
}
|
||||
|
||||
store.applyConfigPaths()
|
||||
|
||||
if want := filepath.Join(dir, "shared", "team.json"); store.Paths.JobsPath != want {
|
||||
t.Errorf("JobsPath: got %q, want %q", store.Paths.JobsPath, want)
|
||||
}
|
||||
if want := filepath.Join(dir, "shared"); store.Paths.JobsDir != want {
|
||||
t.Errorf("JobsDir: got %q, want %q", store.Paths.JobsDir, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobTimeoutRoundTripsThreeStates pins the on-disk encoding that keeps
|
||||
// "inherit" and "no timeout" distinguishable: nil is omitted entirely, while an
|
||||
// explicit 0 is written and read back as a set value.
|
||||
|
||||
Reference in New Issue
Block a user