From 46f01b8fb7084580f5582d3b6fd708264a5ca2f8 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 16 May 2026 01:45:22 +0530 Subject: [PATCH] feat: require --name flag on all provisioning commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resource `name` field is now strictly required on every InstaNode provisioning API endpoint (db, cache, nosql, queue, etc.). Omitting it returns HTTP 400. - `db new`, `cache new`, `nosql new`, `queue new` now take a required `--name` flag instead of an optional positional [name] arg, so the CLI fails fast with a clear "required flag(s) name not set" error before any API round trip. - Added validateResourceName mirroring the server contract (1–64 chars, ^[A-Za-z0-9][A-Za-z0-9 _-]*$) for an early, descriptive local error. - Updated root help, command usage/examples, and README to show --name. - Added monitor_test.go: validation table, missing-name rejection, invalid-name rejection (no API call), and valid-name body check. `instant up` already enforced a required name via manifest validation; no change needed there. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 20 ++++--- cmd/monitor.go | 98 +++++++++++++++++++++--------- cmd/monitor_test.go | 142 ++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 22 +++---- 4 files changed, 236 insertions(+), 46 deletions(-) create mode 100644 cmd/monitor_test.go diff --git a/README.md b/README.md index 582a4eb..8bb3cbb 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,19 @@ go install github.com/instant-dev/cli@latest ## Usage +Every provisioning command requires a `--name` flag. The name must be 1–64 +characters and match `^[A-Za-z0-9][A-Za-z0-9 _-]*$`; omitting it is rejected +both locally and by the API (HTTP 400). + ```bash -instant db new # Provision a Postgres database -instant cache new # Provision a Redis cache -instant nosql new # Provision a MongoDB document store -instant queue new # Provision a NATS JetStream queue -instant resources # List your provisioned resources (requires login) -instant status # Show locally tracked resources -instant login # Log in to your instanode.dev account -instant whoami # Show current account +instant db new --name app-db # Provision a Postgres database +instant cache new --name app-cache # Provision a Redis cache +instant nosql new --name app-docs # Provision a MongoDB document store +instant queue new --name app-jobs # Provision a NATS JetStream queue +instant resources # List your provisioned resources (requires login) +instant status # Show locally tracked resources +instant login # Log in to your instanode.dev account +instant whoami # Show current account ``` ## Build from source diff --git a/cmd/monitor.go b/cmd/monitor.go index 8e92574..816e51d 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "regexp" "text/tabwriter" "github.com/instant-dev/cli/internal/tokens" @@ -14,10 +15,39 @@ import ( ) // ── Provisioning subcommand groups ─────────────────────────────────────────── -// instant db new [name] -// instant cache new [name] -// instant nosql new [name] -// instant queue new [name] +// instant db new --name +// instant cache new --name +// instant nosql new --name +// instant queue new --name +// +// The resource `name` is REQUIRED on every provisioning endpoint. The server +// enforces 1–64 chars matching nameRegexp and rejects an omitted name with +// HTTP 400; the CLI marks --name required so the error surfaces locally +// before any API round trip. + +// nameMaxLen and nameRegexp mirror the server-side resource-name contract +// (1–64 chars, must start with an alphanumeric character). +const nameMaxLen = 64 + +var nameRegexp = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]*$`) + +// resourceName is bound to the required --name flag on every `new` command. +var resourceName string + +// validateResourceName applies the server-side name contract locally so the +// CLI fails fast with a clear message instead of a bare HTTP 400. +func validateResourceName(name string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + if len(name) > nameMaxLen { + return fmt.Errorf("--name must be 1–%d characters (got %d)", nameMaxLen, len(name)) + } + if !nameRegexp.MatchString(name) { + return fmt.Errorf("--name %q is invalid: must match %s", name, nameRegexp.String()) + } + return nil +} var ( dbCmd = &cobra.Command{Use: "db", Short: "Manage Postgres database resources"} @@ -27,40 +57,45 @@ var ( ) var dbNewCmd = &cobra.Command{ - Use: "new [name]", - Short: "Provision a Postgres database (+ pgvector)", - Args: cobra.MaximumNArgs(1), - RunE: makeProvisionCmd("/db/new", "db"), + Use: "new --name ", + Short: "Provision a Postgres database (+ pgvector)", + Example: " instant db new --name app-db", + Args: cobra.NoArgs, + RunE: makeProvisionCmd("/db/new", "db"), } var cacheNewCmd = &cobra.Command{ - Use: "new [name]", - Short: "Provision a Redis cache", - Args: cobra.MaximumNArgs(1), - RunE: makeProvisionCmd("/cache/new", "cache"), + Use: "new --name ", + Short: "Provision a Redis cache", + Example: " instant cache new --name app-cache", + Args: cobra.NoArgs, + RunE: makeProvisionCmd("/cache/new", "cache"), } var nosqlNewCmd = &cobra.Command{ - Use: "new [name]", - Short: "Provision a MongoDB document store", - Args: cobra.MaximumNArgs(1), - RunE: makeProvisionCmd("/nosql/new", "nosql"), + Use: "new --name ", + Short: "Provision a MongoDB document store", + Example: " instant nosql new --name app-docs", + Args: cobra.NoArgs, + RunE: makeProvisionCmd("/nosql/new", "nosql"), } var queueNewCmd = &cobra.Command{ - Use: "new [name]", - Short: "Provision a NATS JetStream queue", - Args: cobra.MaximumNArgs(1), - RunE: makeProvisionCmd("/queue/new", "queue"), + Use: "new --name ", + Short: "Provision a NATS JetStream queue", + Example: " instant queue new --name app-jobs", + Args: cobra.NoArgs, + RunE: makeProvisionCmd("/queue/new", "queue"), } // makeProvisionCmd returns a RunE function that POSTs to the given endpoint -// and prints the provisioned connection URL. +// and prints the provisioned connection URL. The resource name comes from the +// required --name flag. func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { - name := resourceType - if len(args) == 1 { - name = args[0] + name := resourceName + if err := validateResourceName(name); err != nil { + return err } creds, err := provisionResource(endpoint, name) @@ -137,10 +172,10 @@ var statusCmd = &cobra.Command{ Long: `Display all resources saved in ~/.instant-tokens. Resources are saved automatically when you run: - instant db new - instant cache new - instant nosql new - instant queue new + instant db new --name + instant cache new --name + instant nosql new --name + instant queue new --name `, RunE: func(cmd *cobra.Command, args []string) error { store, err := tokens.Load() @@ -170,6 +205,13 @@ Resources are saved automatically when you run: } func init() { + // --name is REQUIRED on every provisioning command. Cobra surfaces a + // clear `required flag(s) "name" not set` error before RunE runs. + for _, c := range []*cobra.Command{dbNewCmd, cacheNewCmd, nosqlNewCmd, queueNewCmd} { + c.Flags().StringVar(&resourceName, "name", "", "Resource name (required, 1–64 chars, matches ^[A-Za-z0-9][A-Za-z0-9 _-]*$)") + _ = c.MarkFlagRequired("name") + } + dbCmd.AddCommand(dbNewCmd) cacheCmd.AddCommand(cacheNewCmd) nosqlCmd.AddCommand(nosqlNewCmd) diff --git a/cmd/monitor_test.go b/cmd/monitor_test.go new file mode 100644 index 0000000..2ef6802 --- /dev/null +++ b/cmd/monitor_test.go @@ -0,0 +1,142 @@ +package cmd + +// White-box tests for the provisioning commands. They live in package `cmd` +// so they can exercise the unexported command tree, the shared `resourceName` +// flag variable, and validateResourceName directly. + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// freshProvisionCmd builds an isolated ` new` command tree so each test +// gets its own flag set (the production commands share the global +// `resourceName` variable, which would leak state between table cases). +func freshProvisionCmd(endpoint, resourceType string) (root *cobra.Command, name *string) { + var bound string + newCmd := &cobra.Command{ + Use: "new", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateResourceName(bound); err != nil { + return err + } + _, err := provisionResource(endpoint, bound) + return err + }, + } + newCmd.Flags().StringVar(&bound, "name", "", "Resource name (required)") + _ = newCmd.MarkFlagRequired("name") + + group := &cobra.Command{Use: resourceType} + group.AddCommand(newCmd) + r := &cobra.Command{Use: "instant"} + r.AddCommand(group) + r.SilenceUsage = true + r.SilenceErrors = true + return r, &bound +} + +func TestValidateResourceName(t *testing.T) { + cases := []struct { + name string + input string + wantErr bool + }{ + {"empty rejected", "", true}, + {"simple ok", "app-db", false}, + {"with spaces ok", "My App DB", false}, + {"underscores ok", "app_db_1", false}, + {"alphanumeric start ok", "1db", false}, + {"leading dash rejected", "-db", true}, + {"leading space rejected", " db", true}, + {"slash rejected", "app/db", true}, + {"max length ok", strings.Repeat("a", nameMaxLen), false}, + {"over max length rejected", strings.Repeat("a", nameMaxLen+1), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateResourceName(tc.input) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestProvisionMissingNameRejected confirms cobra rejects a `new` invocation +// that omits --name before any API call is attempted. +func TestProvisionMissingNameRejected(t *testing.T) { + for _, rt := range []string{"db", "cache", "nosql", "queue"} { + t.Run(rt, func(t *testing.T) { + root, _ := freshProvisionCmd("/"+rt+"/new", rt) + root.SetArgs([]string{rt, "new"}) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + err := root.Execute() + require.Error(t, err, "missing --name must error") + assert.Contains(t, strings.ToLower(err.Error()), "name", + "error should mention the missing name flag") + }) + } +} + +// TestProvisionInvalidNameRejected confirms a syntactically invalid --name is +// rejected locally before the request is sent. +func TestProvisionInvalidNameRejected(t *testing.T) { + hit := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hit = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + root, _ := freshProvisionCmd("/db/new", "db") + root.SetArgs([]string{"db", "new", "--name", "bad/name"}) + err := root.Execute() + require.Error(t, err) + assert.False(t, hit, "API must not be called when --name is invalid") +} + +// TestProvisionValidNameSendsRequest confirms a valid --name reaches the API +// with the name in the JSON body. +func TestProvisionValidNameSendsRequest(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := new(bytes.Buffer) + _, _ = b.ReadFrom(r.Body) + gotBody = b.String() + _, _ = w.Write([]byte(`{"ok":true,"token":"tok_test","name":"app-db","connection_url":"postgres://x"}`)) + })) + defer srv.Close() + withTestAPI(t, srv.URL) + + root, _ := freshProvisionCmd("/db/new", "db") + root.SetArgs([]string{"db", "new", "--name", "app-db"}) + require.NoError(t, root.Execute()) + assert.Contains(t, gotBody, `"name":"app-db"`, "name must be sent in request body") +} + +// withTestAPI points the package-level APIBaseURL / HTTPClient at a test +// server for the duration of the test, restoring them afterward. +func withTestAPI(t *testing.T, baseURL string) { + t.Helper() + prevURL, prevClient := APIBaseURL, HTTPClient + APIBaseURL = baseURL + HTTPClient = &http.Client{Timeout: 5 * time.Second} + t.Cleanup(func() { + APIBaseURL = prevURL + HTTPClient = prevClient + }) +} diff --git a/cmd/root.go b/cmd/root.go index 85ec618..1f0fc5d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,17 +44,19 @@ var rootCmd = &cobra.Command{ Provision databases, caches, queues, and document stores with a single command. No account required to get started. Log in with 'instant login' to persist resources. +Every provisioning command requires a --name flag (1–64 chars). + Examples: - instant db new Provision a Postgres database (+ pgvector) - instant cache new Provision a Redis cache - instant nosql new Provision a MongoDB document store - instant queue new Provision a NATS JetStream queue - instant resources List your provisioned resources (requires login) - instant status Show locally tracked resources - instant login Log in to your instant.dev account - instant logout Remove locally saved credentials - instant whoami Show current account - instant upgrade Open the upgrade page + instant db new --name app-db Provision a Postgres database (+ pgvector) + instant cache new --name app-cache Provision a Redis cache + instant nosql new --name app-docs Provision a MongoDB document store + instant queue new --name app-jobs Provision a NATS JetStream queue + instant resources List your provisioned resources (requires login) + instant status Show locally tracked resources + instant login Log in to your instant.dev account + instant logout Remove locally saved credentials + instant whoami Show current account + instant upgrade Open the upgrade page `, }