From 60d95332507a96845e83368ddc0f69f6233c85ea Mon Sep 17 00:00:00 2001 From: tytv2 Date: Mon, 6 Jul 2026 22:27:33 +0700 Subject: [PATCH] feat(scaffold): one-command scaffold for a new product CLI Add scripts/new-product + templates/new-product to scaffold a new product command group under go/cmd//: parent command, an example command, shared createClient/outputResult helpers, a starter test, and a product CLAUDE.md; it also creates docs/commands//, wires self-registration in go/cmd/register.go, and appends CODEOWNERS lines. The generated package builds, mounts under `grn `, and its test passes out of the box. Document the flow in CONTRIBUTING. This lowers the barrier for other product teams to build their CLI following the established conventions (mirrors the greennode-mcp new_server.py scaffold). --- CONTRIBUTING.md | 16 ++++ scripts/new-product | 91 +++++++++++++++++++ templates/new-product/CLAUDE.md.tmpl | 30 ++++++ templates/new-product/__PRODUCT__.go.tmpl | 26 ++++++ templates/new-product/docs-index.md.tmpl | 9 ++ .../new-product/docs-list-examples.md.tmpl | 30 ++++++ templates/new-product/helpers.go.tmpl | 25 +++++ templates/new-product/list_examples.go.tmpl | 46 ++++++++++ .../new-product/list_examples_test.go.tmpl | 17 ++++ 9 files changed, 290 insertions(+) create mode 100755 scripts/new-product create mode 100644 templates/new-product/CLAUDE.md.tmpl create mode 100644 templates/new-product/__PRODUCT__.go.tmpl create mode 100644 templates/new-product/docs-index.md.tmpl create mode 100644 templates/new-product/docs-list-examples.md.tmpl create mode 100644 templates/new-product/helpers.go.tmpl create mode 100644 templates/new-product/list_examples.go.tmpl create mode 100644 templates/new-product/list_examples_test.go.tmpl diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92954f1..a89c6d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,22 @@ cd go CGO_ENABLED=0 go build -o grn . ``` +## Adding a new product CLI + +Each product is a self-registering command group under `go/cmd//`, mounted +into the single `grn` binary. Scaffold one in seconds: + +```bash +./scripts/new-product # e.g. vdb (lowercase, valid Go package name) +``` + +This generates `go/cmd//` (parent command, an example command, shared +`createClient`/`outputResult` helpers, a starter test, and a product `CLAUDE.md`), +creates `docs/commands//`, wires self-registration in `go/cmd/register.go`, +and appends `.github/CODEOWNERS` lines. CI and the conventions test pick up the new +package automatically. Then follow the "Next steps" the script prints (add your +`_endpoint` to `internal/config` REGIONS, replace the example command, etc.). + ## Development Workflow ### 1. Create a feature branch diff --git a/scripts/new-product b/scripts/new-product new file mode 100755 index 0000000..5f0e50f --- /dev/null +++ b/scripts/new-product @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Scaffold a new product CLI (command group) from templates/new-product. +# +# Usage: ./scripts/new-product +# lowercase, starts with a letter, letters/digits only (a valid Go +# package name), e.g. "vdb", "vstorage". +# +# Generates go/cmd// (parent command, example command, helpers, tests, +# CLAUDE.md), wires self-registration in go/cmd/register.go, adds CODEOWNERS +# lines, and creates docs/commands//. CI (build/test) and the +# conventions test pick up the new package automatically — no other config. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(dirname "$SCRIPT_DIR")" +TMPL="$ROOT/templates/new-product" + +PRODUCT="${1:-}" +if [ -z "$PRODUCT" ]; then + echo "Usage: $0 (lowercase Go package name, e.g. vdb)" >&2 + exit 1 +fi +if ! printf '%s' "$PRODUCT" | grep -Eq '^[a-z][a-z0-9]*$'; then + echo "Error: product '$PRODUCT' must be lowercase, start with a letter, letters/digits only." >&2 + exit 1 +fi + +PKG_DIR="$ROOT/go/cmd/$PRODUCT" +if [ -e "$PKG_DIR" ]; then + echo "Error: $PKG_DIR already exists." >&2 + exit 1 +fi + +# Title = product with first letter upper-cased (exported Go identifier prefix). +TITLE="$(printf '%s' "${PRODUCT:0:1}" | tr '[:lower:]' '[:upper:]')${PRODUCT:1}" +# Display name for help text (upper-cased product; edit generated files to taste). +DISPLAY="$(printf '%s' "$PRODUCT" | tr '[:lower:]' '[:upper:]')" + +render() { + # render + sed -e "s/__PRODUCT__/$PRODUCT/g" \ + -e "s/__TITLE__/$TITLE/g" \ + -e "s/__DISPLAY__/$DISPLAY/g" \ + "$1" >"$2" +} + +echo "Scaffolding product '$PRODUCT' (package $PRODUCT, command 'grn $PRODUCT')..." + +mkdir -p "$PKG_DIR" +render "$TMPL/__PRODUCT__.go.tmpl" "$PKG_DIR/$PRODUCT.go" +render "$TMPL/helpers.go.tmpl" "$PKG_DIR/helpers.go" +render "$TMPL/list_examples.go.tmpl" "$PKG_DIR/list_examples.go" +render "$TMPL/list_examples_test.go.tmpl" "$PKG_DIR/list_examples_test.go" +render "$TMPL/CLAUDE.md.tmpl" "$PKG_DIR/CLAUDE.md" + +DOCS_DIR="$ROOT/docs/commands/$PRODUCT" +mkdir -p "$DOCS_DIR" +render "$TMPL/docs-index.md.tmpl" "$DOCS_DIR/index.md" +render "$TMPL/docs-list-examples.md.tmpl" "$DOCS_DIR/list-examples.md" + +# Wire self-registration: add a blank import to go/cmd/register.go before the +# closing paren of the import block. +REGISTER="$ROOT/go/cmd/register.go" +IMPORT_LINE=" _ \"github.com/vngcloud/greennode-cli/cmd/$PRODUCT\"" +if ! grep -q "cmd/$PRODUCT\"" "$REGISTER"; then + awk -v line="$IMPORT_LINE" ' + /^\)/ && !done { print line; done=1 } + { print } + ' "$REGISTER" >"$REGISTER.tmp" && mv "$REGISTER.tmp" "$REGISTER" +fi + +# Add CODEOWNERS lines (team slug is a placeholder to edit). +CODEOWNERS="$ROOT/.github/CODEOWNERS" +{ + echo "/go/cmd/$PRODUCT/ @vngcloud/$PRODUCT" + echo "/docs/commands/$PRODUCT/ @vngcloud/$PRODUCT" +} >>"$CODEOWNERS" + +echo +echo "Created:" +echo " go/cmd/$PRODUCT/ (parent + example command + helpers + tests + CLAUDE.md)" +echo " docs/commands/$PRODUCT/ (index + list-examples)" +echo " wired go/cmd/register.go, appended .github/CODEOWNERS" +echo +echo "Next steps:" +echo " 1. Add \"${PRODUCT}_endpoint\" to every region in go/internal/config/config.go (REGIONS)." +echo " 2. cd go && CGO_ENABLED=0 go build -o /tmp/grn . && /tmp/grn $PRODUCT --help" +echo " 3. Replace the list-examples starter with real commands (TDD)." +echo " 4. Edit the @vngcloud/$PRODUCT CODEOWNERS slug to your real team." +echo " 5. Add docs/commands/$PRODUCT/ to mkdocs.yml nav if you maintain the nav manually." diff --git a/templates/new-product/CLAUDE.md.tmpl b/templates/new-product/CLAUDE.md.tmpl new file mode 100644 index 0000000..3728f75 --- /dev/null +++ b/templates/new-product/CLAUDE.md.tmpl @@ -0,0 +1,30 @@ +# __DISPLAY__ CLI — product notes + +Product-specific quirks for the `__PRODUCT__` command group. The root `CLAUDE.md` +covers repo-wide conventions; this file records what is specific to __DISPLAY__. + +## Setup checklist (delete items as you complete them) + +- [ ] Add `"__PRODUCT___endpoint": "https://..."` to every region in + `go/internal/config/config.go` (`REGIONS`). `cli.NewClient(cmd, "__PRODUCT__")` + resolves the endpoint from this key. +- [ ] Replace the `list-examples` starter command with real commands. +- [ ] **greennode-cli is the source of truth** for endpoints/body/fields — when in + doubt, read the API and record quirks here rather than trusting stale specs. +- [ ] Fill in the API quirks section below as you discover them. + +## Conventions (enforced by repo conventions test) + +- Command names are `verb-noun` (`list-examples`, `create-widget`, `delete-widget`). + Allowed verbs: list, get, create, update, delete, configure, upgrade, validate, + generate, wait. +- Destructive commands (`delete-*`, disruptive ops) take `--dry-run` and `--force`; + use `cli.Confirm` / `cli.DryRunNotice`. +- Struct-valued flags use `cli.ParseStructFlag` (shorthand `k=v,k2=v2` or JSON). +- Always `validator.ValidateID(id, "flag")` before interpolating an ID into a URL. +- Output through `cli.Output` (respects `--output` / `--query` / `--color`). + +## API quirks + +_Record pagination base (0- vs 1-indexed), status codes, field casing, and any +surprises here as you find them._ diff --git a/templates/new-product/__PRODUCT__.go.tmpl b/templates/new-product/__PRODUCT__.go.tmpl new file mode 100644 index 0000000..5280bfc --- /dev/null +++ b/templates/new-product/__PRODUCT__.go.tmpl @@ -0,0 +1,26 @@ +package __PRODUCT__ + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/cli" +) + +// __TITLE__Cmd is the parent command for all __PRODUCT__ subcommands. +var __TITLE__Cmd = &cobra.Command{ + Use: "__PRODUCT__", + Short: "__DISPLAY__ commands", + Long: "Manage __DISPLAY__ resources.", + // Reject unknown subcommands (parent groups don't error by default in cobra). + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + // Register each subcommand here. + __TITLE__Cmd.AddCommand(listExamplesCmd) + + // Self-register with the CLI so root.go mounts this product automatically. + cli.RegisterService(__TITLE__Cmd) +} diff --git a/templates/new-product/docs-index.md.tmpl b/templates/new-product/docs-index.md.tmpl new file mode 100644 index 0000000..5a4ddd2 --- /dev/null +++ b/templates/new-product/docs-index.md.tmpl @@ -0,0 +1,9 @@ +# __DISPLAY__ commands + +`grn __PRODUCT__` — manage __DISPLAY__ resources. + +## Commands + +| Command | Description | +|---------|-------------| +| [list-examples](list-examples.md) | List example resources (starter — replace) | diff --git a/templates/new-product/docs-list-examples.md.tmpl b/templates/new-product/docs-list-examples.md.tmpl new file mode 100644 index 0000000..cd8c0bb --- /dev/null +++ b/templates/new-product/docs-list-examples.md.tmpl @@ -0,0 +1,30 @@ +# list-examples + +## Description + +List example __DISPLAY__ resources. (Starter command — replace with your real command docs, following the AWS-CLI-style reference format used by the other commands.) + +## Synopsis + +``` +grn __PRODUCT__ list-examples + --parent-id +``` + +## Options + +**`--parent-id`** (string) + +Parent resource ID. + +- Required: Yes + +## Global options + +This command also accepts the global options (`--profile`, `--region`, `--output`, `--query`, `--endpoint-url`, `--debug`, …). + +## Examples + +```bash +grn __PRODUCT__ list-examples --parent-id p-abc12345 +``` diff --git a/templates/new-product/helpers.go.tmpl b/templates/new-product/helpers.go.tmpl new file mode 100644 index 0000000..2829cd3 --- /dev/null +++ b/templates/new-product/helpers.go.tmpl @@ -0,0 +1,25 @@ +package __PRODUCT__ + +import ( + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/cli" + "github.com/vngcloud/greennode-cli/internal/client" +) + +// createClient builds a GreenodeClient for the __PRODUCT__ service from command +// flags. "cli.NewClient" resolves the endpoint via config.GetEndpoint using the +// service name, so add a "__PRODUCT___endpoint" entry to internal/config REGIONS. +func createClient(cmd *cobra.Command) (*client.GreenodeClient, error) { + return cli.NewClient(cmd, "__PRODUCT__") +} + +// outputResult formats and prints the API response using the shared --output / +// --query handling. +func outputResult(cmd *cobra.Command, data interface{}) error { + return cli.Output(cmd, data) +} + +// parseCommaSeparated splits a comma-separated flag value into a trimmed slice. +func parseCommaSeparated(s string) []string { + return cli.ParseCommaSeparated(s) +} diff --git a/templates/new-product/list_examples.go.tmpl b/templates/new-product/list_examples.go.tmpl new file mode 100644 index 0000000..8203ec9 --- /dev/null +++ b/templates/new-product/list_examples.go.tmpl @@ -0,0 +1,46 @@ +package __PRODUCT__ + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +// listExamplesCmd is a starter command showing the standard pattern: +// read flags -> validate -> build client -> call API -> output. +// Replace it with your real commands (follow the CLI conventions: verb-noun +// names, --dry-run + --force on destructive commands, cli.ParseStructFlag for +// struct-valued flags, validate IDs before interpolating into the URL). +var listExamplesCmd = &cobra.Command{ + Use: "list-examples", + Short: "List example __PRODUCT__ resources (starter — replace me)", + RunE: runListExamples, +} + +func init() { + f := listExamplesCmd.Flags() + f.String("parent-id", "", "Parent resource ID (required)") + listExamplesCmd.MarkFlagRequired("parent-id") +} + +func runListExamples(cmd *cobra.Command, args []string) error { + parentID, _ := cmd.Flags().GetString("parent-id") + if err := validator.ValidateID(parentID, "parent-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/parents/%s/examples", parentID), nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/templates/new-product/list_examples_test.go.tmpl b/templates/new-product/list_examples_test.go.tmpl new file mode 100644 index 0000000..7280b82 --- /dev/null +++ b/templates/new-product/list_examples_test.go.tmpl @@ -0,0 +1,17 @@ +package __PRODUCT__ + +import "testing" + +// TestExampleCommandRegistered is a starter test. Replace/extend it with real +// tests for your commands (table-driven; use httptest for API calls). +func TestExampleCommandRegistered(t *testing.T) { + found := false + for _, c := range __TITLE__Cmd.Commands() { + if c.Use == "list-examples" { + found = true + } + } + if !found { + t.Fatal("list-examples not registered under __PRODUCT__") + } +}