Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions docs/guides/admin/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,18 @@ gcloud iam service-accounts keys create sa-key.json \

The installer is interactive. It will open multiple browser windows to create and install a GitHub App for each agent role. Follow the prompts in each window to complete the app setup.

During installation, you'll be prompted to choose repository enrollment:
- **[a] Enroll all repositories** — immediately enrolls all org repos (excluding `.fullsend`)
- **[n] Enroll no repositories** — skip enrollment during install; enroll repositories later using `fullsend admin enable repos`

Near the end, the installer opens a browser to create a fine-grained personal access token (dispatch token). When creating it, make sure to grant **Actions: Read and write** permission scoped to the `.fullsend` repository — otherwise the verification step will fail with a 404.

If the installer fails partway through, run `fullsend admin uninstall "$ORG_NAME"` to clean up before retrying. The uninstall preflight will prompt you to add the `delete_repo` scope if it is missing.

**With WIF (recommended):**

```bash
export REPO_NAME="<repo-name>"

fullsend admin install "$ORG_NAME" \
--repo "$REPO_NAME" \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-wif-provider "$WIF_PROVIDER" \
Expand All @@ -150,10 +151,7 @@ fullsend admin install "$ORG_NAME" \
**With SA key (legacy):**

```bash
export REPO_NAME="<repo-name>"

