Skip to content

Commit a4590ba

Browse files
feat: require --name flag on all provisioning commands (#2)
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) <noreply@anthropic.com>
1 parent eada1aa commit a4590ba

4 files changed

Lines changed: 236 additions & 46 deletions

File tree

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@ go install github.com/instant-dev/cli@latest
1010

1111
## Usage
1212

13+
Every provisioning command requires a `--name` flag. The name must be 1–64
14+
characters and match `^[A-Za-z0-9][A-Za-z0-9 _-]*$`; omitting it is rejected
15+
both locally and by the API (HTTP 400).
16+
1317
```bash
14-
instant db new # Provision a Postgres database
15-
instant cache new # Provision a Redis cache
16-
instant nosql new # Provision a MongoDB document store
17-
instant queue new # Provision a NATS JetStream queue
18-
instant resources # List your provisioned resources (requires login)
19-
instant status # Show locally tracked resources
20-
instant login # Log in to your instanode.dev account
21-
instant whoami # Show current account
18+
instant db new --name app-db # Provision a Postgres database
19+
instant cache new --name app-cache # Provision a Redis cache
20+
instant nosql new --name app-docs # Provision a MongoDB document store
21+
instant queue new --name app-jobs # Provision a NATS JetStream queue
22+
instant resources # List your provisioned resources (requires login)
23+
instant status # Show locally tracked resources
24+
instant login # Log in to your instanode.dev account
25+
instant whoami # Show current account
2226
```
2327

2428
## Build from source

cmd/monitor.go

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,47 @@ import (
77
"io"
88
"net/http"
99
"os"
10+
"regexp"
1011
"text/tabwriter"
1112

1213
"github.com/instant-dev/cli/internal/tokens"
1314
"github.com/spf13/cobra"
1415
)
1516

