From 9bad10ca64bf29de1927fa6d95208d9166d89fd0 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Sun, 19 Apr 2026 19:55:12 +0700 Subject: [PATCH] feat(configure): add project_id field to config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VNG Cloud APIs embed the user's project UUID in many URL paths. Adding project_id to the CLI config lets downstream tools (greenode-mcp-server, future SDKs) read a cached value instead of calling /v1/projects every session. Scope: per-profile field stored in ~/.greenode/config alongside region and output. Not a secret — lives in config, not credentials. - `grn configure`: wizard prompts for Project ID (blank = skip) - `grn configure list`: includes project_id with source column - `grn configure get project_id` / `set project_id ` - `GRN_DEFAULT_PROJECT_ID` env var override - Profiles work as-is (each profile has its own project_id) Co-Authored-By: Claude Sonnet 4.6 --- .changes/next-release/feature-configure-iqcv1jyc.json | 5 +++++ README.md | 3 +++ docs/configuration.md | 9 +++++++++ go/cmd/configure/configure.go | 3 ++- go/cmd/configure/get.go | 2 ++ go/cmd/configure/list.go | 6 ++++-- go/cmd/configure/set.go | 10 ++++++++-- go/internal/config/config.go | 9 +++++++++ go/internal/config/writer.go | 7 +++++-- 9 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 .changes/next-release/feature-configure-iqcv1jyc.json diff --git a/.changes/next-release/feature-configure-iqcv1jyc.json b/.changes/next-release/feature-configure-iqcv1jyc.json new file mode 100644 index 0000000..8a4c17a --- /dev/null +++ b/.changes/next-release/feature-configure-iqcv1jyc.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "configure", + "description": "Add project_id field to configure wizard, configure list/get/set, and GRN_DEFAULT_PROJECT_ID env var override" +} diff --git a/README.md b/README.md index 92f1ea5..1907aa3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Before using the GreenNode CLI, you need to configure your credentials. There ar export GRN_ACCESS_KEY_ID=your-client-id export GRN_SECRET_ACCESS_KEY=your-client-secret export GRN_DEFAULT_REGION=HCM-3 +export GRN_DEFAULT_PROJECT_ID=pro-xxxxxxxx # optional ``` **Method 2: Interactive setup (recommended)** @@ -64,6 +65,7 @@ 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 ``` **Method 3: Credentials file (manual)** @@ -80,6 +82,7 @@ client_secret = your-client-secret [default] region = HCM-3 output = json +project_id = pro-xxxxxxxx ``` Credentials are obtained from the [VNG Cloud IAM Portal](https://hcm-3.console.vngcloud.vn/iam/) under Service Accounts. diff --git a/docs/configuration.md b/docs/configuration.md index 77cd950..9822b31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,8 +13,13 @@ 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` 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. + Credentials are obtained from the [VNG Cloud IAM Portal](https://hcm-3.console.vngcloud.vn/iam/) under Service Accounts. ## Credential resolution order @@ -31,6 +36,7 @@ Credentials are resolved in the following order (highest to lowest priority): | `GRN_ACCESS_KEY_ID` | Client ID (overrides credentials file) | | `GRN_SECRET_ACCESS_KEY` | Client Secret (overrides credentials file) | | `GRN_DEFAULT_REGION` | Default region | +| `GRN_DEFAULT_PROJECT_ID` | Project ID (VNG Cloud project UUID) | | `GRN_PROFILE` | Profile name (default: "default") | | `GRN_DEFAULT_OUTPUT` | Output format | @@ -68,10 +74,12 @@ client_secret = yyy [default] region = HCM-3 output = json +project_id = pro-xxxxxxxx [profile staging] region = HAN output = table +project_id = pro-yyyyyyyy ``` Credentials file is created with `0600` permissions (owner read/write only). @@ -95,6 +103,7 @@ grn configure set region HAN # Set a specific value client_secret ****************c123 config-file ~/.greenode/credentials region HCM-3 config-file ~/.greenode/config output json config-file ~/.greenode/config + project_id pro-xxxxxxxx config-file ~/.greenode/config ``` ## Profiles diff --git a/go/cmd/configure/configure.go b/go/cmd/configure/configure.go index b302735..5d7e69e 100644 --- a/go/cmd/configure/configure.go +++ b/go/cmd/configure/configure.go @@ -48,6 +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) // If user entered masked value or empty, keep original if clientID == maskCred(cfg.ClientID) || clientID == "" { @@ -76,7 +77,7 @@ func runConfigure(cmd *cobra.Command, args []string) { os.Exit(1) } - if err := writer.WriteConfig(profile, region, output); err != nil { + if err := writer.WriteConfig(profile, region, output, projectID); err != nil { fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err) os.Exit(1) } diff --git a/go/cmd/configure/get.go b/go/cmd/configure/get.go index fde7dba..9c2615c 100644 --- a/go/cmd/configure/get.go +++ b/go/cmd/configure/get.go @@ -43,6 +43,8 @@ func runGet(cmd *cobra.Command, args []string) { value = cfg.Output case "profile": value = cfg.Profile + case "project_id": + value = cfg.ProjectID default: fmt.Fprintf(os.Stderr, "Unknown configuration key: %s\n", key) os.Exit(1) diff --git a/go/cmd/configure/list.go b/go/cmd/configure/list.go index af705c8..81b2896 100644 --- a/go/cmd/configure/list.go +++ b/go/cmd/configure/list.go @@ -42,6 +42,7 @@ func runList(cmd *cobra.Command, args []string) { resolveCredEntry("client_secret", cfg.ClientSecret, credsFile), resolveConfigEntry("region", cfg.Region, configFile), resolveConfigEntry("output", cfg.Output, configFile), + resolveConfigEntry("project_id", cfg.ProjectID, configFile), } // Print header @@ -94,8 +95,9 @@ func resolveConfigEntry(name, value, configFile string) configEntry { // Check if value came from env var envMap := map[string]string{ - "region": "GRN_DEFAULT_REGION", - "output": "GRN_DEFAULT_OUTPUT", + "region": "GRN_DEFAULT_REGION", + "output": "GRN_DEFAULT_OUTPUT", + "project_id": "GRN_DEFAULT_PROJECT_ID", } if envVar, ok := envMap[name]; ok { if os.Getenv(envVar) != "" { diff --git a/go/cmd/configure/set.go b/go/cmd/configure/set.go index 0f9eabd..457dd25 100644 --- a/go/cmd/configure/set.go +++ b/go/cmd/configure/set.go @@ -43,13 +43,19 @@ func runSet(cmd *cobra.Command, args []string) { } case "region": cfg, _ := config.LoadConfig(profile) - if err := writer.WriteConfig(profile, value, cfg.Output); err != nil { + if err := writer.WriteConfig(profile, value, cfg.Output, cfg.ProjectID); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "output": cfg, _ := config.LoadConfig(profile) - if err := writer.WriteConfig(profile, cfg.Region, value); err != nil { + if err := writer.WriteConfig(profile, cfg.Region, value, cfg.ProjectID); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "project_id": + cfg, _ := config.LoadConfig(profile) + if err := writer.WriteConfig(profile, cfg.Region, cfg.Output, value); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } diff --git a/go/internal/config/config.go b/go/internal/config/config.go index ce6d7a5..23febf4 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -27,6 +27,7 @@ type Config struct { Region string Output string Profile string + ProjectID string Regions map[string]map[string]string } @@ -105,6 +106,9 @@ func LoadConfig(profile string) (*Config, error) { if v := section.Key("output").String(); v != "" { cfg.Output = v } + if v := section.Key("project_id").String(); v != "" { + cfg.ProjectID = v + } } } @@ -113,6 +117,11 @@ func LoadConfig(profile string) (*Config, error) { cfg.Region = v } + // Env var override for project_id + if v := os.Getenv("GRN_DEFAULT_PROJECT_ID"); v != "" { + cfg.ProjectID = v + } + // Default output if cfg.Output == "" { cfg.Output = "json" diff --git a/go/internal/config/writer.go b/go/internal/config/writer.go index 4e54835..e7549a8 100644 --- a/go/internal/config/writer.go +++ b/go/internal/config/writer.go @@ -45,8 +45,10 @@ func (w *ConfigFileWriter) WriteCredentials(profile, clientID, clientSecret stri return w.save(cfg, filePath) } -// WriteConfig writes region and output for the given profile. -func (w *ConfigFileWriter) WriteConfig(profile, region, output string) error { +// WriteConfig writes region, output, and project_id for the given profile. +// An empty projectID is written as an empty key to explicitly clear any +// previously-saved value. +func (w *ConfigFileWriter) WriteConfig(profile, region, output, projectID string) error { if err := w.ensureDir(); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } @@ -68,6 +70,7 @@ func (w *ConfigFileWriter) WriteConfig(profile, region, output string) error { } section.Key("region").SetValue(region) section.Key("output").SetValue(output) + section.Key("project_id").SetValue(projectID) return w.save(cfg, filePath) }