From 9e38e8f16f4604e6fce07030e7c0787d2f4308fe Mon Sep 17 00:00:00 2001 From: tytv2 Date: Sun, 19 Apr 2026 20:11:59 +0700 Subject: [PATCH] feat(configure): auto-detect project_id during grn configure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user leaves the Project ID prompt blank, the wizard now calls vServer /v1/projects (using the just-entered credentials and region's vServer endpoint) and saves the single returned project. Each user is expected to have exactly one project per region, so using the first result is unambiguous. If auto-detect fails (bad creds, no network, no project), the wizard prints a warning and leaves the field blank — downstream tools (greenode-mcp-server) can still fall back to auto-detect at call time. - `go/cmd/configure/detect_project.go`: lightweight /v1/projects fetch using the existing auth.TokenManager - `go/cmd/configure/configure.go`: auto-invoke when projectID == "" - Docs + README updated with the new prompt flow Co-Authored-By: Claude Sonnet 4.6 --- .../enhancement-configure-uooh8xep.json | 5 ++ README.md | 4 +- docs/configuration.md | 15 +++- go/cmd/configure/configure.go | 18 ++++- go/cmd/configure/detect_project.go | 77 +++++++++++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 .changes/next-release/enhancement-configure-uooh8xep.json create mode 100644 go/cmd/configure/detect_project.go diff --git a/.changes/next-release/enhancement-configure-uooh8xep.json b/.changes/next-release/enhancement-configure-uooh8xep.json new file mode 100644 index 0000000..05b6dbd --- /dev/null +++ b/.changes/next-release/enhancement-configure-uooh8xep.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "configure", + "description": "Auto-detect project_id during grn configure by calling vServer /v1/projects with the given credentials and region" +} diff --git a/README.md b/README.md index 1907aa3..ec39787 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ GRN Client ID [None]: GRN Client Secret [None]: Default region name [HCM-3]: Default output format [json]: -Project ID (leave blank to auto-detect at runtime) [None]: pro-xxxxxxxx +Project ID (leave blank to auto-detect) [None]: +Fetching project_id from HCM-3... +Auto-detected project_id: pro-xxxxxxxx ``` **Method 3: Credentials file (manual)** diff --git a/docs/configuration.md b/docs/configuration.md index 9822b31..60eea29 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,12 +13,19 @@ GRN Client ID [None]: GRN Client Secret [None]: Default region name [HCM-3]: Default output format [json]: -Project ID (leave blank to auto-detect at runtime) [None]: pro-xxxxxxxx +Project ID (leave blank to auto-detect) [None]: +Fetching project_id from HCM-3... +Auto-detected project_id: pro-xxxxxxxx ``` -`Project ID` is the VNG Cloud project UUID (e.g. `pro-e28d4501-...`). Each user may -have multiple projects; pick the one you work with. Leave blank to let downstream -tools (such as the GreenNode MCP Server) auto-detect at first call. +`Project ID` is the VNG Cloud project UUID for the selected region (e.g. +`pro-e28d4501-...`). Leave blank and the wizard calls the vServer API with +your credentials to detect and save it. Each user has one project per region, +so the detection is unambiguous. + +If auto-detect fails (network or auth error), the wizard prints a warning and +leaves the field blank — downstream tools (such as the GreenNode MCP Server) +can still auto-detect at first call. Credentials are obtained from the [VNG Cloud IAM Portal](https://hcm-3.console.vngcloud.vn/iam/) under Service Accounts. diff --git a/go/cmd/configure/configure.go b/go/cmd/configure/configure.go index 5d7e69e..a8afb74 100644 --- a/go/cmd/configure/configure.go +++ b/go/cmd/configure/configure.go @@ -48,7 +48,7 @@ func runConfigure(cmd *cobra.Command, args []string) { clientSecret := promptWithDefault(reader, "Client Secret", maskCred(cfg.ClientSecret)) region := promptWithDefault(reader, "Default region name", cfg.Region) output := promptWithDefault(reader, "Default output format", cfg.Output) - projectID := promptWithDefault(reader, "Project ID (leave blank to auto-detect at runtime)", cfg.ProjectID) + projectID := promptWithDefault(reader, "Project ID (leave blank to auto-detect)", cfg.ProjectID) // If user entered masked value or empty, keep original if clientID == maskCred(cfg.ClientID) || clientID == "" { @@ -70,6 +70,22 @@ func runConfigure(cmd *cobra.Command, args []string) { output = "json" } + // Auto-detect project_id when left blank + if projectID == "" && clientID != "" && clientSecret != "" { + if endpoint, err := vserverEndpointForRegion(region); err != nil { + fmt.Fprintf(os.Stderr, "Warning: cannot determine vServer endpoint for region %q; leaving project_id blank.\n", region) + } else { + fmt.Printf("Fetching project_id from %s...\n", region) + detected, derr := detectProjectID(clientID, clientSecret, endpoint) + if derr != nil { + fmt.Fprintf(os.Stderr, "Warning: auto-detect failed: %v\nLeaving project_id blank.\n", derr) + } else { + fmt.Printf("Auto-detected project_id: %s\n", detected) + projectID = detected + } + } + } + writer := config.NewConfigFileWriter() if err := writer.WriteCredentials(profile, clientID, clientSecret); err != nil { diff --git a/go/cmd/configure/detect_project.go b/go/cmd/configure/detect_project.go new file mode 100644 index 0000000..f806c59 --- /dev/null +++ b/go/cmd/configure/detect_project.go @@ -0,0 +1,77 @@ +package configure + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/vngcloud/greennode-cli/internal/auth" + "github.com/vngcloud/greennode-cli/internal/config" +) + +// vserverEndpointForRegion returns the vServer base URL for a region, +// looking it up in the REGIONS map. +func vserverEndpointForRegion(region string) (string, error) { + r, ok := config.REGIONS[region] + if !ok { + return "", fmt.Errorf("unknown region: %s", region) + } + ep, ok := r["vserver_endpoint"] + if !ok { + return "", fmt.Errorf("no vserver_endpoint configured for region %s", region) + } + return ep, nil +} + +// detectProjectTimeout is short — configure should fail fast, not hang the wizard. +const detectProjectTimeout = 10 * time.Second + +type projectsResponse struct { + Projects []struct { + ProjectID string `json:"projectId"` + } `json:"projects"` +} + +// detectProjectID fetches the caller's project from vServer /v1/projects +// using the given credentials and region's vServer endpoint. +// +// Returns the first projectId. Each user is expected to have exactly one +// project per region; returning the first is safe by that contract. +func detectProjectID(clientID, clientSecret, vserverEndpoint string) (string, error) { + tm := auth.NewTokenManager(clientID, clientSecret) + token, err := tm.GetToken() + if err != nil { + return "", fmt.Errorf("authentication failed: %w", err) + } + + req, err := http.NewRequest("GET", vserverEndpoint+"/v1/projects", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + + httpClient := &http.Client{Timeout: detectProjectTimeout} + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch projects: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var parsed projectsResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(parsed.Projects) == 0 { + return "", fmt.Errorf("account has no project in this region") + } + + return parsed.Projects[0].ProjectID, nil +}