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
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 70 additions & 28 deletions cmd/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,47 @@ import (
"io"
"net/http"
"os"
"regexp"
"text/tabwriter"

"github.com/instant-dev/cli/internal/tokens"
"github.com/spf13/cobra"
)

// ── Provisioning subcommand groups ───────────────────────────────────────────
// instant db new [name]
// instant cache new [name]
// instant nosql new [name]
// instant queue new [name]
// instant db new --name <name>
// instant cache new --name <name>
// instant nosql new --name <name>
// instant queue new --name <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"}
Expand All @@ -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 <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 <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 <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 <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)
Expand Down Expand Up @@ -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 <name>
instant cache new --name <name>
instant nosql new --name <name>
instant queue new --name <name>
`,
RunE: func(cmd *cobra.Command, args []string) error {
store, err := tokens.Load()
Expand Down Expand Up @@ -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)
Expand Down
142 changes: 142 additions & 0 deletions cmd/monitor_test.go
Original file line number Diff line number Diff line change
@@ -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 `<group> 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
})
}
22 changes: 12 additions & 10 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
`,
}

Expand Down