config: migrate the pre-0.1 top-level token into the per-host mapUnverified
A config written by an older build has a single top-level `token:` that
applied to whatever host was active. The host-bound loader ignored that
key, so upgrading silently dropped the stored token ("no token
configured") and the next save rewrote the file without it.
Load now folds a legacy token into `hosts:` under the config's own host
(rickub.com when none is set), never overriding a per-host entry that is
already there, and the next Save writes the current shape.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgkcT3UPxwxukBKojB5eg430b5b18 parent: 1a1d430 modified
internal/config/config.go +30 -0 | @@ -48,6 +48,11 @@ type Config struct { | ||
| 48 | 48 | Host string `yaml:"host,omitempty"` |
| 49 | 49 | // Hosts maps a normalized host URL to the credentials minted for it. |
| 50 | 50 | Hosts map[string]HostConfig `yaml:"hosts,omitempty"` |
| 51 | + // LegacyToken is the pre-0.1 top-level `token:` key, from before tokens were | |
| 52 | + // bound to a host. It is read for migration only: Load folds it into Hosts | |
| 53 | + // under Host (see migrateLegacyToken) and clears it, so it is never written | |
| 54 | + // back and a config saved by an older binary keeps working after an upgrade. | |
| 55 | + LegacyToken string `yaml:"token,omitempty"` | |
| 51 | 56 | } |
| 52 | 57 | |
| 53 | 58 | // NormalizeHost canonicalizes a host URL for use as a config key and for |
| @@ -104,9 +109,34 @@ func LoadFrom(path string) (*Config, error) { | ||
| 104 | 109 | if err := yaml.Unmarshal(data, &c); err != nil { |
| 105 | 110 | return nil, fmt.Errorf("parse %s: %w", path, err) |
| 106 | 111 | } |
| 112 | + c.migrateLegacyToken() | |
| 107 | 113 | return &c, nil |
| 108 | 114 | } |
| 109 | 115 | |
| 116 | +// migrateLegacyToken folds a top-level `token:` — the pre-0.1 config shape, in | |
| 117 | +// which one token applied to whatever host was in effect — into the per-host | |
| 118 | +// map under the config's own host (DefaultHost when none is set), so the | |
| 119 | +// binding rules apply to it and the next Save writes the current shape. A | |
| 120 | +// per-host entry that already exists is never overridden. It reports whether | |
| 121 | +// anything was migrated. | |
| 122 | +func (c *Config) migrateLegacyToken() bool { | |
| 123 | + if c == nil || c.LegacyToken == "" { | |
| 124 | + return false | |
| 125 | + } | |
| 126 | + token := c.LegacyToken | |
| 127 | + c.LegacyToken = "" | |
| 128 | + host := c.Host | |
| 129 | + if host == "" { | |
| 130 | + host = DefaultHost | |
| 131 | + } | |
| 132 | + host = NormalizeHost(host) | |
| 133 | + if c.TokenFor(host) != "" { | |
| 134 | + return false | |
| 135 | + } | |
| 136 | + c.SetToken(host, token) | |
| 137 | + return true | |
| 138 | +} | |
| 139 | + | |
| 110 | 140 | // Save writes the config file (mode 0600), creating parent dirs as needed. |
| 111 | 141 | func (c *Config) Save() error { |
| 112 | 142 | path, err := Path() |
| @@ -48,6 +48,11 @@ type Config struct { | |||
| 48 | Host string `yaml:"host,omitempty"` | 48 | Host string `yaml:"host,omitempty"` |
| 49 | // Hosts maps a normalized host URL to the credentials minted for it. | 49 | // Hosts maps a normalized host URL to the credentials minted for it. |
| 50 | Hosts map[string]HostConfig `yaml:"hosts,omitempty"` | 50 | Hosts map[string]HostConfig `yaml:"hosts,omitempty"` |
| 51 | + // LegacyToken is the pre-0.1 top-level `token:` key, from before tokens were | ||
| 52 | + // bound to a host. It is read for migration only: Load folds it into Hosts | ||
| 53 | + // under Host (see migrateLegacyToken) and clears it, so it is never written | ||
| 54 | + // back and a config saved by an older binary keeps working after an upgrade. | ||
| 55 | + LegacyToken string `yaml:"token,omitempty"` | ||
| 51 | } | 56 | } |
| 52 | 57 | ||
| 53 | // NormalizeHost canonicalizes a host URL for use as a config key and for | 58 | // NormalizeHost canonicalizes a host URL for use as a config key and for |
| @@ -104,9 +109,34 @@ func LoadFrom(path string) (*Config, error) { | |||
| 104 | if err := yaml.Unmarshal(data, &c); err != nil { | 109 | if err := yaml.Unmarshal(data, &c); err != nil { |
| 105 | return nil, fmt.Errorf("parse %s: %w", path, err) | 110 | return nil, fmt.Errorf("parse %s: %w", path, err) |
| 106 | } | 111 | } |
| 112 | + c.migrateLegacyToken() | ||
| 107 | return &c, nil | 113 | return &c, nil |
| 108 | } | 114 | } |
| 109 | 115 | ||
| 116 | +// migrateLegacyToken folds a top-level `token:` — the pre-0.1 config shape, in | ||
| 117 | +// which one token applied to whatever host was in effect — into the per-host | ||
| 118 | +// map under the config's own host (DefaultHost when none is set), so the | ||
| 119 | +// binding rules apply to it and the next Save writes the current shape. A | ||
| 120 | +// per-host entry that already exists is never overridden. It reports whether | ||
| 121 | +// anything was migrated. | ||
| 122 | +func (c *Config) migrateLegacyToken() bool { | ||
| 123 | + if c == nil || c.LegacyToken == "" { | ||
| 124 | + return false | ||
| 125 | + } | ||
| 126 | + token := c.LegacyToken | ||
| 127 | + c.LegacyToken = "" | ||
| 128 | + host := c.Host | ||
| 129 | + if host == "" { | ||
| 130 | + host = DefaultHost | ||
| 131 | + } | ||
| 132 | + host = NormalizeHost(host) | ||
| 133 | + if c.TokenFor(host) != "" { | ||
| 134 | + return false | ||
| 135 | + } | ||
| 136 | + c.SetToken(host, token) | ||
| 137 | + return true | ||
| 138 | +} | ||
| 139 | + | ||
| 110 | // Save writes the config file (mode 0600), creating parent dirs as needed. | 140 | // Save writes the config file (mode 0600), creating parent dirs as needed. |
| 111 | func (c *Config) Save() error { | 141 | func (c *Config) Save() error { |
| 112 | path, err := Path() | 142 | path, err := Path() |
modified
internal/config/config_test.go +70 -0 | @@ -201,3 +201,73 @@ func TestWarnIfInsecure(t *testing.T) { | ||
| 201 | 201 | t.Errorf("unexpected output: %q", buf.String()) |
| 202 | 202 | } |
| 203 | 203 | } |
| 204 | + | |
| 205 | +// A config written by a pre-0.1 binary has one top-level `token:` that applied | |
| 206 | +// to whatever host was active. Loading it must bind that token to the config's | |
| 207 | +// host — not drop it, and not offer it to every host — and the next save must | |
| 208 | +// write the per-host shape. | |
| 209 | +func TestLegacyTopLevelTokenIsBoundToTheConfiguredHost(t *testing.T) { | |
| 210 | + dir := t.TempDir() | |
| 211 | + path := filepath.Join(dir, "config.yaml") | |
| 212 | + legacy := "host: https://dev.rickub.com\ntoken: rickub_pat_old\n" | |
| 213 | + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { | |
| 214 | + t.Fatal(err) | |
| 215 | + } | |
| 216 | + | |
| 217 | + c, err := LoadFrom(path) | |
| 218 | + if err != nil { | |
| 219 | + t.Fatalf("LoadFrom: %v", err) | |
| 220 | + } | |
| 221 | + if got := c.TokenFor("https://dev.rickub.com"); got != "rickub_pat_old" { | |
| 222 | + t.Fatalf("legacy token not bound to its host: TokenFor(dev) = %q", got) | |
| 223 | + } | |
| 224 | + if got := c.TokenFor(DefaultHost); got != "" { | |
| 225 | + t.Errorf("legacy token leaked to %s: %q", DefaultHost, got) | |
| 226 | + } | |
| 227 | + if c.LegacyToken != "" { | |
| 228 | + t.Errorf("LegacyToken still set after migration: %q", c.LegacyToken) | |
| 229 | + } | |
| 230 | + if got := ResolveToken("", c, ResolveHost("", c)); got != "rickub_pat_old" { | |
| 231 | + t.Errorf("ResolveToken after migration = %q, want the legacy token", got) | |
| 232 | + } | |
| 233 | + | |
| 234 | + if err := c.SaveTo(path); err != nil { | |
| 235 | + t.Fatalf("SaveTo: %v", err) | |
| 236 | + } | |
| 237 | + raw, _ := os.ReadFile(path) | |
| 238 | + if !strings.Contains(string(raw), "hosts:") { | |
| 239 | + t.Errorf("saved config is not in the per-host shape:\n%s", raw) | |
| 240 | + } | |
| 241 | + for _, line := range strings.Split(string(raw), "\n") { | |
| 242 | + if strings.HasPrefix(line, "token:") { | |
| 243 | + t.Errorf("saved config still carries a top-level token line: %q", line) | |
| 244 | + } | |
| 245 | + } | |
| 246 | +} | |
| 247 | + | |
| 248 | +func TestLegacyTokenDefaultsToProductionHostAndNeverOverridesAnEntry(t *testing.T) { | |
| 249 | + // No host: the old binary talked to DefaultHost, so that is where it belongs. | |
| 250 | + c := &Config{LegacyToken: "rickub_pat_old"} | |
| 251 | + if !c.migrateLegacyToken() { | |
| 252 | + t.Fatal("migrateLegacyToken reported nothing to do") | |
| 253 | + } | |
| 254 | + if got := c.TokenFor(DefaultHost); got != "rickub_pat_old" { | |
| 255 | + t.Errorf("TokenFor(%s) = %q", DefaultHost, got) | |
| 256 | + } | |
| 257 | + if c.Host != DefaultHost { | |
| 258 | + t.Errorf("Host = %q, want %q", c.Host, DefaultHost) | |
| 259 | + } | |
| 260 | + | |
| 261 | + // A per-host entry already present wins over the stale top-level key. | |
| 262 | + c = &Config{Host: "https://rickub.com", LegacyToken: "rickub_pat_stale"} | |
| 263 | + c.SetToken("https://rickub.com", "rickub_pat_new") | |
| 264 | + if c.migrateLegacyToken() { | |
| 265 | + t.Error("migrateLegacyToken overrode an existing per-host entry") | |
| 266 | + } | |
| 267 | + if got := c.TokenFor("https://rickub.com"); got != "rickub_pat_new" { | |
| 268 | + t.Errorf("TokenFor = %q, want the existing entry", got) | |
| 269 | + } | |
| 270 | + if c.LegacyToken != "" { | |
| 271 | + t.Error("stale legacy token was kept") | |
| 272 | + } | |
| 273 | +} | |
| @@ -201,3 +201,73 @@ func TestWarnIfInsecure(t *testing.T) { | |||
| 201 | t.Errorf("unexpected output: %q", buf.String()) | 201 | t.Errorf("unexpected output: %q", buf.String()) |
| 202 | } | 202 | } |
| 203 | } | 203 | } |
| 204 | + | ||
| 205 | +// A config written by a pre-0.1 binary has one top-level `token:` that applied | ||
| 206 | +// to whatever host was active. Loading it must bind that token to the config's | ||
| 207 | +// host — not drop it, and not offer it to every host — and the next save must | ||
| 208 | +// write the per-host shape. | ||
| 209 | +func TestLegacyTopLevelTokenIsBoundToTheConfiguredHost(t *testing.T) { | ||
| 210 | + dir := t.TempDir() | ||
| 211 | + path := filepath.Join(dir, "config.yaml") | ||
| 212 | + legacy := "host: https://dev.rickub.com\ntoken: rickub_pat_old\n" | ||
| 213 | + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { | ||
| 214 | + t.Fatal(err) | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + c, err := LoadFrom(path) | ||
| 218 | + if err != nil { | ||
| 219 | + t.Fatalf("LoadFrom: %v", err) | ||
| 220 | + } | ||
| 221 | + if got := c.TokenFor("https://dev.rickub.com"); got != "rickub_pat_old" { | ||
| 222 | + t.Fatalf("legacy token not bound to its host: TokenFor(dev) = %q", got) | ||
| 223 | + } | ||
| 224 | + if got := c.TokenFor(DefaultHost); got != "" { | ||
| 225 | + t.Errorf("legacy token leaked to %s: %q", DefaultHost, got) | ||
| 226 | + } | ||
| 227 | + if c.LegacyToken != "" { | ||
| 228 | + t.Errorf("LegacyToken still set after migration: %q", c.LegacyToken) | ||
| 229 | + } | ||
| 230 | + if got := ResolveToken("", c, ResolveHost("", c)); got != "rickub_pat_old" { | ||
| 231 | + t.Errorf("ResolveToken after migration = %q, want the legacy token", got) | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + if err := c.SaveTo(path); err != nil { | ||
| 235 | + t.Fatalf("SaveTo: %v", err) | ||
| 236 | + } | ||
| 237 | + raw, _ := os.ReadFile(path) | ||
| 238 | + if !strings.Contains(string(raw), "hosts:") { | ||
| 239 | + t.Errorf("saved config is not in the per-host shape:\n%s", raw) | ||
| 240 | + } | ||
| 241 | + for _, line := range strings.Split(string(raw), "\n") { | ||
| 242 | + if strings.HasPrefix(line, "token:") { | ||
| 243 | + t.Errorf("saved config still carries a top-level token line: %q", line) | ||
| 244 | + } | ||
| 245 | + } | ||
| 246 | +} | ||
| 247 | + | ||
| 248 | +func TestLegacyTokenDefaultsToProductionHostAndNeverOverridesAnEntry(t *testing.T) { | ||
| 249 | + // No host: the old binary talked to DefaultHost, so that is where it belongs. | ||
| 250 | + c := &Config{LegacyToken: "rickub_pat_old"} | ||
| 251 | + if !c.migrateLegacyToken() { | ||
| 252 | + t.Fatal("migrateLegacyToken reported nothing to do") | ||
| 253 | + } | ||
| 254 | + if got := c.TokenFor(DefaultHost); got != "rickub_pat_old" { | ||
| 255 | + t.Errorf("TokenFor(%s) = %q", DefaultHost, got) | ||
| 256 | + } | ||
| 257 | + if c.Host != DefaultHost { | ||
| 258 | + t.Errorf("Host = %q, want %q", c.Host, DefaultHost) | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + // A per-host entry already present wins over the stale top-level key. | ||
| 262 | + c = &Config{Host: "https://rickub.com", LegacyToken: "rickub_pat_stale"} | ||
| 263 | + c.SetToken("https://rickub.com", "rickub_pat_new") | ||
| 264 | + if c.migrateLegacyToken() { | ||
| 265 | + t.Error("migrateLegacyToken overrode an existing per-host entry") | ||
| 266 | + } | ||
| 267 | + if got := c.TokenFor("https://rickub.com"); got != "rickub_pat_new" { | ||
| 268 | + t.Errorf("TokenFor = %q, want the existing entry", got) | ||
| 269 | + } | ||
| 270 | + if c.LegacyToken != "" { | ||
| 271 | + t.Error("stale legacy token was kept") | ||
| 272 | + } | ||
| 273 | +} | ||