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__") + } +}