fix: support first-user-trial in non-interactive login (#28321)

## Summary

`coder login --first-user-trial=true` was unusable in non-interactive
environments: the trial info fields could only be collected via
interactive prompts, so scripted setup failed with a bare `EOF`.

This adds flags and env vars for every trial info field, extracts the
functions into a helper, turns the EOF into an actionable error, and
fixes a related quirk where enabling the trial via env didn't reliably
suppress the trial prompt.

Fixes coder/coder#23264

## Changes

* Add a flag + env var for each trial field (matching the existing
`first-user-*` convention):
`--first-user-trial-{first-name,last-name,phone-number,job-title,company-name,country,developers}`
and their `CODER_FIRST_USER_TRIAL_*` env vars.
* When a trial field is missing with no input available, return an error
naming the exact flag/env instead of a raw EOF.
* Read `CODER_FIRST_USER_TRIAL` from `inv.Environ` instead of
`os.Getenv` so the flag binding and the "Start a trial?" prompt gate
agree (previously the env toggle could hit an EOF prompt that silently
reset `trial` to `false`).
* Regenerate `coder_login_--help.golden` and
`docs/reference/cli/login.md`.

## Testing

* Non-interactive login via flags and via env vars (env test asserts the
trial is actually provisioned via a recording `TrialGenerator`).
* Missing trial info returns the clear flag-naming error instead of
hanging.
* Existing interactive `TestLogin` subtests pass; `golangci-lint` clean.

## Notes

No strict CLI validation of `country`/`developers` against the UI option
lists: the server doesn't enforce them, and the existing fixture uses
`developers=10-50` which isn't in the CLI bucket list. Can add if
preferred.

---

*Created by Coder Agents on behalf of* @snagles\*.\*
This commit is contained in:
Stefan Naglee
2026-08-31 11:35:59 -04:00
committed by GitHub
parent 9c2e7741be
commit 1fa6ebe5c3
4 changed files with 338 additions and 43 deletions
+100 -43
View File
@@ -148,6 +148,7 @@ func (r *RootCmd) login() *serpent.Command {
name string
password string
trial bool
trialInfo codersdk.CreateFirstUserTrialInfo
useTokenForSession bool
)
cmd := &serpent.Command{
@@ -271,7 +272,7 @@ func (r *RootCmd) login() *serpent.Command {
}
}
if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
if !inv.ParsedFlags().Changed("first-user-trial") && inv.Environ.Get(firstUserTrialEnv) == "" {
v, _ := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Start a trial of Enterprise?",
IsConfirm: true,
@@ -280,49 +281,9 @@ func (r *RootCmd) login() *serpent.Command {
trial = v == "yes" || v == "y"
}
var trialInfo codersdk.CreateFirstUserTrialInfo
if trial {
if trialInfo.FirstName == "" {
trialInfo.FirstName, err = promptTrialInfo(inv, "firstName")
if err != nil {
return err
}
}
if trialInfo.LastName == "" {
trialInfo.LastName, err = promptTrialInfo(inv, "lastName")
if err != nil {
return err
}
}
if trialInfo.PhoneNumber == "" {
trialInfo.PhoneNumber, err = promptTrialInfo(inv, "phoneNumber")
if err != nil {
return err
}
}
if trialInfo.JobTitle == "" {
trialInfo.JobTitle, err = promptTrialInfo(inv, "jobTitle")
if err != nil {
return err
}
}
if trialInfo.CompanyName == "" {
trialInfo.CompanyName, err = promptTrialInfo(inv, "companyName")
if err != nil {
return err
}
}
if trialInfo.Country == "" {
trialInfo.Country, err = promptCountry(inv)
if err != nil {
return err
}
}
if trialInfo.Developers == "" {
trialInfo.Developers, err = promptDevelopers(inv)
if err != nil {
return err
}
if err := collectFirstUserTrialInfo(inv, &trialInfo); err != nil {
return err
}
}
@@ -475,6 +436,48 @@ func (r *RootCmd) login() *serpent.Command {
Description: "Specifies whether a trial license should be provisioned for the Coder deployment or not.",
Value: serpent.BoolOf(&trial),
},
{
Flag: "first-user-trial-first-name",
Env: "CODER_FIRST_USER_TRIAL_FIRST_NAME",
Description: "Specifies the first name of the first user for the deployment. Only used if --first-user-trial is set.",
Value: serpent.StringOf(&trialInfo.FirstName),
},
{
Flag: "first-user-trial-last-name",
Env: "CODER_FIRST_USER_TRIAL_LAST_NAME",
Description: "Specifies the last name of the first user for the deployment. Only used if --first-user-trial is set.",
Value: serpent.StringOf(&trialInfo.LastName),
},
{
Flag: "first-user-trial-phone-number",
Env: "CODER_FIRST_USER_TRIAL_PHONE_NUMBER",
Description: "Specifies the phone number of the first user for the deployment. Only used if --first-user-trial is set.",
Value: serpent.StringOf(&trialInfo.PhoneNumber),
},
{
Flag: "first-user-trial-job-title",
Env: "CODER_FIRST_USER_TRIAL_JOB_TITLE",
Description: "Specifies the job title of the first user for the deployment. Only used if --first-user-trial is set.",
Value: serpent.StringOf(&trialInfo.JobTitle),
},
{
Flag: "first-user-trial-company-name",
Env: "CODER_FIRST_USER_TRIAL_COMPANY_NAME",
Description: "Specifies the company name of the first user for the deployment. Only used if --first-user-trial is set.",
Value: serpent.StringOf(&trialInfo.CompanyName),
},
{
Flag: "first-user-trial-country",
Env: "CODER_FIRST_USER_TRIAL_COUNTRY",
Description: "Specifies the country of the first user for the deployment. Only used if --first-user-trial is set.",
Value: serpent.StringOf(&trialInfo.Country),
},
{
Flag: "first-user-trial-developers",
Env: "CODER_FIRST_USER_TRIAL_DEVELOPERS",
Description: "Specifies the number of developers using the deployment. Only used if --first-user-trial is set.",
Value: serpent.StringOf(&trialInfo.Developers),
},
{
Flag: "use-token-as-session",
Description: "By default, the CLI will generate a new session token when logging in. This flag will instead use the provided token as the session token.",
@@ -580,6 +583,60 @@ func openURL(inv *serpent.Invocation, urlToOpen string) error {
return browser.OpenURL(urlToOpen)
}
// collectFirstUserTrialInfo fills in any trial info fields that were not
// already supplied via CLI flags or environment variables.
//
// In a non-interactive environment (for example an automation script) there
// is no input to prompt against. Some prompts surface that as an io.EOF, but
// the country and developers selects run through bubbletea, which swallows
// io.EOF and would block forever waiting for input that never arrives.
// To avoid hanging, non-interactivity is decided up front via isTTYIn: when
// there is no TTY, every missing field is reported in a single actionable
// error naming the flag and environment variable to set, instead of
// prompting.
func collectFirstUserTrialInfo(inv *serpent.Invocation, info *codersdk.CreateFirstUserTrialInfo) error {
fields := []struct {
flag string
env string
value *string
prompt func(*serpent.Invocation) (string, error)
}{
{"first-user-trial-first-name", "CODER_FIRST_USER_TRIAL_FIRST_NAME", &info.FirstName, func(inv *serpent.Invocation) (string, error) { return promptTrialInfo(inv, "firstName") }},
{"first-user-trial-last-name", "CODER_FIRST_USER_TRIAL_LAST_NAME", &info.LastName, func(inv *serpent.Invocation) (string, error) { return promptTrialInfo(inv, "lastName") }},
{"first-user-trial-phone-number", "CODER_FIRST_USER_TRIAL_PHONE_NUMBER", &info.PhoneNumber, func(inv *serpent.Invocation) (string, error) { return promptTrialInfo(inv, "phoneNumber") }},
{"first-user-trial-job-title", "CODER_FIRST_USER_TRIAL_JOB_TITLE", &info.JobTitle, func(inv *serpent.Invocation) (string, error) { return promptTrialInfo(inv, "jobTitle") }},
{"first-user-trial-company-name", "CODER_FIRST_USER_TRIAL_COMPANY_NAME", &info.CompanyName, func(inv *serpent.Invocation) (string, error) { return promptTrialInfo(inv, "companyName") }},
{"first-user-trial-country", "CODER_FIRST_USER_TRIAL_COUNTRY", &info.Country, promptCountry},
{"first-user-trial-developers", "CODER_FIRST_USER_TRIAL_DEVELOPERS", &info.Developers, promptDevelopers},
}
if !isTTYIn(inv) {
var missing []string
for _, f := range fields {
if *f.value == "" {
missing = append(missing, fmt.Sprintf("--%s (or %s)", f.flag, f.env))
}
}
if len(missing) > 0 {
return xerrors.Errorf("--first-user-trial requires trial info that cannot be prompted for in a non-interactive environment; set %s", strings.Join(missing, ", "))
}
return nil
}
for _, f := range fields {
if *f.value != "" {
continue
}
value, err := f.prompt(inv)
if err != nil {
return err
}
*f.value = value
}
return nil
}
func promptTrialInfo(inv *serpent.Invocation, fieldName string) (string, error) {
value, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: fmt.Sprintf("Please enter %s:", pretty.Sprint(cliui.DefaultStyles.Field, fieldName)),
+147
View File
@@ -283,6 +283,7 @@ func TestLogin(t *testing.T) {
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--force-tty",
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-full-name", coderdtest.FirstUserParams.Name,
"--first-user-email", coderdtest.FirstUserParams.Email,
@@ -325,6 +326,7 @@ func TestLogin(t *testing.T) {
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--force-tty",
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-email", coderdtest.FirstUserParams.Email,
"--first-user-password", coderdtest.FirstUserParams.Password,
@@ -360,6 +362,151 @@ func TestLogin(t *testing.T) {
assert.Empty(t, me.Name)
})
t.Run("InitialUserTrialFlagsNonInteractive", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-full-name", coderdtest.FirstUserParams.Name,
"--first-user-email", coderdtest.FirstUserParams.Email,
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
"--first-user-trial-first-name", coderdtest.TrialUserParams.FirstName,
"--first-user-trial-last-name", coderdtest.TrialUserParams.LastName,
"--first-user-trial-phone-number", coderdtest.TrialUserParams.PhoneNumber,
"--first-user-trial-job-title", coderdtest.TrialUserParams.JobTitle,
"--first-user-trial-company-name", coderdtest.TrialUserParams.CompanyName,
"--first-user-trial-country", coderdtest.TrialUserParams.Country,
"--first-user-trial-developers", coderdtest.TrialUserParams.Developers,
)
ctx := testutil.Context(t, testutil.WaitMedium)
// No PTY is attached, so this exercises the non-interactive path.
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
})
require.NoError(t, err)
client.SetSessionToken(resp.SessionToken)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
})
t.Run("InitialUserTrialFlagsNonInteractiveEnv", func(t *testing.T) {
t.Parallel()
var gotTrial codersdk.LicensorTrialRequest
trialCalled := false
client := coderdtest.New(t, &coderdtest.Options{
TrialGenerator: func(_ context.Context, req codersdk.LicensorTrialRequest) error {
trialCalled = true
gotTrial = req
return nil
},
})
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-full-name", coderdtest.FirstUserParams.Name,
"--first-user-email", coderdtest.FirstUserParams.Email,
"--first-user-password", coderdtest.FirstUserParams.Password,
)
// Enabling the trial purely through the environment must suppress
// the interactive "Start a trial?" prompt and still collect the
// trial info from the environment.
inv.Environ.Set("CODER_FIRST_USER_TRIAL", "true")
inv.Environ.Set("CODER_FIRST_USER_TRIAL_FIRST_NAME", coderdtest.TrialUserParams.FirstName)
inv.Environ.Set("CODER_FIRST_USER_TRIAL_LAST_NAME", coderdtest.TrialUserParams.LastName)
inv.Environ.Set("CODER_FIRST_USER_TRIAL_PHONE_NUMBER", coderdtest.TrialUserParams.PhoneNumber)
inv.Environ.Set("CODER_FIRST_USER_TRIAL_JOB_TITLE", coderdtest.TrialUserParams.JobTitle)
inv.Environ.Set("CODER_FIRST_USER_TRIAL_COMPANY_NAME", coderdtest.TrialUserParams.CompanyName)
inv.Environ.Set("CODER_FIRST_USER_TRIAL_COUNTRY", coderdtest.TrialUserParams.Country)
inv.Environ.Set("CODER_FIRST_USER_TRIAL_DEVELOPERS", coderdtest.TrialUserParams.Developers)
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// The trial must actually be provisioned, proving the env toggle was
// honored and the prompt was skipped.
require.True(t, trialCalled)
assert.Equal(t, coderdtest.FirstUserParams.Email, gotTrial.Email)
assert.Equal(t, coderdtest.TrialUserParams.FirstName, gotTrial.FirstName)
assert.Equal(t, coderdtest.TrialUserParams.LastName, gotTrial.LastName)
assert.Equal(t, coderdtest.TrialUserParams.PhoneNumber, gotTrial.PhoneNumber)
assert.Equal(t, coderdtest.TrialUserParams.JobTitle, gotTrial.JobTitle)
assert.Equal(t, coderdtest.TrialUserParams.CompanyName, gotTrial.CompanyName)
assert.Equal(t, coderdtest.TrialUserParams.Country, gotTrial.Country)
assert.Equal(t, coderdtest.TrialUserParams.Developers, gotTrial.Developers)
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: coderdtest.FirstUserParams.Password,
})
require.NoError(t, err)
client.SetSessionToken(resp.SessionToken)
me, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
})
t.Run("InitialUserTrialNonInteractiveMissingInfo", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-full-name", coderdtest.FirstUserParams.Name,
"--first-user-email", coderdtest.FirstUserParams.Email,
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
)
ctx := testutil.Context(t, testutil.WaitMedium)
// No PTY is attached and the trial info flags are missing, so the
// command must fail fast instead of blocking on a prompt that can
// never receive input.
err := inv.WithContext(ctx).Run()
require.Error(t, err)
require.ErrorContains(t, err, "non-interactive")
// Every missing field must be reported in a single error.
require.ErrorContains(t, err, "--first-user-trial-first-name")
require.ErrorContains(t, err, "CODER_FIRST_USER_TRIAL_FIRST_NAME")
require.ErrorContains(t, err, "--first-user-trial-developers")
require.ErrorContains(t, err, "CODER_FIRST_USER_TRIAL_DEVELOPERS")
})
t.Run("InitialUserTrialNonInteractiveMissingSelectInfo", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
// Supply every text field but omit the two select-backed fields
// (country and developers). These go through bubbletea, which
// swallows io.EOF and would otherwise hang forever; they must
// instead produce the same actionable error as the text fields.
inv, _ := clitest.New(
t, "login", client.URL.String(),
"--first-user-username", coderdtest.FirstUserParams.Username,
"--first-user-full-name", coderdtest.FirstUserParams.Name,
"--first-user-email", coderdtest.FirstUserParams.Email,
"--first-user-password", coderdtest.FirstUserParams.Password,
"--first-user-trial",
"--first-user-trial-first-name", coderdtest.TrialUserParams.FirstName,
"--first-user-trial-last-name", coderdtest.TrialUserParams.LastName,
"--first-user-trial-phone-number", coderdtest.TrialUserParams.PhoneNumber,
"--first-user-trial-job-title", coderdtest.TrialUserParams.JobTitle,
"--first-user-trial-company-name", coderdtest.TrialUserParams.CompanyName,
)
ctx := testutil.Context(t, testutil.WaitMedium)
err := inv.WithContext(ctx).Run()
require.Error(t, err)
require.ErrorContains(t, err, "--first-user-trial-country")
require.ErrorContains(t, err, "CODER_FIRST_USER_TRIAL_COUNTRY")
require.ErrorContains(t, err, "--first-user-trial-developers")
require.ErrorContains(t, err, "CODER_FIRST_USER_TRIAL_DEVELOPERS")
// The satisfied text fields must not be named as missing.
require.NotContains(t, err.Error(), "--first-user-trial-first-name")
})
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
+28
View File
@@ -28,6 +28,34 @@ OPTIONS:
Specifies whether a trial license should be provisioned for the Coder
deployment or not.
--first-user-trial-company-name string, $CODER_FIRST_USER_TRIAL_COMPANY_NAME
Specifies the company name of the first user for the deployment. Only
used if --first-user-trial is set.
--first-user-trial-country string, $CODER_FIRST_USER_TRIAL_COUNTRY
Specifies the country of the first user for the deployment. Only used
if --first-user-trial is set.
--first-user-trial-developers string, $CODER_FIRST_USER_TRIAL_DEVELOPERS
Specifies the number of developers using the deployment. Only used if
--first-user-trial is set.
--first-user-trial-first-name string, $CODER_FIRST_USER_TRIAL_FIRST_NAME
Specifies the first name of the first user for the deployment. Only
used if --first-user-trial is set.
--first-user-trial-job-title string, $CODER_FIRST_USER_TRIAL_JOB_TITLE
Specifies the job title of the first user for the deployment. Only
used if --first-user-trial is set.
--first-user-trial-last-name string, $CODER_FIRST_USER_TRIAL_LAST_NAME
Specifies the last name of the first user for the deployment. Only
used if --first-user-trial is set.
--first-user-trial-phone-number string, $CODER_FIRST_USER_TRIAL_PHONE_NUMBER
Specifies the phone number of the first user for the deployment. Only
used if --first-user-trial is set.
--first-user-username string, $CODER_FIRST_USER_USERNAME
Specifies a username to use if creating the first user for the
deployment.
+63
View File
@@ -73,6 +73,69 @@ Specifies a password to use if creating the first user for the deployment.
Specifies whether a trial license should be provisioned for the Coder deployment or not.
### --first-user-trial-first-name
| | |
|-------------|-------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_TRIAL_FIRST_NAME</code> |
Specifies the first name of the first user for the deployment. Only used if --first-user-trial is set.
### --first-user-trial-last-name
| | |
|-------------|------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_TRIAL_LAST_NAME</code> |
Specifies the last name of the first user for the deployment. Only used if --first-user-trial is set.
### --first-user-trial-phone-number
| | |
|-------------|---------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_TRIAL_PHONE_NUMBER</code> |
Specifies the phone number of the first user for the deployment. Only used if --first-user-trial is set.
### --first-user-trial-job-title
| | |
|-------------|------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_TRIAL_JOB_TITLE</code> |
Specifies the job title of the first user for the deployment. Only used if --first-user-trial is set.
### --first-user-trial-company-name
| | |
|-------------|---------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_TRIAL_COMPANY_NAME</code> |
Specifies the company name of the first user for the deployment. Only used if --first-user-trial is set.
### --first-user-trial-country
| | |
|-------------|----------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_TRIAL_COUNTRY</code> |
Specifies the country of the first user for the deployment. Only used if --first-user-trial is set.
### --first-user-trial-developers
| | |
|-------------|-------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_FIRST_USER_TRIAL_DEVELOPERS</code> |
Specifies the number of developers using the deployment. Only used if --first-user-trial is set.
### --use-token-as-session
| | |