fullsend admin install "$ORG_NAME" \
--repo "$REPO_NAME" \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-credentials-file sa-key.json
Expand All @@ -168,7 +166,6 @@ If you already have fullsend installed with a service account key:
2. Re-run the installer with WIF flags (the installer updates secrets in-place):
```bash
fullsend admin install "$ORG_NAME" \
--repo "$REPO_NAME" \
--skip-app-setup \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
Expand All @@ -179,11 +176,9 @@ If you already have fullsend installed with a service account key:
4. Delete the old SA key: `gcloud iam service-accounts keys delete <KEY_ID> --iam-account=...`
5. Remove the `FULLSEND_GCP_SA_KEY_JSON` secret from the `.fullsend` repo settings

**Note**: the `--repo` flag can be repeated to onboard multiple repositories.

## 3. Merge enrollment PRs

After install completes, the installer dispatches a workflow that creates an enrollment PR in each repo passed via `--repo`. These PRs add a shim workflow (`.github/workflows/fullsend.yaml`) that wires events to the agent pipeline.
If you chose to enroll repositories during install, the installer dispatches a workflow that creates an enrollment PR in each enrolled repo. These PRs add a shim workflow (`.github/workflows/fullsend.yaml`) that wires events to the agent pipeline.

Review and merge each enrollment PR to complete enrollment.

Expand Down
115 changes: 96 additions & 19 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"os/exec"
Expand Down Expand Up @@ -85,7 +86,6 @@ func validateOrgName(org string) error {
}

func newInstallCmd() *cobra.Command {
var repos []string
var agents string
var dryRun bool
var skipAppSetup bool
Expand Down Expand Up @@ -186,8 +186,37 @@ func newInstallCmd() *cobra.Command {
inferenceProviderName = loadExistingInferenceProvider(ctx, client, org)
}

// Prompt for enrollment choice: all or none.
enrollAll, err := promptEnrollment(printer, os.Stdin)
if err != nil {
Comment thread
ggallen marked this conversation as resolved.
return err
}

var repos []string
var allRepos []forge.Repository
if enrollAll {
// Discover repos and filter out .fullsend.
discovered, err := client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
}
allRepos = discovered
for _, r := range allRepos {
if r.Name != forge.ConfigRepoName {
repos = append(repos, r.Name)
}
}
printer.StepInfo(fmt.Sprintf("Enrolling all %d repositories (excluding %s)", len(repos), forge.ConfigRepoName))
} else {
printer.StepInfo("No repositories will be enrolled during install")
printer.StepInfo(fmt.Sprintf("To enroll repositories later, use:"))
printer.StepInfo(fmt.Sprintf(" fullsend admin enable repos %s <repo-name> [repo-name...]", org))
Comment thread
ggallen marked this conversation as resolved.
printer.StepInfo(fmt.Sprintf(" fullsend admin enable repos %s --all", org))
}
printer.Blank()

if dryRun {
return runDryRun(ctx, client, printer, org, repos, roles, inferenceProvider, inferenceProviderName)
return runDryRun(ctx, client, printer, org, repos, roles, inferenceProvider, inferenceProviderName, allRepos)
}

// Collect agent credentials via app setup.
Expand All @@ -203,11 +232,10 @@ func newInstallCmd() *cobra.Command {
agentCreds = creds
}

return runInstall(ctx, client, printer, org, repos, roles, agentCreds, inferenceProvider, inferenceProviderName, vendorBinary)
return runInstall(ctx, client, printer, org, repos, roles, agentCreds, inferenceProvider, inferenceProviderName, vendorBinary, allRepos)
},
}

cmd.Flags().StringSliceVar(&repos, "repo", nil, "repositories to enable (repeatable)")
cmd.Flags().StringVar(&agents, "agents", "fullsend,triage,coder,review", "comma-separated agent roles")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them")
cmd.Flags().BoolVar(&skipAppSetup, "skip-app-setup", false, "skip GitHub App creation/setup")
Expand Down Expand Up @@ -346,19 +374,29 @@ func newAnalyzeCmd() *cobra.Command {
}

// runDryRun builds a layer stack with empty credentials and analyzes.
func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, inferenceProvider inference.Provider, inferenceProviderName string) error {
// If discoveredRepos is non-nil, it will be used instead of calling ListOrgRepos.
func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, inferenceProvider inference.Provider, inferenceProviderName string, discoveredRepos []forge.Repository) error {
printer.Header("Dry run - analyzing what install would do")
printer.Blank()

allRepos, err := client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
var allRepos []forge.Repository
var err error

if discoveredRepos != nil {
allRepos = discoveredRepos
printer.StepDone(fmt.Sprintf("Using %d discovered repositories", len(allRepos)))
} else {
allRepos, err = client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
}
printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos)))
}

repoNames := repoNameList(allRepos)
hasPrivate := hasPrivateRepos(allRepos)

// Validate that every --repo value matches a discovered repo.
// Validate that every enabled repository matches a discovered repo.
if err := validateEnabledRepos(enabledRepos, repoNames); err != nil {
return err
}
Expand Down Expand Up @@ -467,7 +505,7 @@ func ensureConfigRepoExists(ctx context.Context, client forge.Client, printer *u
return nil
}

// validateEnabledRepos checks that every --repo value exists in the
// validateEnabledRepos checks that every enabled repository exists in the
// discovered (eligible) repo list. Repos filtered out by ListOrgRepos
// (forks, archived) will not appear in discoveredNames, so this catches
Comment thread
ggallen marked this conversation as resolved.
// the case where a user targets a fork or archived repo.
Expand Down Expand Up @@ -498,21 +536,29 @@ func validateEnabledRepos(enabledRepos, discoveredNames []string) error {
}

// runInstall performs the full installation.
func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, agentCreds []layers.AgentCredentials, inferenceProvider inference.Provider, inferenceProviderName string, vendorBinary bool) error {
printer.Header("Discovering repositories")

allRepos, err := client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
// If discoveredRepos is non-nil, it will be used instead of calling ListOrgRepos.
func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, agentCreds []layers.AgentCredentials, inferenceProvider inference.Provider, inferenceProviderName string, vendorBinary bool, discoveredRepos []forge.Repository) error {
var allRepos []forge.Repository
var err error

if discoveredRepos != nil {
allRepos = discoveredRepos
printer.Header("Using discovered repositories")
printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos)))
} else {
printer.Header("Discovering repositories")
allRepos, err = client.ListOrgRepos(ctx, org)
if err != nil {
return fmt.Errorf("listing org repos: %w", err)
}
printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos)))
}

repoNames := repoNameList(allRepos)
hasPrivate := hasPrivateRepos(allRepos)

printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos)))
printer.Blank()

// Validate that every --repo value matches a discovered repo.
// Validate that every enabled repository matches a discovered repo.
if err := validateEnabledRepos(enabledRepos, repoNames); err != nil {
return err
}
Expand Down Expand Up @@ -890,6 +936,37 @@ func collectEnrolledRepoIDs(allRepos []forge.Repository, enabledRepos []string)
return ids
}

// promptEnrollment asks the user whether to enroll all repositories or none.
// Returns true if the user chooses to enroll all, false if none.
// Accepts an io.Reader to enable testing without os.Stdin.
func promptEnrollment(printer *ui.Printer, in io.Reader) (bool, error) {
printer.Header("Repository Enrollment")
printer.Blank()
printer.StepInfo("Choose repository enrollment:")
printer.StepInfo(" [a] Enroll all repositories (excluding .fullsend)")
printer.StepInfo(" [n] Enroll no repositories (configure later with 'fullsend admin enable repos')")
printer.Blank()

reader := bufio.NewReader(in)
for {
printer.StepInfo("Enter choice (a/n): ")
choice, err := reader.ReadString('\n')
if err != nil {
return false, fmt.Errorf("reading enrollment choice: %w", err)
}
choice = strings.TrimSpace(strings.ToLower(choice))

switch choice {
case "a", "all":
return true, nil
case "n", "none":
return false, nil
default:
printer.StepWarn(fmt.Sprintf("Invalid choice: %q (expected 'a' or 'n')", choice))
}
}
}

// promptDispatchToken checks whether the dispatch token org secret already
// exists and, if not, opens the browser to GitHub's pre-filled fine-grained
// PAT creation page and prompts the user to paste the result.
Expand Down
103 changes: 100 additions & 3 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sort"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -38,9 +39,6 @@ func TestInstallCmd_RequiresOrg(t *testing.T) {
func TestInstallCmd_Flags(t *testing.T) {
cmd := newInstallCmd()

repoFlag := cmd.Flags().Lookup("repo")
require.NotNil(t, repoFlag, "expected --repo flag")

agentsFlag := cmd.Flags().Lookup("agents")
require.NotNil(t, agentsFlag, "expected --agents flag")
assert.Equal(t, "fullsend,triage,coder,review", agentsFlag.DefValue)
Expand All @@ -60,6 +58,10 @@ func TestInstallCmd_Flags(t *testing.T) {

wifSAEmailFlag := cmd.Flags().Lookup("gcp-wif-sa-email")
require.NotNil(t, wifSAEmailFlag, "expected --gcp-wif-sa-email flag")

// --repo flag should not exist (issue #495)
repoFlag := cmd.Flags().Lookup("repo")
assert.Nil(t, repoFlag, "--repo flag should have been removed")
}

func TestUninstallCmd_RequiresOrg(t *testing.T) {
Expand Down Expand Up @@ -627,3 +629,98 @@ func TestRunDisableRepos_CommitMessageFormat(t *testing.T) {
require.Len(t, client.CreatedFiles, 1)
assert.Contains(t, client.CreatedFiles[0].Message, "chore: disable 2 repositories")
}

func TestPromptEnrollment_ChooseAll(t *testing.T) {
tests := []struct {
name string
input string
}{
{"lowercase a", "a\n"},
{"uppercase A", "A\n"},
{"word all", "all\n"},
{"word ALL", "ALL\n"},
{"with spaces", " a \n"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
input := strings.NewReader(tc.input)
printer := ui.New(&discardWriter{})

enrollAll, err := promptEnrollment(printer, input)
require.NoError(t, err)
assert.True(t, enrollAll)
})
}
}

func TestPromptEnrollment_ChooseNone(t *testing.T) {
tests := []struct {
name string
input string
}{
{"lowercase n", "n\n"},
{"uppercase N", "N\n"},
{"word none", "none\n"},
{"word NONE", "NONE\n"},
{"with spaces", " n \n"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
input := strings.NewReader(tc.input)
printer := ui.New(&discardWriter{})

enrollAll, err := promptEnrollment(printer, input)
require.NoError(t, err)
assert.False(t, enrollAll)
})
}
}

func TestPromptEnrollment_RetryOnInvalidInput(t *testing.T) {
// First input is invalid, second is valid.
input := strings.NewReader("invalid\na\n")
printer := ui.New(&discardWriter{})

enrollAll, err := promptEnrollment(printer, input)
require.NoError(t, err)
assert.True(t, enrollAll)
}

func TestPromptEnrollment_MultipleRetriesBeforeValid(t *testing.T) {
// Multiple invalid inputs before valid one.
input := strings.NewReader("y\nyes\nmaybe\nn\n")
printer := ui.New(&discardWriter{})

enrollAll, err := promptEnrollment(printer, input)
require.NoError(t, err)
assert.False(t, enrollAll)
}

func TestPromptEnrollment_ErrorOnEOF(t *testing.T) {
// EOF without any valid input.
input := strings.NewReader("")
printer := ui.New(&discardWriter{})

_, err := promptEnrollment(printer, input)
require.Error(t, err)
assert.Contains(t, err.Error(), "reading enrollment choice")
}

func TestPromptEnrollment_ErrorOnReadFailure(t *testing.T) {
// errorReader always returns an error.
input := &errorReader{}
printer := ui.New(&discardWriter{})

_, err := promptEnrollment(printer, input)
require.Error(t, err)
assert.Contains(t, err.Error(), "reading enrollment choice")
}

// errorReader is a test helper that always returns an error on Read.
type errorReader struct{}

func (e *errorReader) Read(p []byte) (n int, err error) {
return 0, fmt.Errorf("simulated read error")
}
Loading