rickub/clipublic Fork
⑂ 30b5b18
Commits
⬇ Clone ▾
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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_01FgkcT3UPxwxukBKojB5eg4
Olivier Girardot committed 2026-09-02T21:03:38+02:00 Browse files
30b5b18 parent: 1a1d430
modified internal/config/config.go +30 -0
@@ -48,6 +48,11 @@ type Config struct {
4848 Host string `yaml:"host,omitempty"`
4949 // Hosts maps a normalized host URL to the credentials minted for it.
5050 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"`
5156 }
5257
5358 // NormalizeHost canonicalizes a host URL for use as a config key and for
@@ -104,9 +109,34 @@ func LoadFrom(path string) (*Config, error) {
104109 if err := yaml.Unmarshal(data, &c); err != nil {
105110 return nil, fmt.Errorf("parse %s: %w", path, err)
106111 }
112+ c.migrateLegacyToken()
107113 return &c, nil
108114 }
109115
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+
110140 // Save writes the config file (mode 0600), creating parent dirs as needed.
111141 func (c *Config) Save() error {
112142 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 for58 // 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, nil113 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) {
201201 t.Errorf("unexpected output: %q", buf.String())
202202 }
203203 }
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+}