diff --git a/.github/agents/migrator-php-to-go.agent.md b/.github/agents/migrator-php-to-go.agent.md new file mode 100644 index 00000000..6390b6fb --- /dev/null +++ b/.github/agents/migrator-php-to-go.agent.md @@ -0,0 +1,336 @@ +--- +name: PHP to Go Command Migrator Specialist Agent +description: Specialist agent for migrating CLI commands from PHP (Symfony Console) to Go (Cobra). Handles API client implementation, command creation, integration tests, and ensures exact output matching. +--- + +# PHP to Go Command Migration Specialist + +You are a specialist agent responsible for migrating CLI commands from the legacy PHP implementation to native Go. This CLI was originally written in PHP using Symfony Console and is being incrementally migrated to Go using Cobra. + +## Your Expertise + +- Deep knowledge of Symfony Console (PHP) command structure +- Expert in Go and the Cobra CLI library +- Understanding of RESTful API client patterns +- Integration testing strategies for CLI applications + +## API References + +When implementing API client methods, use these authoritative resources: + +- **OpenAPI Specification**: https://docs.upsun.com/api/ - The official API documentation with all endpoints, request/response schemas +- **PHP SDK**: https://github.com/platformsh/platformsh-client-php - Reference implementation showing how API calls are structured + +## Repository Structure + +### Source (PHP - Legacy) +- `legacy/src/Command/` - PHP commands using Symfony Console +- `legacy/src/Service/` - PHP services (API client, Config, Table formatting, etc.) +- Commands extend `CommandBase` and use `#[AsCommand]` attributes +- Dependency injection for services like `Api`, `Config`, `Table`, `PropertyFormatter` + +### Target (Go - New) +- `commands/` - Go commands using Cobra +- `internal/api/` - Go API client (building the Upsun Go SDK) +- `internal/config/` - Configuration management +- `internal/selectors/` - Interactive selectors (project, org, environment) - CREATE IF NEEDED +- `integration-tests/` - Integration tests that run the built CLI binary +- `pkg/mockapi/` - Mock API server for testing + +## Migration Workflow + +When asked to migrate a command (e.g., "migrate project:list"), follow these steps: + +### Step 1: Analyze the PHP Command + +1. Read the PHP command file in `legacy/src/Command/` +2. Document: + - Command name (from `#[AsCommand(name: '...')]`) + - Aliases (from `#[AsCommand(..., aliases: [...])]`) + - Description + - All arguments and options (including hidden ones) + - Output format (table columns, JSON structure, plain text) + - API calls made (check injected services) + - Any interactive prompts or selectors + +### Step 2: Check for Existing Integration Tests + +1. Look for existing tests in `integration-tests/` matching the command +2. If tests exist, they will serve as the specification for expected output +3. If no tests exist, note that we need to create them + +### Step 3: Implement API Methods (if needed) + +If the command makes API calls not yet available in `internal/api/`: + +1. **Check the OpenAPI spec** at https://docs.upsun.com/api/ for endpoint details +2. **Reference the PHP SDK** at https://github.com/platformsh/platformsh-client-php for implementation patterns +3. Analyze the PHP API service in `legacy/src/Service/Api.php` +4. Add new methods to `internal/api/client.go` or create new files in `internal/api/` +5. Follow existing patterns: + ```go + // Example pattern from internal/api/client.go + func (c *Client) GetResource(ctx context.Context, id string) (*Resource, error) { + url, err := c.baseURLWithSegments("resources", id) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) + if err != nil { + return nil, err + } + resp, err := c.HTTPClient.Do(req) + // ... handle response + } + ``` + +### Step 4: Implement Selectors (if needed) + +If the command needs interactive selection (project, org, environment): + +1. Check if the selector exists in `internal/selectors/` +2. If not, create it following this pattern: + ```go + package selectors + + import ( + "github.com/upsun/cli/internal/api" + "github.com/upsun/cli/internal/config" + ) + + type ProjectSelector struct { + client *api.Client + config *config.Config + } + + func (s *ProjectSelector) Select(ctx context.Context) (*api.Project, error) { + // Interactive selection logic + } + ``` + +### Step 5: Create the Go Command + +Create a new file in `commands/` following existing patterns: + +```go +package commands + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/upsun/cli/internal/config" +) + +func newXxxCommand(cnf *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "namespace:action", // MUST match PHP exactly + Aliases: []string{"alias1"}, // MUST match PHP exactly + Short: "Description", // MUST match PHP exactly + Args: cobra.ExactArgs(0), // Match PHP argument requirements + Run: func(cmd *cobra.Command, args []string) { + // Implementation + }, + } + + // Add flags matching PHP options EXACTLY + cmd.Flags().String("format", "table", "The output format") + cmd.Flags().Bool("pipe", false, "Output a simple list of IDs") + + viper.BindPFlags(cmd.Flags()) + + return cmd +} +``` + +### Step 6: Register the Command + +Add the command to `commands/root.go` in the appropriate place. + +### Step 7: Create/Update Integration Tests + +In `integration-tests/`: + +1. If tests exist, update them to also test the Go implementation +2. If tests don't exist, create them to verify: + - Output matches PHP exactly (table format, columns, spacing) + - All flags work correctly + - Error messages are consistent + - Exit codes match + +Example test pattern: +```go +func TestXxxCommand(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + // Set up mock data + apiHandler.SetProjects([]*mockapi.Project{...}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + + // Test table output matches exactly + assertTrimmed(t, ` ++----+-------+--------+ +| ID | Title | Region | ++----+-------+--------+ +| x | Y | z | ++----+-------+--------+ +`, f.Run("command:name")) +} +``` + +## Critical Requirements + +### MUST Preserve +1. **Command name**: Use exact same `namespace:action` format +2. **Aliases**: Include all aliases from PHP command +3. **Arguments**: Same positional arguments in same order +4. **Options/Flags**: Same names, shortcuts, and defaults +5. **Output format**: Table columns, spacing, and structure must match +6. **Behavior**: Same filtering, sorting, pagination logic +7. **Exit codes**: Same exit codes for success/failure cases +8. **Error messages**: Similar error message format + +### Output Matching + +The Go command output MUST be character-for-character identical to PHP output for: +- Table headers and data alignment +- JSON structure and key names +- Plain text format with `--pipe` flag +- Error messages to stderr + +Use the `tablewriter` package or similar to match PHP's table output format. + +## Implementation Patterns + +### API Patterns - Use HAL Links + +**CRITICAL**: The API uses signed HAL links. Never construct API URLs manually for reference endpoints. + +```go +// WRONG - will fail with "sig is a required field" error +refURL := "ref/projects?in=" + strings.Join(ids, ",") + +// CORRECT - extract HAL links from API responses +projectRefURL := extractHALLink(accessResp.Links, "ref:projects") +``` + +The API returns `_links` in responses containing pre-signed URLs. Always use these links: +- `ref:projects:0` - Link to fetch project references +- `ref:organizations:0` - Link to fetch organization references +- These links include a `sig` parameter required by the real API + +### Authentication Pattern + +Use `auth.NewLegacyCLIClient` to get an authenticated HTTP client: + +```go +legacyCLIClient, err := auth.NewLegacyCLIClient(ctx, + makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin())) +if err != nil { + return err +} +if err := legacyCLIClient.EnsureAuthenticated(ctx); err != nil { + return err +} +apiClient, err := api.NewClient(cnf.API.BaseURL, legacyCLIClient.HTTPClient) +``` + +### Table Output - Terminal Width Handling + +**CRITICAL**: The legacy PHP CLI uses `AdaptiveTable` which wraps text to terminal width. The Go implementation must do the same. + +Use `internal/tableoutput` which: +- Detects terminal width using `golang.org/x/term` +- Shrinks columns proportionally when table is too wide +- Word-wraps cell content at word boundaries +- Handles multi-line cells properly + +```go +table := tableoutput.New("ID", "Title", "Region") +table.AddRow("proj-1", "Project 1", "us-3.platform.sh") +table.RenderTable(cmd.OutOrStdout()) // Auto-detects terminal width +``` + +### Column Configuration + +Support dynamic columns like the PHP CLI: +- Default columns based on config (e.g., org columns when organizations enabled) +- `--columns` flag for custom column selection +- `--format` flag for output format (table, plain, csv) + +### Common Pitfalls to Avoid + +1. **Don't construct ref URLs manually** - Use HAL links from API responses +2. **Don't ignore terminal width** - Tables must wrap to fit +3. **Don't forget authentication** - Use LegacyCLIClient for auth +4. **Don't use `for _, x := range` with large structs** - Use index to avoid copies +5. **Test with real API** - Mock tests may not catch sig parameter issues + +## Decision Points - Ask the User + +If you encounter any of these situations, STOP and ask the user: + +1. **Complex dependencies**: If the PHP command depends on other commands or complex services not yet migrated, ask: + > "This command depends on [X] which isn't migrated yet. Should I: + > A) Also migrate [X] first + > B) Keep using the PHP fallback for [X] + > C) Skip this functionality for now" + +2. **API endpoints not documented**: If you can't determine the API structure from PHP code, ask for clarification. + +3. **Ambiguous behavior**: If PHP code has unclear behavior or edge cases, ask for expected behavior. + +4. **Missing mock API support**: If `pkg/mockapi/` doesn't support the endpoints needed for testing, inform the user that mockapi needs to be extended. + +## Example Migration Session + +**User**: Migrate the `project:list` command + +**Agent**: +1. Read `legacy/src/Command/Project/ProjectListCommand.php` +2. Found: name=`project:list`, aliases=`['projects', 'pro']` +3. Found options: `--pipe`, `--region`, `--title`, `--my`, `--sort`, `--reverse`, `--page`, `--count`, `--org`, `--format` +4. Found table columns: id, title, region, organization_name, organization_type, status, created_at +5. Check `integration-tests/project_list_test.go` - tests exist! +6. API calls: `getMyProjects()` - check if implemented in `internal/api/` +7. Create `commands/project_list.go` +8. Register in `commands/root.go` +9. Verify tests pass with both PHP and Go implementations + +## Files to Modify/Create + +For a typical command migration, you will: + +1. **Create**: `commands/{namespace}_{action}.go` - The new Go command +2. **Modify**: `commands/root.go` - Register the new command +3. **Create/Modify**: `internal/api/*.go` - API client methods (if needed) +4. **Create**: `internal/selectors/*.go` - Selectors (if needed) +5. **Create/Modify**: `integration-tests/{command}_test.go` - Integration tests +6. **Create/Modify**: `pkg/mockapi/*.go` - Mock API support (if needed) + +## Testing + +After migration: +1. Build the CLI: `make build` or `make single` +2. Run integration tests: `go test ./integration-tests/... -run TestXxx` +3. Manual verification: Run both PHP and Go versions, compare output + +## Summary Checklist + +Before completing a migration, verify: + +- [ ] Command name matches PHP exactly +- [ ] All aliases preserved +- [ ] All arguments preserved +- [ ] All options/flags preserved with same defaults +- [ ] Output format matches (table, JSON, pipe) +- [ ] API methods implemented in `internal/api/` +- [ ] Selectors created in `internal/selectors/` (if needed) +- [ ] Command registered in `commands/root.go` +- [ ] Integration tests created/updated +- [ ] Tests pass diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..a5a26fe4 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,58 @@ +# Copilot Setup Steps +# Pre-installs dependencies before Copilot coding agent starts working +# See: https://docs.github.com/en/copilot/customizing-copilot/customizing-the-development-environment-for-copilot-coding-agent + +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: >- + curl, + filter, + json, + mbstring, + openssl, + pcntl, + pcre, + phar, + posix, + zlib + tools: composer:v2 + ini-values: phar.readonly=0 + + - name: Install Go dependencies + run: go mod download + + - name: Install PHP dependencies + run: | + cd legacy + composer install --no-interaction --prefer-dist + + - name: Build CLI binary (for integration tests) + run: make single + + - name: Verify build + run: ./dist/*/upsun --version || ./dist/*/platform --version diff --git a/CLAUDE.md b/CLAUDE.md index cc9d9db0..93c2f391 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ go test -v -run TestName ./path/to/package ### Hybrid CLI System The CLI operates as a wrapper around a legacy PHP CLI: -- Go layer: Handles new commands (init, list, version, config:install, project:convert) and core infrastructure +- Go layer: Handles new commands (init, list, version, config:install, project:convert, project:list) and core infrastructure - PHP layer: Legacy commands are proxied through `internal/legacy/CLIWrapper` - The PHP CLI (platform.phar) is embedded at build time via go:embed @@ -66,7 +66,7 @@ The CLI operates as a wrapper around a legacy PHP CLI: **Commands**: `commands/` - `root.go`: Root command that sets up the Cobra CLI and delegates to legacy CLI when needed -- Native Go commands: init, list, version, config:install, project:convert, completion +- Native Go commands: init, list, version, config:install, project:convert, project:list, completion - Unrecognized commands are passed to the legacy PHP CLI **Configuration**: `internal/config/` @@ -87,16 +87,29 @@ The CLI operates as a wrapper around a legacy PHP CLI: **API Client**: `internal/api/` - HTTP client for interacting with Platform.sh/Upsun API -- Handles authentication, organizations, and resource management +- Handles authentication, organizations, projects, and resource management +- Uses HAL links from API responses for signed URL requests **Authentication**: `internal/auth/` - JWT handling and OAuth2 flow - Custom transport for API authentication +- `LegacyCLIClient` wraps auth through the legacy CLI's token management + +**Table Output**: `internal/tableoutput/` +- Provides terminal-width-aware table formatting +- Automatically wraps text to fit terminal width +- Supports table, plain (TSV), and CSV output formats **Project Initialization**: `internal/init/` - AI-powered project configuration generation - Integrates with whatsun library for codebase analysis +## Migrating Commands from PHP to Go + +> **Use the specialized agent**: For migrating CLI commands from PHP to Go, use the **PHP to Go Command Migrator Specialist Agent** located at `.github/agents/migrator-php-to-go.agent.md`. This agent has detailed instructions for the migration workflow, API patterns, and testing requirements. +> +> In VS Code with GitHub Copilot, you can activate this agent by referencing it in chat or using the agent picker. + ### Build System **Multi-Vendor Support**: diff --git a/README.md b/README.md index 8c91432d..09fc489c 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,19 @@ git tag -m 'Release v5.0.0' 'v5.0.0' make release ``` +## Contributing + +### Migrating Commands from PHP to Go + +This CLI is being incrementally migrated from PHP (Symfony Console) to Go (Cobra). If you want to help migrate commands, use the **PHP to Go Command Migrator Specialist Agent** located at `.github/agents/migrator-php-to-go.agent.md`. + +The agent provides detailed instructions for: +- Analyzing PHP commands in `legacy/src/Command/` +- Implementing API client methods in `internal/api/` +- Creating Go commands in `commands/` +- Writing integration tests +- Ensuring output matches the legacy PHP CLI exactly + ## Licenses This binary redistributes PHP in a binary form, which comes with the [PHP License](https://www.php.net/license/3_01.txt). diff --git a/commands/project_list.go b/commands/project_list.go new file mode 100644 index 00000000..80d19ca3 --- /dev/null +++ b/commands/project_list.go @@ -0,0 +1,353 @@ +package commands + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/upsun/cli/internal/api" + "github.com/upsun/cli/internal/auth" + "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/tableoutput" +) + +// Column definitions for project list +type projectColumn struct { + Header string + Key string + Get func(p *api.ProjectInfo) string +} + +var allProjectColumns = []projectColumn{ + {Header: "ID", Key: "id", Get: func(p *api.ProjectInfo) string { return p.ID }}, + {Header: "Title", Key: "title", Get: func(p *api.ProjectInfo) string { + if p.Title == "" { + return "[Untitled Project]" + } + return p.Title + }}, + {Header: "Region", Key: "region", Get: func(p *api.ProjectInfo) string { return p.Region }}, + {Header: "Org name", Key: "organization_name", Get: func(p *api.ProjectInfo) string { + if p.OrganizationRef != nil { + return p.OrganizationRef.Name + } + return "" + }}, + {Header: "Org ID", Key: "organization_id", Get: func(p *api.ProjectInfo) string { + if p.OrganizationRef != nil { + return p.OrganizationRef.ID + } + return p.OrganizationID + }}, + {Header: "Org label", Key: "organization_label", Get: func(p *api.ProjectInfo) string { + if p.OrganizationRef != nil { + return p.OrganizationRef.Label + } + return "" + }}, + {Header: "Org type", Key: "organization_type", Get: func(p *api.ProjectInfo) string { + if p.OrganizationRef != nil { + return p.OrganizationRef.Type + } + return "" + }}, + {Header: "Status", Key: "status", Get: func(p *api.ProjectInfo) string { return p.Status }}, + {Header: "Created", Key: "created_at", Get: func(p *api.ProjectInfo) string { + return p.CreatedAt.Format("2006-01-02 15:04:05") + }}, +} + +func getDefaultProjectColumns(cnf *config.Config) []string { + cols := []string{"id", "title", "region"} + if cnf.API.EnableOrganizations { + cols = append(cols, "organization_name", "organization_type") + } + return cols +} + +func getProjectColumn(key string) *projectColumn { + for i := range allProjectColumns { + if allProjectColumns[i].Key == key { + return &allProjectColumns[i] + } + } + return nil +} + +func newProjectListCommand(cnf *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "project:list", + Aliases: []string{"projects", "pro"}, + Short: "Get a list of all active projects", + RunE: func(cmd *cobra.Command, _ []string) error { + return runProjectList(cmd, cnf) + }, + } + + cmd.Flags().Bool("pipe", false, "Output a simple list of project IDs. Disables pagination.") + cmd.Flags().String("region", "", "Filter by region (exact match)") + cmd.Flags().String("title", "", "Filter by title (case-insensitive search)") + cmd.Flags().Bool("my", false, "Display only the projects you own") + cmd.Flags().Int("refresh", 1, "Whether to refresh the list") + cmd.Flags().String("sort", "title", "A property to sort by") + cmd.Flags().Bool("reverse", false, "Sort in reverse (descending) order") + cmd.Flags().Int("page", 0, "Page number. This enables pagination.") + cmd.Flags().IntP("count", "c", 0, "The number of projects to display per page. Use 0 to disable pagination.") + + if cnf.API.EnableOrganizations { + cmd.Flags().StringP("org", "o", "", "Filter by organization name or ID") + cmd.Flags().String("org-type", "", "Filter by organization type") + } + + cmd.Flags().String("format", "table", "The output format: table, plain, csv, or tsv") + cmd.Flags().String("columns", "", "Columns to display (comma-separated)") + cmd.Flags().Bool("no-header", false, "Do not output the table header") + + _ = viper.BindPFlags(cmd.Flags()) + + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + cmd.Root().Run(cmd.Root(), append([]string{"help", "project:list"}, args...)) + }) + + return cmd +} + +func runProjectList(cmd *cobra.Command, cnf *config.Config) error { + ctx := cmd.Context() + + // Create the legacy CLI client for authentication + legacyCLIClient, err := auth.NewLegacyCLIClient(ctx, + makeLegacyCLIWrapper(cnf, cmd.OutOrStdout(), cmd.ErrOrStderr(), cmd.InOrStdin())) + if err != nil { + return err + } + + if err := legacyCLIClient.EnsureAuthenticated(ctx); err != nil { + return err + } + + apiClient, err := api.NewClient(cnf.API.BaseURL, legacyCLIClient.HTTPClient) + if err != nil { + return err + } + + // Show loading message + fmt.Fprint(cmd.ErrOrStderr(), "Loading projects...\r") + + // Fetch projects + projects, err := apiClient.GetMyProjects(ctx) + if err != nil { + return err + } + + // Clear the loading message + fmt.Fprint(cmd.ErrOrStderr(), " \r") + + // Apply filters + projects = filterProjects(cmd, projects, cnf, apiClient) + + // Sort projects + sortKey, _ := cmd.Flags().GetString("sort") + sortProjects(projects, sortKey) + + reverse, _ := cmd.Flags().GetBool("reverse") + if reverse { + slices.Reverse(projects) + } + + // Handle --pipe output + pipe, _ := cmd.Flags().GetBool("pipe") + if pipe { + for _, p := range projects { + fmt.Fprintln(cmd.OutOrStdout(), p.ID) + } + return nil + } + + // Check if no projects found and display appropriate message + if len(projects) == 0 { + filtersInUse := getFiltersInUse(cmd, cnf) + if len(filtersInUse) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No projects found (filters in use: %s).\n", strings.Join(filtersInUse, ", ")) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "No projects found.") + } + return nil + } + + // Determine columns to display + columns := getSelectedColumns(cmd, cnf) + + // Build table + table := buildProjectTable(projects, columns) + + // Render output + return renderOutput(cmd, table) +} + +func filterProjects( + cmd *cobra.Command, + projects []*api.ProjectInfo, + cnf *config.Config, + apiClient *api.Client, +) []*api.ProjectInfo { + ctx := cmd.Context() + result := projects + + // Filter by region + region, _ := cmd.Flags().GetString("region") + if region != "" { + result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool { + return !strings.EqualFold(p.Region, region) + }) + } + + // Filter by title + title, _ := cmd.Flags().GetString("title") + if title != "" { + titleLower := strings.ToLower(title) + result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool { + return !strings.Contains(strings.ToLower(p.Title), titleLower) + }) + } + + // Filter by --my (ownership) + my, _ := cmd.Flags().GetBool("my") + if my { + myUserID, err := apiClient.GetMyUserID(ctx) + if err == nil && myUserID != "" { + if cnf.API.EnableOrganizations { + result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool { + if p.OrganizationRef != nil { + return p.OrganizationRef.OwnerID != myUserID + } + return true + }) + } + } + } + + // Filter by organization + if cnf.API.EnableOrganizations { + org, _ := cmd.Flags().GetString("org") + if org != "" { + result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool { + if p.OrganizationRef == nil { + return true + } + return p.OrganizationRef.ID != org && p.OrganizationRef.Name != org + }) + } + + orgType, _ := cmd.Flags().GetString("org-type") + if orgType != "" { + result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool { + if p.OrganizationRef == nil { + return true + } + return p.OrganizationRef.Type != orgType + }) + } + } + + return result +} + +func getFiltersInUse(cmd *cobra.Command, cnf *config.Config) []string { + var filters []string + + if region, _ := cmd.Flags().GetString("region"); region != "" { + filters = append(filters, "--region") + } + if title, _ := cmd.Flags().GetString("title"); title != "" { + filters = append(filters, "--title") + } + if my, _ := cmd.Flags().GetBool("my"); my { + filters = append(filters, "--my") + } + if cnf.API.EnableOrganizations { + if org, _ := cmd.Flags().GetString("org"); org != "" { + filters = append(filters, "--org") + } + if orgType, _ := cmd.Flags().GetString("org-type"); orgType != "" { + filters = append(filters, "--org-type") + } + } + + return filters +} + +func sortProjects(projects []*api.ProjectInfo, sortKey string) { + slices.SortFunc(projects, func(a, b *api.ProjectInfo) int { + switch sortKey { + case "id": + return cmp.Compare(a.ID, b.ID) + case "region": + return cmp.Compare(a.Region, b.Region) + case "created_at": + return a.CreatedAt.Compare(b.CreatedAt) + default: // title + return cmp.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title)) + } + }) +} + +func getSelectedColumns(cmd *cobra.Command, cnf *config.Config) []projectColumn { + columnsStr, _ := cmd.Flags().GetString("columns") + var selectedKeys []string + + if columnsStr != "" { + selectedKeys = strings.Split(columnsStr, ",") + for i, k := range selectedKeys { + selectedKeys[i] = strings.TrimSpace(k) + } + } else { + selectedKeys = getDefaultProjectColumns(cnf) + } + + columns := make([]projectColumn, 0, len(selectedKeys)) + for _, key := range selectedKeys { + if col := getProjectColumn(key); col != nil { + columns = append(columns, *col) + } + } + + return columns +} + +func buildProjectTable(projects []*api.ProjectInfo, columns []projectColumn) *tableoutput.Table { + headers := make([]string, len(columns)) + for i, col := range columns { + headers[i] = col.Header + } + + table := tableoutput.New(headers...) + for _, p := range projects { + row := make([]string, len(columns)) + for i, col := range columns { + row[i] = col.Get(p) + } + table.AddRow(row...) + } + + return table +} + +func renderOutput(cmd *cobra.Command, table *tableoutput.Table) error { + format, _ := cmd.Flags().GetString("format") + noHeader, _ := cmd.Flags().GetBool("no-header") + w := cmd.OutOrStdout() + + switch format { + case "csv": + return table.RenderCSV(w, noHeader) + case "tsv", "plain": + return table.RenderPlain(w) + default: // table + return table.RenderTable(w) + } +} diff --git a/commands/root.go b/commands/root.go index 49178829..5c8ad523 100644 --- a/commands/root.go +++ b/commands/root.go @@ -148,6 +148,7 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob newHelpCommand(cnf), newInitCommand(cnf, assets), newListCommand(cnf), + newProjectListCommand(cnf), validateCmd, versionCommand, ) diff --git a/internal/api/project.go b/internal/api/project.go new file mode 100644 index 00000000..3b0de4dc --- /dev/null +++ b/internal/api/project.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "time" +) + +// ProjectInfo contains basic information about a project. +type ProjectInfo struct { + ID string `json:"id"` + Title string `json:"title"` + Region string `json:"region"` + Status string `json:"status"` + OrganizationID string `json:"organization_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // OrganizationRef contains reference information for the organization + OrganizationRef *OrganizationRef `json:"-"` +} + +// OrganizationRef is a reference to an organization. +type OrganizationRef struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Label string `json:"label"` + OwnerID string `json:"owner_id"` +} + +// userGrant represents a user's access grant to a resource. +type userGrant struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + OrganizationID string `json:"organization_id"` + UserID string `json:"user_id"` + Permissions []string `json:"permissions"` + GrantedAt time.Time `json:"granted_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type userExtendedAccessResponse struct { + Items []userGrant `json:"items"` + Links HALLinks `json:"_links"` +} + +// GetMyProjects returns the list of projects accessible to the current user. +func (c *Client) GetMyProjects(ctx context.Context) ([]*ProjectInfo, error) { + // Get the current user's ID + meURL, err := c.resolveURL("users/me") + if err != nil { + return nil, err + } + + var me struct { + ID string `json:"id"` + } + if err := c.getResource(ctx, meURL.String(), &me); err != nil { + return nil, err + } + + // Get user extended access (project grants) + accessURL, err := c.resolveURL("users/" + url.PathEscape(me.ID) + "/extended-access?filter[resource_type]=project") + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, accessURL.String(), http.NoBody) + if err != nil { + return nil, Error{Original: err, URL: accessURL.String()} + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, Error{Original: err, URL: accessURL.String(), Response: resp} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, Error{Response: resp, URL: accessURL.String()} + } + + var accessResp userExtendedAccessResponse + if err := json.NewDecoder(resp.Body).Decode(&accessResp); err != nil { + return nil, Error{Original: err, URL: accessURL.String()} + } + + if len(accessResp.Items) == 0 { + return []*ProjectInfo{}, nil + } + + // Extract the HAL links for project and organization references + // The API returns links like "ref:projects:0" and "ref:organizations:0" + projectRefURL := extractHALLink(accessResp.Links, "ref:projects") + orgRefURL := extractHALLink(accessResp.Links, "ref:organizations") + + // Fetch project references using the HAL link (which includes sig parameter) + projects, err := c.getProjectRefsFromLink(ctx, projectRefURL) + if err != nil { + return nil, err + } + + // Fetch organization references using the HAL link + orgs, err := c.getOrgRefsFromLink(ctx, orgRefURL) + if err != nil { + return nil, err + } + + // Combine project and organization data + result := make([]*ProjectInfo, 0, len(projects)) + for _, project := range projects { + if project == nil { + continue + } + if orgRef, ok := orgs[project.OrganizationID]; ok { + project.OrganizationRef = orgRef + } + result = append(result, project) + } + + return result, nil +} diff --git a/internal/api/refs.go b/internal/api/refs.go new file mode 100644 index 00000000..0bce03d9 --- /dev/null +++ b/internal/api/refs.go @@ -0,0 +1,62 @@ +package api + +import ( + "context" + "strings" +) + +type projectRefsResponse map[string]*ProjectInfo + +type orgRefsResponse map[string]*OrganizationRef + +// extractHALLink finds a HAL link by prefix (e.g., "ref:projects" matches "ref:projects:0") +func extractHALLink(links HALLinks, prefix string) string { + for key, val := range links { + if strings.HasPrefix(key, prefix) { + if m, ok := val.(map[string]any); ok { + if href, ok := m["href"].(string); ok { + return href + } + } + } + } + return "" +} + +// getProjectRefsFromLink fetches project references using a HAL link URL. +func (c *Client) getProjectRefsFromLink(ctx context.Context, linkPath string) (map[string]*ProjectInfo, error) { + if linkPath == "" { + return map[string]*ProjectInfo{}, nil + } + + refURL, err := c.resolveURL(linkPath) + if err != nil { + return nil, err + } + + var refs projectRefsResponse + if err := c.getResource(ctx, refURL.String(), &refs); err != nil { + return nil, err + } + + return refs, nil +} + +// getOrgRefsFromLink fetches organization references using a HAL link URL. +func (c *Client) getOrgRefsFromLink(ctx context.Context, linkPath string) (map[string]*OrganizationRef, error) { + if linkPath == "" { + return map[string]*OrganizationRef{}, nil + } + + refURL, err := c.resolveURL(linkPath) + if err != nil { + return nil, err + } + + var refs orgRefsResponse + if err := c.getResource(ctx, refURL.String(), &refs); err != nil { + return nil, err + } + + return refs, nil +} diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 00000000..9015c73d --- /dev/null +++ b/internal/api/users.go @@ -0,0 +1,22 @@ +package api + +import ( + "context" +) + +// GetMyUserID returns the current user's ID. +func (c *Client) GetMyUserID(ctx context.Context) (string, error) { + meURL, err := c.resolveURL("users/me") + if err != nil { + return "", err + } + + var me struct { + ID string `json:"id"` + } + if err := c.getResource(ctx, meURL.String(), &me); err != nil { + return "", err + } + + return me.ID, nil +} diff --git a/internal/tableoutput/table.go b/internal/tableoutput/table.go new file mode 100644 index 00000000..9b8c8ec0 --- /dev/null +++ b/internal/tableoutput/table.go @@ -0,0 +1,311 @@ +// Package tableoutput provides table formatting for CLI output. +package tableoutput + +import ( + "encoding/csv" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" +) + +const ( + defaultTermWidth = 80 + minColumnWidth = 10 +) + +// Table represents a table for output. +type Table struct { + Header []string + Rows [][]string + MaxWidth int // Maximum table width (0 = auto-detect from terminal) +} + +// New creates a new table with the given header. +func New(header ...string) *Table { + return &Table{ + Header: header, + Rows: make([][]string, 0), + MaxWidth: 0, + } +} + +// AddRow adds a row to the table. +func (t *Table) AddRow(row ...string) { + t.Rows = append(t.Rows, row) +} + +// getTerminalWidth returns the terminal width or a default value. +func getTerminalWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return defaultTermWidth + } + return width +} + +// RenderTable renders the table in a box format with borders. +func (t *Table) RenderTable(w io.Writer) error { + if len(t.Header) == 0 { + return nil + } + + maxWidth := t.MaxWidth + if maxWidth == 0 { + maxWidth = getTerminalWidth() + } + + // Calculate natural column widths (without wrapping) + widths := make([]int, len(t.Header)) + for i, h := range t.Header { + widths[i] = len(h) + } + for _, row := range t.Rows { + for i, cell := range row { + if i < len(widths) { + // Consider max line width for multi-line cells + maxLineWidth := maxLineWidth(cell) + if maxLineWidth > widths[i] { + widths[i] = maxLineWidth + } + } + } + } + + // Calculate table overhead (borders: | + space on each side + |) + // For n columns: n+1 borders (|) + 2*n spaces = 3n+1 + overhead := 3*len(widths) + 1 + + // Check if table fits; if not, shrink columns proportionally + totalWidth := overhead + for _, w := range widths { + totalWidth += w + } + + if totalWidth > maxWidth && maxWidth > overhead { + widths = t.shrinkColumns(widths, maxWidth-overhead) + } + + // Wrap cell content to fit column widths + wrappedHeader := t.wrapRow(t.Header, widths) + wrappedRows := make([][][]string, len(t.Rows)) + for i, row := range t.Rows { + wrappedRows[i] = t.wrapRow(row, widths) + } + + // Build separator line + sep := make([]string, len(widths)) + for i, w := range widths { + sep[i] = strings.Repeat("-", w+2) + } + separator := "+" + strings.Join(sep, "+") + "+" + + // Print header + fmt.Fprintln(w, separator) + t.printWrappedRow(w, wrappedHeader, widths) + fmt.Fprintln(w, separator) + + // Print rows + for _, wrappedRow := range wrappedRows { + t.printWrappedRow(w, wrappedRow, widths) + } + fmt.Fprintln(w, separator) + + return nil +} + +// shrinkColumns adjusts column widths to fit within availableWidth. +func (t *Table) shrinkColumns(widths []int, availableWidth int) []int { + total := 0 + for _, w := range widths { + total += w + } + + if total <= availableWidth { + return widths + } + + // Calculate how much we need to shrink + excess := total - availableWidth + newWidths := make([]int, len(widths)) + copy(newWidths, widths) + + // Shrink proportionally, but respect minimum width + for excess > 0 { + // Find the widest column that can be shrunk + maxIdx := -1 + maxWidth := minColumnWidth + for i, w := range newWidths { + if w > maxWidth { + maxWidth = w + maxIdx = i + } + } + + if maxIdx == -1 { + // All columns at minimum width + break + } + + // Shrink the widest column by 1 + newWidths[maxIdx]-- + excess-- + } + + return newWidths +} + +// wrapRow wraps each cell in a row to fit its column width. +// Returns a slice of lines for each cell. +func (t *Table) wrapRow(row []string, widths []int) [][]string { + result := make([][]string, len(widths)) + for i := range widths { + cell := "" + if i < len(row) { + cell = row[i] + } + result[i] = wrapText(cell, widths[i]) + } + return result +} + +// printWrappedRow prints a row that may have multi-line cells. +func (t *Table) printWrappedRow(w io.Writer, wrappedCells [][]string, widths []int) { + // Find the maximum number of lines in any cell + maxLines := 0 + for _, lines := range wrappedCells { + if len(lines) > maxLines { + maxLines = len(lines) + } + } + + if maxLines == 0 { + maxLines = 1 + } + + // Print each line + for lineIdx := 0; lineIdx < maxLines; lineIdx++ { + cells := make([]string, len(widths)) + for colIdx, width := range widths { + val := "" + if colIdx < len(wrappedCells) && lineIdx < len(wrappedCells[colIdx]) { + val = wrappedCells[colIdx][lineIdx] + } + cells[colIdx] = fmt.Sprintf(" %-*s ", width, val) + } + fmt.Fprintln(w, "|"+strings.Join(cells, "|")+"|") + } +} + +// wrapText wraps text to fit within maxWidth, breaking on word boundaries when possible. +func wrapText(text string, maxWidth int) []string { + if maxWidth <= 0 { + maxWidth = minColumnWidth + } + + // Handle existing newlines + lines := strings.Split(text, "\n") + var result []string + + for _, line := range lines { + if len(line) <= maxWidth { + result = append(result, line) + continue + } + + // Wrap this line + wrapped := wordWrap(line, maxWidth) + result = append(result, wrapped...) + } + + if len(result) == 0 { + result = []string{""} + } + + return result +} + +// wordWrap wraps a single line at word boundaries. +func wordWrap(text string, maxWidth int) []string { + if len(text) <= maxWidth { + return []string{text} + } + + var lines []string + words := strings.Fields(text) + if len(words) == 0 { + return []string{""} + } + + currentLine := words[0] + for _, word := range words[1:] { + if len(currentLine)+1+len(word) <= maxWidth { + currentLine += " " + word + } else { + // Current line is full + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + // If word is longer than maxWidth, break it + if len(word) > maxWidth { + for len(word) > maxWidth { + lines = append(lines, word[:maxWidth]) + word = word[maxWidth:] + } + currentLine = word + } else { + currentLine = word + } + } + } + + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + + return lines +} + +// maxLineWidth returns the maximum line width in a potentially multi-line string. +func maxLineWidth(s string) int { + maxLen := 0 + for _, line := range strings.Split(s, "\n") { + if len(line) > maxLen { + maxLen = len(line) + } + } + return maxLen +} + +// RenderPlain renders the table in a plain tab-separated format. +func (t *Table) RenderPlain(w io.Writer) error { + if len(t.Header) == 0 { + return nil + } + + fmt.Fprintln(w, strings.Join(t.Header, "\t")) + for _, row := range t.Rows { + fmt.Fprintln(w, strings.Join(row, "\t")) + } + + return nil +} + +// RenderCSV renders the table as CSV. +func (t *Table) RenderCSV(w io.Writer, noHeader bool) error { + cw := csv.NewWriter(w) + if !noHeader { + if err := cw.Write(t.Header); err != nil { + return err + } + } + for _, row := range t.Rows { + if err := cw.Write(row); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} diff --git a/internal/tableoutput/table_test.go b/internal/tableoutput/table_test.go new file mode 100644 index 00000000..baa72759 --- /dev/null +++ b/internal/tableoutput/table_test.go @@ -0,0 +1,115 @@ +package tableoutput + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTable_RenderTable(t *testing.T) { + table := New("ID", "Title", "Region") + table.MaxWidth = 200 // Set wide width to avoid wrapping in this test + table.AddRow("proj-1", "Project 1", "region-1") + table.AddRow("proj-2", "Project 2", "region-2") + + var buf bytes.Buffer + err := table.RenderTable(&buf) + assert.NoError(t, err) + + expected := strings.TrimSpace(` ++--------+-----------+----------+ +| ID | Title | Region | ++--------+-----------+----------+ +| proj-1 | Project 1 | region-1 | +| proj-2 | Project 2 | region-2 | ++--------+-----------+----------+`) + + assert.Equal(t, expected, strings.TrimSpace(buf.String())) +} + +func TestTable_RenderTable_Wrapping(t *testing.T) { + table := New("ID", "Description") + table.MaxWidth = 40 // Force narrow width + table.AddRow("proj-1", "This is a very long description that should wrap") + + var buf bytes.Buffer + err := table.RenderTable(&buf) + assert.NoError(t, err) + + // Verify the output contains wrapped content (multiple lines for description) + output := buf.String() + assert.Contains(t, output, "proj-1") + assert.Contains(t, output, "This is") + // The exact wrapping depends on the algorithm, but the table should render + assert.True(t, strings.Contains(output, "\n")) +} + +func TestTable_RenderPlain(t *testing.T) { + table := New("ID", "Title", "Region") + table.AddRow("proj-1", "Project 1", "region-1") + table.AddRow("proj-2", "Project 2", "region-2") + + var buf bytes.Buffer + err := table.RenderPlain(&buf) + assert.NoError(t, err) + + expected := strings.TrimSpace(` +ID Title Region +proj-1 Project 1 region-1 +proj-2 Project 2 region-2`) + + assert.Equal(t, expected, strings.TrimSpace(buf.String())) +} + +func TestTable_RenderCSV(t *testing.T) { + table := New("ID", "Title", "Region") + table.AddRow("proj-1", "Project 1", "region-1") + table.AddRow("proj-2", "Project 2", "region-2") + + var buf bytes.Buffer + err := table.RenderCSV(&buf, false) + assert.NoError(t, err) + + expected := strings.TrimSpace(` +ID,Title,Region +proj-1,Project 1,region-1 +proj-2,Project 2,region-2`) + + assert.Equal(t, expected, strings.TrimSpace(buf.String())) +} + +func TestTable_RenderCSV_NoHeader(t *testing.T) { + table := New("ID", "Title", "Region") + table.AddRow("proj-1", "Project 1", "region-1") + table.AddRow("proj-2", "Project 2", "region-2") + + var buf bytes.Buffer + err := table.RenderCSV(&buf, true) + assert.NoError(t, err) + + expected := strings.TrimSpace(` +proj-1,Project 1,region-1 +proj-2,Project 2,region-2`) + + assert.Equal(t, expected, strings.TrimSpace(buf.String())) +} + +func TestWordWrap(t *testing.T) { + cases := []struct { + input string + maxWidth int + expected []string + }{ + {"short", 10, []string{"short"}}, + {"hello world", 5, []string{"hello", "world"}}, + {"a very long word", 8, []string{"a very", "long", "word"}}, + {"", 10, []string{""}}, + } + + for _, tc := range cases { + result := wordWrap(tc.input, tc.maxWidth) + assert.Equal(t, tc.expected, result, "for input %q with maxWidth %d", tc.input, tc.maxWidth) + } +}