diff --git a/cmd/browsers.go b/cmd/browsers.go index d799667..06592fc 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -118,8 +118,8 @@ type Int64Flag struct { Value int64 } -// Regular expression to validate CUID2 identifiers (24 lowercase alphanumeric characters). -var cuidRegex = regexp.MustCompile(`^[a-z0-9]{24}$`) +// Regular expression to validate CUID2 identifiers (starts with a letter, 24 lowercase alphanumeric characters). +var cuidRegex = regexp.MustCompile(`^[a-z][a-z0-9]{23}$`) // getAvailableViewports returns the list of supported viewport configurations. func getAvailableViewports() []string { diff --git a/cmd/projects.go b/cmd/projects.go new file mode 100644 index 0000000..5d01716 --- /dev/null +++ b/cmd/projects.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/packages/param" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var projectsCmd = &cobra.Command{ + Use: "projects", + Short: "Manage projects", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +// resolveProjectArg resolves a positional project argument that may be an ID or +// a name. If it looks like a cuid2 ID it is returned as-is; otherwise we list +// projects and find the matching name (case-insensitive). +func resolveProjectArg(cmd *cobra.Command, client kernel.Client, val string) (string, error) { + if cuidRegex.MatchString(val) { + return val, nil + } + resolved, err := resolveProjectByName(cmd.Context(), client, val) + if err != nil { + return "", err + } + return resolved, nil +} + +var projectsListCmd = &cobra.Command{ + Use: "list", + Short: "List projects", + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + ctx := cmd.Context() + + projects, err := client.Projects.List(ctx, kernel.ProjectListParams{}) + if err != nil { + pterm.Error.Println("Failed to list projects:", err) + return nil + } + + if len(projects.Items) == 0 { + pterm.Info.Println("No projects found") + return nil + } + + table := pterm.TableData{{"ID", "Name", "Status", "Created At"}} + for _, p := range projects.Items { + table = append(table, []string{p.ID, p.Name, string(p.Status), p.CreatedAt.String()}) + } + _ = pterm.DefaultTable.WithHasHeader(true).WithData(table).Render() + return nil + }, +} + +var projectsCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + ctx := cmd.Context() + + project, err := client.Projects.New(ctx, kernel.ProjectNewParams{ + CreateProjectRequest: kernel.CreateProjectRequestParam{ + Name: args[0], + }, + }) + if err != nil { + pterm.Error.Println("Failed to create project:", err) + return nil + } + + pterm.Success.Printf("Created project: %s (ID: %s)\n", project.Name, project.ID) + return nil + }, +} + +var projectsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a project by ID or name", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + ctx := cmd.Context() + + projectID, err := resolveProjectArg(cmd, client, args[0]) + if err != nil { + return err + } + + project, err := client.Projects.Get(ctx, projectID) + if err != nil { + pterm.Error.Println("Failed to get project:", err) + return nil + } + + table := pterm.TableData{ + {"Field", "Value"}, + {"ID", project.ID}, + {"Name", project.Name}, + {"Status", string(project.Status)}, + {"Created At", project.CreatedAt.String()}, + {"Updated At", project.UpdatedAt.String()}, + } + _ = pterm.DefaultTable.WithHasHeader(true).WithData(table).Render() + return nil + }, +} + +var projectsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + ctx := cmd.Context() + + projectID, err := resolveProjectArg(cmd, client, args[0]) + if err != nil { + return err + } + + err = client.Projects.Delete(ctx, projectID) + if err != nil { + pterm.Error.Println("Failed to delete project:", err) + return nil + } + + pterm.Success.Printf("Deleted project: %s\n", projectID) + return nil + }, +} + +var projectsLimitsGetCmd = &cobra.Command{ + Use: "get-limits ", + Short: "Get project limit overrides", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + ctx := cmd.Context() + + projectID, err := resolveProjectArg(cmd, client, args[0]) + if err != nil { + return err + } + + limits, err := client.Projects.Limits.Get(ctx, projectID) + if err != nil { + pterm.Error.Println("Failed to get project limits:", err) + return nil + } + + out, _ := json.MarshalIndent(limits, "", " ") + fmt.Println(string(out)) + return nil + }, +} + +var projectsLimitsSetCmd = &cobra.Command{ + Use: "set-limits ", + Short: "Set project limit overrides", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + ctx := cmd.Context() + + projectID, err := resolveProjectArg(cmd, client, args[0]) + if err != nil { + return err + } + + inner := kernel.UpdateProjectLimitsRequestParam{} + limitFlags := []string{ + "max-concurrent-sessions", + "max-persistent-sessions", + "max-concurrent-invocations", + "max-pooled-sessions", + } + for _, name := range limitFlags { + if cmd.Flags().Changed(name) { + v, _ := cmd.Flags().GetInt64(name) + if v < 0 { + return fmt.Errorf("--%s must be non-negative (got %d); use 0 to remove the cap", name, v) + } + } + } + if cmd.Flags().Changed("max-concurrent-sessions") { + v, _ := cmd.Flags().GetInt64("max-concurrent-sessions") + inner.MaxConcurrentSessions = param.NewOpt(v) + } + if cmd.Flags().Changed("max-persistent-sessions") { + v, _ := cmd.Flags().GetInt64("max-persistent-sessions") + inner.MaxPersistentSessions = param.NewOpt(v) + } + if cmd.Flags().Changed("max-concurrent-invocations") { + v, _ := cmd.Flags().GetInt64("max-concurrent-invocations") + inner.MaxConcurrentInvocations = param.NewOpt(v) + } + if cmd.Flags().Changed("max-pooled-sessions") { + v, _ := cmd.Flags().GetInt64("max-pooled-sessions") + inner.MaxPooledSessions = param.NewOpt(v) + } + params := kernel.ProjectLimitUpdateParams{ + UpdateProjectLimitsRequest: inner, + } + + limits, err := client.Projects.Limits.Update(ctx, projectID, params) + if err != nil { + pterm.Error.Println("Failed to set project limits:", err) + return nil + } + + out, _ := json.MarshalIndent(limits, "", " ") + pterm.Success.Println("Project limits updated:") + fmt.Println(string(out)) + return nil + }, +} + +func init() { + projectsLimitsSetCmd.Flags().Int64("max-concurrent-sessions", 0, "Maximum concurrent browser sessions (0 to remove cap)") + projectsLimitsSetCmd.Flags().Int64("max-persistent-sessions", 0, "Maximum persistent browser sessions (0 to remove cap)") + projectsLimitsSetCmd.Flags().Int64("max-concurrent-invocations", 0, "Maximum concurrent app invocations (0 to remove cap)") + projectsLimitsSetCmd.Flags().Int64("max-pooled-sessions", 0, "Maximum pooled sessions capacity (0 to remove cap)") + + projectsCmd.AddCommand(projectsListCmd) + projectsCmd.AddCommand(projectsCreateCmd) + projectsCmd.AddCommand(projectsGetCmd) + projectsCmd.AddCommand(projectsDeleteCmd) + projectsCmd.AddCommand(projectsLimitsGetCmd) + projectsCmd.AddCommand(projectsLimitsSetCmd) +} diff --git a/cmd/proxies/check.go b/cmd/proxies/check.go index 5f47fae..a820ff4 100644 --- a/cmd/proxies/check.go +++ b/cmd/proxies/check.go @@ -20,7 +20,7 @@ func (p ProxyCmd) Check(ctx context.Context, in ProxyCheckInput) error { pterm.Info.Printf("Running health check on proxy %s...\n", in.ID) } - proxy, err := p.proxies.Check(ctx, in.ID) + proxy, err := p.proxies.Check(ctx, in.ID, kernel.ProxyCheckParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/proxies/check_test.go b/cmd/proxies/check_test.go index 8f24ecb..c130206 100644 --- a/cmd/proxies/check_test.go +++ b/cmd/proxies/check_test.go @@ -13,7 +13,7 @@ func TestProxyCheck_ShowsBypassHosts(t *testing.T) { buf := captureOutput(t) fake := &FakeProxyService{ - CheckFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { + CheckFunc: func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { return &kernel.ProxyCheckResponse{ ID: id, Name: "Proxy 1", diff --git a/cmd/proxies/common_test.go b/cmd/proxies/common_test.go index 48f13cf..df49b76 100644 --- a/cmd/proxies/common_test.go +++ b/cmd/proxies/common_test.go @@ -41,7 +41,7 @@ type FakeProxyService struct { GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyGetResponse, error) NewFunc func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error - CheckFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) + CheckFunc func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) } func (f *FakeProxyService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) { @@ -73,9 +73,9 @@ func (f *FakeProxyService) Delete(ctx context.Context, id string, opts ...option return nil } -func (f *FakeProxyService) Check(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { +func (f *FakeProxyService) Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { if f.CheckFunc != nil { - return f.CheckFunc(ctx, id, opts...) + return f.CheckFunc(ctx, id, body, opts...) } return &kernel.ProxyCheckResponse{ID: id, Type: kernel.ProxyCheckResponseTypeDatacenter}, nil } diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index c8e7a38..bf55d9f 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -13,7 +13,7 @@ type ProxyService interface { Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyGetResponse, err error) New(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (res *kernel.ProxyNewResponse, err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) - Check(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error) + Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error) } // ProxyCmd handles proxy operations independent of cobra. diff --git a/cmd/root.go b/cmd/root.go index 8179d8c..09628e3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -104,6 +105,7 @@ func init() { rootCmd.PersistentFlags().BoolP("version", "v", false, "Print the CLI version") rootCmd.PersistentFlags().BoolP("no-color", "", false, "Disable color output") rootCmd.PersistentFlags().String("log-level", "warn", "Set the log level (trace, debug, info, warn, error, fatal, print)") + rootCmd.PersistentFlags().String("project", "", "Project ID or name to scope all requests to (or set KERNEL_PROJECT_ID env var)") rootCmd.SilenceUsage = true rootCmd.SilenceErrors = true cobra.OnInitialize(initConfig) @@ -122,12 +124,41 @@ func init() { return nil } - // Get authenticated client with OAuth tokens or API key fallback - client, err := auth.GetAuthenticatedClient(option.WithHeader("X-Kernel-Cli-Version", metadata.Version)) + clientOpts := []option.RequestOption{ + option.WithHeader("X-Kernel-Cli-Version", metadata.Version), + } + + projectVal, _ := cmd.Flags().GetString("project") + if projectVal == "" { + projectVal = os.Getenv("KERNEL_PROJECT_ID") + } + + // If the value looks like a name (not a cuid2 ID), we need to + // resolve it after authenticating. Build the client first without + // the project header, resolve, then re-create with the header. + needsResolve := projectVal != "" && !looksLikeCUID(projectVal) + + if projectVal != "" && !needsResolve { + clientOpts = append(clientOpts, option.WithHeader("X-Kernel-Project-Id", projectVal)) + } + + client, err := auth.GetAuthenticatedClient(clientOpts...) if err != nil { return fmt.Errorf("authentication required: %w", err) } + if needsResolve { + resolved, resolveErr := resolveProjectByName(cmd.Context(), *client, projectVal) + if resolveErr != nil { + return resolveErr + } + clientOpts = append(clientOpts, option.WithHeader("X-Kernel-Project-Id", resolved)) + client, err = auth.GetAuthenticatedClient(clientOpts...) + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + } + ctx := context.WithValue(cmd.Context(), util.KernelClientKey, *client) cmd.SetContext(ctx) return nil @@ -144,6 +175,7 @@ func init() { rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(credentialsCmd) rootCmd.AddCommand(credentialProvidersCmd) + rootCmd.AddCommand(projectsCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) rootCmd.AddCommand(upgradeCmd) @@ -223,6 +255,41 @@ func isUsageError(err error) bool { return false } +// looksLikeCUID returns true if s matches the cuid2 format used for resource IDs. +// Delegates to the shared cuidRegex defined in browsers.go. +func looksLikeCUID(s string) bool { + return cuidRegex.MatchString(s) +} + +// resolveProjectByName lists the caller's projects and returns the ID of the +// one whose name matches (case-insensitive). Returns an error if no match or +// multiple matches are found. +func resolveProjectByName(ctx context.Context, client kernel.Client, name string) (string, error) { + const maxProjects = 200 + projects, err := client.Projects.List(ctx, kernel.ProjectListParams{ + Limit: param.NewOpt(int64(maxProjects)), + }) + if err != nil { + return "", fmt.Errorf("failed to resolve project name %q: %w", name, err) + } + var matched []struct{ id, name string } + lower := strings.ToLower(name) + for _, p := range projects.Items { + if strings.ToLower(p.Name) == lower { + matched = append(matched, struct{ id, name string }{p.ID, p.Name}) + } + } + switch len(matched) { + case 0: + return "", fmt.Errorf("no project found with name %q", name) + case 1: + pterm.Debug.Printf("Resolved project %q → %s\n", matched[0].name, matched[0].id) + return matched[0].id, nil + default: + return "", fmt.Errorf("multiple projects match name %q; use a project ID instead", name) + } +} + // onCancel runs a function when the provided context is cancelled func onCancel(ctx context.Context, fn func()) { go func() { diff --git a/go.mod b/go.mod index bbc599b..8da7e41 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6 + github.com/kernel/kernel-go-sdk v0.48.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index 2c777ed..2b1d6dd 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6 h1:RBlGCN3IagI0b+XrWsb5FOUV/18tniuL6oHFAb7MMHE= -github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.48.0 h1:XX1VVs8D5q+rBMkZovXmKAQa94w+6oEJzxBLikfPaxw= +github.com/kernel/kernel-go-sdk v0.48.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=