Skip to content

Commit 86dbb5e

Browse files
fix(cli): four contained DX gaps — PAT signpost, deploy flag-parse, dup auth print, --version VCS fallback (#28)
1. PAT fallback signpost (P1). `instant login` poll-timeout error and `login --help` now point stranded users at minting a Personal Access Token (https://instanode.dev/app/settings) and authenticating via `instant --token <pat> ...` or `INSTANT_TOKEN=<pat>`. README documents the --token / INSTANT_TOKEN auth path + resolution order. Single `patWorkaroundHint` const feeds both emitters (rule 16, no drift). 2. Deploy stub flag-parse (P2). The `deploy` parent + every `newDeployStub` sub-command set FParseErrWhitelist{UnknownFlags: true}, so `instant deploy new --name foo` reaches the MCP/curl pointer instead of dying with cobra `unknown flag: --name` before RunE. 3. De-dupe auth error (P2). `instant resources` (no auth) no longer prints "Not logged in…" to os.Stderr in the handler AND the returned error via main.go — the handler is now silent and main.go owns the single print. 4. --version VCS fallback (P3). resolveBuildInfo() backfills empty ldflag commit/buildTime from runtime/debug.ReadBuildInfo() vcs.revision/vcs.time (short-SHA'd), so `go install`/`go build` binaries print the real SHA instead of `dev (unknown, unknown)`. Sentinel preserved when no VCS data (go run / -buildvcs=false). Tests: cmd/dx_gaps_2026_06_10_test.go covers all four; fixes 2 & 3 verified red-without-fix / green-with-fix. Gate green: go build ./... + go vet ./... + go test ./... -short + golangci-lint run ./... (0 issues). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3adcb59 commit 86dbb5e

6 files changed

Lines changed: 412 additions & 12 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ instant login # Log in to your instanode.dev account
4444
instant whoami # Show current account
4545
```
4646

47+
### Authentication
48+
49+
`instant login` runs a browser device-flow and saves your credentials to
50+
`~/.instant-config`. If that flow times out, or you're on a headless box,
51+
skip it entirely with a **Personal Access Token**: mint one at
52+
[instanode.dev/app/settings](https://instanode.dev/app/settings), then
53+
authenticate any command in one of two ways:
54+
55+
```bash
56+
instant --token <pat> resources # per-invocation flag (highest priority)
57+
export INSTANT_TOKEN=<pat> # environment variable for the session
58+
instant resources
59+
```
60+
61+
Resolution order is `--token` flag → `INSTANT_TOKEN` env var → saved
62+
`instant login` credentials. Both PAT paths skip the browser entirely, so
63+
they're the recommended auth for CI and agent scripts.
64+
4765
### Targeting an environment
4866

4967
Every `new` verb accepts an optional `--env` flag that the API honors

cmd/deploy_stub.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ Track the upcoming native CLI support at:
7474
https://github.com/InstaNode-dev/cli/issues
7575
`,
7676
Args: cobra.NoArgs,
77+
// B15-P2 follow-up: tolerate unknown flags so `instant deploy --name foo`
78+
// (or any flag an agent reaches for) lands on the helpful MCP/curl pointer
79+
// instead of dying with cobra's `unknown flag: --name` BEFORE RunE runs.
80+
// These are stub commands with no real flags of their own; the whole point
81+
// is that any invocation shape reaches the "use this instead" message.
82+
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
7783
RunE: func(cmd *cobra.Command, args []string) error {
7884
// Print the long help (covers the alternative-surface pointers)
7985
// and exit non-zero so scripts that test exit code don't proceed
@@ -94,6 +100,13 @@ func newDeployStub(verb, extra string) *cobra.Command {
94100
Use: verb,
95101
Short: short,
96102
Args: cobra.ArbitraryArgs,
103+
// Tolerate unknown flags (e.g. `instant deploy new --name foo --env
104+
// production`) so the invocation reaches the MCP/curl pointer below
105+
// instead of dying with cobra's `unknown flag: --name` pre-RunE. An
106+
// agent that reflexively passes the real deploy flags must still land
107+
// on the "use this instead" message — not a flag-parse error that
108+
// looks like a bug in its own command construction.
109+
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
97110
RunE: func(cmd *cobra.Command, args []string) error {
98111
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
99112
"`instant deploy %s` is not yet implemented in the CLI.\n"+

cmd/discover.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,13 @@ func runResources(cmd *cobra.Command) error {
9696
if haveAuth() {
9797
return errSessionExpired()
9898
}
99-
// In JSON mode the envelope is the only signal; skip the stderr
100-
// hint so a `--json | jq` pipeline isn't disturbed.
101-
if !jsonModeOn(cmd) {
102-
fmt.Fprintln(os.Stderr, "Not logged in. Run `instant login` first.")
103-
}
99+
// De-dupe: previously this branch ALSO printed "Not logged in. Run
100+
// `instant login` first." to stderr, then returned errAuthRequired —
101+
// whose message main.go (run → Fprintln(stderr, err)) prints again.
102+
// The user saw the not-logged-in guidance twice. Return the error
103+
// silently and let main.go own the single print. (The errAuthRequired
104+
// message already names `instant login`, so no guidance is lost; JSON
105+
// mode is unaffected because nothing extra is written to stderr.)
104106
return errAuthRequired("authentication required — run `instant login` first")
105107
}
106108

cmd/dx_gaps_2026_06_10_test.go

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
package cmd
2+
3+
// dx_gaps_2026_06_10_test.go — four contained DX-gap fixes (2026-06-10):
4+
//
5+
// 1. PAT fallback signpost on `instant login` poll timeout AND in
6+
// `login --help`. A stranded device-flow user must learn the
7+
// --token / INSTANT_TOKEN escape hatch.
8+
// 2. `instant deploy new --name foo` must reach the helpful MCP/curl
9+
// pointer instead of dying with cobra `unknown flag: --name`.
10+
// 3. `instant resources` (no auth) must print the "not logged in"
11+
// guidance EXACTLY ONCE (main.go owns the print; the handler no
12+
// longer also writes to os.Stderr).
13+
// 4. `instant --version` falls back to runtime/debug VCS metadata when
14+
// ldflags are empty (the `go install` / `go build` path).
15+
16+
import (
17+
"runtime/debug"
18+
"strings"
19+
"testing"
20+
)
21+
22+
// ── Fix 1: PAT fallback signpost ────────────────────────────────────────────
23+
24+
// TestLoginTimeout_PrintsPATWorkaround pins that the login-poll timeout error
25+
// surfaces the Personal Access Token escape hatch (settings URL + both
26+
// --token and INSTANT_TOKEN forms). A user whose browser flow stalls must be
27+
// told how to authenticate without it.
28+
func TestLoginTimeout_PrintsPATWorkaround(t *testing.T) {
29+
withCleanState(t)
30+
withShortPolls(t)
31+
32+
prev := APIBaseURL
33+
// Unroutable host → every poll attempt errors and the loop eventually
34+
// hits the deadline, returning the timeout error.
35+
APIBaseURL = "http://127.0.0.1:1"
36+
t.Cleanup(func() { APIBaseURL = prev })
37+
38+
_, err := pollForAuthCompletion("s1")
39+
if err == nil {
40+
t.Fatal("expected timeout error")
41+
}
42+
msg := err.Error()
43+
for _, want := range []string{
44+
"https://instanode.dev/app/settings",
45+
"--token",
46+
"INSTANT_TOKEN",
47+
} {
48+
if !strings.Contains(msg, want) {
49+
t.Errorf("timeout error must mention %q for the PAT workaround; got %q", want, msg)
50+
}
51+
}
52+
}
53+
54+
// TestLoginHelp_DocumentsPATWorkaround pins that `instant login --help`
55+
// (the Long text) tells the user about the PAT escape hatch before they
56+
// even start a flow that might time out / fail on a headless box.
57+
func TestLoginHelp_DocumentsPATWorkaround(t *testing.T) {
58+
long := loginCmd.Long
59+
for _, want := range []string{
60+
"https://instanode.dev/app/settings",
61+
"--token",
62+
"INSTANT_TOKEN",
63+
} {
64+
if !strings.Contains(long, want) {
65+
t.Errorf("login --help Long must mention %q; got %q", want, long)
66+
}
67+
}
68+
}
69+
70+
// TestPATHint_SingleSource guards rule 16 (one token, all sites): the timeout
71+
// error and the help text must both derive their core sentence from the same
72+
// const so they can never drift apart.
73+
func TestPATHint_SingleSource(t *testing.T) {
74+
if !strings.Contains(patWorkaroundHint, "https://instanode.dev/app/settings") {
75+
t.Errorf("patWorkaroundHint missing settings URL: %q", patWorkaroundHint)
76+
}
77+
if !strings.Contains(patWorkaroundHint, "--token") ||
78+
!strings.Contains(patWorkaroundHint, "INSTANT_TOKEN") {
79+
t.Errorf("patWorkaroundHint missing one of the two PAT auth forms: %q", patWorkaroundHint)
80+
}
81+
}
82+
83+
// ── Fix 2: deploy stub tolerates unknown flags ──────────────────────────────
84+
85+
// TestDeployStub_UnknownFlagReachesPointer pins that an agent passing the
86+
// REAL deploy flags (`--name`, `--env`) to the stub still lands on the
87+
// helpful MCP/curl pointer rather than cobra's `unknown flag: --name`
88+
// flag-parse error (which fires BEFORE RunE and looks like a bug in the
89+
// agent's own command).
90+
func TestDeployStub_UnknownFlagReachesPointer(t *testing.T) {
91+
newITContext(t)
92+
93+
cases := [][]string{
94+
{"deploy", "new", "--name", "foo"},
95+
{"deploy", "new", "--name", "foo", "--env", "production"},
96+
{"deploy", "logs", "some-id", "--follow"},
97+
{"deploy", "--name", "foo"}, // bare parent + unknown flag
98+
}
99+
for _, args := range cases {
100+
args := args
101+
t.Run(strings.Join(args, "_"), func(t *testing.T) {
102+
_, stderr, err := run(args...)
103+
if err == nil {
104+
t.Fatalf("%v: MUST exit non-zero (stub not implemented)", args)
105+
}
106+
// The whole point: the error must be the not-implemented
107+
// pointer, NOT an "unknown flag" flag-parse failure.
108+
combined := strings.ToLower(stderr + err.Error())
109+
if strings.Contains(combined, "unknown flag") {
110+
t.Errorf("%v: reached cobra unknown-flag error instead of the pointer; got %q",
111+
args, combined)
112+
}
113+
if !strings.Contains(combined, "implement") {
114+
t.Errorf("%v: error must mention not-implemented pointer; got %q", args, combined)
115+
}
116+
})
117+
}
118+
}
119+
120+
// ── Fix 3: single not-logged-in print ───────────────────────────────────────
121+
122+
// TestResources_NoAuth_NoDuplicateStderrHint pins that the resources 401
123+
// no-auth branch no longer ALSO writes "Not logged in. Run `instant login`
124+
// first." to os.Stderr. Pre-fix the handler printed that sentence AND main.go
125+
// printed the returned errAuthRequired message — two not-logged-in lines for
126+
// one failure. main.go now owns the single print; the handler is silent.
127+
//
128+
// We capture the OS-level stderr (the pre-fix print used fmt.Fprintln(os.Stderr,
129+
// …), which bypasses cobra's SetErr buffer that run() captures).
130+
func TestResources_NoAuth_NoDuplicateStderrHint(t *testing.T) {
131+
c := newITContext(t)
132+
c.mock.mu.Lock()
133+
c.mock.requireAuth = true
134+
c.mock.mu.Unlock()
135+
136+
resetJSONFlags()
137+
138+
var err error
139+
_, osStderr := captureStdout(t, func() {
140+
_, _, err = run("resources")
141+
})
142+
if err == nil {
143+
t.Fatal("resources w/o auth: expected non-nil err (exit 3)")
144+
}
145+
if ExitCodeFor(err) != ExitAuthRequired {
146+
t.Errorf("exit code = %d, want %d (ExitAuthRequired)", ExitCodeFor(err), ExitAuthRequired)
147+
}
148+
// The handler must no longer write the legacy "Not logged in." sentence
149+
// to os.Stderr; main.go owns the single user-facing print.
150+
if strings.Contains(osStderr, "Not logged in. Run") {
151+
t.Errorf("handler must not print the legacy not-logged-in hint to os.Stderr (main.go owns it); got %q",
152+
osStderr)
153+
}
154+
// The returned error still names `instant login` so no guidance is lost.
155+
if !strings.Contains(err.Error(), "instant login") {
156+
t.Errorf("returned error must still point at `instant login`; got %q", err.Error())
157+
}
158+
}
159+
160+
// ── Fix 4: --version VCS fallback ───────────────────────────────────────────
161+
162+
// fakeBuildInfo builds a *debug.BuildInfo carrying the given vcs settings so
163+
// resolveBuildInfo's backfill path is exercised without a real git checkout.
164+
func fakeBuildInfo(rev, vtime string) func() (*debug.BuildInfo, bool) {
165+
return func() (*debug.BuildInfo, bool) {
166+
bi := &debug.BuildInfo{}
167+
if rev != "" {
168+
bi.Settings = append(bi.Settings, debug.BuildSetting{Key: "vcs.revision", Value: rev})
169+
}
170+
if vtime != "" {
171+
bi.Settings = append(bi.Settings, debug.BuildSetting{Key: "vcs.time", Value: vtime})
172+
}
173+
return bi, true
174+
}
175+
}
176+
177+
// TestResolveBuildInfo_LdflagsWin: when ldflags are stamped, the VCS reader is
178+
// never consulted — the stamped values pass straight through.
179+
func TestResolveBuildInfo_LdflagsWin(t *testing.T) {
180+
v, c, b := resolveBuildInfo("v1.2.3", "abc1234", "2026-06-10T00:00:00Z",
181+
fakeBuildInfo("ffffffffffffffffffffffffffffffffffffffff", "2099-01-01T00:00:00Z"))
182+
if v != "v1.2.3" || c != "abc1234" || b != "2026-06-10T00:00:00Z" {
183+
t.Errorf("ldflag values must win, got (%q,%q,%q)", v, c, b)
184+
}
185+
}
186+
187+
// TestResolveBuildInfo_VCSFallback: empty commit/buildTime (the go install /
188+
// go build path) are backfilled from VCS metadata, and the revision is
189+
// shortened to the platform's 7-char form.
190+
func TestResolveBuildInfo_VCSFallback(t *testing.T) {
191+
v, c, b := resolveBuildInfo("", "", "",
192+
fakeBuildInfo("0123456789abcdef0123456789abcdef01234567", "2026-06-09T12:00:00Z"))
193+
if v != "dev" {
194+
t.Errorf("version = %q, want dev (no ldflag, no VCS version)", v)
195+
}
196+
if c != "0123456" {
197+
t.Errorf("commit = %q, want short VCS sha 0123456", c)
198+
}
199+
if b != "2026-06-09T12:00:00Z" {
200+
t.Errorf("buildTime = %q, want VCS time", b)
201+
}
202+
}
203+
204+
// TestResolveBuildInfo_SentinelInputFallsBack pins the REAL production path:
205+
// main.go declares the un-stamped defaults as the SENTINELS "dev"/"unknown"
206+
// (not ""), so SetBuildInfo passes sentinels. The fallback must treat those
207+
// as unset and still backfill from VCS — a naive `== ""` guard would not.
208+
func TestResolveBuildInfo_SentinelInputFallsBack(t *testing.T) {
209+
v, c, b := resolveBuildInfo("dev", "unknown", "unknown",
210+
fakeBuildInfo("abcdef0123456789abcdef0123456789abcdef01", "2026-06-08T08:00:00Z"))
211+
if v != "dev" {
212+
t.Errorf("version = %q, want dev", v)
213+
}
214+
if c != "abcdef0" {
215+
t.Errorf("commit = %q, want short VCS sha abcdef0 (sentinel must not block fallback)", c)
216+
}
217+
if b != "2026-06-08T08:00:00Z" {
218+
t.Errorf("buildTime = %q, want VCS time (sentinel must not block fallback)", b)
219+
}
220+
}
221+
222+
// TestResolveBuildInfo_NoVCSNoLdflags: nothing available anywhere → the
223+
// pre-existing dev/unknown/unknown sentinel is preserved (e.g. `go run`).
224+
func TestResolveBuildInfo_NoVCSNoLdflags(t *testing.T) {
225+
v, c, b := resolveBuildInfo("", "", "", func() (*debug.BuildInfo, bool) { return nil, false })
226+
if v != "dev" || c != "unknown" || b != "unknown" {
227+
t.Errorf("sentinel fallback broken, got (%q,%q,%q)", v, c, b)
228+
}
229+
// nil reader must also be tolerated.
230+
v, c, b = resolveBuildInfo("", "", "", nil)
231+
if v != "dev" || c != "unknown" || b != "unknown" {
232+
t.Errorf("nil-reader fallback broken, got (%q,%q,%q)", v, c, b)
233+
}
234+
}
235+
236+
// TestShortSHA_ShortInputUnchanged: a sub-7-char revision is returned as-is so
237+
// an unexpected/dirty value still surfaces rather than being mangled.
238+
func TestShortSHA_ShortInputUnchanged(t *testing.T) {
239+
if got := shortSHA("abc"); got != "abc" {
240+
t.Errorf("shortSHA(abc) = %q, want abc", got)
241+
}
242+
if got := shortSHA("0123456789"); got != "0123456" {
243+
t.Errorf("shortSHA truncation = %q, want 0123456", got)
244+
}
245+
}
246+
247+
// TestSetBuildInfo_VersionStringShape is an end-to-end smoke over SetBuildInfo
248+
// (which calls resolveBuildInfo with the real debug.ReadBuildInfo): the
249+
// resulting rootCmd.Version is the documented "<v> (<c>, <t>)" shape and is
250+
// never the empty-paren placeholder.
251+
func TestSetBuildInfo_VersionStringShape(t *testing.T) {
252+
prev := rootCmd.Version
253+
t.Cleanup(func() { rootCmd.Version = prev })
254+
255+
SetBuildInfo("v9.9.9", "deadbee", "2026-06-10T00:00:00Z")
256+
if rootCmd.Version != "v9.9.9 (deadbee, 2026-06-10T00:00:00Z)" {
257+
t.Errorf("rootCmd.Version = %q", rootCmd.Version)
258+
}
259+
260+
// Sentinel ldflags (main.go's un-stamped defaults) → resolveBuildInfo
261+
// runs against the test binary's real build info. The shape must still
262+
// be well-formed (never "(, )") and must start with the dev sentinel.
263+
// (Under `go test` the binary carries no runtime vcs settings, so this
264+
// lands on dev (unknown, unknown) — still well-formed.)
265+
SetBuildInfo("dev", "unknown", "unknown")
266+
if !strings.HasPrefix(rootCmd.Version, "dev (") || !strings.HasSuffix(rootCmd.Version, ")") {
267+
t.Errorf("sentinel-ldflag Version malformed: %q", rootCmd.Version)
268+
}
269+
if strings.Contains(rootCmd.Version, "(, )") {
270+
t.Errorf("Version must never have empty commit+time fields: %q", rootCmd.Version)
271+
}
272+
}

cmd/login.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ import (
1818
"github.com/InstaNode-dev/cli/internal/tokens"
1919
)
2020

21+
// patWorkaroundHint is the single source of truth for the "your login timed
22+
// out — mint a PAT instead" signpost. It is surfaced in two places: the
23+
// pollForAuthCompletion timeout error (so a stranded user sees it the moment
24+
// the browser flow stalls) and the `instant login --help` long text (so a
25+
// user who already knows the device-flow is flaky finds the escape hatch
26+
// before they even start). The CLI already honours --token / INSTANT_TOKEN
27+
// (root.go initConfig priority chain) and instanode-web mints PATs at
28+
// /app/settings — but until this hint shipped the CLI never told a timed-out
29+
// user that path existed. Kept as a package const (not an inline literal) so
30+
// the two emitters can never drift (CLAUDE.md rule 16 — one token, all sites).
31+
const patWorkaroundHint = "Login timed out. Workaround: mint a Personal Access Token at " +
32+
"https://instanode.dev/app/settings and run `instant --token <pat> ...` or " +
33+
"`export INSTANT_TOKEN=<pat>`."
34+
2135
// pollInterval is how often the CLI checks for auth completion.
2236
//
2337
// Declared as var (not const) so tests can lower it to milliseconds without
@@ -47,6 +61,11 @@ Subsequent commands will use it automatically for authenticated API calls.
4761
4862
If you upgrade to a paid plan, run `+"`instant login`"+` again to refresh
4963
your tier — or the CLI will detect it automatically on the next API call.
64+
65+
If the browser flow times out or you're on a headless machine, skip it:
66+
mint a Personal Access Token at https://instanode.dev/app/settings, then
67+
authenticate any command with `+"`instant --token <pat> ...`"+` or by exporting
68+
`+"`INSTANT_TOKEN=<pat>`"+` in your shell.
5069
`,
5170
RunE: runLogin,
5271
}
@@ -244,7 +263,8 @@ func pollForAuthCompletion(sessionID string) (*authResult, error) {
244263
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, raw)
245264
}
246265

247-
return nil, fmt.Errorf("timed out waiting for login after %.0f minutes; try again", pollTimeout.Minutes())
266+
return nil, fmt.Errorf("timed out waiting for login after %.0f minutes; try again.\n%s",
267+
pollTimeout.Minutes(), patWorkaroundHint)
248268
}
249269

250270
// pollForTierUpgrade polls GET /auth/me until the tier changes, up to 5 minutes.

0 commit comments

Comments
 (0)