feat: migrate config beside executable
This commit is contained in:
@@ -15,6 +15,8 @@ import (
|
||||
|
||||
const currentVersion = 1
|
||||
|
||||
var ErrDefaultConfigUnavailable = errors.New("default config is unavailable")
|
||||
|
||||
type Settings struct {
|
||||
ChromePath string `json:"chromePath"`
|
||||
EdgePath string `json:"edgePath"`
|
||||
@@ -59,15 +61,106 @@ func New(path string) (*Store, error) {
|
||||
}
|
||||
|
||||
func DefaultPath() (string, error) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: resolve executable", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
return defaultPathForExecutable(executable)
|
||||
}
|
||||
|
||||
// LegacyPath is the pre-portable configuration location. It is read only when
|
||||
// the executable-side config does not exist yet.
|
||||
func LegacyPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve user config dir: %w", err)
|
||||
return "", fmt.Errorf("%w: resolve legacy config directory", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
return filepath.Join(dir, "chub", "config.json"), nil
|
||||
}
|
||||
|
||||
// OpenDefault returns the config store beside the currently executing binary.
|
||||
// A valid legacy user config is copied there once only when the target does not
|
||||
// already exist. The legacy file is deliberately retained for recovery.
|
||||
func OpenDefault() (*Store, error) {
|
||||
target, err := DefaultPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, targetExists, err := defaultStore(target)
|
||||
if err != nil || targetExists {
|
||||
return store, err
|
||||
}
|
||||
legacy, err := LegacyPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return migrateLegacyConfig(store, legacy)
|
||||
}
|
||||
|
||||
func (s *Store) Path() string { return s.path }
|
||||
|
||||
func defaultPathForExecutable(executable string) (string, error) {
|
||||
path := strings.TrimSpace(executable)
|
||||
if path == "" || !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%w: executable path must be absolute", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(filepath.Clean(path)), "config.json"), nil
|
||||
}
|
||||
|
||||
func openDefaultAt(target, legacy string) (*Store, error) {
|
||||
store, targetExists, err := defaultStore(target)
|
||||
if err != nil || targetExists {
|
||||
return store, err
|
||||
}
|
||||
return migrateLegacyConfig(store, legacy)
|
||||
}
|
||||
|
||||
func defaultStore(target string) (*Store, bool, error) {
|
||||
store, err := New(target)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%w: initialize target store", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
info, err := os.Stat(store.Path())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return store, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%w: inspect target config", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, false, fmt.Errorf("%w: target config is a directory", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
return store, true, nil
|
||||
}
|
||||
|
||||
func migrateLegacyConfig(target *Store, legacyPath string) (*Store, error) {
|
||||
if target == nil {
|
||||
return nil, fmt.Errorf("%w: target store is unavailable", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
legacy, err := New(legacyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: initialize legacy store", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
if filepath.Clean(target.Path()) == filepath.Clean(legacy.Path()) {
|
||||
return target, nil
|
||||
}
|
||||
info, err := os.Stat(legacy.Path())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return target, nil
|
||||
}
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, fmt.Errorf("%w: inspect legacy config", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
value, err := legacy.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: validate legacy config", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
if err := target.Save(value); err != nil {
|
||||
return nil, fmt.Errorf("%w: migrate legacy config", ErrDefaultConfigUnavailable)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (s *Store) Load() (File, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -100,6 +101,135 @@ func TestStoreMissingFileReturnsDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPathForExecutableUsesExecutableDirectory(t *testing.T) {
|
||||
executable := filepath.Join(t.TempDir(), "bin", "chub.exe")
|
||||
path, err := defaultPathForExecutable(executable)
|
||||
if err != nil || path != filepath.Join(filepath.Dir(executable), "config.json") {
|
||||
t.Fatalf("defaultPathForExecutable() = %q, %v", path, err)
|
||||
}
|
||||
if _, err := defaultPathForExecutable("chub.exe"); !errors.Is(err, ErrDefaultConfigUnavailable) {
|
||||
t.Fatalf("relative executable error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDefaultAtMigratesValidLegacyWithoutRemovingIt(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "portable", "config.json")
|
||||
legacy := filepath.Join(directory, "legacy", "config.json")
|
||||
legacyStore, err := New(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := File{
|
||||
Settings: Settings{RemoteDebugStartPort: 9777, CloseOnExit: true},
|
||||
Instances: []Instance{{
|
||||
ID: "migrated",
|
||||
Name: "迁移实例",
|
||||
PreferredRemoteDebugPort: 9777,
|
||||
Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\migrated`},
|
||||
}},
|
||||
}
|
||||
if err := legacyStore.Save(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := openDefaultAt(target, legacy)
|
||||
if err != nil || store.Path() != target {
|
||||
t.Fatalf("openDefaultAt() = %#v, %v", store, err)
|
||||
}
|
||||
got, err := store.Load()
|
||||
if err != nil || len(got.Instances) != 1 || got.Instances[0].ID != "migrated" || got.Instances[0].PreferredRemoteDebugPort != 9777 {
|
||||
t.Fatalf("migrated config = %#v, error = %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(legacy); err != nil {
|
||||
t.Fatalf("legacy config was not retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDefaultAtPrefersExistingTargetOverLegacy(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "portable", "config.json")
|
||||
legacy := filepath.Join(directory, "legacy", "config.json")
|
||||
targetStore, err := New(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := targetStore.Save(File{Instances: []Instance{{ID: "target", PreferredRemoteDebugPort: 9666}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacy, []byte(`not-json`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := openDefaultAt(target, legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("existing target was affected by legacy error: %v", err)
|
||||
}
|
||||
got, err := store.Load()
|
||||
if err != nil || len(got.Instances) != 1 || got.Instances[0].ID != "target" {
|
||||
t.Fatalf("target config = %#v, error = %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDefaultAtLeavesMissingTargetAbsentWithoutLegacy(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "portable", "config.json")
|
||||
store, err := openDefaultAt(target, filepath.Join(directory, "legacy", "config.json"))
|
||||
if err != nil || store.Path() != target {
|
||||
t.Fatalf("openDefaultAt() = %#v, %v", store, err)
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target was unexpectedly created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDefaultAtRejectsInvalidLegacyWithoutCreatingTarget(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "portable", "config.json")
|
||||
legacy := filepath.Join(directory, "legacy", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacy, []byte(`{`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) {
|
||||
t.Fatalf("invalid legacy error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target was created after invalid legacy: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(legacy); err != nil {
|
||||
t.Fatalf("legacy config was not retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDefaultAtRejectsUncreatableTargetWithoutTouchingLegacy(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
parentFile := filepath.Join(directory, "not-a-directory")
|
||||
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(parentFile, "config.json")
|
||||
legacy := filepath.Join(directory, "legacy", "config.json")
|
||||
legacyStore, err := New(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := legacyStore.Save(File{Instances: []Instance{{ID: "legacy", PreferredRemoteDebugPort: 9666}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := openDefaultAt(target, legacy); !errors.Is(err, ErrDefaultConfigUnavailable) {
|
||||
t.Fatalf("uncreatable target error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(legacy); err != nil {
|
||||
t.Fatalf("legacy config was changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRoundTripsReferencedProxyWithoutCredentials(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
store, err := New(path)
|
||||
|
||||
Reference in New Issue
Block a user