diff --git a/go/cmd/api/main.go b/go/cmd/api/main.go index e93e6a7f5f..183e5f3b30 100644 --- a/go/cmd/api/main.go +++ b/go/cmd/api/main.go @@ -4,10 +4,10 @@ import ( "context" "github.com/unkeyed/unkey/go/apps/api" + "github.com/unkeyed/unkey/go/pkg/cli" "github.com/unkeyed/unkey/go/pkg/clock" "github.com/unkeyed/unkey/go/pkg/tls" "github.com/unkeyed/unkey/go/pkg/uid" - "github.com/urfave/cli/v3" ) var Cmd = &cli.Command{ @@ -16,176 +16,74 @@ var Cmd = &cli.Command{ Flags: []cli.Flag{ // Server Configuration - &cli.IntFlag{ - Name: "http-port", - Usage: "HTTP port for the API server to listen on. Default: 7070", - Sources: cli.EnvVars("UNKEY_HTTP_PORT"), - Value: 7070, - Required: false, - }, - &cli.BoolFlag{ - Name: "color", - Usage: "Enable colored log output. Default: true", - Sources: cli.EnvVars("UNKEY_LOGS_COLOR"), - Value: true, - Required: false, - }, - &cli.BoolFlag{ - Name: "test-mode", - Usage: "Enable test mode. WARNING: Potentially unsafe, may trust client inputs blindly. Default: false", - Sources: cli.EnvVars("UNKEY_TEST_MODE"), - Value: false, - Required: false, - }, + cli.Int("http-port", "HTTP port for the API server to listen on. Default: 7070", + cli.Default(7070), cli.EnvVar("UNKEY_HTTP_PORT")), + cli.Bool("color", "Enable colored log output. Default: true", + cli.Default(true), cli.EnvVar("UNKEY_LOGS_COLOR")), + cli.Bool("test-mode", "Enable test mode. WARNING: Potentially unsafe, may trust client inputs blindly. Default: false", + cli.Default(false), cli.EnvVar("UNKEY_TEST_MODE")), // Instance Identification - &cli.StringFlag{ - Name: "platform", - Usage: "Cloud platform identifier for this node. Used for logging and metrics.", - Sources: cli.EnvVars("UNKEY_PLATFORM"), - Required: false, - }, - &cli.StringFlag{ - Name: "image", - Usage: "Container image identifier. Used for logging and metrics.", - Sources: cli.EnvVars("UNKEY_IMAGE"), - Required: false, - }, - &cli.StringFlag{ - Name: "region", - Usage: "Geographic region identifier. Used for logging and routing. Default: unknown", - Sources: cli.EnvVars("UNKEY_REGION", "AWS_REGION"), - Value: "unknown", - Required: false, - }, - &cli.StringFlag{ - Name: "instance-id", - Usage: "Unique identifier for this instance. Auto-generated if not provided.", - Sources: cli.EnvVars("UNKEY_INSTANCE_ID"), - Value: uid.New(uid.InstancePrefix, 4), - Required: false, - }, + cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics.", + cli.EnvVar("UNKEY_PLATFORM")), + cli.String("image", "Container image identifier. Used for logging and metrics.", + cli.EnvVar("UNKEY_IMAGE")), + cli.String("region", "Geographic region identifier. Used for logging and routing. Default: unknown", + cli.Default("unknown"), cli.EnvVar("UNKEY_REGION"), cli.EnvVar("AWS_REGION")), + cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided.", + cli.Default(uid.New(uid.InstancePrefix, 4)), cli.EnvVar("UNKEY_INSTANCE_ID")), // Database Configuration - &cli.StringFlag{ - Name: "database-primary", - Usage: "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", - Sources: cli.EnvVars("UNKEY_DATABASE_PRIMARY"), - Required: true, - }, - &cli.StringFlag{ - Name: "database-replica", - Usage: "MySQL connection string for read-replica. Reduces load on primary database. Format same as database-primary.", - Sources: cli.EnvVars("UNKEY_DATABASE_REPLICA"), - Required: false, - }, + cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", + cli.Required(), cli.EnvVar("UNKEY_DATABASE_PRIMARY")), + cli.String("database-replica", "MySQL connection string for read-replica. Reduces load on primary database. Format same as database-primary.", + cli.EnvVar("UNKEY_DATABASE_REPLICA")), // Caching and Storage - &cli.StringFlag{ - Name: "redis-url", - Usage: "Redis connection string for rate-limiting and distributed counters. Example: redis://localhost:6379", - Sources: cli.EnvVars("UNKEY_REDIS_URL"), - Required: false, - }, - &cli.StringFlag{ - Name: "clickhouse-url", - Usage: "ClickHouse connection string for analytics. Recommended for production. Example: clickhouse://user:pass@host:9000/unkey", - Sources: cli.EnvVars("UNKEY_CLICKHOUSE_URL"), - Required: false, - }, + cli.String("redis-url", "Redis connection string for rate-limiting and distributed counters. Example: redis://localhost:6379", + cli.EnvVar("UNKEY_REDIS_URL")), + cli.String("clickhouse-url", "ClickHouse connection string for analytics. Recommended for production. Example: clickhouse://user:pass@host:9000/unkey", + cli.EnvVar("UNKEY_CLICKHOUSE_URL")), // Observability - &cli.BoolFlag{ - Name: "otel", - Usage: "Enable OpenTelemetry tracing and metrics", - Sources: cli.EnvVars("UNKEY_OTEL"), - Required: false, - }, + cli.Bool("otel", "Enable OpenTelemetry tracing and metrics", + cli.EnvVar("UNKEY_OTEL")), + cli.Float("otel-trace-sampling-rate", "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided. Default: 0.25", + cli.Default(0.25), cli.EnvVar("UNKEY_OTEL_TRACE_SAMPLING_RATE")), + cli.Int("prometheus-port", "Enable Prometheus /metrics endpoint on specified port. Set to 0 to disable.", + cli.Default(0), cli.EnvVar("UNKEY_PROMETHEUS_PORT")), // TLS Configuration - &cli.StringFlag{ - Name: "tls-cert-file", - Usage: "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS.", - Sources: cli.EnvVars("UNKEY_TLS_CERT_FILE"), - Required: false, - TakesFile: true, - }, - &cli.StringFlag{ - Name: "tls-key-file", - Usage: "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS.", - Sources: cli.EnvVars("UNKEY_TLS_KEY_FILE"), - Required: false, - TakesFile: true, - }, - - &cli.FloatFlag{ - Name: "otel-trace-sampling-rate", - Usage: "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided. Default: 0.25", - Sources: cli.EnvVars("UNKEY_OTEL_TRACE_SAMPLING_RATE"), - Value: 0.25, - Required: false, - }, - &cli.IntFlag{ - Name: "prometheus-port", - Usage: "Enable Prometheus /metrics endpoint on specified port. Set to 0 to disable.", - Sources: cli.EnvVars("UNKEY_PROMETHEUS_PORT"), - Value: 0, - Required: false, - }, + cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS.", + cli.EnvVar("UNKEY_TLS_CERT_FILE")), + cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS.", + cli.EnvVar("UNKEY_TLS_KEY_FILE")), // Vault Configuration - &cli.StringSliceFlag{ - Name: "vault-master-keys", - Usage: "Vault master keys for encryption", - Sources: cli.EnvVars("UNKEY_VAULT_MASTER_KEYS"), - Value: []string{}, - Required: false, - }, + cli.StringSlice("vault-master-keys", "Vault master keys for encryption", + cli.EnvVar("UNKEY_VAULT_MASTER_KEYS")), // S3 Configuration - &cli.StringFlag{ - Name: "vault-s3-url", - Usage: "S3 Compatible Endpoint URL ", - Sources: cli.EnvVars("UNKEY_VAULT_S3_URL"), - Value: "", - Required: false, - }, - &cli.StringFlag{ - Name: "vault-s3-bucket", - Usage: "S3 bucket name", - Sources: cli.EnvVars("UNKEY_VAULT_S3_BUCKET"), - Value: "", - Required: false, - }, - &cli.StringFlag{ - Name: "vault-s3-access-key-id", - Usage: "S3 access key ID", - Sources: cli.EnvVars("UNKEY_VAULT_S3_ACCESS_KEY_ID"), - Value: "", - Required: false, - }, - &cli.StringFlag{ - Name: "vault-s3-secret-access-key", - Usage: "S3 secret access key", - Sources: cli.EnvVars("UNKEY_VAULT_S3_SECRET_ACCESS_KEY"), - Value: "", - Required: false, - }, + cli.String("vault-s3-url", "S3 Compatible Endpoint URL", + cli.EnvVar("UNKEY_VAULT_S3_URL")), + cli.String("vault-s3-bucket", "S3 bucket name", + cli.EnvVar("UNKEY_VAULT_S3_BUCKET")), + cli.String("vault-s3-access-key-id", "S3 access key ID", + cli.EnvVar("UNKEY_VAULT_S3_ACCESS_KEY_ID")), + cli.String("vault-s3-secret-access-key", "S3 secret access key", + cli.EnvVar("UNKEY_VAULT_S3_SECRET_ACCESS_KEY")), // ClickHouse Proxy Service Configuration - &cli.BoolFlag{ - Name: "chproxy-enabled", - Usage: "Enable ClickHouse proxy endpoints for high-throughput event collection", - Sources: cli.EnvVars("UNKEY_CHPROXY_ENABLED"), - Value: false, - Required: false, - }, - &cli.StringFlag{ - Name: "chproxy-auth-token", - Usage: "Authentication token for ClickHouse proxy endpoints. Required when proxy is enabled.", - Sources: cli.EnvVars("UNKEY_CHPROXY_AUTH_TOKEN"), - Required: false, - }, + cli.Bool( + "chproxy-enabled", + "Enable ClickHouse proxy endpoints for high-throughput event collection", + cli.EnvVar("UNKEY_CHPROXY_ENABLED"), + ), + cli.String( + "chproxy-auth-token", + "Authentication token for ClickHouse proxy endpoints. Required when proxy is enabled.", + cli.EnvVar("UNKEY_CHPROXY_AUTH_TOKEN"), + ), }, Action: action, diff --git a/go/cmd/cli/cli/command.go b/go/cmd/cli/cli/command.go deleted file mode 100644 index cd9fdd4811..0000000000 --- a/go/cmd/cli/cli/command.go +++ /dev/null @@ -1,107 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "os" -) - -// Action represents a command handler function that receives context and the parsed command -type Action func(context.Context, *Command) error - -// Command represents a CLI command with its configuration and runtime state -type Command struct { - // Configuration - Name string // Command name (e.g., "deploy", "version") - Usage string // Short description shown in help - Description string // Longer description for detailed help - Version string // Version string (only used for root command) - Commands []*Command // Subcommands - Flags []Flag // Available flags for this command - Action Action // Function to execute when command is run - Aliases []string // Alternative names for this command - - // Runtime state (populated during parsing) - args []string // Non-flag arguments passed to command - flagMap map[string]Flag // Map for O(1) flag lookup - parent *Command // Parent command (for building usage paths) -} - -// Args returns the non-flag arguments passed to the command -// Example: "mycli deploy myapp" -> Args() returns ["myapp"] -func (c *Command) Args() []string { - return c.args -} - -// String returns the value of a string flag by name -// Returns empty string if flag doesn't exist or isn't a StringFlag -func (c *Command) String(name string) string { - if flag, ok := c.flagMap[name]; ok { - if sf, ok := flag.(*StringFlag); ok { - return sf.Value() - } - } - return "" -} - -// Bool returns the value of a boolean flag by name -// Returns false if flag doesn't exist or isn't a BoolFlag -func (c *Command) Bool(name string) bool { - if flag, ok := c.flagMap[name]; ok { - if bf, ok := flag.(*BoolFlag); ok { - return bf.Value() - } - } - return false -} - -// Int returns the value of an integer flag by name -// Returns 0 if flag doesn't exist or isn't an IntFlag -func (c *Command) Int(name string) int { - if flag, ok := c.flagMap[name]; ok { - if inf, ok := flag.(*IntFlag); ok { - return inf.Value() - } - } - return 0 -} - -// Float returns the value of a float flag by name -// Returns 0.0 if flag doesn't exist or isn't a FloatFlag -func (c *Command) Float(name string) float64 { - if flag, ok := c.flagMap[name]; ok { - if ff, ok := flag.(*FloatFlag); ok { - return ff.Value() - } - } - return 0.0 -} - -// StringSlice returns the value of a string slice flag by name -// Returns empty slice if flag doesn't exist or isn't a StringSliceFlag -func (c *Command) StringSlice(name string) []string { - if flag, ok := c.flagMap[name]; ok { - if ssf, ok := flag.(*StringSliceFlag); ok { - return ssf.Value() - } - } - return []string{} -} - -// Run executes the command with the given arguments (typically os.Args) -// This is the main entry point for CLI execution -func (c *Command) Run(ctx context.Context, args []string) error { - if len(args) == 0 { - return fmt.Errorf("no arguments provided") - } - // Parse arguments starting from index 1 (skip program name) - return c.parse(ctx, args[1:]) -} - -// Exit provides a clean way to exit with an error message and code -// This is a convenience function that prints the message and calls os.Exit -func Exit(message string, code int) error { - fmt.Println(message) - os.Exit(code) - return nil // unreachable but satisfies error interface -} diff --git a/go/cmd/cli/cli/flag.go b/go/cmd/cli/cli/flag.go deleted file mode 100644 index da1c6d0ef4..0000000000 --- a/go/cmd/cli/cli/flag.go +++ /dev/null @@ -1,268 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "strconv" - "strings" -) - -// Flag represents a command line flag interface -// All flag types must implement these methods -type Flag interface { - Name() string // The flag name (without dashes) - Usage() string // Help text describing the flag - Required() bool // Whether this flag is mandatory - Parse(value string) error // Parse string value into the flag's type - IsSet() bool // Whether the flag was explicitly set by user -} - -// baseFlag contains common fields and methods shared by all flag types -type baseFlag struct { - name string // Flag name - usage string // Help description - envVar string // Environment variable to check for default - required bool // Whether flag is mandatory - set bool // Whether user explicitly provided this flag -} - -// Name returns the flag name -func (b *baseFlag) Name() string { return b.name } - -// Usage returns the flag's help text -func (b *baseFlag) Usage() string { return b.usage } - -// Required returns whether this flag is mandatory -func (b *baseFlag) Required() bool { return b.required } - -// IsSet returns whether the user explicitly provided this flag -func (b *baseFlag) IsSet() bool { return b.set } - -// EnvVar returns the environment variable name for this flag -func (b *baseFlag) EnvVar() string { return b.envVar } - -// StringFlag represents a string command line flag -type StringFlag struct { - baseFlag - value string // Current value -} - -// Parse sets the flag value from a string -func (f *StringFlag) Parse(value string) error { - f.value = value - f.set = true - return nil -} - -// Value returns the current string value -func (f *StringFlag) Value() string { return f.value } - -// BoolFlag represents a boolean command line flag -type BoolFlag struct { - baseFlag - value bool // Current value -} - -// Parse sets the flag value from a string -// Empty string means the flag was provided without a value (--flag), which sets it to true -// Otherwise parses as boolean: "true", "false", "1", "0", etc. -func (f *BoolFlag) Parse(value string) error { - if value == "" { - f.value = true - f.set = true - return nil - } - parsed, err := strconv.ParseBool(value) - if err != nil { - return fmt.Errorf("invalid boolean value: %s", value) - } - f.value = parsed - f.set = true - return nil -} - -// Value returns the current boolean value -func (f *BoolFlag) Value() bool { return f.value } - -// IntFlag represents an integer command line flag -type IntFlag struct { - baseFlag - value int // Current value -} - -// Parse sets the flag value from a string -func (f *IntFlag) Parse(value string) error { - parsed, err := strconv.Atoi(value) - if err != nil { - return fmt.Errorf("invalid integer value: %s", value) - } - f.value = parsed - f.set = true - return nil -} - -// Value returns the current integer value -func (f *IntFlag) Value() int { return f.value } - -// FloatFlag represents a float64 command line flag -type FloatFlag struct { - baseFlag - value float64 // Current value -} - -// Parse sets the flag value from a string -func (f *FloatFlag) Parse(value string) error { - parsed, err := strconv.ParseFloat(value, 64) - if err != nil { - return fmt.Errorf("invalid float value: %s", value) - } - f.value = parsed - f.set = true - return nil -} - -// Value returns the current float64 value -func (f *FloatFlag) Value() float64 { return f.value } - -// StringSliceFlag represents a string slice command line flag -type StringSliceFlag struct { - baseFlag - value []string // Current value -} - -// parseCommaSeparated splits a comma-separated string into a slice of trimmed non-empty strings -func (f *StringSliceFlag) parseCommaSeparated(value string) []string { - if value == "" { - return []string{} - } - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - result = append(result, trimmed) - } - } - return result -} - -// Parse sets the flag value from a string (comma-separated values) -func (f *StringSliceFlag) Parse(value string) error { - f.value = f.parseCommaSeparated(value) - f.set = true - return nil -} - -// Value returns the current string slice value -func (f *StringSliceFlag) Value() []string { return f.value } - -// String creates a new string flag with environment variable support -// If envVar is provided and set, it will be used as the default value -func String(name, usage, defaultValue, envVar string, required bool) *StringFlag { - flag := &StringFlag{ - baseFlag: baseFlag{ - name: name, - usage: usage, - envVar: envVar, - required: required, - }, - value: defaultValue, - } - // Check environment variable for default value - if envVar != "" { - if envValue := os.Getenv(envVar); envValue != "" { - flag.value = envValue - flag.set = true // Mark as set since env var was found - } - } - return flag -} - -// Bool creates a new boolean flag with environment variable support -func Bool(name, usage, envVar string, required bool) *BoolFlag { - flag := &BoolFlag{ - baseFlag: baseFlag{ - name: name, - usage: usage, - envVar: envVar, - required: required, - }, - } - // Check environment variable for default value - if envVar != "" { - if envValue := os.Getenv(envVar); envValue != "" { - if parsed, err := strconv.ParseBool(envValue); err == nil { - flag.value = parsed - flag.set = true // Mark as set since env var was found - } - } - } - return flag -} - -// Int creates a new integer flag with environment variable support -func Int(name, usage string, defaultValue int, envVar string, required bool) *IntFlag { - flag := &IntFlag{ - baseFlag: baseFlag{ - name: name, - usage: usage, - envVar: envVar, - required: required, - }, - value: defaultValue, - } - // Check environment variable for default value - if envVar != "" { - if envValue := os.Getenv(envVar); envValue != "" { - if parsed, err := strconv.Atoi(envValue); err == nil { - flag.value = parsed - flag.set = true // Mark as set since env var was found - } - } - } - return flag -} - -// Float creates a new float flag with environment variable support -func Float(name, usage string, defaultValue float64, envVar string, required bool) *FloatFlag { - flag := &FloatFlag{ - baseFlag: baseFlag{ - name: name, - usage: usage, - envVar: envVar, - required: required, - }, - value: defaultValue, - } - // Check environment variable for default value - if envVar != "" { - if envValue := os.Getenv(envVar); envValue != "" { - if parsed, err := strconv.ParseFloat(envValue, 64); err == nil { - flag.value = parsed - flag.set = true // Mark as set since env var was found - } - } - } - return flag -} - -// StringSlice creates a new string slice flag with environment variable support -func StringSlice(name, usage string, defaultValue []string, envVar string, required bool) *StringSliceFlag { - flag := &StringSliceFlag{ - baseFlag: baseFlag{ - name: name, - usage: usage, - envVar: envVar, - required: required, - }, - value: defaultValue, - } - // Check environment variable for default value - if envVar != "" { - if envValue := os.Getenv(envVar); envValue != "" { - flag.value = flag.parseCommaSeparated(envValue) - flag.set = true // Mark as set since env var was found - } - } - return flag -} diff --git a/go/cmd/cli/cli/help.go b/go/cmd/cli/cli/help.go deleted file mode 100644 index 34483e69d0..0000000000 --- a/go/cmd/cli/cli/help.go +++ /dev/null @@ -1,155 +0,0 @@ -package cli - -import ( - "fmt" - "strings" -) - -// showHelp displays comprehensive help information for the command -// This includes name, description, usage, subcommands, and flags -func (c *Command) showHelp() { - // Command name and usage description - fmt.Printf("NAME:\n %s", c.Name) - if c.Usage != "" { - fmt.Printf(" - %s", c.Usage) - } - fmt.Printf("\n\n") - - // Extended description if available - if c.Description != "" { - fmt.Printf("DESCRIPTION:\n %s\n\n", c.Description) - } - - // Build and show usage line - c.showUsageLine() - - // Show version for root command - if c.Version != "" { - fmt.Printf("VERSION:\n %s\n\n", c.Version) - } - - // Show available subcommands - if len(c.Commands) > 0 { - c.showCommands() - } - - // Show command-specific flags if any exist - if len(c.Flags) > 0 { - fmt.Printf("OPTIONS:\n") - for _, flag := range c.Flags { - c.showFlag(flag) - } - fmt.Printf("\n") - } - - // Always show global options - fmt.Printf("GLOBAL OPTIONS:\n") - fmt.Printf(" %-25s %s\n", "--help, -h", "show help") - - // Add version flag only for root command (commands with Version set) - if c.Version != "" { - fmt.Printf(" %-25s %s\n", "--version, -v", "print the version") - } - fmt.Printf("\n") -} - -// showUsageLine displays the command usage syntax -func (c *Command) showUsageLine() { - fmt.Printf("USAGE:\n ") - - // Build full command path (parent commands + this command) - path := c.buildCommandPath() - fmt.Printf("%s", strings.Join(path, " ")) - - // Add syntax indicators - if len(c.Flags) > 0 { - fmt.Printf(" [options]") - } - if len(c.Commands) > 0 { - fmt.Printf(" [command]") - } - fmt.Printf("\n\n") -} - -// buildCommandPath constructs the full command path from root to current command -func (c *Command) buildCommandPath() []string { - var path []string - - // Walk up the parent chain to build full path - cmd := c - for cmd != nil { - path = append([]string{cmd.Name}, path...) - cmd = cmd.parent - } - return path -} - -// showCommands displays all available subcommands in a formatted table -func (c *Command) showCommands() { - fmt.Printf("COMMANDS:\n") - - // Find the longest command name for alignment - maxLen := 0 - for _, cmd := range c.Commands { - if len(cmd.Name) > maxLen { - maxLen = len(cmd.Name) - } - } - - // Display each command with aliases - for _, cmd := range c.Commands { - name := cmd.Name - if len(cmd.Aliases) > 0 { - name += fmt.Sprintf(", %s", strings.Join(cmd.Aliases, ", ")) - } - fmt.Printf(" %-*s %s\n", maxLen+10, name, cmd.Usage) - } - - // Add built-in help command - fmt.Printf(" %-*s %s\n", maxLen+10, "help, h", "Shows help for commands") - fmt.Printf("\n") -} - -// showFlag displays a single flag with proper formatting -func (c *Command) showFlag(flag Flag) { - // Build flag name(s) - support both short and long forms - name := fmt.Sprintf("--%s", flag.Name()) - if len(flag.Name()) == 1 { - name = fmt.Sprintf("-%s, --%s", flag.Name(), flag.Name()) - } - - // Build usage description - usage := flag.Usage() - - // Add required indicator - if flag.Required() { - usage += " (required)" - } - - // Add environment variable info if available - envVar := c.getEnvVar(flag) - if envVar != "" { - usage += fmt.Sprintf(" [$%s]", envVar) - } - - // Display with consistent formatting - fmt.Printf(" %-25s %s\n", name, usage) -} - -// getEnvVar extracts environment variable name from flag if it supports it -func (c *Command) getEnvVar(flag Flag) string { - switch f := flag.(type) { - case *StringFlag: - return f.EnvVar() - case *BoolFlag: - return f.EnvVar() - case *IntFlag: - return f.EnvVar() - case *FloatFlag: - return f.EnvVar() - case *StringSliceFlag: - return f.EnvVar() - default: - return "" - } -} diff --git a/go/cmd/cli/commands/deploy/build_docker.go b/go/cmd/cli/commands/deploy/build_docker.go deleted file mode 100644 index ba664c5707..0000000000 --- a/go/cmd/cli/commands/deploy/build_docker.go +++ /dev/null @@ -1,84 +0,0 @@ -package deploy - -import ( - "context" - "fmt" - "os/exec" - "strings" - "time" - - "github.com/unkeyed/unkey/go/pkg/git" -) - -func generateImageTag(opts *DeployOptions, gitInfo git.Info) string { - if gitInfo.ShortSHA != "" { - return fmt.Sprintf("%s-%s", opts.Branch, gitInfo.ShortSHA) - } - return fmt.Sprintf("%s-%d", opts.Branch, time.Now().Unix()) -} - -func buildImage(ctx context.Context, opts *DeployOptions, dockerImage string) error { - buildArgs := []string{"build"} - if opts.Dockerfile != "Dockerfile" { - buildArgs = append(buildArgs, "-f", opts.Dockerfile) - } - buildArgs = append(buildArgs, - "-t", dockerImage, - "--build-arg", fmt.Sprintf("VERSION=%s", opts.Commit), - opts.Context, - ) - - cmd := exec.CommandContext(ctx, "docker", buildArgs...) - - // Stream output directly instead of complex pipe handling - output, err := cmd.CombinedOutput() - if err != nil { - fmt.Printf("Docker build failed:\n%s\n", string(output)) - return ErrDockerBuildFailed - } - - return nil -} - -func pushImage(ctx context.Context, dockerImage, registry string) error { - cmd := exec.CommandContext(ctx, "docker", "push", dockerImage) - output, err := cmd.CombinedOutput() - if err != nil { - detailedMsg := classifyPushError(string(output), registry) - return fmt.Errorf("%s: %w", detailedMsg, err) - } - fmt.Printf("%s\n", string(output)) - return nil -} - -func classifyPushError(output, registry string) string { - output = strings.TrimSpace(output) - registryHost := getRegistryHost(registry) - - switch { - case strings.Contains(output, "denied"): - return fmt.Sprintf("registry access denied. try: docker login %s", registryHost) - - case strings.Contains(output, "not found") || strings.Contains(output, "404"): - return "registry not found. create repository or use --registry=your-registry/your-app" - - case strings.Contains(output, "unauthorized"): - return fmt.Sprintf("authentication required. run: docker login %s", registryHost) - - default: - return output - } -} - -func getRegistryHost(registry string) string { - parts := strings.Split(registry, "/") - if len(parts) > 0 { - return parts[0] - } - return "docker.io" -} - -func isDockerAvailable() bool { - cmd := exec.Command("docker", "--version") - return cmd.Run() == nil -} diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go deleted file mode 100644 index a286f157fe..0000000000 --- a/go/cmd/cli/commands/deploy/deploy.go +++ /dev/null @@ -1,302 +0,0 @@ -package deploy - -import ( - "context" - "errors" - "fmt" - - "github.com/unkeyed/unkey/go/cmd/cli/cli" - ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" - "github.com/unkeyed/unkey/go/pkg/git" - "github.com/unkeyed/unkey/go/pkg/otel/logging" -) - -// Step predictor - maps current step message patterns to next expected steps -// Based on the actual workflow messages from version.go -// TODO: In the future directly get those from hydra -var stepSequence = map[string]string{ - "Version queued and ready to start": "Downloading Docker image:", - "Downloading Docker image:": "Building rootfs from Docker image:", - "Building rootfs from Docker image:": "Uploading rootfs image to storage", - "Uploading rootfs image to storage": "Creating VM for version:", - "Creating VM for version:": "VM booted successfully:", - "VM booted successfully:": "Assigned hostname:", - "Assigned hostname:": "Version deployment completed successfully", -} - -var ( - ErrDockerNotFound = errors.New("docker command not found - please install Docker") - ErrDockerBuildFailed = errors.New("docker build failed") -) - -// DeployOptions contains all configuration for deployment -type DeployOptions struct { - WorkspaceID string - ProjectID string - Context string - Branch string - DockerImage string - Dockerfile string - Commit string - Registry string - SkipPush bool - ControlPlaneURL string - AuthToken string -} - -var DeployFlags = []cli.Flag{ - // Required flags - cli.String("workspace-id", "Workspace ID", "", "UNKEY_WORKSPACE_ID", true), - cli.String("project-id", "Project ID", "", "UNKEY_PROJECT_ID", true), - - // Optional flags with defaults - cli.String("context", "Docker context path", ".", "", false), - cli.String("branch", "Git branch", "main", "", false), - cli.String("docker-image", "Pre-built docker image", "", "", false), - cli.String("dockerfile", "Path to Dockerfile", "Dockerfile", "", false), - cli.String("commit", "Git commit SHA", "", "", false), - cli.String("registry", "Docker registry", "ghcr.io/unkeyed/deploy", "UNKEY_DOCKER_REGISTRY", false), - cli.Bool("skip-push", "Skip pushing to registry (for local testing)", "", false), - - // Control plane flags (internal) - cli.String("control-plane-url", "Control plane URL", "http://localhost:7091", "", false), - cli.String("auth-token", "Control plane auth token", "ctrl-secret-token", "", false), -} - -// Command defines the deploy CLI command -var Command = &cli.Command{ - Name: "deploy", - Usage: "Deploy a new version", - Description: `Build and deploy a new version of your application. -Builds a Docker image from the specified context and -deploys it to the Unkey platform. - -EXAMPLES: - # Basic deployment - unkey deploy \ - --workspace-id=ws_4QgQsKsKfdm3nGeC \ - --project-id=proj_9aiaks2dzl6mcywnxjf \ - --context=./demo_api - - # Deploy with your own registry - unkey deploy \ - --workspace-id=ws_4QgQsKsKfdm3nGeC \ - --project-id=proj_9aiaks2dzl6mcywnxjf \ - --registry=docker.io/mycompany/myapp - - # Local development (skip push) - unkey deploy \ - --workspace-id=ws_4QgQsKsKfdm3nGeC \ - --project-id=proj_9aiaks2dzl6mcywnxjf \ - --skip-push - - # Deploy pre-built image - unkey deploy \ - --workspace-id=ws_4QgQsKsKfdm3nGeC \ - --project-id=proj_9aiaks2dzl6mcywnxjf \ - --docker-image=ghcr.io/user/app:v1.0.0`, - Flags: DeployFlags, - Action: DeployAction, -} - -func DeployAction(ctx context.Context, cmd *cli.Command) error { - opts := &DeployOptions{ - WorkspaceID: cmd.String("workspace-id"), - ProjectID: cmd.String("project-id"), - Context: cmd.String("context"), - Branch: cmd.String("branch"), - DockerImage: cmd.String("docker-image"), - Dockerfile: cmd.String("dockerfile"), - Commit: cmd.String("commit"), - Registry: cmd.String("registry"), - SkipPush: cmd.Bool("skip-push"), - ControlPlaneURL: cmd.String("control-plane-url"), - AuthToken: cmd.String("auth-token"), - } - - return executeDeploy(ctx, opts) -} - -// Updated executeDeploy function - remove global spinner for deployment steps -func executeDeploy(ctx context.Context, opts *DeployOptions) error { - ui := NewUI() - logger := logging.New() - gitInfo := git.GetInfo() - - if opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { - opts.Branch = gitInfo.Branch - } - if opts.Commit == "" && gitInfo.CommitSHA != "" { - opts.Commit = gitInfo.CommitSHA - } - - fmt.Printf("Unkey Deploy Progress\n") - fmt.Printf("──────────────────────────────────────────────────\n") - printSourceInfo(opts, gitInfo) - - ui.Print("Preparing deployment") - - var dockerImage string - - if opts.DockerImage == "" { - if !isDockerAvailable() { - ui.PrintError("Docker not found - please install Docker") - ui.PrintErrorDetails(ErrDockerNotFound.Error()) - return nil - } - imageTag := generateImageTag(opts, gitInfo) - dockerImage = fmt.Sprintf("%s:%s", opts.Registry, imageTag) - - ui.Print(fmt.Sprintf("Building image: %s", dockerImage)) - if err := buildImage(ctx, opts, dockerImage); err != nil { - ui.PrintError("Docker build failed") - ui.PrintErrorDetails(err.Error()) - return nil - } - ui.PrintSuccess("Image built successfully") - } else { - dockerImage = opts.DockerImage - ui.Print("Using pre-built Docker image") - } - - if !opts.SkipPush && opts.DockerImage == "" { - ui.Print("Pushing to registry") - if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { - ui.PrintError("Push failed but continuing deployment") - ui.PrintErrorDetails(err.Error()) - // INFO: For now we are ignoring registry push because everyone one is working locally, - // omit this when comments and put the return nil back when going to prod - // return nil - } else { - ui.PrintSuccess("Image pushed successfully") - } - } else if opts.SkipPush { - ui.Print("Skipping registry push") - } - - ui.Print("Creating deployment") - - controlPlane := NewControlPlaneClient(opts) - versionId, err := controlPlane.CreateVersion(ctx, dockerImage) - if err != nil { - ui.PrintError("Failed to create version") - ui.PrintErrorDetails(err.Error()) - return nil - } - - ui.PrintSuccess(fmt.Sprintf("Version created: %s", versionId)) - - onStatusChange := func(event VersionStatusEvent) error { - if event.CurrentStatus == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { - return handleVersionFailure(controlPlane, event.Version, ui) - } - return nil - } - - onStepUpdate := func(event VersionStepEvent) error { - return handleStepUpdate(event, ui) - } - - err = controlPlane.PollVersionStatus(ctx, logger, versionId, onStatusChange, onStepUpdate) - if err != nil { - // Complete any running step spinner on error - ui.CompleteCurrentStep("Deployment failed", false) - return err - } - - // Complete final step if still spinning - ui.CompleteCurrentStep("Version deployment completed successfully", true) - ui.PrintSuccess("Deployment completed successfully") - - fmt.Printf("\n") - printCompletionInfo(opts, gitInfo, versionId) - fmt.Printf("\n") - - return nil -} - -func getNextStepMessage(currentMessage string) string { - // Check if current message starts with any known step pattern - for key, next := range stepSequence { - if len(currentMessage) >= len(key) && currentMessage[:len(key)] == key { - return next - } - } - return "" -} - -func handleStepUpdate(event VersionStepEvent, ui *UI) error { - step := event.Step - - if step.GetErrorMessage() != "" { - ui.CompleteCurrentStep(step.GetMessage(), false) - ui.PrintErrorDetails(step.GetErrorMessage()) - return fmt.Errorf("deployment failed: %s", step.GetErrorMessage()) - } - - if step.GetMessage() != "" { - message := step.GetMessage() - nextStep := getNextStepMessage(message) - - if !ui.stepSpinning { - // First step - start spinner, then complete and start next - ui.StartStepSpinner(message) - ui.CompleteStepAndStartNext(message, nextStep) - } else { - // Complete current step and start next - ui.CompleteStepAndStartNext(message, nextStep) - } - } - - return nil -} - -func handleVersionFailure(controlPlane *ControlPlaneClient, version *ctrlv1.Version, ui *UI) error { - errorMsg := controlPlane.getFailureMessage(version) - ui.CompleteCurrentStep("Deployment failed", false) - ui.PrintError("Deployment failed") - ui.PrintErrorDetails(errorMsg) - return fmt.Errorf("deployment failed: %s", errorMsg) -} - -func printSourceInfo(opts *DeployOptions, gitInfo git.Info) { - fmt.Printf("Source Information:\n") - fmt.Printf(" Branch: %s\n", opts.Branch) - - if gitInfo.IsRepo && gitInfo.CommitSHA != "" { - - commitInfo := gitInfo.ShortSHA - if gitInfo.IsDirty { - commitInfo += " (dirty)" - } - fmt.Printf(" Commit: %s\n", commitInfo) - } - - fmt.Printf(" Context: %s\n", opts.Context) - - if opts.DockerImage != "" { - fmt.Printf(" Image: %s\n", opts.DockerImage) - } - - fmt.Printf("\n") -} - -func printCompletionInfo(opts *DeployOptions, gitInfo git.Info, versionId string) { - if versionId == "" || opts.WorkspaceID == "" || opts.Branch == "" { - fmt.Printf("✓ Deployment completed\n") - return - } - - fmt.Printf("Deployment Summary:\n") - fmt.Printf(" Version: %s\n", versionId) - fmt.Printf(" Status: Ready\n") - fmt.Printf(" Environment: Production\n") - - identifier := versionId - if gitInfo.ShortSHA != "" { - identifier = gitInfo.ShortSHA - } - - domain := fmt.Sprintf("https://%s-%s-%s.unkey.app", opts.Branch, identifier, opts.WorkspaceID) - fmt.Printf(" URL: %s\n", domain) -} diff --git a/go/cmd/cli/commands/init/init.go b/go/cmd/cli/commands/init/init.go deleted file mode 100644 index 3b5b91f0ed..0000000000 --- a/go/cmd/cli/commands/init/init.go +++ /dev/null @@ -1,69 +0,0 @@ -package init - -import ( - "context" - "fmt" - - "github.com/unkeyed/unkey/go/cmd/cli/cli" -) - -var Command = &cli.Command{ - Name: "init", - Usage: "Initialize configuration file for Unkey CLI", - Description: `Initialize a configuration file to store default values for workspace ID, project ID, and context path. -This will create a configuration file that can be used to avoid specifying common flags repeatedly. - -EXAMPLES: - # Create default config file (./unkey.json) - unkey init - - # Create config file at custom location - unkey init --config=./my-project.json - - # Initialize with specific values - unkey init --workspace-id=ws_123 --project-id=proj_456`, - Flags: []cli.Flag{ - cli.String("config", "Configuration file path", "./unkey.json", "", false), - cli.String("workspace-id", "Default workspace ID to save in config", "", "", false), - cli.String("project-id", "Default project ID to save in config", "", "", false), - cli.String("context", "Default Docker context path to save in config", "", "", false), - }, - Action: run, -} - -func run(ctx context.Context, cmd *cli.Command) error { - configPath := cmd.String("config") - workspaceID := cmd.String("workspace-id") - projectID := cmd.String("project-id") - contextPath := cmd.String("context") - - fmt.Println("🚀 Unkey CLI Configuration Setup") - fmt.Println("") - - // For now, just show what would be saved - fmt.Println("Configuration file support coming soon!") - fmt.Println("") - fmt.Printf("Config file location: %s\n", configPath) - - if workspaceID != "" { - fmt.Printf("Workspace ID: %s\n", workspaceID) - } - if projectID != "" { - fmt.Printf("Project ID: %s\n", projectID) - } - if contextPath != "" { - fmt.Printf("Context path: %s\n", contextPath) - } - - fmt.Println("") - fmt.Println("For now, use flags directly:") - fmt.Println("") - fmt.Println("Example:") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --context=./demo_api") - fmt.Println("") - - return nil -} diff --git a/go/cmd/cli/commands/versions/versions.go b/go/cmd/cli/commands/versions/versions.go deleted file mode 100644 index 643d1c3524..0000000000 --- a/go/cmd/cli/commands/versions/versions.go +++ /dev/null @@ -1,164 +0,0 @@ -package versions - -import ( - "context" - "fmt" - - "github.com/unkeyed/unkey/go/cmd/cli/cli" - "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" -) - -// VersionListOptions holds options for version list command -type VersionListOptions struct { - Branch string - Status string - Limit int -} - -// Command defines the version CLI command with subcommands -var Command = &cli.Command{ - Name: "version", - Usage: "Manage API versions", - Description: `Create, list, and manage versions of your API. - -Versions are immutable snapshots of your code, configuration, and infrastructure settings. - -EXAMPLES: - # Create new version - unkey version create --workspace-id=ws_123 --project-id=proj_456 - - # List versions - unkey version list - unkey version list --branch=main --limit=20 - - # Get specific version - unkey version get v_abc123def456`, - Commands: []*cli.Command{ - createCmd, - listCmd, - getCmd, - }, -} - -// createCmd handles version create (alias for deploy) -var createCmd = &cli.Command{ - Name: "create", - Aliases: []string{"deploy"}, - Usage: "Create a new version (same as deploy)", - Description: "Same as 'unkey deploy'. See 'unkey help deploy' for details.", - Flags: deploy.DeployFlags, - Action: deploy.DeployAction, -} - -// listCmd handles version listing -var listCmd = &cli.Command{ - Name: "list", - Usage: "List versions", - Description: `List all versions with optional filtering. - -EXAMPLES: - # List all versions - unkey version list - - # Filter by branch - unkey version list --branch=main - - # Filter by status and limit results - unkey version list --status=active --limit=5`, - Flags: []cli.Flag{ - cli.String("branch", "Filter by branch", "", "", false), - cli.String("status", "Filter by status (pending, building, active, failed)", "", "", false), - cli.Int("limit", "Number of versions to show", 10, "", false), - }, - Action: listAction, -} - -// getCmd handles getting version details -var getCmd = &cli.Command{ - Name: "get", - Usage: "Get version details", - Description: `Get detailed information about a specific version. - -USAGE: - unkey version get - -EXAMPLES: - unkey version get v_abc123def456`, - Action: getAction, -} - -// listAction handles the version list command execution -func listAction(ctx context.Context, cmd *cli.Command) error { - opts := &VersionListOptions{ - Branch: cmd.String("branch"), - Status: cmd.String("status"), - Limit: cmd.Int("limit"), - } - - // Display filter info if provided - filters := []string{} - if opts.Branch != "" { - filters = append(filters, fmt.Sprintf("branch=%s", opts.Branch)) - } - if opts.Status != "" { - filters = append(filters, fmt.Sprintf("status=%s", opts.Status)) - } - filters = append(filters, fmt.Sprintf("limit=%d", opts.Limit)) - - if len(filters) > 1 { - fmt.Printf("Listing versions (%s)\n", fmt.Sprintf("%v", filters)) - } else { - fmt.Printf("Listing versions (limit=%d)\n", opts.Limit) - } - fmt.Println() - - // TODO: Add actual version listing logic here - // This would typically: - // 1. Call control plane API with filters - // 2. Parse response - // 3. Format and display results - - // Mock data for demonstration - fmt.Println("ID STATUS BRANCH CREATED") - fmt.Println("v_abc123def456 ACTIVE main 2024-01-01 12:00:00") - if opts.Branch == "" || opts.Branch == "feature" { - fmt.Println("v_def456ghi789 ACTIVE feature 2024-01-01 11:00:00") - } - if opts.Status == "" || opts.Status == "failed" { - fmt.Println("v_ghi789jkl012 FAILED main 2024-01-01 10:00:00") - } - - return nil -} - -// getAction handles the version get command execution -func getAction(ctx context.Context, cmd *cli.Command) error { - args := cmd.Args() - if len(args) == 0 { - return cli.Exit("version get requires a version ID", 1) - } - - versionID := args[0] - fmt.Printf("Getting version: %s\n", versionID) - fmt.Println() - - // TODO: Add actual version get logic here - // This would typically: - // 1. Call control plane API with version ID - // 2. Parse response - // 3. Display detailed information - - // Mock data for demonstration - fmt.Printf("Version: %s\n", versionID) - fmt.Printf("Status: ACTIVE\n") - fmt.Printf("Branch: main\n") - fmt.Printf("Created: 2024-01-01 12:00:00\n") - fmt.Printf("Docker Image: ghcr.io/unkeyed/deploy:main-abc123\n") - fmt.Printf("Commit: abc123def456789\n") - fmt.Println() - fmt.Printf("Hostnames:\n") - fmt.Printf(" - https://main-abc123-workspace.unkey.app\n") - fmt.Printf(" - https://api.acme.com\n") - - return nil -} diff --git a/go/cmd/cli/main.go b/go/cmd/cli/main.go deleted file mode 100644 index d96409f649..0000000000 --- a/go/cmd/cli/main.go +++ /dev/null @@ -1,31 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - - "github.com/unkeyed/unkey/go/cmd/cli/cli" - "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" - initcmd "github.com/unkeyed/unkey/go/cmd/cli/commands/init" - "github.com/unkeyed/unkey/go/cmd/cli/commands/versions" - "github.com/unkeyed/unkey/go/pkg/version" -) - -func main() { - app := &cli.Command{ - Name: "unkey", - Usage: "Deploy and manage your API versions", - Version: version.Version, - Commands: []*cli.Command{ - deploy.Command, - versions.Command, - initcmd.Command, - }, - } - - if err := app.Run(context.Background(), os.Args); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} diff --git a/go/cmd/ctrl/main.go b/go/cmd/ctrl/main.go index 0c379e5a1c..8fe824f828 100644 --- a/go/cmd/ctrl/main.go +++ b/go/cmd/ctrl/main.go @@ -4,10 +4,10 @@ import ( "context" "github.com/unkeyed/unkey/go/apps/ctrl" + "github.com/unkeyed/unkey/go/pkg/cli" "github.com/unkeyed/unkey/go/pkg/clock" "github.com/unkeyed/unkey/go/pkg/tls" "github.com/unkeyed/unkey/go/pkg/uid" - "github.com/urfave/cli/v3" ) var Cmd = &cli.Command{ @@ -16,117 +16,47 @@ var Cmd = &cli.Command{ Flags: []cli.Flag{ // Server Configuration - &cli.IntFlag{ - Name: "http-port", - Usage: "HTTP port for the control plane server to listen on. Default: 8080", - Sources: cli.EnvVars("UNKEY_HTTP_PORT"), - Value: 8080, - Required: false, - }, - &cli.BoolFlag{ - Name: "color", - Usage: "Enable colored log output. Default: true", - Sources: cli.EnvVars("UNKEY_LOGS_COLOR"), - Value: true, - Required: false, - }, + cli.Int("http-port", "HTTP port for the control plane server to listen on. Default: 8080", + cli.Default(8080), cli.EnvVar("UNKEY_HTTP_PORT")), + cli.Bool("color", "Enable colored log output. Default: true", + cli.Default(true), cli.EnvVar("UNKEY_LOGS_COLOR")), // Instance Identification - &cli.StringFlag{ - Name: "platform", - Usage: "Cloud platform identifier for this node. Used for logging and metrics.", - Sources: cli.EnvVars("UNKEY_PLATFORM"), - Required: false, - }, - &cli.StringFlag{ - Name: "image", - Usage: "Container image identifier. Used for logging and metrics.", - Sources: cli.EnvVars("UNKEY_IMAGE"), - Required: false, - }, - &cli.StringFlag{ - Name: "region", - Usage: "Geographic region identifier. Used for logging and routing. Default: unknown", - Sources: cli.EnvVars("UNKEY_REGION", "AWS_REGION"), - Value: "unknown", - Required: false, - }, - &cli.StringFlag{ - Name: "instance-id", - Usage: "Unique identifier for this instance. Auto-generated if not provided.", - Sources: cli.EnvVars("UNKEY_INSTANCE_ID"), - Value: uid.New(uid.InstancePrefix, 4), - Required: false, - }, + cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics.", + cli.EnvVar("UNKEY_PLATFORM")), + cli.String("image", "Container image identifier. Used for logging and metrics.", + cli.EnvVar("UNKEY_IMAGE")), + cli.String("region", "Geographic region identifier. Used for logging and routing. Default: unknown", + cli.Default("unknown"), cli.EnvVar("UNKEY_REGION"), cli.EnvVar("AWS_REGION")), + cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided.", + cli.Default(uid.New(uid.InstancePrefix, 4)), cli.EnvVar("UNKEY_INSTANCE_ID")), // Database Configuration - &cli.StringFlag{ - Name: "database-primary", - Usage: "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", - Sources: cli.EnvVars("UNKEY_DATABASE_PRIMARY"), - Required: true, - }, - - &cli.StringFlag{ - Name: "database-hydra", - Usage: "MySQL connection string for hydra database. Required for all deployments. Example: user:pass@host:3306/hydra?parseTime=true", - Sources: cli.EnvVars("UNKEY_DATABASE_HYDRA"), - Required: true, - }, + cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", + cli.Required(), cli.EnvVar("UNKEY_DATABASE_PRIMARY")), + cli.String("database-hydra", "MySQL connection string for hydra database. Required for all deployments. Example: user:pass@host:3306/hydra?parseTime=true", + cli.Required(), cli.EnvVar("UNKEY_DATABASE_HYDRA")), // Observability - &cli.BoolFlag{ - Name: "otel", - Usage: "Enable OpenTelemetry tracing and metrics", - Sources: cli.EnvVars("UNKEY_OTEL"), - Required: false, - }, - &cli.FloatFlag{ - Name: "otel-trace-sampling-rate", - Usage: "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided. Default: 0.25", - Sources: cli.EnvVars("UNKEY_OTEL_TRACE_SAMPLING_RATE"), - Value: 0.25, - Required: false, - }, + cli.Bool("otel", "Enable OpenTelemetry tracing and metrics", + cli.EnvVar("UNKEY_OTEL")), + cli.Float("otel-trace-sampling-rate", "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided. Default: 0.25", + cli.Default(0.25), cli.EnvVar("UNKEY_OTEL_TRACE_SAMPLING_RATE")), // TLS Configuration - &cli.StringFlag{ - Name: "tls-cert-file", - Usage: "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS.", - Sources: cli.EnvVars("UNKEY_TLS_CERT_FILE"), - Required: false, - TakesFile: true, - }, - &cli.StringFlag{ - Name: "tls-key-file", - Usage: "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS.", - Sources: cli.EnvVars("UNKEY_TLS_KEY_FILE"), - Required: false, - TakesFile: true, - }, + cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS.", + cli.EnvVar("UNKEY_TLS_CERT_FILE")), + cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS.", + cli.EnvVar("UNKEY_TLS_KEY_FILE")), // Control Plane Specific - &cli.StringFlag{ - Name: "auth-token", - Usage: "Authentication token for control plane API access. Required for secure deployments.", - Sources: cli.EnvVars("UNKEY_AUTH_TOKEN"), - Required: false, - }, - &cli.StringFlag{ - Name: "metald-address", - Usage: "Full URL of the metald service for VM operations. Required for deployments. Example: https://metald.example.com:8080", - Sources: cli.EnvVars("UNKEY_METALD_ADDRESS"), - Required: true, - }, - &cli.StringFlag{ - Name: "spiffe-socket-path", - Usage: "Path to SPIFFE agent socket for mTLS authentication. Default: /var/lib/spire/agent/agent.sock", - Sources: cli.EnvVars("UNKEY_SPIFFE_SOCKET_PATH"), - Value: "/var/lib/spire/agent/agent.sock", - Required: false, - }, + cli.String("auth-token", "Authentication token for control plane API access. Required for secure deployments.", + cli.EnvVar("UNKEY_AUTH_TOKEN")), + cli.String("metald-address", "Full URL of the metald service for VM operations. Required for deployments. Example: https://metald.example.com:8080", + cli.Required(), cli.EnvVar("UNKEY_METALD_ADDRESS")), + cli.String("spiffe-socket-path", "Path to SPIFFE agent socket for mTLS authentication. Default: /var/lib/spire/agent/agent.sock", + cli.Default("/var/lib/spire/agent/agent.sock"), cli.EnvVar("UNKEY_SPIFFE_SOCKET_PATH")), }, - Action: action, } diff --git a/go/cmd/deploy/build_docker.go b/go/cmd/deploy/build_docker.go new file mode 100644 index 0000000000..0991e52d15 --- /dev/null +++ b/go/cmd/deploy/build_docker.go @@ -0,0 +1,397 @@ +package deploy + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/unkeyed/unkey/go/pkg/git" +) + +const ( + // Timeouts + DockerBuildTimeout = 10 * time.Minute + + // Build arguments + VersionBuildArg = "VERSION" + + // Progress messages + ProgressBuilding = "Building..." + + // Limits + MaxOutputLines = 5 + MaxErrorLines = 3 +) + +var ( + ErrDockerNotFound = errors.New("docker command not found - please install Docker") + ErrDockerBuildFailed = errors.New("docker build failed") + ErrInvalidContext = errors.New("invalid build context") + ErrDockerfileNotFound = errors.New("dockerfile not found") + ErrBuildTimeout = errors.New("docker build timed out") +) + +// generateImageTag creates a unique tag for the Docker image +func generateImageTag(opts DeployOptions, gitInfo git.Info) string { + if gitInfo.ShortSHA != "" { + return fmt.Sprintf("%s-%s", opts.Branch, gitInfo.ShortSHA) + } + return fmt.Sprintf("%s-%d", opts.Branch, time.Now().Unix()) +} + +// isDockerAvailable checks if Docker is installed and accessible +func isDockerAvailable() error { + cmd := exec.Command("docker", "--version") + if err := cmd.Run(); err != nil { + return ErrDockerNotFound + } + return nil +} + +// buildImage builds the Docker image with proper error hierarchy +func buildImage(ctx context.Context, opts DeployOptions, dockerImage string, ui *UI) error { + // Sub-step 1: Validate inputs + if err := validateImagePath(opts); err != nil { + ui.PrintStepError("Validation failed") + ui.PrintErrorDetails(err.Error()) + return err + } + ui.PrintStepSuccess("Build inputs validated") + + // Sub-step 2: Prepare build command + buildArgs := []string{"build"} + if opts.Dockerfile != DefaultDockerfile { + buildArgs = append(buildArgs, "-f", opts.Dockerfile) + } + buildArgs = append(buildArgs, + "-t", dockerImage, + "--build-arg", fmt.Sprintf("%s=%s", VersionBuildArg, opts.Commit), + opts.Context, + ) + + ui.PrintStepSuccess("Build command prepared") + if opts.Verbose { + ui.PrintBuildProgress(fmt.Sprintf("Running: docker %s", strings.Join(buildArgs, " "))) + } + + // Sub-step 3: Execute Docker build + ui.PrintStepSuccess("Starting Docker build") + buildCtx, cancel := context.WithTimeout(ctx, DockerBuildTimeout) + defer cancel() + + cmd := exec.CommandContext(buildCtx, "docker", buildArgs...) + + var buildErr error + if opts.Verbose { + buildErr = buildImageVerbose(cmd, buildCtx, ui) + } else { + buildErr = buildImageWithSpinner(cmd, buildCtx, ui) + } + + if buildErr != nil { + ui.PrintStepError("Docker build failed") + ui.PrintErrorDetails(buildErr.Error()) + return buildErr + } + + return nil +} + +// buildImageVerbose shows all Docker output in real-time +func buildImageVerbose(cmd *exec.Cmd, buildCtx context.Context, ui *UI) error { + // Set up pipes for real-time output + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Start the command + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start docker build: %w", err) + } + + // Stream stdout + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + ui.PrintBuildProgress(scanner.Text()) + } + }() + + // Stream stderr + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + ui.PrintBuildError(scanner.Text()) + } + }() + + // Wait for completion + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case <-buildCtx.Done(): + if buildCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("%w after %v", ErrBuildTimeout, DockerBuildTimeout) + } + return buildCtx.Err() + case err := <-done: + if err != nil { + return fmt.Errorf("%w: %v", ErrDockerBuildFailed, err) + } + } + + return nil +} + +// buildImageWithSpinner shows progress spinner with current step +func buildImageWithSpinner(cmd *exec.Cmd, buildCtx context.Context, ui *UI) error { + // Set up pipes to capture output for progress tracking + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Start the command + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start docker build: %w", err) + } + + // Track all output for error reporting + var outputMu sync.Mutex + allOutput := []string{} + + // Start progress spinner + ui.StartStepSpinner(ProgressBuilding) + + // Stream stdout and track progress + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + + outputMu.Lock() + allOutput = append(allOutput, line) + outputMu.Unlock() + + // Update spinner with current step + if step := extractDockerStep(line); step != "" { + ui.UpdateStepSpinner(fmt.Sprintf("%s %s", ProgressBuilding, step)) + } + } + }() + + // Stream stderr + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + + outputMu.Lock() + allOutput = append(allOutput, line) + outputMu.Unlock() + } + }() + + // Wait for completion + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case <-buildCtx.Done(): + ui.CompleteCurrentStep("Build timed out", false) + if buildCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("%w after %v", ErrBuildTimeout, DockerBuildTimeout) + } + return buildCtx.Err() + case err := <-done: + if err != nil { + ui.CompleteCurrentStep("Build failed", false) + + outputMu.Lock() + outputCopy := make([]string, len(allOutput)) + copy(outputCopy, allOutput) + outputMu.Unlock() + + // Show last few lines of output for debugging + if len(outputCopy) > 0 { + ui.PrintBuildError("Last few lines of output:") + lines := outputCopy + if len(lines) > MaxOutputLines { + lines = lines[len(lines)-MaxOutputLines:] + } + for _, line := range lines { + ui.PrintBuildProgress(line) + } + } + return fmt.Errorf("%w: %s", ErrDockerBuildFailed, classifyError(strings.Join(outputCopy, "\n"))) + } + } + + ui.CompleteCurrentStep("Docker build completed successfully", true) + return nil +} + +// extractDockerStep extracts meaningful step info from Docker output +func extractDockerStep(line string) string { + line = strings.TrimSpace(line) + + // Look for Docker build steps + if strings.HasPrefix(line, "#") && strings.Contains(line, "[") { + // Extract step like "#5 [builder 1/6] FROM docker.io/library/golang" + if idx := strings.Index(line, "]"); idx > 0 { + step := line[:idx+1] + // Clean up the step display + step = strings.ReplaceAll(step, "#", "Step ") + return step + } + } + + // Look for other meaningful progress + switch { + case strings.Contains(line, "DONE"): + return "Step completed" + case strings.Contains(line, "CACHED"): + return "Using cache" + case strings.Contains(line, "exporting"): + return "Exporting image" + case strings.Contains(line, "naming to"): + return "Tagging image" + } + + return "" +} + +// pushImage pushes the built Docker image to the registry +func pushImage(ctx context.Context, dockerImage, registry string) error { + cmd := exec.CommandContext(ctx, "docker", "push", dockerImage) + output, err := cmd.CombinedOutput() + if err != nil { + detailedMsg := classifyPushError(string(output), registry) + return fmt.Errorf("%s: %w", detailedMsg, err) + } + + // Show push output + if len(output) > 0 { + fmt.Printf("%s\n", string(output)) + } + + return nil +} + +// validateImagePath - pre-flight checks using error constants +func validateImagePath(opts DeployOptions) error { + // Context directory exists? + if _, err := os.Stat(opts.Context); os.IsNotExist(err) { + return fmt.Errorf("%w: directory '%s' does not exist", ErrInvalidContext, opts.Context) + } + + // Dockerfile exists? + dockerfilePath := opts.Dockerfile + if !filepath.IsAbs(dockerfilePath) { + dockerfilePath = filepath.Join(opts.Context, opts.Dockerfile) + } + + if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { + return fmt.Errorf("%w: '%s' not found", ErrDockerfileNotFound, dockerfilePath) + } + + return nil +} + +// classifyError provides helpful error messages +func classifyError(output string) string { + output = strings.ToLower(output) + + switch { + case strings.Contains(output, "dockerfile"): + return "Dockerfile issue - check path and syntax" + case strings.Contains(output, "no such file"): + return "File not found - check COPY/ADD paths" + case strings.Contains(output, "permission denied"): + return "Permission denied - check file permissions" + case strings.Contains(output, "network") || strings.Contains(output, "timeout"): + return "Network error - check internet connection" + case strings.Contains(output, "manifest unknown"): + return "Base image not found - check FROM instruction" + case strings.Contains(output, "space") || strings.Contains(output, "disk"): + return "Insufficient disk space" + default: + // Return last few lines for debugging + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) > MaxErrorLines { + return strings.Join(lines[len(lines)-MaxErrorLines:], "\n") + } + return strings.TrimSpace(output) + } +} + +// classifyPushError provides detailed error messages for push failures +func classifyPushError(output, registry string) string { + output = strings.TrimSpace(output) + registryHost := getRegistryHost(registry) + + switch { + case strings.Contains(output, "denied"): + return fmt.Sprintf("registry access denied. try: docker login %s", registryHost) + case strings.Contains(output, "not found") || strings.Contains(output, "404"): + return "registry not found. create repository or use --registry=your-registry/your-app" + case strings.Contains(output, "unauthorized"): + return fmt.Sprintf("authentication required. run: docker login %s", registryHost) + default: + return output + } +} + +// getRegistryHost extracts the registry hostname from a full registry path +func getRegistryHost(registry string) string { + parts := strings.Split(registry, "/") + if len(parts) > 0 { + return parts[0] + } + return DefaultRegistry +} + +// UI methods for build progress +func (ui *UI) PrintBuildProgress(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" %s\n", message) +} + +func (ui *UI) PrintBuildError(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" %s⚠%s %s\n", ColorYellow, ColorReset, message) +} + +// UpdateStepSpinner updates the current step spinner message +func (ui *UI) UpdateStepSpinner(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + if ui.stepSpinning { + ui.currentStep = message + } +} diff --git a/go/cmd/deploy/config.go b/go/cmd/deploy/config.go new file mode 100644 index 0000000000..3f231e0118 --- /dev/null +++ b/go/cmd/deploy/config.go @@ -0,0 +1,170 @@ +package deploy + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +var ( + ErrWorkspaceIDRequired = errors.New("workspace ID is required (use --workspace-id flag or edit unkey.json)") + ErrProjectIDRequired = errors.New("project ID is required (use --project-id flag or edit unkey.json)") + ErrConfigPathResolve = errors.New("failed to resolve config path") + ErrConfigFileRead = errors.New("failed to read config file") + ErrConfigFileParse = errors.New("failed to parse config file") + ErrConfigFileWrite = errors.New("failed to write config file") + ErrConfigMarshal = errors.New("failed to marshal config") + ErrDirectoryCreate = errors.New("failed to create directory") +) + +type Config struct { + WorkspaceID string `json:"workspace_id"` + ProjectID string `json:"project_id"` + Context string `json:"context"` +} + +// loadConfig loads configuration from unkey.json in the specified directory. +// If configPath is empty, uses current directory. +// If the file doesn't exist, it returns an empty config without error. +func loadConfig(configPath string) (*Config, error) { + // If no config path specified, use current directory + if configPath == "" { + configPath = "." + } + + // Check if directory exists first + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil, fmt.Errorf("config directory '%s' does not exist", configPath) + } else if err != nil { + return nil, fmt.Errorf("failed to access config directory '%s': %w", configPath, err) + } + + // Always look for unkey.json in the specified directory + configFile := filepath.Join(configPath, "unkey.json") + + // Check if file exists + if _, err := os.Stat(configFile); os.IsNotExist(err) { + // Return empty config if file doesn't exist but directory does + return &Config{}, nil + } + + data, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("%w %s: %w", ErrConfigFileRead, configFile, err) + } + + config := &Config{} + if err := json.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("%w %s: %w", ErrConfigFileParse, configFile, err) + } + + return config, nil +} + +// configExists checks if unkey.json exists in the specified directory +func configExists(configDir string) bool { + configPath := filepath.Join(configDir, "unkey.json") + _, err := os.Stat(configPath) + return err == nil +} + +// getConfigFilePath returns the full path to unkey.json in the specified directory +func getConfigFilePath(configDir string) string { + if configDir == "" { + configDir = "." + } + return filepath.Join(configDir, "unkey.json") +} + +// createConfigWithValues creates a new unkey.json file with the provided values +func createConfigWithValues(configDir, workspaceID, projectID, context string) error { + // Create directory if it doesn't exist + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("%w %s: %w", ErrDirectoryCreate, configDir, err) + } + + config := &Config{ + WorkspaceID: workspaceID, + ProjectID: projectID, + Context: context, + } + + configPath := filepath.Join(configDir, "unkey.json") + if err := writeConfig(configPath, config); err != nil { + return fmt.Errorf("%w: %w", ErrConfigFileWrite, err) + } + + return nil +} + +// writeConfig writes the config struct to a JSON file +func writeConfig(configPath string, config *Config) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("%w: %w", ErrConfigMarshal, err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("%w: %w", ErrConfigFileWrite, err) + } + + return nil +} + +// mergeWithFlags merges config values with command flags, with flags taking precedence +func (c *Config) mergeWithFlags(workspaceID, projectID, context string) *Config { + merged := &Config{ + WorkspaceID: c.WorkspaceID, + ProjectID: c.ProjectID, + Context: c.Context, + } + + // Flags override config values + if workspaceID != "" { + merged.WorkspaceID = workspaceID + } + if projectID != "" { + merged.ProjectID = projectID + } + if context != "" { + merged.Context = context + } + + // Set default context if empty + if merged.Context == "" { + merged.Context = "." + } + + return merged +} + +// validate checks if required fields are present and not placeholder values +func (c *Config) validate() error { + if c.WorkspaceID == "" || c.WorkspaceID == "ws_your_workspace_id" { + return ErrWorkspaceIDRequired + } + if c.ProjectID == "" || c.ProjectID == "proj_your_project_id" { + return ErrProjectIDRequired + } + return nil +} + +// getConfigPath resolves the config directory path +func getConfigPath(configFlag string) (string, error) { + if configFlag == "" { + return ".", nil + } + + // Convert to absolute path if relative + if !filepath.IsAbs(configFlag) { + abs, err := filepath.Abs(configFlag) + if err != nil { + return "", fmt.Errorf("%w '%s': %w", ErrConfigPathResolve, configFlag, err) + } + return abs, nil + } + + return configFlag, nil +} diff --git a/go/cmd/cli/commands/deploy/control_plane.go b/go/cmd/deploy/control_plane.go similarity index 93% rename from go/cmd/cli/commands/deploy/control_plane.go rename to go/cmd/deploy/control_plane.go index 1418c6979f..db9e2f654c 100644 --- a/go/cmd/cli/commands/deploy/control_plane.go +++ b/go/cmd/deploy/control_plane.go @@ -34,11 +34,11 @@ type VersionStepEvent struct { // ControlPlaneClient handles API operations with the control plane type ControlPlaneClient struct { client ctrlv1connect.VersionServiceClient - opts *DeployOptions + opts DeployOptions } // NewControlPlaneClient creates a new control plane client -func NewControlPlaneClient(opts *DeployOptions) *ControlPlaneClient { +func NewControlPlaneClient(opts DeployOptions) *ControlPlaneClient { httpClient := &http.Client{} client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) @@ -178,12 +178,17 @@ func (c *ControlPlaneClient) processNewSteps( Step: step, Status: currentStatus, } - if err := onStepUpdate(event); err != nil { return err } - } + // INFO: This is for demo purposes only. + // Adding a small delay between deployment steps to make the progression + // visually observable during demos. This allows viewers to see each + // individual step (VM boot, rootfs loading, etc.) rather than having + // everything complete too quickly to follow. + time.Sleep(800 * time.Millisecond) + } // Mark this step as processed processedSteps[stepTimestamp] = true } diff --git a/go/cmd/deploy/init.go b/go/cmd/deploy/init.go new file mode 100644 index 0000000000..3a81632fe3 --- /dev/null +++ b/go/cmd/deploy/init.go @@ -0,0 +1,97 @@ +package deploy + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/unkeyed/unkey/go/pkg/cli" +) + +const ( + // Init messages + InitHeaderTitle = "Unkey Configuration Setup" + InitHeaderSeparator = "──────────────────────────────────────────────────" +) + +func handleInit(cmd *cli.Command, ui *UI) error { + configDir := cmd.String("config") + if configDir == "" { + configDir = "." + } + + configPath := getConfigFilePath(configDir) + force := cmd.Bool("force") + + fmt.Printf("%s\n", InitHeaderTitle) + fmt.Printf("%s\n", InitHeaderSeparator) + + // Check if config file already exists + if configExists(configDir) { + fmt.Printf("Configuration file already exists at: %s\n", configPath) + if !force && !promptConfirm("Do you want to overwrite it?") { + fmt.Printf("Configuration setup cancelled.\n") + return nil + } + fmt.Printf("\n") + } + + // Interactive prompts for configuration + fmt.Printf("Please provide the following configuration details:\n\n") + + fmt.Printf("Workspace ID: ") + workspaceID := readLine() + if workspaceID == "" { + return fmt.Errorf("workspace ID is required") + } + + fmt.Printf("Project ID: ") + projectID := readLine() + if projectID == "" { + return fmt.Errorf("project ID is required") + } + + fmt.Printf("Build context path [.]: ") + context := readLine() + if context == "" { + context = "." + } + + fmt.Printf("\n") // Add spacing before status messages + + // Create configuration with user input + ui.Print("Creating configuration file") + if err := createConfigWithValues(configDir, workspaceID, projectID, context); err != nil { + ui.PrintError("Failed to create config file") + return fmt.Errorf("failed to create config file: %w", err) + } + ui.PrintSuccess(fmt.Sprintf("Configuration file created at: %s", configPath)) + + printInitNextSteps() + return nil +} + +func printInitNextSteps() { + fmt.Printf("\n") // Consistent spacing + fmt.Printf("Configuration complete!\n") + fmt.Printf("\n") + fmt.Printf("You can now deploy without any flags:\n") + fmt.Printf(" unkey deploy\n") + fmt.Printf("\n") + fmt.Printf("Or override specific values:\n") + fmt.Printf(" unkey deploy --workspace-id=ws_different\n") + fmt.Printf(" unkey deploy --context=./other-app\n") +} + +func promptConfirm(message string) bool { + fmt.Printf("%s (y/N): ", message) + response := strings.ToLower(strings.TrimSpace(readLine())) + return response == "y" || response == "yes" +} + +func readLine() string { + reader := bufio.NewReader(os.Stdin) + line, _ := reader.ReadString('\n') + return strings.TrimSpace(line) +} diff --git a/go/cmd/deploy/main.go b/go/cmd/deploy/main.go index b862024902..e3318e9acd 100644 --- a/go/cmd/deploy/main.go +++ b/go/cmd/deploy/main.go @@ -1,19 +1,427 @@ package deploy import ( - "github.com/unkeyed/unkey/go/cmd/version" - "github.com/urfave/cli/v3" + "context" + "fmt" + "strings" + + ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/go/pkg/cli" + "github.com/unkeyed/unkey/go/pkg/git" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +const ( + // Default values + DefaultBranch = "main" + DefaultDockerfile = "Dockerfile" + DefaultRegistry = "ghcr.io/unkeyed/deploy" + DefaultControlPlaneURL = "http://localhost:7091" + DefaultAuthToken = "ctrl-secret-token" + DefaultEnvironment = "Production" + + // Environment variables + EnvWorkspaceID = "UNKEY_WORKSPACE_ID" + EnvRegistry = "UNKEY_REGISTRY" + + // URL prefixes + HTTPSPrefix = "https://" + HTTPPrefix = "http://" + LocalhostPrefix = "localhost:" + + // UI Messages + HeaderTitle = "Unkey Deploy Progress" + HeaderSeparator = "──────────────────────────────────────────────────" + + // Step messages + MsgPreparingDeployment = "Preparing deployment" + MsgCreatingDeployment = "Creating deployment" + MsgSkippingRegistryPush = "Skipping registry push" + MsgUsingPreBuiltImage = "Using pre-built Docker image" + MsgPushingToRegistry = "Pushing to registry" + MsgImageBuiltSuccessfully = "Image built successfully" + MsgImagePushedSuccessfully = "Image pushed successfully" + MsgPushFailedContinuing = "Push failed but continuing deployment" + MsgDockerNotFound = "Docker not found - please install Docker" + MsgFailedToCreateVersion = "Failed to create version" + MsgDeploymentFailed = "Deployment failed" + MsgDeploymentCompleted = "Deployment completed successfully" + MsgVersionDeploymentCompleted = "Version deployment completed successfully" + + // Source info labels + LabelBranch = "Branch" + LabelCommit = "Commit" + LabelContext = "Context" + LabelImage = "Image" + + // Completion info labels + CompletionTitle = "Deployment Complete" + CompletionVersionID = "Version ID" + CompletionStatus = "Status" + CompletionEnvironment = "Environment" + CompletionDomains = "Domains" + CompletionReady = "Ready" + CompletionNoHostnames = "No hostnames assigned" + + // Git status + GitDirtyMarker = " (dirty)" ) -// Cmd is an alias for "version create" to provide a more intuitive top-level command +// Step predictor - maps current step message patterns to next expected steps +var stepSequence = map[string]string{ + "Version queued and ready to start": "Downloading Docker image:", + "Downloading Docker image:": "Building rootfs from Docker image:", + "Building rootfs from Docker image:": "Uploading rootfs image to storage", + "Uploading rootfs image to storage": "Creating VM for version:", + "Creating VM for version:": "VM booted successfully:", + "VM booted successfully:": "Assigned hostname:", + "Assigned hostname:": MsgVersionDeploymentCompleted, +} + +// DeployOptions contains all configuration for deployment +type DeployOptions struct { + WorkspaceID string + ProjectID string + Context string + Branch string + DockerImage string + Dockerfile string + Commit string + Registry string + SkipPush bool + Verbose bool + ControlPlaneURL string + AuthToken string +} + +var DeployFlags = []cli.Flag{ + // Config directory flag (highest priority) + cli.String("config", "Directory containing unkey.json config file"), + // Init flag + cli.Bool("init", "Initialize configuration file in the specified directory"), + cli.Bool("force", "Force overwrite existing configuration file when using --init"), + // Required flags (can be provided via config file) + cli.String("workspace-id", "Workspace ID", cli.EnvVar(EnvWorkspaceID)), + cli.String("project-id", "Project ID", cli.EnvVar("UNKEY_PROJECT_ID")), + // Optional flags with defaults + cli.String("context", "Build context path"), + cli.String("branch", "Git branch", cli.Default(DefaultBranch)), + cli.String("docker-image", "Pre-built docker image"), + cli.String("dockerfile", "Path to Dockerfile", cli.Default(DefaultDockerfile)), + cli.String("commit", "Git commit SHA"), + cli.String("registry", "Container registry", + cli.Default(DefaultRegistry), + cli.EnvVar(EnvRegistry)), + cli.Bool("skip-push", "Skip pushing to registry (for local testing)"), + cli.Bool("verbose", "Show detailed output for build and deployment operations"), + // Control plane flags (internal) + cli.String("control-plane-url", "Control plane URL", cli.Default(DefaultControlPlaneURL)), + cli.String("auth-token", "Control plane auth token", cli.Default(DefaultAuthToken)), +} + +// Cmd defines the deploy CLI command var Cmd = &cli.Command{ Name: "deploy", - Usage: "Deploy a new version of your API (alias for 'version create')", - Description: `Deploy is a convenience command that creates a new version of your API. - -This is equivalent to running 'unkey version create'.`, - - // Copy all flags from version create command - Flags: version.Cmd.Commands[0].Flags, - Action: version.Cmd.Commands[0].Action, + Usage: "Deploy a new version or initialize configuration", + Description: `Build and deploy a new version of your application, or initialize configuration. + +When used with --init, creates a configuration template file. +Otherwise, builds a container image from the specified context and +deploys it to the Unkey platform. + +The deploy command will automatically load configuration from unkey.json +in the current directory or specified config directory. + +EXAMPLES: + # Initialize configuration file + unkey deploy --init + + # Initialize in specific directory + unkey deploy --init --config=./my-project + + # Force overwrite existing config + unkey deploy --init --force + + # Deploy using config file (./unkey.json) + unkey deploy + + # Deploy with config from specific directory + unkey deploy --config=./test-docker + + # Deploy overriding workspace from config + unkey deploy --workspace-id=ws_different + + # Deploy with specific context (overrides config) + unkey deploy --context=./demo_api + + # Deploy with your own registry + unkey deploy \ + --workspace-id=ws_4QgQsKsKfdm3nGeC \ + --project-id=proj_9aiaks2dzl6mcywnxjf \ + --registry=docker.io/mycompany/myapp + + # Local development (skip push) + unkey deploy --skip-push + + # Deploy pre-built image + unkey deploy --docker-image=ghcr.io/user/app:v1.0.0 + + # Show detailed build and deployment output + unkey deploy --verbose`, + Flags: DeployFlags, + Action: DeployAction, +} + +func DeployAction(ctx context.Context, cmd *cli.Command) error { + // Handle --init flag + if cmd.Bool("init") { + ui := NewUI() + return handleInit(cmd, ui) + } + + // Load configuration file + configPath, err := getConfigPath(cmd.String("config")) + if err != nil { + return err + } + + cfg, err := loadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Merge config with command flags (flags take precedence) + finalConfig := cfg.mergeWithFlags( + cmd.String("workspace-id"), + cmd.String("project-id"), + cmd.String("context"), + ) + + // Validate that we have required fields + if err := finalConfig.validate(); err != nil { + return err // Clean error message already + } + + opts := DeployOptions{ + WorkspaceID: finalConfig.WorkspaceID, + ProjectID: finalConfig.ProjectID, + Context: finalConfig.Context, + Branch: cmd.String("branch"), + DockerImage: cmd.String("docker-image"), + Dockerfile: cmd.String("dockerfile"), + Commit: cmd.String("commit"), + Registry: cmd.String("registry"), + SkipPush: cmd.Bool("skip-push"), + Verbose: cmd.Bool("verbose"), + ControlPlaneURL: cmd.String("control-plane-url"), + AuthToken: cmd.String("auth-token"), + } + + return executeDeploy(ctx, opts) +} + +func executeDeploy(ctx context.Context, opts DeployOptions) error { + ui := NewUI() + logger := logging.New() + gitInfo := git.GetInfo() + + // Auto-detect branch and commit from git if not specified + if opts.Branch == DefaultBranch && gitInfo.IsRepo && gitInfo.Branch != "" { + opts.Branch = gitInfo.Branch + } + if opts.Commit == "" && gitInfo.CommitSHA != "" { + opts.Commit = gitInfo.CommitSHA + } + + // Print header + fmt.Printf("%s\n", HeaderTitle) + fmt.Printf("%s\n", HeaderSeparator) + printSourceInfo(opts, gitInfo) + + ui.Print(MsgPreparingDeployment) + + var dockerImage string + + // Build or use pre-built Docker image + if opts.DockerImage == "" { + // Check Docker availability using updated function + if err := isDockerAvailable(); err != nil { + ui.PrintError(MsgDockerNotFound) + return err + } + + // Generate image tag and full image name + imageTag := generateImageTag(opts, gitInfo) + dockerImage = fmt.Sprintf("%s:%s", opts.Registry, imageTag) + + ui.Print(fmt.Sprintf("Building image: %s", dockerImage)) + + if err := buildImage(ctx, opts, dockerImage, ui); err != nil { + // Don't print additional error, buildImage already reported it with proper hierarchy + return err + } + ui.PrintSuccess(MsgImageBuiltSuccessfully) + } else { + dockerImage = opts.DockerImage + ui.Print(MsgUsingPreBuiltImage) + } + + // Push to registry, unless skipped or using pre-built image + if !opts.SkipPush && opts.DockerImage == "" { + ui.Print(MsgPushingToRegistry) + if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { + ui.PrintError(MsgPushFailedContinuing) + ui.PrintErrorDetails(err.Error()) + // NOTE: Currently ignoring push failures for local development + // For production deployment, uncomment the line below: + // return err + } else { + ui.PrintSuccess(MsgImagePushedSuccessfully) + } + } else if opts.SkipPush { + ui.Print(MsgSkippingRegistryPush) + } + + // Create deployment version + ui.Print(MsgCreatingDeployment) + controlPlane := NewControlPlaneClient(opts) + versionId, err := controlPlane.CreateVersion(ctx, dockerImage) + if err != nil { + ui.PrintError(MsgFailedToCreateVersion) + ui.PrintErrorDetails(err.Error()) + return err + } + ui.PrintSuccess(fmt.Sprintf("Version created: %s", versionId)) + + // Track final version for completion info + var finalVersion *ctrlv1.Version + + // Handle version status changes + onStatusChange := func(event VersionStatusEvent) error { + switch event.CurrentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: + return handleVersionFailure(controlPlane, event.Version, ui) + case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: + // Store version but don't print success, wait for polling to complete + finalVersion = event.Version + } + return nil + } + + // Handle deployment step updates + onStepUpdate := func(event VersionStepEvent) error { + return handleStepUpdate(event, ui) + } + + // Poll for deployment completion + err = controlPlane.PollVersionStatus(ctx, logger, versionId, onStatusChange, onStepUpdate) + if err != nil { + ui.CompleteCurrentStep(MsgDeploymentFailed, false) + return err + } + + // Print final success message only after all polling is complete + if finalVersion != nil { + ui.CompleteCurrentStep(MsgVersionDeploymentCompleted, true) + ui.PrintSuccess(MsgDeploymentCompleted) + fmt.Printf("\n") + printCompletionInfo(finalVersion) + fmt.Printf("\n") + } + + return nil +} + +func getNextStepMessage(currentMessage string) string { + // Check if current message starts with any known step pattern + for key, next := range stepSequence { + if len(currentMessage) >= len(key) && currentMessage[:len(key)] == key { + return next + } + } + return "" +} + +func handleStepUpdate(event VersionStepEvent, ui *UI) error { + step := event.Step + + if step.GetErrorMessage() != "" { + ui.CompleteCurrentStep(step.GetMessage(), false) + ui.PrintErrorDetails(step.GetErrorMessage()) + return fmt.Errorf("deployment failed: %s", step.GetErrorMessage()) + } + + if step.GetMessage() != "" { + message := step.GetMessage() + nextStep := getNextStepMessage(message) + + if !ui.stepSpinning { + // First step - start spinner, then complete and start next + ui.StartStepSpinner(message) + ui.CompleteStepAndStartNext(message, nextStep) + } else { + // Complete current step and start next + ui.CompleteStepAndStartNext(message, nextStep) + } + } + + return nil +} + +func handleVersionFailure(controlPlane *ControlPlaneClient, version *ctrlv1.Version, ui *UI) error { + errorMsg := controlPlane.getFailureMessage(version) + ui.CompleteCurrentStep(MsgDeploymentFailed, false) + ui.PrintError(MsgDeploymentFailed) + ui.PrintErrorDetails(errorMsg) + return fmt.Errorf("deployment failed: %s", errorMsg) +} + +func printSourceInfo(opts DeployOptions, gitInfo git.Info) { + fmt.Printf("Source Information:\n") + fmt.Printf(" %s: %s\n", LabelBranch, opts.Branch) + + if gitInfo.IsRepo && gitInfo.CommitSHA != "" { + commitInfo := gitInfo.ShortSHA + if gitInfo.IsDirty { + commitInfo += GitDirtyMarker + } + fmt.Printf(" %s: %s\n", LabelCommit, commitInfo) + } + + fmt.Printf(" %s: %s\n", LabelContext, opts.Context) + + if opts.DockerImage != "" { + fmt.Printf(" %s: %s\n", LabelImage, opts.DockerImage) + } + + fmt.Printf("\n") +} + +func printCompletionInfo(version *ctrlv1.Version) { + if version == nil || version.GetId() == "" { + fmt.Printf("✓ Deployment completed\n") + return + } + + fmt.Println() + fmt.Println(CompletionTitle) + fmt.Printf(" %s: %s\n", CompletionVersionID, version.GetId()) + fmt.Printf(" %s: %s\n", CompletionStatus, CompletionReady) + fmt.Printf(" %s: %s\n", CompletionEnvironment, DefaultEnvironment) + + fmt.Println() + fmt.Println(CompletionDomains) + + hostnames := version.GetHostnames() + if len(hostnames) > 0 { + for _, hostname := range hostnames { + if strings.HasPrefix(hostname, LocalhostPrefix) { + fmt.Printf(" %s%s\n", HTTPPrefix, hostname) + } else { + fmt.Printf(" %s%s\n", HTTPSPrefix, hostname) + } + } + } else { + fmt.Printf(" %s\n", CompletionNoHostnames) + } } diff --git a/go/cmd/cli/commands/deploy/ui.go b/go/cmd/deploy/ui.go similarity index 74% rename from go/cmd/cli/commands/deploy/ui.go rename to go/cmd/deploy/ui.go index 12601dd9c9..477e6773ee 100644 --- a/go/cmd/cli/commands/deploy/ui.go +++ b/go/cmd/deploy/ui.go @@ -6,7 +6,7 @@ import ( "time" ) -// Color constants +// Colors const ( ColorReset = "\033[0m" ColorRed = "\033[31m" @@ -14,6 +14,14 @@ const ( ColorYellow = "\033[33m" ) +// Symbols +const ( + SymbolTick = "✔" + SymbolCross = "✘" + SymbolBullet = "●" + SymbolArrow = "=>" +) + var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} type UI struct { @@ -30,37 +38,37 @@ func NewUI() *UI { func (ui *UI) Print(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf("%s•%s %s\n", ColorYellow, ColorReset, message) + fmt.Printf("%s%s%s %s\n", ColorYellow, SymbolBullet, ColorReset, message) } func (ui *UI) PrintSuccess(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf("%s✓%s %s\n", ColorGreen, ColorReset, message) + fmt.Printf("%s%s%s %s\n", ColorGreen, SymbolTick, ColorReset, message) } func (ui *UI) PrintError(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf("%s✗%s %s\n", ColorRed, ColorReset, message) + fmt.Printf("%s%s%s %s\n", ColorRed, SymbolCross, ColorReset, message) } func (ui *UI) PrintErrorDetails(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf(" %s->%s %s\n", ColorRed, ColorReset, message) + fmt.Printf(" %s%s%s %s\n", ColorRed, SymbolArrow, ColorReset, message) } func (ui *UI) PrintStepSuccess(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, message) + fmt.Printf(" %s%s%s %s\n", ColorGreen, SymbolTick, ColorReset, message) } func (ui *UI) PrintStepError(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf(" %s✗%s %s\n", ColorRed, ColorReset, message) + fmt.Printf(" %s%s%s %s\n", ColorRed, SymbolCross, ColorReset, message) } func (ui *UI) spinnerLoop(prefix string, messageGetter func() string, isActive func() bool) { @@ -103,9 +111,9 @@ func (ui *UI) StopSpinner(finalMessage string, success bool) { ui.spinning = false fmt.Print("\r\033[K") if success { - fmt.Printf("%s✓%s %s\n", ColorGreen, ColorReset, finalMessage) + fmt.Printf("%s%s%s %s\n", ColorGreen, SymbolTick, ColorReset, finalMessage) } else { - fmt.Printf("%s✗%s %s\n", ColorRed, ColorReset, finalMessage) + fmt.Printf("%s%s%s %s\n", ColorRed, SymbolCross, ColorReset, finalMessage) } } @@ -118,7 +126,6 @@ func (ui *UI) StartStepSpinner(message string) { ui.currentStep = message ui.stepSpinning = true ui.mu.Unlock() - ui.spinnerLoop(" ", func() string { return ui.currentStep }, func() bool { return ui.stepSpinning }) } @@ -128,7 +135,7 @@ func (ui *UI) CompleteStepAndStartNext(completedMessage, nextMessage string) { if ui.stepSpinning { ui.stepSpinning = false fmt.Print("\r\033[K") - fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, completedMessage) + fmt.Printf(" %s%s%s %s\n", ColorGreen, SymbolTick, ColorReset, completedMessage) } // Start next step if provided @@ -152,8 +159,8 @@ func (ui *UI) CompleteCurrentStep(message string, success bool) { ui.stepSpinning = false fmt.Print("\r\033[K") if success { - fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, message) + fmt.Printf(" %s%s%s %s\n", ColorGreen, SymbolTick, ColorReset, message) } else { - fmt.Printf(" %s✗%s %s\n", ColorRed, ColorReset, message) + fmt.Printf(" %s%s%s %s\n", ColorRed, SymbolCross, ColorReset, message) } } diff --git a/go/cmd/healthcheck/main.go b/go/cmd/healthcheck/main.go index 1dff2bb399..5426576f0a 100644 --- a/go/cmd/healthcheck/main.go +++ b/go/cmd/healthcheck/main.go @@ -5,23 +5,35 @@ import ( "fmt" "net/http" - "github.com/urfave/cli/v3" + "github.com/unkeyed/unkey/go/pkg/cli" ) var Cmd = &cli.Command{ - Name: "healthcheck", + Name: "healthcheck", + Usage: "Perform an HTTP healthcheck against a given URL", Description: `Perform an HTTP healthcheck against a given URL. - This command exits with 0 if the status code is 200, otherwise it exits with 1. - `, - ArgsUsage: ``, - Action: run, + +USAGE: + unkey healthcheck + +EXAMPLES: + # Check if a service is healthy + unkey healthcheck https://api.unkey.dev/health + + # Check local service + unkey healthcheck http://localhost:8080/health`, + Action: runAction, } // nolint:gocognit -func run(ctx context.Context, cmd *cli.Command) error { +func runAction(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args() + if len(args) == 0 { + return fmt.Errorf("you must provide a url like so: 'unkey healthcheck '") + } - url := cmd.Args().First() + url := args[0] if url == "" { return fmt.Errorf("you must provide a url like so: 'unkey healthcheck '") } @@ -37,5 +49,6 @@ func run(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("healthcheck failed with status code %d", res.StatusCode) } + fmt.Printf("✓ Healthcheck passed: %s returned %d\n", url, res.StatusCode) return nil } diff --git a/go/cmd/quotacheck/main.go b/go/cmd/quotacheck/main.go index 6a9d253aab..5e439864e3 100644 --- a/go/cmd/quotacheck/main.go +++ b/go/cmd/quotacheck/main.go @@ -2,52 +2,35 @@ package quotacheck import ( "bytes" + "context" "encoding/json" "fmt" "net/http" "time" - "context" - + "github.com/unkeyed/unkey/go/pkg/cli" "github.com/unkeyed/unkey/go/pkg/clickhouse" "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/otel/logging" "golang.org/x/text/language" "golang.org/x/text/message" "golang.org/x/text/number" - - "github.com/urfave/cli/v3" ) var Cmd = &cli.Command{ Name: "quotacheck", - Description: "Check for exceeded quotas", + Usage: "Check for exceeded quotas", + Description: "Check for exceeded quotas and optionally send Slack notifications", Flags: []cli.Flag{ - - &cli.StringFlag{ - Name: "clickhouse-url", - Usage: "URL for the ClickHouse database", - Sources: cli.EnvVars("CLICKHOUSE_URL"), - Required: true, - }, - &cli.StringFlag{ - Name: "database-dsn", - Usage: "DSN for the primary database", - Sources: cli.EnvVars("DATABASE_DSN"), - Required: true, - }, - &cli.StringFlag{ - Name: "slack-webhook-url", - Usage: "Slack webhook URL to send notifications", - Sources: cli.EnvVars("SLACK_WEBHOOK_URL"), - }, + cli.String("clickhouse-url", "URL for the ClickHouse database", cli.EnvVar("CLICKHOUSE_URL"), cli.Required()), + cli.String("database-dsn", "DSN for the primary database", cli.EnvVar("DATABASE_DSN"), cli.Required()), + cli.String("slack-webhook-url", "Slack webhook URL to send notifications", cli.EnvVar("SLACK_WEBHOOK_URL")), }, Action: run, } // nolint:gocognit func run(ctx context.Context, cmd *cli.Command) error { - year, month, _ := time.Now().Date() logger := logging.New() @@ -59,7 +42,6 @@ func run(ctx context.Context, cmd *cli.Command) error { ReadOnlyDSN: "", Logger: logger, }) - if err != nil { return err } @@ -68,7 +50,6 @@ func run(ctx context.Context, cmd *cli.Command) error { URL: cmd.String("clickhouse-url"), Logger: logger, }) - if err != nil { return err } @@ -130,9 +111,7 @@ func run(ctx context.Context, cmd *cli.Command) error { // sendSlackNotification sends a message to a Slack webhook func sendSlackNotification(webhookURL string, e db.ListWorkspacesRow, used int64) error { - payload := map[string]any{ - "text": fmt.Sprintf("Quota Exceeded: %s", e.Workspace.Name), "blocks": []map[string]any{ { @@ -167,7 +146,6 @@ func sendSlackNotification(webhookURL string, e db.ListWorkspacesRow, used int64 { "type": "section", "fields": []map[string]any{ - { "type": "mrkdwn", "text": fmt.Sprintf("*Workspace Tier:*\n%s", e.Workspace.Tier.String), @@ -181,7 +159,6 @@ func sendSlackNotification(webhookURL string, e db.ListWorkspacesRow, used int64 { "type": "section", "fields": []map[string]any{ - { "type": "mrkdwn", "text": fmt.Sprintf("*Limit:*\n%s", message.NewPrinter(language.English).Sprint(number.Decimal(e.Quotas.RequestsPerMonth))), diff --git a/go/cmd/run/main.go b/go/cmd/run/main.go index 745c09ae2e..334daafe15 100644 --- a/go/cmd/run/main.go +++ b/go/cmd/run/main.go @@ -1,9 +1,12 @@ package run import ( + "context" + "fmt" + "github.com/unkeyed/unkey/go/cmd/api" "github.com/unkeyed/unkey/go/cmd/ctrl" - "github.com/urfave/cli/v3" + "github.com/unkeyed/unkey/go/pkg/cli" ) var Cmd = &cli.Command{ @@ -11,10 +14,30 @@ var Cmd = &cli.Command{ Usage: "Run Unkey services", Description: `Run various Unkey services including: - api: The main API server for validating and managing API keys - - ctrl: The control plane service for managing infrastructure`, + - ctrl: The control plane service for managing infrastructure + +EXAMPLES: + # Run the API server + unkey run api + + # Run the control plane + unkey run ctrl + # Show available services + unkey run --help`, Commands: []*cli.Command{ api.Cmd, ctrl.Cmd, }, + Action: runAction, +} + +func runAction(ctx context.Context, cmd *cli.Command) error { + fmt.Println("Available services:") + fmt.Println(" api - The main API server for validating and managing API keys") + fmt.Println(" ctrl - The control plane service for managing infrastructure") + fmt.Println() + fmt.Println("Use 'unkey run ' to start a specific service") + fmt.Println("Use 'unkey run --help' for service-specific options") + return nil } diff --git a/go/cmd/version/bootstrap.go b/go/cmd/version/bootstrap.go deleted file mode 100644 index 604627fd54..0000000000 --- a/go/cmd/version/bootstrap.go +++ /dev/null @@ -1,140 +0,0 @@ -package version - -import ( - "context" - "database/sql" - "fmt" - "time" - - _ "github.com/go-sql-driver/mysql" - "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/otel/logging" - "github.com/unkeyed/unkey/go/pkg/uid" - "github.com/urfave/cli/v3" -) - -// TODO: REMOVE THIS ENTIRE FILE - This is a temporary bootstrap helper -// Remove once we have proper UI for project management - -var bootstrapProjectCmd = &cli.Command{ - Name: "bootstrap-project", - Usage: "TEMPORARY: Create a project for testing (remove once we have UI)", - Description: `TEMPORARY BOOTSTRAP HELPER - REMOVE ONCE WE HAVE PROPER UI - -This command directly creates a project in the database for testing purposes. -This bypasses proper API workflows and should be removed once we have a UI.`, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "workspace-id", - Usage: "Workspace ID", - Required: true, - }, - &cli.StringFlag{ - Name: "slug", - Usage: "Project slug", - Value: "my-api", - Required: false, - }, - &cli.StringFlag{ - Name: "db-url", - Usage: "Database connection string", - Value: "root:password@tcp(localhost:3306)/unkey", - Required: false, - }, - }, - Action: bootstrapProjectAction, -} - -func bootstrapProjectAction(ctx context.Context, cmd *cli.Command) error { - logger := logging.New() - - workspaceID := cmd.String("workspace-id") - projectSlug := cmd.String("slug") - dbURL := cmd.String("db-url") - - fmt.Printf("🚧 TEMPORARY BOOTSTRAP - Creating project...\n") - fmt.Printf(" Workspace ID: %s\n", workspaceID) - fmt.Printf(" Project Slug: %s\n", projectSlug) - fmt.Println() - - // Connect to database (TEMPORARY - this should be done via API) - sqlDB, err := sql.Open("mysql", dbURL) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - defer sqlDB.Close() - - // Create workspace if it doesn't exist - _, err = db.Query.FindWorkspaceByID(ctx, sqlDB, workspaceID) - if err != nil { - if db.IsNotFound(err) { - // Workspace doesn't exist, create it - fmt.Printf("📁 Creating workspace: %s\n", workspaceID) - now := time.Now().UnixMilli() - err = db.Query.InsertWorkspace(ctx, sqlDB, db.InsertWorkspaceParams{ - ID: workspaceID, - OrgID: "org_bootstrap", // hardcoded for bootstrap - Name: workspaceID, // use ID as name for simplicity - CreatedAt: now, - }) - if err != nil { - return fmt.Errorf("failed to create workspace: %w", err) - } - fmt.Printf("✅ Workspace created: %s\n", workspaceID) - } else { - return fmt.Errorf("failed to validate workspace: %w", err) - } - } else { - fmt.Printf("📁 Using existing workspace: %s\n", workspaceID) - } - - // Check if project already exists - _, err = db.Query.FindProjectByWorkspaceSlug(ctx, sqlDB, db.FindProjectByWorkspaceSlugParams{ - WorkspaceID: workspaceID, - Slug: projectSlug, - }) - if err == nil { - return fmt.Errorf("project with slug '%s' already exists in workspace '%s'", projectSlug, workspaceID) - } else if !db.IsNotFound(err) { - return fmt.Errorf("failed to check existing project: %w", err) - } - - // Generate project ID - projectID := uid.New("proj") - - // Create project - now := time.Now().UnixMilli() - err = db.Query.InsertProject(ctx, sqlDB, db.InsertProjectParams{ - ID: projectID, - WorkspaceID: workspaceID, - PartitionID: "part_default", // hardcoded for now - Name: projectSlug, // use slug as name for simplicity - Slug: projectSlug, - GitRepositoryUrl: sql.NullString{String: "", Valid: false}, - DefaultBranch: sql.NullString{String: "main", Valid: true}, - DeleteProtection: sql.NullBool{Bool: false, Valid: true}, - CreatedAt: now, - UpdatedAt: sql.NullInt64{Int64: now, Valid: true}, - }) - if err != nil { - return fmt.Errorf("failed to create project: %w", err) - } - - fmt.Printf("✅ Project created successfully!\n") - fmt.Printf(" Project ID: %s\n", projectID) - fmt.Printf(" Workspace ID: %s\n", workspaceID) - fmt.Printf(" Project Slug: %s\n", projectSlug) - fmt.Println() - - fmt.Printf("📋 Use these values for deployment:\n") - fmt.Printf(" unkey-cli create --workspace-id=%s --project-id=%s\n", workspaceID, projectID) - fmt.Printf("\n") - fmt.Printf("🗑️ Remember to remove this bootstrap command once we have proper UI!\n") - - logger.Info("bootstrap project created", - "project_id", projectID, - "workspace_id", workspaceID, - "project_slug", projectSlug) - - return nil -} diff --git a/go/cmd/version/main.go b/go/cmd/version/main.go index 85cdbaa7c1..3826dc4d13 100644 --- a/go/cmd/version/main.go +++ b/go/cmd/version/main.go @@ -1,25 +1,11 @@ package version import ( - "bufio" "context" - "errors" "fmt" - "io" "log/slog" - "net/http" - "os/exec" - "strings" - "time" - "connectrpc.com/connect" - ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" - "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" - "github.com/unkeyed/unkey/go/pkg/codes" - "github.com/unkeyed/unkey/go/pkg/fault" - "github.com/unkeyed/unkey/go/pkg/git" - "github.com/unkeyed/unkey/go/pkg/otel/logging" - "github.com/urfave/cli/v3" + "github.com/unkeyed/unkey/go/pkg/cli" ) var Cmd = &cli.Command{ @@ -28,403 +14,32 @@ var Cmd = &cli.Command{ Description: `Create, list, and manage versions of your API. Versions are immutable snapshots of your code, configuration, and infrastructure settings.`, - Commands: []*cli.Command{ - createCmd, getCmd, listCmd, rollbackCmd, - // TODO: Remove this bootstrap command once we have a proper UI - bootstrapProjectCmd, // defined in bootstrap.go - }, -} - -var createCmd = &cli.Command{ - Name: "create", - Aliases: []string{"deploy"}, - Usage: "Create a new version of your API", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "branch", - Usage: "Git branch name", - Value: "main", - Required: false, - }, - &cli.StringFlag{ - Name: "docker-image", - Usage: "Docker image tag (e.g., ghcr.io/user/app:tag). If not provided, builds from current directory", - Required: false, - }, - &cli.BoolFlag{ - Name: "force-build", - Usage: "Force build Docker image even if --docker-image is provided", - }, - &cli.StringFlag{ - Name: "dockerfile", - Usage: "Path to Dockerfile", - Value: "Dockerfile", - Required: false, - }, - &cli.StringFlag{ - Name: "context", - Usage: "Build context directory", - Value: ".", - Required: false, - }, - &cli.StringFlag{ - Name: "commit", - Usage: "Git commit SHA", - Required: false, - }, - &cli.StringFlag{ - Name: "control-plane-url", - Usage: "Control plane base URL", - Value: "http://localhost:7091", - Required: false, - }, - &cli.StringFlag{ - Name: "auth-token", - Usage: "Control plane auth token", - Value: "ctrl-secret-token", - Required: false, - }, - &cli.StringFlag{ - Name: "workspace-id", - Usage: "Workspace ID", - Required: true, - }, - &cli.StringFlag{ - Name: "project-id", - Usage: "Project ID", - Required: true, - }, }, - Action: createAction, -} - -func createAction(ctx context.Context, cmd *cli.Command) error { - logger := logging.New() - - // Get workspace and project IDs from CLI flags - workspaceID := cmd.String("workspace-id") - projectID := cmd.String("project-id") - - // Get Git information automatically - gitInfo := git.GetInfo() - - // Use Git info as defaults, allow CLI flags to override - branch := cmd.String("branch") - if branch == "main" && gitInfo.IsRepo { // CLI default is "main" - branch = gitInfo.Branch - } - - commit := cmd.String("commit") - if commit == "" && gitInfo.CommitSHA != "" { - commit = gitInfo.CommitSHA - } - - dockerImage := cmd.String("docker-image") - dockerfile := cmd.String("dockerfile") - buildContext := cmd.String("context") - - // Always build the image, ignoring any provided docker-image - dockerImage = "" - - return runDeploymentSteps(ctx, cmd, workspaceID, projectID, branch, dockerImage, dockerfile, buildContext, commit, logger) -} - -func printDeploymentComplete(version *ctrlv1.Version) { - fmt.Println() - fmt.Println("Deployment Complete") - fmt.Printf(" Version ID: %s\n", version.GetId()) - fmt.Printf(" Status: Ready\n") - fmt.Printf(" Environment: Production\n") - - fmt.Println() - fmt.Println("Domains") - hostnames := version.GetHostnames() - if len(hostnames) > 0 { - for _, hostname := range hostnames { - // Check if it's a localhost hostname (don't add https://) - if strings.HasPrefix(hostname, "localhost:") { - fmt.Printf(" http://%s\n", hostname) - } else { - fmt.Printf(" https://%s\n", hostname) - } - } - } else { - fmt.Printf(" No hostnames assigned\n") - } -} - -func runDeploymentSteps(ctx context.Context, cmd *cli.Command, workspace, project, branch, dockerImage, dockerfile, buildContext, commit string, logger logging.Logger) error { - - // Get Git info for better image tagging - gitInfo := git.GetInfo() - - // Print source information immediately - fmt.Println("Source") - fmt.Printf(" Branch: %s\n", branch) - if gitInfo.CommitSHA != "" { - fmt.Printf(" Commit: %s\n", gitInfo.CommitSHA) - if gitInfo.IsDirty { - fmt.Printf(" Status: Working directory has uncommitted changes\n") - } - } - fmt.Println() - - // If no docker image provided, build one - if dockerImage == "" { - // Generate image tag using Git info when available - var imageTag string - if gitInfo.ShortSHA != "" { - imageTag = fmt.Sprintf("%s-%s", branch, gitInfo.ShortSHA) - } else { - // Fallback to timestamp if no Git info - timestamp := time.Now().Unix() - imageTag = fmt.Sprintf("%s-%d", branch, timestamp) - } - dockerImage = fmt.Sprintf("ghcr.io/unkeyed/deploy-wip:%s", imageTag) - - fmt.Printf("Building Docker image %s...\n", dockerImage) - - // Build the Docker image with minimal output - var buildArgs []string - buildArgs = append(buildArgs, "build") - - // Only add -f flag if dockerfile is not the default "Dockerfile" - if dockerfile != "Dockerfile" { - buildArgs = append(buildArgs, "-f", dockerfile) - } - - buildArgs = append(buildArgs, - "-t", dockerImage, - "--build-arg", fmt.Sprintf("VERSION=%s-%s", branch, commit), - buildContext, - ) - - buildCmd := exec.CommandContext(ctx, "docker", buildArgs...) - - // Create pipes to capture stdout and stderr - stdout, err := buildCmd.StdoutPipe() - if err != nil { - return fmt.Errorf("failed to create stdout pipe: %w", err) - } - stderr, err := buildCmd.StderrPipe() - if err != nil { - return fmt.Errorf("failed to create stderr pipe: %w", err) - } - - // Start the build command - if startErr := buildCmd.Start(); startErr != nil { - return fmt.Errorf("failed to start docker build: %w", startErr) - } - - // Capture all output for error reporting - var allOutput strings.Builder - - // Create a combined reader for both stdout and stderr - combinedOutput := io.MultiReader(stdout, stderr) - scanner := bufio.NewScanner(combinedOutput) - - // Process output line by line - for scanner.Scan() { - line := scanner.Text() - allOutput.WriteString(line + "\n") - - // Print all docker build output - fmt.Printf(" %s\n", line) - } - - // Wait for the build to complete - err = buildCmd.Wait() - - if err != nil { - fmt.Printf("Docker build failed\n") - // Show the full build output on failure - for _, line := range strings.Split(allOutput.String(), "\n") { - if strings.TrimSpace(line) != "" { - fmt.Printf(" %s\n", line) - } - } - return fmt.Errorf("docker build failed: %w", err) - } - - fmt.Printf("Publishing Docker image...\n") - - pushCmd := exec.CommandContext(ctx, "docker", "push", dockerImage) - - // Capture output for error reporting - var pushOutput strings.Builder - pushCmd.Stdout = &pushOutput - pushCmd.Stderr = &pushOutput - - // Run the push - if err := pushCmd.Run(); err != nil { - fmt.Printf("Docker push failed\n") - // Show the push output on failure - for _, line := range strings.Split(pushOutput.String(), "\n") { - if strings.TrimSpace(line) != "" { - fmt.Printf(" %s\n", line) - } - } - - fmt.Println("ignore push errors for now") - // return err - } - } - - // Create control plane client - controlPlaneURL := cmd.String("control-plane-url") - authToken := cmd.String("auth-token") - - httpClient := &http.Client{} - client := ctrlv1connect.NewVersionServiceClient(httpClient, controlPlaneURL) - - // Create version request - createReq := connect.NewRequest(&ctrlv1.CreateVersionRequest{ - WorkspaceId: workspace, - ProjectId: project, - Branch: branch, - SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, - GitCommitSha: cmd.String("commit"), - EnvironmentId: "env_prod", - DockerImageTag: dockerImage, - }) - - // Add auth header - createReq.Header().Set("Authorization", "Bearer "+authToken) - - // Call the API - createResp, err := client.CreateVersion(ctx, createReq) - if err != nil { - fmt.Println() - - // Check if it's a connection error - if strings.Contains(err.Error(), "connection refused") { - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), - fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", controlPlaneURL)), - fault.Public("Unable to connect to control plane. Is it running?"), - ) - } - - // Check if it's an auth error - if connectErr := new(connect.Error); errors.As(err, &connectErr) { - if connectErr.Code() == connect.CodeUnauthenticated { - return fault.Wrap(err, - fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), - fault.Internal(fmt.Sprintf("Authentication failed with token: %s", authToken)), - fault.Public("Authentication failed. Check your auth token."), - ) - } - } - - // Generic API error - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), - fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), - fault.Public("Failed to create version. Please try again."), - ) - } - - versionID := createResp.Msg.GetVersionId() - fmt.Printf("Creating Version\n") - fmt.Printf(" Version ID: %s\n", versionID) - - // Poll for version status updates - finalVersion, err := pollVersionStatus(ctx, logger, client, versionID) - if err != nil { - return fmt.Errorf("deployment failed: %w", err) - } - - printDeploymentComplete(finalVersion) - - return nil } -// pollVersionStatus polls the control plane API and displays deployment steps as they occur -func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1connect.VersionServiceClient, versionID string) (*ctrlv1.Version, error) { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - timeout := time.NewTimer(300 * time.Second) // 5 minute timeout for full deployment - defer timeout.Stop() - - displayedSteps := make(map[string]bool) - - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-timeout.C: - fmt.Printf("Error: Deployment timeout after 5 minutes\n") - return nil, fmt.Errorf("deployment timeout") - case <-ticker.C: - // Always poll version status - getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ - VersionId: versionID, - }) - getReq.Header().Set("Authorization", "Bearer ctrl-secret-token") - - getResp, err := client.GetVersion(ctx, getReq) - if err != nil { - logger.Debug("Failed to get version status", "error", err, "version_id", versionID) - continue - } - - version := getResp.Msg.GetVersion() - - // Display version steps in real-time - steps := version.GetSteps() - for _, step := range steps { - stepKey := step.GetStatus() - if !displayedSteps[stepKey] { - displayVersionStep(step) - displayedSteps[stepKey] = true - } - } - - // Check if deployment is complete - if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { - return version, nil - } - - // Check if deployment failed - if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { - return nil, fmt.Errorf("deployment failed") - } - } - } -} - -// displayVersionStep shows a version step with appropriate formatting -func displayVersionStep(step *ctrlv1.VersionStep) { - message := step.GetMessage() - - // Display only the actual message from the database, indented under "Creating Version" - if message != "" { - fmt.Printf(" %s\n", message) - } +var getCmd = &cli.Command{ + Name: "get", + Usage: "Get details about a version", + Description: `Get details about a specific version. - // Show error message if present - if step.GetErrorMessage() != "" { - fmt.Printf(" Error: %s\n", step.GetErrorMessage()) - } -} +USAGE: + unkey version get -var getCmd = &cli.Command{ - Name: "get", - Usage: "Get details about a version", - ArgsUsage: "", +EXAMPLES: + unkey version get v_abc123def456`, Action: func(ctx context.Context, cmd *cli.Command) error { logger := slog.Default() - if cmd.Args().Len() < 1 { - return cli.Exit("version ID required", 1) + args := cmd.Args() + if len(args) < 1 { + return fmt.Errorf("version ID required") } - versionID := cmd.Args().First() + versionID := args[0] logger.Info("Getting version details", "version_id", versionID) // Call control plane API to get version @@ -443,23 +58,13 @@ var listCmd = &cli.Command{ Name: "list", Usage: "List versions", Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "branch", - Usage: "Filter by branch", - }, - &cli.StringFlag{ - Name: "status", - Usage: "Filter by status (pending, building, active, failed)", - }, - &cli.IntFlag{ - Name: "limit", - Usage: "Number of versions to show", - Value: 10, - }, + cli.String("branch", "Filter by branch"), + cli.String("status", "Filter by status (pending, building, active, failed)"), + cli.Int("limit", "Number of versions to show", cli.Default(10)), }, Action: func(ctx context.Context, cmd *cli.Command) error { // Hardcoded for demo - workspace := "acme" + workspace := "Acme" project := "my-api" fmt.Printf("Versions for %s/%s:\n", workspace, project) @@ -474,24 +79,29 @@ var listCmd = &cli.Command{ } var rollbackCmd = &cli.Command{ - Name: "rollback", - Usage: "Rollback to a previous version", - ArgsUsage: " ", + Name: "rollback", + Usage: "Rollback to a previous version", + Description: `Rollback to a previous version. + +USAGE: + unkey version rollback + +EXAMPLES: + unkey version rollback my-api.unkey.app v_abc123def456 + unkey version rollback my-api.unkey.app v_abc123def456 --force`, Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "force", - Usage: "Skip confirmation prompt", - }, + cli.Bool("force", "Skip confirmation prompt"), }, Action: func(ctx context.Context, cmd *cli.Command) error { logger := slog.Default() - if cmd.Args().Len() < 2 { - return cli.Exit("hostname and version ID required", 1) + args := cmd.Args() + if len(args) < 2 { + return fmt.Errorf("hostname and version ID required") } - hostname := cmd.Args().Get(0) - versionID := cmd.Args().Get(1) + hostname := args[0] + versionID := args[1] force := cmd.Bool("force") logger.Info("Rolling back version", diff --git a/go/go.mod b/go/go.mod index 21106cbcf0..bcac2d1b9d 100644 --- a/go/go.mod +++ b/go/go.mod @@ -28,7 +28,6 @@ require ( github.com/sqlc-dev/sqlc v1.28.0 github.com/stretchr/testify v1.10.0 github.com/unkeyed/unkey/go/deploy/pkg/tls v0.0.0-00010101000000-000000000000 - github.com/urfave/cli/v3 v3.3.3 go.opentelemetry.io/contrib/bridges/otelslog v0.11.0 go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 go.opentelemetry.io/contrib/processors/minsev v0.9.0 diff --git a/go/go.sum b/go/go.sum index 357db6adbf..33d45f926e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -330,8 +330,6 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= -github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= github.com/wI2L/jsondiff v0.6.1 h1:ISZb9oNWbP64LHnu4AUhsMF5W0FIj5Ok3Krip9Shqpw= diff --git a/go/main.go b/go/main.go index d13b37fcd2..af239c28f3 100644 --- a/go/main.go +++ b/go/main.go @@ -9,20 +9,20 @@ import ( "github.com/unkeyed/unkey/go/cmd/healthcheck" "github.com/unkeyed/unkey/go/cmd/quotacheck" "github.com/unkeyed/unkey/go/cmd/run" - versioncmd "github.com/unkeyed/unkey/go/cmd/version" - "github.com/unkeyed/unkey/go/pkg/version" - "github.com/urfave/cli/v3" + "github.com/unkeyed/unkey/go/cmd/version" + "github.com/unkeyed/unkey/go/pkg/cli" + versioncmd "github.com/unkeyed/unkey/go/pkg/version" ) func main() { app := &cli.Command{ - Name: "unkey", - Usage: "Run unkey ", - Version: version.Version, - + Name: "unkey", + Usage: "Run unkey", + Description: `Unkey CLI – deploy, run and administer Unkey services.`, + Version: versioncmd.Version, Commands: []*cli.Command{ run.Cmd, - versioncmd.Cmd, + version.Cmd, deploy.Cmd, healthcheck.Cmd, quotacheck.Cmd, @@ -31,10 +31,7 @@ func main() { err := app.Run(context.Background(), os.Args) if err != nil { - fmt.Println() - fmt.Println() fmt.Println(err.Error()) - fmt.Println() os.Exit(1) } } diff --git a/go/pkg/cli/command.go b/go/pkg/cli/command.go new file mode 100644 index 0000000000..c09013bddd --- /dev/null +++ b/go/pkg/cli/command.go @@ -0,0 +1,265 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "strings" +) + +var ( + ErrFlagNotFound = errors.New("flag not found") + ErrWrongFlagType = errors.New("wrong flag type") + ErrNoArguments = errors.New("no arguments provided") +) + +// Action represents a command handler function that receives context and the parsed command +type Action func(context.Context, *Command) error + +// Command represents a CLI command with its configuration and runtime state +type Command struct { + // Configuration + Name string // Command name (e.g., "deploy", "version") + Usage string // Short description shown in help + Description string // Longer description for detailed help + Version string // Version string (only used for root command) + Commands []*Command // Subcommands + Flags []Flag // Available flags for this command + Action Action // Function to execute when command is run + Aliases []string // Alternative names for this command + + // Runtime state (populated during parsing) + args []string // Non-flag arguments passed to command + flagMap map[string]Flag // Map for O(1) flag lookup + parent *Command // Parent command (for building usage paths) +} + +// Args returns the non-flag arguments passed to the command +// Example: "mycli deploy myapp" -> Args() returns ["myapp"] +func (c *Command) Args() []string { + return c.args +} + +// String returns the value of a string flag by name +// Returns empty string if flag doesn't exist or isn't a StringFlag +func (c *Command) String(name string) string { + if flag, ok := c.flagMap[name]; ok { + if sf, ok := flag.(*StringFlag); ok { + return sf.Value() + } + } + return "" +} + +// RequireString returns the value of a string flag by name +// Panics if flag doesn't exist or isn't a StringFlag +func (c *Command) RequireString(name string) string { + flag, ok := c.flagMap[name] + if !ok { + panic(c.newFlagNotFoundError(name)) + } + + sf, ok := flag.(*StringFlag) + if !ok { + panic(c.newWrongFlagTypeError(name, flag, "StringFlag")) + } + + return sf.Value() +} + +// Bool returns the value of a boolean flag by name +// Returns false if flag doesn't exist or isn't a BoolFlag +func (c *Command) Bool(name string) bool { + if flag, ok := c.flagMap[name]; ok { + if bf, ok := flag.(*BoolFlag); ok { + return bf.Value() + } + } + return false +} + +// RequireBool returns the value of a boolean flag by name +// Panics if flag doesn't exist or isn't a BoolFlag +func (c *Command) RequireBool(name string) bool { + flag, ok := c.flagMap[name] + if !ok { + panic(c.newFlagNotFoundError(name)) + } + + bf, ok := flag.(*BoolFlag) + if !ok { + panic(c.newWrongFlagTypeError(name, flag, "BoolFlag")) + } + + return bf.Value() +} + +// Int returns the value of an integer flag by name +// Returns 0 if flag doesn't exist or isn't an IntFlag +func (c *Command) Int(name string) int { + if flag, ok := c.flagMap[name]; ok { + if inf, ok := flag.(*IntFlag); ok { + return inf.Value() + } + } + return 0 +} + +// RequireInt returns the value of an integer flag by name +// Panics if flag doesn't exist or isn't an IntFlag +func (c *Command) RequireInt(name string) int { + flag, ok := c.flagMap[name] + if !ok { + panic(c.newFlagNotFoundError(name)) + } + + inf, ok := flag.(*IntFlag) + if !ok { + panic(c.newWrongFlagTypeError(name, flag, "IntFlag")) + } + + return inf.Value() +} + +// Float returns the value of a float flag by name +// Returns 0.0 if flag doesn't exist or isn't a FloatFlag +func (c *Command) Float(name string) float64 { + if flag, ok := c.flagMap[name]; ok { + if ff, ok := flag.(*FloatFlag); ok { + return ff.Value() + } + } + return 0.0 +} + +// RequireFloat returns the value of a float flag by name +// Panics if flag doesn't exist or isn't a FloatFlag +func (c *Command) RequireFloat(name string) float64 { + flag, ok := c.flagMap[name] + if !ok { + panic(c.newFlagNotFoundError(name)) + } + + ff, ok := flag.(*FloatFlag) + if !ok { + panic(c.newWrongFlagTypeError(name, flag, "FloatFlag")) + } + + return ff.Value() +} + +// StringSlice returns the value of a string slice flag by name +// Returns empty slice if flag doesn't exist or isn't a StringSliceFlag +func (c *Command) StringSlice(name string) []string { + if flag, ok := c.flagMap[name]; ok { + if ssf, ok := flag.(*StringSliceFlag); ok { + return ssf.Value() + } + } + return []string{} +} + +// RequireStringSlice returns the value of a string slice flag by name +// Panics if flag doesn't exist or isn't a StringSliceFlag +func (c *Command) RequireStringSlice(name string) []string { + flag, ok := c.flagMap[name] + if !ok { + panic(c.newFlagNotFoundError(name)) + } + + ssf, ok := flag.(*StringSliceFlag) + if !ok { + panic(c.newWrongFlagTypeError(name, flag, "StringSliceFlag")) + } + + return ssf.Value() +} + +// Run executes the command with the given arguments (typically os.Args) +// This is the main entry point for CLI execution +func (c *Command) Run(ctx context.Context, args []string) error { + if len(args) == 0 { + return ErrNoArguments + } + // Parse arguments starting from index 1 (skip program name) + return c.parse(ctx, args[1:]) +} + +// newFlagNotFoundError creates a structured error for missing flags +func (c *Command) newFlagNotFoundError(flagName string) error { + return fmt.Errorf("%w: %q in command %q - available flags: %s", + ErrFlagNotFound, flagName, c.Name, c.getAvailableFlags()) +} + +// newWrongFlagTypeError creates a structured error for type mismatches +func (c *Command) newWrongFlagTypeError(flagName string, flag Flag, expectedType string) error { + actualType := c.getFlagType(flag) + availableOfType := c.getFlagsByType(expectedType) + + return fmt.Errorf("%w: flag %q is %s, expected %s in command %q - available %s flags: %s", + ErrWrongFlagType, flagName, actualType, expectedType, c.Name, + strings.ToLower(expectedType), availableOfType) +} + +// Helper functions for error reporting + +// getFlagType returns a human-readable type name for a flag +func (c *Command) getFlagType(flag Flag) string { + switch flag.(type) { + case *StringFlag: + return "StringFlag" + case *BoolFlag: + return "BoolFlag" + case *IntFlag: + return "IntFlag" + case *FloatFlag: + return "FloatFlag" + case *StringSliceFlag: + return "StringSliceFlag" + default: + return "unknown flag type" + } +} + +// getAvailableFlags returns a comma-separated list of all available flag names +func (c *Command) getAvailableFlags() string { + if len(c.Flags) == 0 { + return "none" + } + + names := make([]string, len(c.Flags)) + for i, flag := range c.Flags { + names[i] = flag.Name() + } + + return strings.Join(names, ", ") +} + +// getFlagsByType returns a comma-separated list of flags of the specified type +func (c *Command) getFlagsByType(flagType string) string { + var matching []string + + for _, flag := range c.Flags { + if c.getFlagType(flag) == flagType { + matching = append(matching, flag.Name()) + } + } + + if len(matching) == 0 { + return "none" + } + + return strings.Join(matching, ", ") +} + +// ExitFunc allows dependency injection for testing +var ExitFunc = os.Exit + +// Exit provides a clean way to exit with an error message and code +// This is a convenience function that prints the message and calls os.Exit +func Exit(message string, code int) error { + fmt.Println(message) + ExitFunc(code) + return nil // unreachable but satisfies error interface +} diff --git a/go/pkg/cli/flag.go b/go/pkg/cli/flag.go new file mode 100644 index 0000000000..85bafc0dcc --- /dev/null +++ b/go/pkg/cli/flag.go @@ -0,0 +1,521 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +var ( + ErrValidationFailed = errors.New("validation failed") + ErrInvalidBoolValue = errors.New("invalid boolean value") + ErrInvalidIntValue = errors.New("invalid integer value") + ErrInvalidFloatValue = errors.New("invalid float value") +) + +type Flag interface { + Name() string // The flag name (without dashes) + Usage() string // Help text describing the flag + Required() bool // Whether this flag is mandatory + Parse(value string) error // Parse string value into the flag's type + IsSet() bool // Whether the flag was explicitly set by user + HasValue() bool // Whether the flag has any value (from user, env, or default) +} + +// ValidateFunc represents a function that validates a flag value +type ValidateFunc func(value string) error + +// baseFlag contains common fields and methods shared by all flag types +type baseFlag struct { + name string // Flag name + usage string // Help description + envVar string // Environment variable to check for default + required bool // Whether flag is mandatory + set bool // Whether user explicitly provided this flag + validate ValidateFunc // Optional validation function +} + +// Name returns the flag name +func (b *baseFlag) Name() string { return b.name } + +// Usage returns the flag's help text +func (b *baseFlag) Usage() string { return b.usage } + +// Required returns whether this flag is mandatory +func (b *baseFlag) Required() bool { return b.required } + +// IsSet returns whether the user explicitly provided this flag +func (b *baseFlag) IsSet() bool { return b.set } + +// EnvVar returns the environment variable name for this flag +func (b *baseFlag) EnvVar() string { return b.envVar } + +// StringFlag represents a string command line flag +type StringFlag struct { + baseFlag + value string // Current value + hasEnvValue bool // Track if value came from environment +} + +// Parse sets the flag value from a string +func (f *StringFlag) Parse(value string) error { + // Run validation if provided + if f.validate != nil { + if err := f.validate(value); err != nil { + return newValidationError(f.name, err) + } + } + f.value = value + f.set = true + return nil +} + +// Value returns the current string value +func (f *StringFlag) Value() string { return f.value } + +// HasValue returns true if the flag has any non-empty value or came from environment +func (f *StringFlag) HasValue() bool { return f.value != "" || f.hasEnvValue } + +// BoolFlag represents a boolean command line flag +type BoolFlag struct { + baseFlag + value bool + hasEnvValue bool // Track if value came from environment +} + +// Parse sets the flag value from a string +// Empty string means the flag was provided without a value (--flag), which sets it to true +// Otherwise parses as boolean: "true", "false", "1", "0", etc. +func (f *BoolFlag) Parse(value string) error { + var parsed bool + if value == "" { + parsed = true + } else { + var err error + parsed, err = strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidBoolValue, value) + } + } + + // Run validation if provided - validate the original input string, not the parsed boolean + if f.validate != nil { + if err := f.validate(value); err != nil { + return newValidationError(f.name, err) + } + } + + f.value = parsed + f.set = true + return nil +} + +// Value returns the current boolean value +func (f *BoolFlag) Value() bool { return f.value } + +// HasValue returns true - boolean flags always have a meaningful value +func (f *BoolFlag) HasValue() bool { return true } + +// IntFlag represents an integer command line flag +type IntFlag struct { + baseFlag + value int // Current value + hasEnvValue bool // Track if value came from environment +} + +// Parse sets the flag value from a string +func (f *IntFlag) Parse(value string) error { + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidIntValue, value) + } + + // Run validation if provided + if f.validate != nil { + if err := f.validate(value); err != nil { + return newValidationError(f.name, err) + } + } + + f.value = parsed + f.set = true + return nil +} + +// Value returns the current integer value +func (f *IntFlag) Value() int { return f.value } + +// HasValue returns true if the flag has a non-zero value or came from environment +func (f *IntFlag) HasValue() bool { return f.value != 0 || f.hasEnvValue } + +// FloatFlag represents a float64 command line flag +type FloatFlag struct { + baseFlag + value float64 // Current value + hasEnvValue bool // Track if value came from environment +} + +// Parse sets the flag value from a string +func (f *FloatFlag) Parse(value string) error { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidFloatValue, value) + } + + // Run validation if provided + if f.validate != nil { + if err := f.validate(value); err != nil { + return newValidationError(f.name, err) + } + } + + f.value = parsed + f.set = true + return nil +} + +// Value returns the current float64 value +func (f *FloatFlag) Value() float64 { return f.value } + +// HasValue returns true if the flag has a non-zero value or came from environment +func (f *FloatFlag) HasValue() bool { return f.value != 0.0 || f.hasEnvValue } + +// StringSliceFlag represents a string slice command line flag +type StringSliceFlag struct { + baseFlag + value []string // Current value + hasEnvValue bool // Track if value came from environment +} + +// parseCommaSeparated splits a comma-separated string into a slice of trimmed non-empty strings +func (f *StringSliceFlag) parseCommaSeparated(value string) []string { + if value == "" { + return []string{} + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// Parse sets the flag value from a string (comma-separated values) +func (f *StringSliceFlag) Parse(value string) error { + parsed := f.parseCommaSeparated(value) + + // Run validation if provided (validate the original comma-separated string) + if f.validate != nil { + if err := f.validate(value); err != nil { + return newValidationError(f.name, err) + } + } + + f.value = parsed + f.set = true + return nil +} + +// Value returns the current string slice value +func (f *StringSliceFlag) Value() []string { return f.value } + +// HasValue returns true if the slice is not empty or came from environment +func (f *StringSliceFlag) HasValue() bool { return len(f.value) > 0 || f.hasEnvValue } + +// FlagOption represents an option for configuring flags +type FlagOption func(flag any) + +// Required marks a flag as mandatory +func Required() FlagOption { + return func(f any) { + switch flag := f.(type) { + case *StringFlag: + flag.required = true + case *BoolFlag: + flag.required = true + case *IntFlag: + flag.required = true + case *FloatFlag: + flag.required = true + case *StringSliceFlag: + flag.required = true + } + } +} + +// EnvVar sets an environment variable to check for default values +func EnvVar(envVar string) FlagOption { + return func(f any) { + switch flag := f.(type) { + case *StringFlag: + flag.envVar = envVar + case *BoolFlag: + flag.envVar = envVar + case *IntFlag: + flag.envVar = envVar + case *FloatFlag: + flag.envVar = envVar + case *StringSliceFlag: + flag.envVar = envVar + } + } +} + +// Validate sets a validation function for the flag +func Validate(fn ValidateFunc) FlagOption { + return func(f any) { + switch flag := f.(type) { + case *StringFlag: + flag.validate = fn + case *BoolFlag: + flag.validate = fn + case *IntFlag: + flag.validate = fn + case *FloatFlag: + flag.validate = fn + case *StringSliceFlag: + flag.validate = fn + } + } +} + +// Default sets a default value for the flag +func Default(value any) FlagOption { + return func(f any) { + var err error + switch flag := f.(type) { + case *StringFlag: + if v, ok := value.(string); ok { + flag.value = v + } else { + err = fmt.Errorf("default value for string flag '%s' must be string, got %T", flag.name, value) + } + case *BoolFlag: + if v, ok := value.(bool); ok { + flag.value = v + } else { + err = fmt.Errorf("default value for bool flag '%s' must be bool, got %T", flag.name, value) + } + case *IntFlag: + if v, ok := value.(int); ok { + flag.value = v + } else { + err = fmt.Errorf("default value for int flag '%s' must be int, got %T", flag.name, value) + } + case *FloatFlag: + if v, ok := value.(float64); ok { + flag.value = v + } else { + err = fmt.Errorf("default value for float flag '%s' must be float64, got %T", flag.name, value) + } + case *StringSliceFlag: + if v, ok := value.([]string); ok { + flag.value = v + } else { + err = fmt.Errorf("default value for string slice flag '%s' must be []string, got %T", flag.name, value) + } + } + + if err != nil { + Exit(fmt.Sprintf("Configuration error: %s", err.Error()), 1) + } + } +} + +// String creates a new string flag with optional configuration +func String(name, usage string, opts ...FlagOption) *StringFlag { + flag := &StringFlag{ + baseFlag: baseFlag{ + name: name, + usage: usage, + required: false, // Default to not required + }, + value: "", // Default to empty string + } + + // Apply options + for _, opt := range opts { + opt(flag) + } + + // Check environment variable for default value if specified + if flag.envVar != "" { + if envValue := os.Getenv(flag.envVar); envValue != "" { + // Apply validation to environment variable values + if flag.validate != nil { + if err := flag.validate(envValue); err != nil { + Exit(fmt.Sprintf("Environment variable error: validation failed for %s=%q: %v", + flag.envVar, envValue, err), 1) + } + } + flag.value = envValue + flag.hasEnvValue = true + // Don't mark as explicitly set - this is from environment + } + } + + return flag +} + +// Bool creates a new boolean flag with optional configuration +func Bool(name, usage string, opts ...FlagOption) *BoolFlag { + flag := &BoolFlag{ + baseFlag: baseFlag{ + name: name, + usage: usage, + required: false, // Default to not required + }, + value: false, // Default to false + } + + // Apply options + for _, opt := range opts { + opt(flag) + } + + // Check environment variable for default value if specified + if flag.envVar != "" { + if envValue := os.Getenv(flag.envVar); envValue != "" { + parsed, err := strconv.ParseBool(envValue) + if err != nil { + Exit(fmt.Sprintf("Environment variable error: invalid boolean value in %s=%q: %v", + flag.envVar, envValue, err), 1) + } + // Apply validation to environment variable values + if flag.validate != nil { + if err := flag.validate(envValue); err != nil { + Exit(fmt.Sprintf("Environment variable error: validation failed for %s=%q: %v", + flag.envVar, envValue, err), 1) + } + } + flag.value = parsed + flag.hasEnvValue = true + // Don't mark as explicitly set - this is from environment + } + } + return flag +} + +// Int creates a new integer flag with optional configuration +func Int(name, usage string, opts ...FlagOption) *IntFlag { + flag := &IntFlag{ + baseFlag: baseFlag{ + name: name, + usage: usage, + required: false, // Default to not required + }, + value: 0, // Default to zero + } + + // Apply options + for _, opt := range opts { + opt(flag) + } + + // Check environment variable for default value if specified + if flag.envVar != "" { + if envValue := os.Getenv(flag.envVar); envValue != "" { + parsed, err := strconv.Atoi(envValue) + if err != nil { + Exit(fmt.Sprintf("Environment variable error: invalid integer value in %s=%q: %v", + flag.envVar, envValue, err), 1) + } + // Apply validation to environment variable values + if flag.validate != nil { + if err := flag.validate(envValue); err != nil { + Exit(fmt.Sprintf("Environment variable error: validation failed for %s=%q: %v", + flag.envVar, envValue, err), 1) + } + } + flag.value = parsed + flag.hasEnvValue = true + // Don't mark as explicitly set - this is from environment + } + } + + return flag +} + +// Float creates a new float flag with optional configuration +func Float(name, usage string, opts ...FlagOption) *FloatFlag { + flag := &FloatFlag{ + baseFlag: baseFlag{ + name: name, + usage: usage, + required: false, // Default to not required + }, + value: 0.0, // Default to zero + } + + // Apply options + for _, opt := range opts { + opt(flag) + } + + // Check environment variable for default value if specified + if flag.envVar != "" { + if envValue := os.Getenv(flag.envVar); envValue != "" { + parsed, err := strconv.ParseFloat(envValue, 64) + if err != nil { + Exit(fmt.Sprintf("Environment variable error: invalid float value in %s=%q: %v", + flag.envVar, envValue, err), 1) + } + // Apply validation to environment variable values + if flag.validate != nil { + if err := flag.validate(envValue); err != nil { + Exit(fmt.Sprintf("Environment variable error: validation failed for %s=%q: %v", + flag.envVar, envValue, err), 1) + } + } + flag.value = parsed + flag.hasEnvValue = true + // Don't mark as explicitly set - this is from environment + } + } + + return flag +} + +// StringSlice creates a new string slice flag with optional configuration +func StringSlice(name, usage string, opts ...FlagOption) *StringSliceFlag { + flag := &StringSliceFlag{ + baseFlag: baseFlag{ + name: name, + usage: usage, + required: false, // Default to not required + }, + value: []string{}, // Default to empty slice + } + + // Apply options + for _, opt := range opts { + opt(flag) + } + + // Check environment variable for default value if specified + if flag.envVar != "" { + if envValue := os.Getenv(flag.envVar); envValue != "" { + // Apply validation to environment variable values + if flag.validate != nil { + if err := flag.validate(envValue); err != nil { + Exit(fmt.Sprintf("Environment variable error: validation failed for %s=%q: %v", + flag.envVar, envValue, err), 1) + } + } + flag.value = flag.parseCommaSeparated(envValue) + flag.hasEnvValue = true + // Don't mark as explicitly set - this is from environment + } + } + + return flag +} + +func newValidationError(flagName string, err error) error { + return fmt.Errorf("%w for flag %s: %w", ErrValidationFailed, flagName, err) +} diff --git a/go/pkg/cli/flag_test.go b/go/pkg/cli/flag_test.go new file mode 100644 index 0000000000..f5f3de259b --- /dev/null +++ b/go/pkg/cli/flag_test.go @@ -0,0 +1,861 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStringFlag_BasicParsing(t *testing.T) { + flag := String("test", "test flag") + err := flag.Parse("hello") + require.NoError(t, err) + require.Equal(t, "hello", flag.Value()) + require.True(t, flag.IsSet()) + require.True(t, flag.HasValue()) +} + +func TestStringFlag_WithValidation_Failure(t *testing.T) { + flag := String("url", "URL flag", Validate(validateURL)) + err := flag.Parse("invalid-url") + require.Error(t, err) + require.Contains(t, err.Error(), ErrValidationFailed.Error()) +} + +func TestStringFlag_WithEnvVar(t *testing.T) { + os.Setenv("TEST_STRING", "env-value") + defer os.Unsetenv("TEST_STRING") + + flag := String("test", "test flag", EnvVar("TEST_STRING")) + require.Equal(t, "env-value", flag.Value()) + require.False(t, flag.IsSet()) + require.True(t, flag.HasValue()) +} + +func TestStringFlag_ValidationOnEnvVar(t *testing.T) { + os.Setenv("INVALID_URL", "not-a-url") + defer os.Unsetenv("INVALID_URL") + + exitCode, exitCalled, cleanup := mockExit() + defer cleanup() + + // Capture the panic and validate the exit behavior + defer func() { + r := recover() + require.NotNil(t, r, "Expected panic from mocked Exit") + require.Equal(t, "exit called", r) + require.True(t, *exitCalled, "Exit should have been called") + require.Equal(t, 1, *exitCode, "Exit code should be 1") + }() + + String("url", "URL flag", EnvVar("INVALID_URL"), Validate(validateURL)) +} + +// BoolFlag Tests +func TestBoolFlag_EmptyValue(t *testing.T) { + flag := Bool("verbose", "verbose flag") + err := flag.Parse("") + require.NoError(t, err) + require.True(t, flag.Value()) + require.True(t, flag.IsSet()) +} + +func TestBoolFlag_WithEnvVar_InvalidValue(t *testing.T) { + os.Setenv("INVALID_BOOL", "maybe") + defer os.Unsetenv("INVALID_BOOL") + + exitCode, exitCalled, cleanup := mockExit() + defer cleanup() + + // Capture the panic and validate the exit behavior + defer func() { + r := recover() + require.NotNil(t, r, "Expected panic from mocked Exit") + require.Equal(t, "exit called", r) + require.True(t, *exitCalled, "Exit should have been called") + require.Equal(t, 1, *exitCode, "Exit code should be 1") + }() + + Bool("test", "test flag", EnvVar("INVALID_BOOL")) +} + +func TestBoolFlag_InvalidValue(t *testing.T) { + flag := Bool("verbose", "verbose flag") + err := flag.Parse("maybe") + require.Error(t, err) + require.Contains(t, err.Error(), ErrInvalidBoolValue.Error()) +} + +func TestBoolFlag_ValidationOnEmptyValue(t *testing.T) { + flag := Bool("verbose", "verbose flag", Validate(func(s string) error { + if s != "" && s != "true" && s != "false" { + return fmt.Errorf("only 'true' or 'false' allowed") + } + return nil + })) + + // Empty string should pass validation (gets converted to true) + err := flag.Parse("") + require.NoError(t, err) + require.True(t, flag.Value()) +} + +func TestIntFlag_ValidInteger(t *testing.T) { + flag := Int("count", "count flag") + err := flag.Parse("42") + require.NoError(t, err) + require.Equal(t, 42, flag.Value()) +} + +func TestIntFlag_InvalidInteger(t *testing.T) { + flag := Int("count", "count flag") + err := flag.Parse("not-a-number") + require.Error(t, err) + require.Contains(t, err.Error(), ErrInvalidIntValue.Error()) +} + +func TestIntFlag_WithValidation_Failure(t *testing.T) { + flag := Int("port", "port flag", Validate(validatePort)) + err := flag.Parse("70000") + require.Error(t, err) + require.Contains(t, err.Error(), ErrValidationFailed.Error()) +} + +func TestIntFlag_ZeroValueWithEnv(t *testing.T) { + os.Setenv("COUNT", "0") + defer os.Unsetenv("COUNT") + + flag := Int("count", "count flag", EnvVar("COUNT")) + require.Equal(t, 0, flag.Value()) + require.True(t, flag.HasValue()) +} + +func TestFloatFlag_ValidFloat(t *testing.T) { + flag := Float("rate", "rate flag") + err := flag.Parse("3.14") + require.NoError(t, err) + require.Equal(t, 3.14, flag.Value()) +} + +func TestFloatFlag_InvalidFloat(t *testing.T) { + flag := Float("rate", "rate flag") + err := flag.Parse("not-a-number") + require.Error(t, err) + require.Contains(t, err.Error(), ErrInvalidFloatValue.Error()) +} + +func TestFloatFlag_ZeroValueWithEnv(t *testing.T) { + os.Setenv("ZERO_RATE", "0.0") + defer os.Unsetenv("ZERO_RATE") + + flag := Float("rate", "rate flag", EnvVar("ZERO_RATE")) + require.Equal(t, 0.0, flag.Value()) + require.True(t, flag.HasValue()) +} + +func TestFloatFlag_ValidationOnEnvVar(t *testing.T) { + os.Setenv("INVALID_RANGE", "2.5") + defer os.Unsetenv("INVALID_RANGE") + + validateRange := func(s string) error { + if val, err := strconv.ParseFloat(s, 64); err != nil || val < 0 || val > 1 { + return fmt.Errorf("must be between 0 and 1") + } + return nil + } + + exitCode, exitCalled, cleanup := mockExit() + defer cleanup() + + // Capture the panic and validate the exit behavior + defer func() { + r := recover() + require.NotNil(t, r, "Expected panic from mocked Exit") + require.Equal(t, "exit called", r) + require.True(t, *exitCalled, "Exit should have been called") + require.Equal(t, 1, *exitCode, "Exit code should be 1") + }() + + Float("rate", "rate flag", EnvVar("INVALID_RANGE"), Validate(validateRange)) +} + +func TestStringSliceFlag_CommaSeparated(t *testing.T) { + flag := StringSlice("tags", "tags flag") + err := flag.Parse("foo,bar,baz") + require.NoError(t, err) + require.Equal(t, []string{"foo", "bar", "baz"}, flag.Value()) +} + +func TestStringSliceFlag_FilterEmptyValues(t *testing.T) { + flag := StringSlice("tags", "tags flag") + err := flag.Parse("foo,,bar,") + require.NoError(t, err) + require.Equal(t, []string{"foo", "bar"}, flag.Value()) +} + +func TestStringSliceFlag_WithEnvVar(t *testing.T) { + os.Setenv("TAGS", "web,api,service") + defer os.Unsetenv("TAGS") + + flag := StringSlice("tags", "tags flag", EnvVar("TAGS")) + require.Equal(t, []string{"web", "api", "service"}, flag.Value()) + require.False(t, flag.IsSet()) + require.True(t, flag.HasValue()) +} + +func TestStringSliceFlag_ValidationOnEnvVar(t *testing.T) { + os.Setenv("INVALID_TAGS", "valid,invalid;;tag") + defer os.Unsetenv("INVALID_TAGS") + + validateNoSemicolons := func(s string) error { + if strings.Contains(s, ";;") { + return fmt.Errorf("double semicolons not allowed") + } + return nil + } + + exitCode, exitCalled, cleanup := mockExit() + defer cleanup() + + // Capture the panic and validate the exit behavior + defer func() { + r := recover() + require.NotNil(t, r, "Expected panic from mocked Exit") + require.Equal(t, "exit called", r) + require.True(t, *exitCalled, "Exit should have been called") + require.Equal(t, 1, *exitCode, "Exit code should be 1") + }() + + StringSlice("tags", "tags flag", EnvVar("INVALID_TAGS"), Validate(validateNoSemicolons)) +} + +func TestCommandLineOverrideEnvironment(t *testing.T) { + os.Setenv("PORT", "3000") + defer os.Unsetenv("PORT") + + portFlag := Int("port", "Server port", EnvVar("PORT"), Default(8080)) + cmd := &Command{ + Name: "test", + Flags: []Flag{portFlag}, + } + + args := []string{"--port", "9000"} + err := cmd.parse(context.Background(), args) + require.NoError(t, err) + require.Equal(t, 9000, cmd.Int("port")) + require.True(t, portFlag.IsSet()) +} + +func TestRequiredFlagMissing(t *testing.T) { + requiredFlag := String("required", "required flag", Required()) + cmd := &Command{ + Name: "test", + Flags: []Flag{requiredFlag}, + } + + args := []string{} + err := cmd.parse(context.Background(), args) + require.Error(t, err) + require.Contains(t, err.Error(), "required flag missing: required") +} + +// Require Function Tests +func TestRequireString_Success(t *testing.T) { + flag := String("api-key", "API key") + cmd := &Command{ + Name: "test", + Flags: []Flag{flag}, + } + cmd.initFlagMap() + + err := flag.Parse("secret-key") + require.NoError(t, err) + + result := cmd.RequireString("api-key") + require.Equal(t, "secret-key", result) +} + +func TestRequireString_FlagNotFound(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{String("existing", "existing flag")}, + } + cmd.initFlagMap() + + require.Panics(t, func() { + cmd.RequireString("non-existent") + }) +} + +func TestRequireString_WrongType(t *testing.T) { + boolFlag := Bool("verbose", "verbose flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{boolFlag}, + } + cmd.initFlagMap() + + require.Panics(t, func() { + cmd.RequireString("verbose") + }) +} + +func TestRequireVsSafeAccessors(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{ + String("existing", "existing flag"), + Int("port", "port flag"), + }, + } + cmd.initFlagMap() + + // Safe accessors return zero values for missing flags + require.Equal(t, "", cmd.String("missing")) + require.Equal(t, false, cmd.Bool("missing")) + require.Equal(t, 0, cmd.Int("missing")) + require.Equal(t, 0.0, cmd.Float("missing")) + require.Equal(t, []string{}, cmd.StringSlice("missing")) + + // Require accessors panic for missing flags + require.Panics(t, func() { cmd.RequireString("missing") }) + require.Panics(t, func() { cmd.RequireBool("missing") }) +} + +func TestRequireBool_Success(t *testing.T) { + flag := Bool("verbose", "verbose flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{flag}, + } + cmd.initFlagMap() + + err := flag.Parse("true") + require.NoError(t, err) + + result := cmd.RequireBool("verbose") + require.True(t, result) +} + +func TestRequireBool_WrongType(t *testing.T) { + stringFlag := String("config", "config flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{stringFlag}, + } + cmd.initFlagMap() + + require.Panics(t, func() { + cmd.RequireBool("config") + }) +} + +func TestRequireInt_Success(t *testing.T) { + flag := Int("port", "port flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{flag}, + } + cmd.initFlagMap() + + err := flag.Parse("8080") + require.NoError(t, err) + + result := cmd.RequireInt("port") + require.Equal(t, 8080, result) +} + +func TestRequireInt_WrongType(t *testing.T) { + stringFlag := String("config", "config flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{stringFlag}, + } + cmd.initFlagMap() + + require.Panics(t, func() { + cmd.RequireInt("config") + }) +} + +func TestRequireFloat_Success(t *testing.T) { + flag := Float("rate", "rate flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{flag}, + } + cmd.initFlagMap() + + err := flag.Parse("3.14") + require.NoError(t, err) + + result := cmd.RequireFloat("rate") + require.Equal(t, 3.14, result) +} + +func TestRequireFloat_WrongType(t *testing.T) { + intFlag := Int("port", "port flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{intFlag}, + } + cmd.initFlagMap() + + require.Panics(t, func() { + cmd.RequireFloat("port") + }) +} + +func TestRequireStringSlice_Success(t *testing.T) { + flag := StringSlice("tags", "tags flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{flag}, + } + cmd.initFlagMap() + + err := flag.Parse("foo,bar,baz") + require.NoError(t, err) + + result := cmd.RequireStringSlice("tags") + require.Equal(t, []string{"foo", "bar", "baz"}, result) +} + +func TestRequireStringSlice_WrongType(t *testing.T) { + stringFlag := String("config", "config flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{stringFlag}, + } + cmd.initFlagMap() + + require.Panics(t, func() { + cmd.RequireStringSlice("config") + }) +} + +func TestBoolFlag_WithEnvVar(t *testing.T) { + os.Setenv("VERBOSE", "true") + defer os.Unsetenv("VERBOSE") + + flag := Bool("verbose", "verbose flag", EnvVar("VERBOSE")) + require.True(t, flag.Value()) + require.False(t, flag.IsSet()) + require.True(t, flag.HasValue()) +} + +func TestBoolFlag_WithEnvVar_False(t *testing.T) { + os.Setenv("QUIET", "false") + defer os.Unsetenv("QUIET") + + flag := Bool("quiet", "quiet flag", EnvVar("QUIET")) + require.False(t, flag.Value()) + require.False(t, flag.IsSet()) + require.True(t, flag.HasValue()) +} + +func validateURL(s string) error { + if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") && !strings.HasPrefix(s, "postgres://") { + return fmt.Errorf("invalid URL format") + } + return nil +} + +func validatePort(s string) error { + port, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("invalid port number") + } + if port < 1 || port > 65535 { + return fmt.Errorf("port must be between 1-65535") + } + return nil +} + +func TestCommand_Run_NoArguments(t *testing.T) { + cmd := &Command{Name: "test"} + err := cmd.Run(context.Background(), []string{}) + + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoArguments)) +} + +func TestCommand_RequireString_FlagNotFound(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{String("existing", "existing flag")}, + } + cmd.initFlagMap() + + // Capture the panic and verify the error structure + defer func() { + r := recover() + require.NotNil(t, r, "Expected panic for missing flag") + + err, ok := r.(error) + require.True(t, ok, "Panic value should be an error") + require.True(t, errors.Is(err, ErrFlagNotFound)) + require.Contains(t, err.Error(), "non-existent") + require.Contains(t, err.Error(), "command \"test\"") + require.Contains(t, err.Error(), "available flags: existing") + }() + + cmd.RequireString("non-existent") +} + +func TestCommand_RequireString_WrongType(t *testing.T) { + boolFlag := Bool("verbose", "verbose flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{boolFlag}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r, "Expected panic for wrong type") + + err, ok := r.(error) + require.True(t, ok, "Panic value should be an error") + require.True(t, errors.Is(err, ErrWrongFlagType)) + require.Contains(t, err.Error(), "verbose") + require.Contains(t, err.Error(), "BoolFlag") + require.Contains(t, err.Error(), "expected StringFlag") + require.Contains(t, err.Error(), "available stringflag flags: none") + }() + + cmd.RequireString("verbose") +} + +func TestCommand_RequireBool_FlagNotFound(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrFlagNotFound)) + require.Contains(t, err.Error(), "missing") + require.Contains(t, err.Error(), "available flags: none") + }() + + cmd.RequireBool("missing") +} + +func TestCommand_RequireBool_WrongType(t *testing.T) { + stringFlag := String("config", "config flag") + intFlag := Int("port", "port flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{stringFlag, intFlag}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrWrongFlagType)) + require.Contains(t, err.Error(), "config") + require.Contains(t, err.Error(), "StringFlag") + require.Contains(t, err.Error(), "expected BoolFlag") + require.Contains(t, err.Error(), "available boolflag flags: none") + }() + + cmd.RequireBool("config") +} + +func TestCommand_RequireInt_FlagNotFound(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{String("name", "name flag")}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrFlagNotFound)) + }() + + cmd.RequireInt("count") +} + +func TestCommand_RequireInt_WrongType(t *testing.T) { + floatFlag := Float("rate", "rate flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{floatFlag}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrWrongFlagType)) + require.Contains(t, err.Error(), "rate") + require.Contains(t, err.Error(), "FloatFlag") + require.Contains(t, err.Error(), "expected IntFlag") + }() + + cmd.RequireInt("rate") +} + +func TestCommand_RequireFloat_FlagNotFound(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrFlagNotFound)) + }() + + cmd.RequireFloat("missing") +} + +func TestCommand_RequireFloat_WrongType(t *testing.T) { + intFlag := Int("port", "port flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{intFlag}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrWrongFlagType)) + require.Contains(t, err.Error(), "port") + require.Contains(t, err.Error(), "IntFlag") + require.Contains(t, err.Error(), "expected FloatFlag") + }() + + cmd.RequireFloat("port") +} + +func TestCommand_RequireStringSlice_FlagNotFound(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrFlagNotFound)) + }() + + cmd.RequireStringSlice("tags") +} + +func TestCommand_RequireStringSlice_WrongType(t *testing.T) { + stringFlag := String("config", "config flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{stringFlag}, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + require.True(t, errors.Is(err, ErrWrongFlagType)) + require.Contains(t, err.Error(), "config") + require.Contains(t, err.Error(), "StringFlag") + require.Contains(t, err.Error(), "expected StringSliceFlag") + }() + + cmd.RequireStringSlice("config") +} + +// Test error helpers work correctly +func TestCommand_ErrorHelpers_FlagsByType(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{ + String("name", "name flag"), + String("config", "config flag"), + Bool("verbose", "verbose flag"), + Int("port", "port flag"), + Int("timeout", "timeout flag"), + Float("rate", "rate flag"), + StringSlice("tags", "tags flag"), + }, + } + + // Test getFlagsByType returns correct flags + require.Equal(t, "name, config", cmd.getFlagsByType("StringFlag")) + require.Equal(t, "verbose", cmd.getFlagsByType("BoolFlag")) + require.Equal(t, "port, timeout", cmd.getFlagsByType("IntFlag")) + require.Equal(t, "rate", cmd.getFlagsByType("FloatFlag")) + require.Equal(t, "tags", cmd.getFlagsByType("StringSliceFlag")) + require.Equal(t, "none", cmd.getFlagsByType("NonexistentFlag")) +} + +func TestCommand_ErrorHelpers_AvailableFlags(t *testing.T) { + // Test with no flags + cmd := &Command{Name: "test", Flags: []Flag{}} + require.Equal(t, "none", cmd.getAvailableFlags()) + + // Test with multiple flags + cmd.Flags = []Flag{ + String("name", "name flag"), + Bool("verbose", "verbose flag"), + Int("port", "port flag"), + } + expected := "name, verbose, port" + require.Equal(t, expected, cmd.getAvailableFlags()) +} + +func TestCommand_ErrorHelpers_GetFlagType(t *testing.T) { + cmd := &Command{Name: "test"} + + require.Equal(t, "StringFlag", cmd.getFlagType(String("test", "test"))) + require.Equal(t, "BoolFlag", cmd.getFlagType(Bool("test", "test"))) + require.Equal(t, "IntFlag", cmd.getFlagType(Int("test", "test"))) + require.Equal(t, "FloatFlag", cmd.getFlagType(Float("test", "test"))) + require.Equal(t, "StringSliceFlag", cmd.getFlagType(StringSlice("test", "test"))) +} + +// Test that safe accessors still work as expected (no panics) +func TestCommand_SafeAccessors_MissingFlags(t *testing.T) { + cmd := &Command{ + Name: "test", + Flags: []Flag{}, + } + cmd.initFlagMap() + + // All safe accessors should return zero values, no panics + require.Equal(t, "", cmd.String("missing")) + require.Equal(t, false, cmd.Bool("missing")) + require.Equal(t, 0, cmd.Int("missing")) + require.Equal(t, 0.0, cmd.Float("missing")) + require.Equal(t, []string{}, cmd.StringSlice("missing")) +} + +func TestCommand_SafeAccessors_WrongType(t *testing.T) { + boolFlag := Bool("verbose", "verbose flag") + cmd := &Command{ + Name: "test", + Flags: []Flag{boolFlag}, + } + cmd.initFlagMap() + + // Safe accessors should return zero values for wrong types, no panics + require.Equal(t, "", cmd.String("verbose")) // Bool flag accessed as String + require.Equal(t, 0, cmd.Int("verbose")) // Bool flag accessed as Int + require.Equal(t, 0.0, cmd.Float("verbose")) // Bool flag accessed as Float + require.Equal(t, []string{}, cmd.StringSlice("verbose")) // Bool flag accessed as StringSlice +} + +// Test successful Require* calls +func TestCommand_RequireSuccess(t *testing.T) { + stringFlag := String("name", "name flag") + boolFlag := Bool("verbose", "verbose flag") + intFlag := Int("port", "port flag") + floatFlag := Float("rate", "rate flag") + sliceFlag := StringSlice("tags", "tags flag") + + cmd := &Command{ + Name: "test", + Flags: []Flag{stringFlag, boolFlag, intFlag, floatFlag, sliceFlag}, + } + cmd.initFlagMap() + + // Set up flag values + require.NoError(t, stringFlag.Parse("test-name")) + require.NoError(t, boolFlag.Parse("true")) + require.NoError(t, intFlag.Parse("8080")) + require.NoError(t, floatFlag.Parse("3.14")) + require.NoError(t, sliceFlag.Parse("foo,bar")) + + // All Require* calls should succeed + require.Equal(t, "test-name", cmd.RequireString("name")) + require.Equal(t, true, cmd.RequireBool("verbose")) + require.Equal(t, 8080, cmd.RequireInt("port")) + require.Equal(t, 3.14, cmd.RequireFloat("rate")) + require.Equal(t, []string{"foo", "bar"}, cmd.RequireStringSlice("tags")) +} + +// Test that error messages are informative +func TestCommand_ErrorMessage_Quality(t *testing.T) { + cmd := &Command{ + Name: "deploy", + Flags: []Flag{ + String("config", "config file"), + Bool("verbose", "verbose output"), + Int("timeout", "timeout seconds"), + }, + } + cmd.initFlagMap() + + defer func() { + r := recover() + require.NotNil(t, r) + + err, ok := r.(error) + require.True(t, ok) + + errMsg := err.Error() + // Should contain all the important context + require.Contains(t, errMsg, "flag not found") + require.Contains(t, errMsg, "missing-flag") + require.Contains(t, errMsg, "deploy") + require.Contains(t, errMsg, "available flags: config, verbose, timeout") + }() + + cmd.RequireString("missing-flag") +} + +func mockExit() (exitCode *int, exitCalled *bool, cleanup func()) { + var code int + var called bool + + originalExit := ExitFunc + ExitFunc = func(c int) { + code = c + called = true + panic("exit called") // Prevent actual exit + } + + return &code, &called, func() { + ExitFunc = originalExit + } +} diff --git a/go/pkg/cli/help.go b/go/pkg/cli/help.go new file mode 100644 index 0000000000..7ccfd47bb5 --- /dev/null +++ b/go/pkg/cli/help.go @@ -0,0 +1,239 @@ +package cli + +import ( + "fmt" + "strings" +) + +// showHelp displays comprehensive help information for the command +// This includes name, description, usage, subcommands, and flags +func (c *Command) showHelp() { + c.showHeader() + c.showUsageLine() + c.showVersion() + c.showCommands() + c.showFlags() + c.showGlobalOptions() +} + +// showHeader displays the command name and description +func (c *Command) showHeader() { + fmt.Printf("NAME:\n %s", c.Name) + if c.Usage != "" { + fmt.Printf(" - %s", c.Usage) + } + fmt.Printf("\n\n") + + if c.Description != "" { + fmt.Printf("DESCRIPTION:\n %s\n\n", c.Description) + } +} + +// showUsageLine displays the command usage syntax +func (c *Command) showUsageLine() { + fmt.Printf("USAGE:\n ") + + path := c.buildCommandPath() + fmt.Printf("%s", strings.Join(path, " ")) + + // Add syntax indicators based on what's available + if len(c.Flags) > 0 { + fmt.Printf(" [options]") + } + if len(c.Commands) > 0 { + fmt.Printf(" [command]") + } + + // Add arguments indicator if this command has an action but no subcommands + if c.Action != nil && len(c.Commands) == 0 { + fmt.Printf(" [arguments...]") + } + + fmt.Printf("\n\n") +} + +// buildCommandPath constructs the full command path from root to current command +func (c *Command) buildCommandPath() []string { + var path []string + + // Walk up the parent chain to build full path + cmd := c + for cmd != nil { + path = append([]string{cmd.Name}, path...) + cmd = cmd.parent + } + + return path +} + +// showVersion displays version information for root commands +func (c *Command) showVersion() { + if c.Version != "" { + fmt.Printf("VERSION:\n %s\n\n", c.Version) + } +} + +// showCommands displays all available subcommands in a formatted table +func (c *Command) showCommands() { + if len(c.Commands) == 0 { + return + } + + fmt.Printf("COMMANDS:\n") + + // Calculate alignment for clean formatting + maxLen := c.calculateMaxCommandLength() + + // Display each command with aliases + for _, cmd := range c.Commands { + c.showSingleCommand(cmd, maxLen) + } + + // Add built-in help command + fmt.Printf(" %-*s %s\n", maxLen+10, "help, h", "Shows help for commands") + fmt.Printf("\n") +} + +// calculateMaxCommandLength finds the longest command name for alignment +func (c *Command) calculateMaxCommandLength() int { + maxLen := 0 + for _, cmd := range c.Commands { + nameWithAliases := cmd.Name + if len(cmd.Aliases) > 0 { + nameWithAliases += fmt.Sprintf(", %s", strings.Join(cmd.Aliases, ", ")) + } + if len(nameWithAliases) > maxLen { + maxLen = len(nameWithAliases) + } + } + // Also consider the help command length + helpLen := len("help, h") + if helpLen > maxLen { + maxLen = helpLen + } + return maxLen +} + +// showSingleCommand displays one command with proper formatting +func (c *Command) showSingleCommand(cmd *Command, maxLen int) { + name := cmd.Name + if len(cmd.Aliases) > 0 { + name += fmt.Sprintf(", %s", strings.Join(cmd.Aliases, ", ")) + } + fmt.Printf(" %-*s %s\n", maxLen+10, name, cmd.Usage) +} + +// showFlags displays command-specific flags if any exist +func (c *Command) showFlags() { + if len(c.Flags) == 0 { + return + } + + fmt.Printf("OPTIONS:\n") + for _, flag := range c.Flags { + c.showFlag(flag) + } + fmt.Printf("\n") +} + +// showGlobalOptions displays help and version flags +func (c *Command) showGlobalOptions() { + fmt.Printf("GLOBAL OPTIONS:\n") + fmt.Printf(" %-25s %s\n", "--help, -h", "show help") + + // Add version flag only for root command (commands with Version set) + if c.Version != "" { + fmt.Printf(" %-25s %s\n", "--version, -v", "print the version") + } + fmt.Printf("\n") +} + +// showFlag displays a single flag with proper formatting +func (c *Command) showFlag(flag Flag) { + flagName := c.buildFlagName(flag) + usage := c.buildFlagUsage(flag) + + fmt.Printf(" %-25s %s\n", flagName, usage) +} + +// buildFlagName constructs the flag name string with proper formatting +func (c *Command) buildFlagName(flag Flag) string { + name := flag.Name() + + // For single character flags, show both short and long form + if len(name) == 1 { + return fmt.Sprintf("-%s", name) + } + + // For multi-character flags, just show long form + return fmt.Sprintf("--%s", name) +} + +// buildFlagUsage constructs the complete usage string for a flag +func (c *Command) buildFlagUsage(flag Flag) string { + usage := flag.Usage() + + // Add required indicator + if flag.Required() { + usage += " (required)" + } + + // Add environment variable info if available + if envVar := c.getEnvVar(flag); envVar != "" { + usage += fmt.Sprintf(" [$%s]", envVar) + } + + // Add default value if present and not required + if !flag.Required() { + if defaultVal := c.getDefaultValue(flag); defaultVal != "" { + usage += fmt.Sprintf(" (default: %s)", defaultVal) + } + } + + return usage +} + +// getEnvVar extracts environment variable name from flag if it supports it +func (c *Command) getEnvVar(flag Flag) string { + switch f := flag.(type) { + case *StringFlag: + return f.EnvVar() + case *BoolFlag: + return f.EnvVar() + case *IntFlag: + return f.EnvVar() + case *FloatFlag: + return f.EnvVar() + case *StringSliceFlag: + return f.EnvVar() + default: + return "" + } +} + +// getDefaultValue extracts and formats the default value from a flag +func (c *Command) getDefaultValue(flag Flag) string { + switch f := flag.(type) { + case *StringFlag: + if val := f.Value(); val != "" { + return fmt.Sprintf(`"%s"`, val) + } + case *BoolFlag: + if f.HasValue() { + return fmt.Sprintf("%t", f.Value()) + } + case *IntFlag: + if f.HasValue() { + return fmt.Sprintf("%d", f.Value()) + } + case *FloatFlag: + if f.HasValue() { + return fmt.Sprintf("%.2f", f.Value()) + } + case *StringSliceFlag: + if val := f.Value(); len(val) > 0 { + return fmt.Sprintf(`["%s"]`, strings.Join(val, `", "`)) + } + } + return "" +} diff --git a/go/pkg/cli/output.go b/go/pkg/cli/output.go deleted file mode 100644 index 2741c24337..0000000000 --- a/go/pkg/cli/output.go +++ /dev/null @@ -1,144 +0,0 @@ -package cli - -import ( - "bufio" - "fmt" - "io" - "strings" - "sync" - "time" -) - -// StreamingOutput provides a fixed-height scrolling view of command output -type StreamingOutput struct { - title string - lines []string - maxLines int - mu sync.Mutex - done chan bool - lastUpdate time.Time -} - -// NewStreamingOutput creates a new streaming output display -func NewStreamingOutput(title string, maxLines int) *StreamingOutput { - if maxLines <= 0 { - maxLines = 5 - } - return &StreamingOutput{ - title: title, - lines: make([]string, 0, maxLines), - maxLines: maxLines, - done: make(chan bool, 1), - mu: sync.Mutex{}, - lastUpdate: time.Now(), - } -} - -// Start begins the display update loop -func (s *StreamingOutput) Start() { - // Clear the lines we'll be using - for i := 0; i < s.maxLines+2; i++ { - fmt.Println() - } - - go func() { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - spinners := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - spinIdx := 0 - - for { - select { - case <-s.done: - s.render("✓", true) - return - case <-ticker.C: - s.render(spinners[spinIdx%len(spinners)], false) - spinIdx++ - } - } - }() -} - -// Stream reads from the reader and displays lines in the viewport -func (s *StreamingOutput) Stream(r io.Reader) { - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := scanner.Text() - // Clean up the line - line = strings.TrimSpace(line) - if line != "" { - s.AddLine(line) - } - } -} - -// AddLine adds a line to the display -func (s *StreamingOutput) AddLine(line string) { - s.mu.Lock() - defer s.mu.Unlock() - - // Truncate long lines - if len(line) > 80 { - line = line[:77] + "..." - } - - // Add to buffer - s.lines = append(s.lines, line) - - // Keep only the last N lines - if len(s.lines) > s.maxLines { - s.lines = s.lines[len(s.lines)-s.maxLines:] - } - - s.lastUpdate = time.Now() -} - -// Stop stops the display and shows final state -func (s *StreamingOutput) Stop() { - close(s.done) - time.Sleep(150 * time.Millisecond) // Let final render complete -} - -// render updates the display -func (s *StreamingOutput) render(spinner string, final bool) { - s.mu.Lock() - defer s.mu.Unlock() - - // Move cursor up to start of our display area - fmt.Printf("\033[%dA", s.maxLines+2) - - // Title line - if final { - fmt.Printf("%s %s\033[K\n", spinner, s.title) - } else { - fmt.Printf("%s %s\033[K\n", spinner, s.title) - } - - // Separator - fmt.Printf(" ├─────────────────────────────────────────────────────────────────────────\033[K\n") - - // Content lines - for i := 0; i < s.maxLines; i++ { - if i < len(s.lines) { - fmt.Printf(" │ %s\033[K\n", s.lines[i]) - } else { - fmt.Printf(" │\033[K\n") - } - } - - // Bottom border - if final { - fmt.Printf(" └─────────────────────────────────────────────────────────────────────────\033[K\n") - } -} - -// RunCommandWithOutput runs a command and displays its output in a streaming viewport -func RunCommandWithOutput(title string, command io.Reader) error { - output := NewStreamingOutput(title, 5) - output.Start() - output.Stream(command) - output.Stop() - return nil -} diff --git a/go/cmd/cli/cli/parser.go b/go/pkg/cli/parser.go similarity index 93% rename from go/cmd/cli/cli/parser.go rename to go/pkg/cli/parser.go index 845fb45a2a..21d11af0d1 100644 --- a/go/cmd/cli/cli/parser.go +++ b/go/pkg/cli/parser.go @@ -11,12 +11,7 @@ import ( // This handles flag parsing, subcommand routing, and help display func (c *Command) parse(ctx context.Context, args []string) error { // Initialize flagMap if not already done - if c.flagMap == nil { - c.flagMap = make(map[string]Flag) - for _, flag := range c.Flags { - c.flagMap[flag.Name()] = flag - } - } + c.initFlagMap() var commandArgs []string for i := 0; i < len(args); i++ { @@ -157,9 +152,20 @@ func (c *Command) parseFlag(args []string, i *int) error { } // validateRequiredFlags checks that all required flags have been set +func (c *Command) initFlagMap() { + if c.flagMap != nil { + return + } + c.flagMap = make(map[string]Flag) + for _, flag := range c.Flags { + c.flagMap[flag.Name()] = flag + } +} + +// Replace validateRequiredFlags method with: func (c *Command) validateRequiredFlags() error { for _, flag := range c.Flags { - if flag.Required() && !flag.IsSet() { + if flag.Required() && !flag.HasValue() { return fmt.Errorf("required flag missing: %s", flag.Name()) } }