Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<product>/`, mounted
into the single `grn` binary. Scaffold one in seconds:

```bash
./scripts/new-product <product> # e.g. vdb (lowercase, valid Go package name)
```

This generates `go/cmd/<product>/` (parent command, an example command, shared
`createClient`/`outputResult` helpers, a starter test, and a product `CLAUDE.md`),
creates `docs/commands/<product>/`, 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
`<product>_endpoint` to `internal/config` REGIONS, replace the example command, etc.).

## Development Workflow

### 1. Create a feature branch
Expand Down
91 changes: 91 additions & 0 deletions scripts/new-product
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Scaffold a new product CLI (command group) from templates/new-product.
#
# Usage: ./scripts/new-product <product>
# <product> lowercase, starts with a letter, letters/digits only (a valid Go
# package name), e.g. "vdb", "vstorage".
#
# Generates go/cmd/<product>/ (parent command, example command, helpers, tests,
# CLAUDE.md), wires self-registration in go/cmd/register.go, adds CODEOWNERS
# lines, and creates docs/commands/<product>/. 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 <product> (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 <src.tmpl> <dest>
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."
30 changes: 30 additions & 0 deletions templates/new-product/CLAUDE.md.tmpl
Original file line number Diff line number Diff line change
@@ -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._
26 changes: 26 additions & 0 deletions templates/new-product/__PRODUCT__.go.tmpl
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions templates/new-product/docs-index.md.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# __DISPLAY__ commands

`grn __PRODUCT__` — manage __DISPLAY__ resources.

## Commands

| Command | Description |
|---------|-------------|
| [list-examples](list-examples.md) | List example resources (starter — replace) |
30 changes: 30 additions & 0 deletions templates/new-product/docs-list-examples.md.tmpl
Original file line number Diff line number Diff line change
@@ -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 <value>
```

## 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
```
25 changes: 25 additions & 0 deletions templates/new-product/helpers.go.tmpl
Original file line number Diff line number Diff line change
@@ -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)
}
46 changes: 46 additions & 0 deletions templates/new-product/list_examples.go.tmpl
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 17 additions & 0 deletions templates/new-product/list_examples_test.go.tmpl
Original file line number Diff line number Diff line change
@@ -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__")
}
}
Loading