From c84f31eb4b24e6fb7fe040ec238df39f8d53b2d4 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Tue, 28 Apr 2026 10:59:05 +0200 Subject: [PATCH 1/2] fix(config): default APIURL to platform.grnds.io (forge subdomain doesn't exist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported live: \`grounds preview list\` failed with Get "https://forge.grnds.io/v1/preview-envs": dial tcp: lookup forge.grnds.io: no such host The default in DefaultAPIURL was wrong — there is no \`forge.grnds.io\` DNS record. The actual forge ingress is at \`platform.grnds.io\` (managed manually in Cloudflare per platform_dns_manual). Aligns with the grounds-push Gradle plugin's GROUNDS_API_URL fallback which already points to platform.grnds.io. Users who set GROUNDS_API_URL or \`apiUrl:\` in ~/.config/grounds/ config.yaml are unaffected; only those falling back to defaults were hitting NXDOMAIN. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/config/config_test.go | 2 +- internal/config/defaults.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9e361b2..1d5676e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,7 +12,7 @@ func TestLoadDefaults(t *testing.T) { if err != nil { t.Fatalf("load: %v", err) } - if cfg.APIURL != "https://forge.grnds.io" { + if cfg.APIURL != "https://platform.grnds.io" { t.Errorf("APIURL = %q", cfg.APIURL) } if cfg.DefaultTarget != "dev" { diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 7cdb534..1c40f43 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -1,7 +1,7 @@ package config const ( - DefaultAPIURL = "https://forge.grnds.io" + DefaultAPIURL = "https://platform.grnds.io" DefaultTarget = "dev" DefaultOutput = "table" DefaultColor = "auto" From b91c7d8494dda04701499c0a9d45eec575ec4a0d Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Tue, 28 Apr 2026 11:33:04 +0200 Subject: [PATCH 2/2] fix(doctor): refresh expired access tokens instead of reporting failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported live: \`grounds doctor\` kept showing ✗ auth token expired (run 'grounds login') even though every actual command (\`preview list\`, \`push\`, …) worked fine. Real commands route through FileTokenSource.Token which transparently exchanges the refresh_token for a fresh access_token; doctor was doing a static \`time.Now().After(ExpiresAt)\` check on the cached access_token and didn't try refreshing. Keycloak's defaults: access_token ≈ 5 min, refresh_token ≈ 30 d. That gap meant doctor would report "expired" most of the time the CLI sat idle, prompting the user to re-login when nothing was actually wrong. doctor now mirrors the FileTokenSource flow: - if access still valid → report it + how long it lasts + when the refresh expires - if access expired but refresh still valid → call Refresh, persist, report success ("refreshed; valid 5m0s") - if both expired → only then say "session expired" Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/grounds/commands/doctor.go | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/cmd/grounds/commands/doctor.go b/cmd/grounds/commands/doctor.go index 4efcd0c..19c68ac 100644 --- a/cmd/grounds/commands/doctor.go +++ b/cmd/grounds/commands/doctor.go @@ -78,10 +78,34 @@ func checkAuth(ctx context.Context) checkResult { if err != nil { return checkResult{name: "auth", ok: false, msg: "not logged in"} } - if time.Now().After(c.ExpiresAt) { - return checkResult{name: "auth", ok: false, msg: "token expired (run 'grounds login')"} + // The access_token Keycloak issues is short-lived (≈5 min by default) + // but the refresh_token lives much longer (≈30 d). Real commands go + // through FileTokenSource.Token which transparently refreshes; doctor + // must do the same or it reports "expired" while everything else + // works fine. Drop down to refresh + persist if needed. + if time.Now().After(c.ExpiresAt.Add(-30 * time.Second)) { + if time.Now().After(c.RefreshExpiresAt) { + return checkResult{name: "auth", ok: false, msg: "session expired (run 'grounds login')"} + } + device := &auth.DeviceClient{ + Issuer: defaultIssuer, + ClientID: defaultClientID, + HTTP: defaultHTTP(), + } + fresh, err := device.Refresh(ctx, c.RefreshToken) + if err != nil { + return checkResult{name: "auth", ok: false, msg: "refresh failed: " + err.Error() + " (run 'grounds login')"} + } + c.AccessToken = fresh.AccessToken + c.RefreshToken = fresh.RefreshToken + c.ExpiresAt = time.Now().Add(time.Duration(fresh.ExpiresIn) * time.Second) + c.RefreshExpiresAt = time.Now().Add(time.Duration(fresh.RefreshExpiresIn) * time.Second) + if err := store.Save(c); err != nil { + return checkResult{name: "auth", ok: false, msg: "refresh ok but persist failed: " + err.Error()} + } + return checkResult{name: "auth", ok: true, msg: c.PreferredUser + " (refreshed; valid " + time.Until(c.ExpiresAt).Round(time.Minute).String() + ")"} } - return checkResult{name: "auth", ok: true, msg: c.PreferredUser + " (token valid for " + time.Until(c.ExpiresAt).Round(time.Minute).String() + ")"} + return checkResult{name: "auth", ok: true, msg: c.PreferredUser + " (valid " + time.Until(c.ExpiresAt).Round(time.Minute).String() + ", refresh in " + time.Until(c.RefreshExpiresAt).Round(time.Hour).String() + ")"} } func checkAPI(ctx context.Context) checkResult {