1617
// ── Provisioning subcommand groups ───────────────────────────────────────────
17-
// instant db new [name]
18-
// instant cache new [name]
19-
// instant nosql new [name]
20-
// instant queue new [name]
18+
// instant db new --name <name>
19+
// instant cache new --name <name>
20+
// instant nosql new --name <name>
21+
// instant queue new --name <name>
22+
//
23+
// The resource `name` is REQUIRED on every provisioning endpoint. The server
24+
// enforces 1–64 chars matching nameRegexp and rejects an omitted name with
25+
// HTTP 400; the CLI marks --name required so the error surfaces locally
26+
// before any API round trip.
27+
28+
// nameMaxLen and nameRegexp mirror the server-side resource-name contract
29+
// (1–64 chars, must start with an alphanumeric character).
30+
const nameMaxLen = 64
31+
32+
var nameRegexp = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]*$`)
33+
34+
// resourceName is bound to the required --name flag on every `new` command.
35+
var resourceName string
36+
37+
// validateResourceName applies the server-side name contract locally so the
38+
// CLI fails fast with a clear message instead of a bare HTTP 400.
39+
func validateResourceName(name string) error {
40+
if name == "" {
41+
return fmt.Errorf("--name is required")
42+
}
43+
if len(name) > nameMaxLen {
44+
return fmt.Errorf("--name must be 1–%d characters (got %d)", nameMaxLen, len(name))
45+
}
46+
if !nameRegexp.MatchString(name) {
47+
return fmt.Errorf("--name %q is invalid: must match %s", name, nameRegexp.String())
48+
}
49+
return nil
50+
}
2151

2252
var (
2353
dbCmd = &cobra.Command{Use: "db", Short: "Manage Postgres database resources"}
@@ -27,40 +57,45 @@ var (
2757
)
2858

2959
var dbNewCmd = &cobra.Command{
30-
Use: "new [name]",
31-
Short: "Provision a Postgres database (+ pgvector)",
32-
Args: cobra.MaximumNArgs(1),
33-
RunE: makeProvisionCmd("/db/new", "db"),
60+
Use: "new --name <name>",
61+
Short: "Provision a Postgres database (+ pgvector)",
62+
Example: " instant db new --name app-db",
63+
Args: cobra.NoArgs,
64+
RunE: makeProvisionCmd("/db/new", "db"),
3465
}
3566

3667
var cacheNewCmd = &cobra.Command{
37-
Use: "new [name]",
38-
Short: "Provision a Redis cache",
39-
Args: cobra.MaximumNArgs(1),
40-
RunE: makeProvisionCmd("/cache/new", "cache"),
68+
Use: "new --name <name>",
69+
Short: "Provision a Redis cache",
70+
Example: " instant cache new --name app-cache",
71+
Args: cobra.NoArgs,
72+
RunE: makeProvisionCmd("/cache/new", "cache"),
4173
}
4274

4375
var nosqlNewCmd = &cobra.Command{
44-
Use: "new [name]",
45-
Short: "Provision a MongoDB document store",
46-
Args: cobra.MaximumNArgs(1),
47-
RunE: makeProvisionCmd("/nosql/new", "nosql"),
76+
Use: "new --name <name>",
77+
Short: "Provision a MongoDB document store",
78+
Example: " instant nosql new --name app-docs",
79+
Args: cobra.NoArgs,
80+
RunE: makeProvisionCmd("/nosql/new", "nosql"),
4881
}
4982

5083
var queueNewCmd = &cobra.Command{
51-
Use: "new [name]",
52-
Short: "Provision a NATS JetStream queue",
53-
Args: cobra.MaximumNArgs(1),
54-
RunE: makeProvisionCmd("/queue/new", "queue"),
84+
Use: "new --name <name>",
85+
Short: "Provision a NATS JetStream queue",
86+
Example: " instant queue new --name app-jobs",
87+
Args: cobra.NoArgs,
88+
RunE: makeProvisionCmd("/queue/new", "queue"),
5589
}
5690

5791
// makeProvisionCmd returns a RunE function that POSTs to the given endpoint
58-
// and prints the provisioned connection URL.
92+
// and prints the provisioned connection URL. The resource name comes from the
93+
// required --name flag.
5994
func makeProvisionCmd(endpoint, resourceType string) func(*cobra.Command, []string) error {
6095
return func(cmd *cobra.Command, args []string) error {
61-
name := resourceType
62-
if len(args) == 1 {
63-
name = args[0]
96+
name := resourceName
97+
if err := validateResourceName(name); err != nil {
98+
return err
6499
}
65100

66101
creds, err := provisionResource(endpoint, name)
@@ -137,10 +172,10 @@ var statusCmd = &cobra.Command{
137172
Long: `Display all resources saved in ~/.instant-tokens.
138173
139174
Resources are saved automatically when you run:
140-
instant db new
141-
instant cache new
142-
instant nosql new
143-
instant queue new
175+
instant db new --name <name>
176+
instant cache new --name <name>
177+
instant nosql new --name <name>
178+
instant queue new --name <name>
144179
`,
145180
RunE: func(cmd *cobra.Command, args []string) error {
146181
store, err := tokens.Load()
@@ -170,6 +205,13 @@ Resources are saved automatically when you run:
170205
}
171206

172207
func init() {
208+
// --name is REQUIRED on every provisioning command. Cobra surfaces a
209+
// clear `required flag(s) "name" not set` error before RunE runs.
210+
for _, c := range []*cobra.Command{dbNewCmd, cacheNewCmd, nosqlNewCmd, queueNewCmd} {
211+
c.Flags().StringVar(&resourceName, "name", "", "Resource name (required, 1–64 chars, matches ^[A-Za-z0-9][A-Za-z0-9 _-]*$)")
212+
_ = c.MarkFlagRequired("name")
213+
}
214+
173215
dbCmd.AddCommand(dbNewCmd)
174216
cacheCmd.AddCommand(cacheNewCmd)
175217
nosqlCmd.AddCommand(nosqlNewCmd)

cmd/monitor_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package cmd
2+
3+
// White-box tests for the provisioning commands. They live in package `cmd`
4+
// so they can exercise the unexported command tree, the shared `resourceName`
5+
// flag variable, and validateResourceName directly.
6+
7+
import (
8+
"bytes"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"testing"
13+
"time"
14+
15+
"github.com/spf13/cobra"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
// freshProvisionCmd builds an isolated `<group> new` command tree so each test
21+
// gets its own flag set (the production commands share the global
22+
// `resourceName` variable, which would leak state between table cases).
23+
func freshProvisionCmd(endpoint, resourceType string) (root *cobra.Command, name *string) {
24+
var bound string
25+
newCmd := &cobra.Command{
26+
Use: "new",
27+
Args: cobra.NoArgs,
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
if err := validateResourceName(bound); err != nil {
30+
return err
31+
}
32+
_, err := provisionResource(endpoint, bound)
33+
return err
34+
},
35+
}
36+
newCmd.Flags().StringVar(&bound, "name", "", "Resource name (required)")
37+
_ = newCmd.MarkFlagRequired("name")
38+
39+
group := &cobra.Command{Use: resourceType}
40+
group.AddCommand(newCmd)
41+
r := &cobra.Command{Use: "instant"}
42+
r.AddCommand(group)
43+
r.SilenceUsage = true
44+
r.SilenceErrors = true
45+
return r, &bound
46+
}
47+
48+
func TestValidateResourceName(t *testing.T) {
49+
cases := []struct {
50+
name string
51+
input string
52+
wantErr bool
53+
}{
54+
{"empty rejected", "", true},
55+
{"simple ok", "app-db", false},
56+
{"with spaces ok", "My App DB", false},
57+
{"underscores ok", "app_db_1", false},
58+
{"alphanumeric start ok", "1db", false},
59+
{"leading dash rejected", "-db", true},
60+
{"leading space rejected", " db", true},
61+
{"slash rejected", "app/db", true},
62+
{"max length ok", strings.Repeat("a", nameMaxLen), false},
63+
{"over max length rejected", strings.Repeat("a", nameMaxLen+1), true},
64+
}
65+
for _, tc := range cases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
err := validateResourceName(tc.input)
68+
if tc.wantErr {
69+
require.Error(t, err)
70+
} else {
71+
require.NoError(t, err)
72+
}
73+
})
74+
}
75+
}
76+
77+
// TestProvisionMissingNameRejected confirms cobra rejects a `new` invocation
78+
// that omits --name before any API call is attempted.
79+
func TestProvisionMissingNameRejected(t *testing.T) {
80+
for _, rt := range []string{"db", "cache", "nosql", "queue"} {
81+
t.Run(rt, func(t *testing.T) {
82+
root, _ := freshProvisionCmd("/"+rt+"/new", rt)
83+
root.SetArgs([]string{rt, "new"})
84+
root.SetOut(&bytes.Buffer{})
85+
root.SetErr(&bytes.Buffer{})
86+
err := root.Execute()
87+
require.Error(t, err, "missing --name must error")
88+
assert.Contains(t, strings.ToLower(err.Error()), "name",
89+
"error should mention the missing name flag")
90+
})
91+
}
92+
}
93+
94+
// TestProvisionInvalidNameRejected confirms a syntactically invalid --name is
95+
// rejected locally before the request is sent.
96+
func TestProvisionInvalidNameRejected(t *testing.T) {
97+
hit := false
98+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
99+
hit = true
100+
w.WriteHeader(http.StatusOK)
101+
}))
102+
defer srv.Close()
103+
withTestAPI(t, srv.URL)
104+
105+
root, _ := freshProvisionCmd("/db/new", "db")
106+
root.SetArgs([]string{"db", "new", "--name", "bad/name"})
107+
err := root.Execute()
108+
require.Error(t, err)
109+
assert.False(t, hit, "API must not be called when --name is invalid")
110+
}
111+
112+
// TestProvisionValidNameSendsRequest confirms a valid --name reaches the API
113+
// with the name in the JSON body.
114+
func TestProvisionValidNameSendsRequest(t *testing.T) {
115+
var gotBody string
116+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117+
b := new(bytes.Buffer)
118+
_, _ = b.ReadFrom(r.Body)
119+
gotBody = b.String()
120+
_, _ = w.Write([]byte(`{"ok":true,"token":"tok_test","name":"app-db","connection_url":"postgres://x"}`))
121+
}))
122+
defer srv.Close()
123+
withTestAPI(t, srv.URL)
124+
125+
root, _ := freshProvisionCmd("/db/new", "db")
126+
root.SetArgs([]string{"db", "new", "--name", "app-db"})
127+
require.NoError(t, root.Execute())
128+
assert.Contains(t, gotBody, `"name":"app-db"`, "name must be sent in request body")
129+
}
130+
131+
// withTestAPI points the package-level APIBaseURL / HTTPClient at a test
132+
// server for the duration of the test, restoring them afterward.
133+
func withTestAPI(t *testing.T, baseURL string) {
134+
t.Helper()
135+
prevURL, prevClient := APIBaseURL, HTTPClient
136+
APIBaseURL = baseURL
137+
HTTPClient = &http.Client{Timeout: 5 * time.Second}
138+
t.Cleanup(func() {
139+
APIBaseURL = prevURL
140+
HTTPClient = prevClient
141+
})
142+
}

cmd/root.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,19 @@ var rootCmd = &cobra.Command{
4444
Provision databases, caches, queues, and document stores with a single command.
4545
No account required to get started. Log in with 'instant login' to persist resources.
4646
47+
Every provisioning command requires a --name flag (1–64 chars).
48+
4749
Examples:
48-
instant db new Provision a Postgres database (+ pgvector)
49-
instant cache new Provision a Redis cache
50-
instant nosql new Provision a MongoDB document store
51-
instant queue new Provision a NATS JetStream queue
52-
instant resources List your provisioned resources (requires login)
53-
instant status Show locally tracked resources
54-
instant login Log in to your instant.dev account
55-
instant logout Remove locally saved credentials
56-
instant whoami Show current account
57-
instant upgrade Open the upgrade page
50+
instant db new --name app-db Provision a Postgres database (+ pgvector)
51+
instant cache new --name app-cache Provision a Redis cache
52+
instant nosql new --name app-docs Provision a MongoDB document store
53+
instant queue new --name app-jobs Provision a NATS JetStream queue
54+
instant resources List your provisioned resources (requires login)
55+
instant status Show locally tracked resources
56+
instant login Log in to your instant.dev account
57+
instant logout Remove locally saved credentials
58+
instant whoami Show current account
59+
instant upgrade Open the upgrade page
5860
`,
5961
}
6062

0 commit comments

Comments
 (0)