diff --git a/go/cmd/cli/cli/command.go b/go/cmd/cli/cli/command.go new file mode 100644 index 00000000000..cd9fdd4811d --- /dev/null +++ b/go/cmd/cli/cli/command.go @@ -0,0 +1,107 @@ +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 new file mode 100644 index 00000000000..da1c6d0ef4a --- /dev/null +++ b/go/cmd/cli/cli/flag.go @@ -0,0 +1,268 @@ +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 new file mode 100644 index 00000000000..34483e69d05 --- /dev/null +++ b/go/cmd/cli/cli/help.go @@ -0,0 +1,155 @@ +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/cli/parser.go b/go/cmd/cli/cli/parser.go new file mode 100644 index 00000000000..845fb45a2ad --- /dev/null +++ b/go/cmd/cli/cli/parser.go @@ -0,0 +1,167 @@ +package cli + +import ( + "context" + "fmt" + "slices" + "strings" +) + +// parse processes command line arguments and executes the appropriate action +// 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 + } + } + + var commandArgs []string + for i := 0; i < len(args); i++ { + arg := args[i] + + // Handle help flags first - these short-circuit normal processing + if arg == "-h" || arg == "--help" || arg == "help" { + c.showHelp() + return nil + } + + // Handle version flags - print version and exit + if (arg == "-v" || arg == "--version") && c.Version != "" { + fmt.Println(c.Version) + return nil + } + + // Handle "help " pattern - show help for specific subcommand + if arg == "help" && i+1 < len(args) { + cmdName := args[i+1] + for _, subcmd := range c.Commands { + if subcmd.Name == cmdName { + subcmd.parent = c + subcmd.showHelp() + return nil + } + // Check aliases + if slices.Contains(subcmd.Aliases, cmdName) { + subcmd.parent = c + subcmd.showHelp() + return nil + } + } + return fmt.Errorf("unknown command: %s", cmdName) + } + + // Check for subcommands (non-flag arguments) + if !strings.HasPrefix(arg, "-") { + // Look for matching subcommand + for _, subcmd := range c.Commands { + if subcmd.Name == arg { + subcmd.parent = c + return subcmd.parse(ctx, args[i+1:]) + } + // Check aliases + if slices.Contains(subcmd.Aliases, arg) { + subcmd.parent = c + return subcmd.parse(ctx, args[i+1:]) + } + } + // Not a subcommand, treat as regular argument + commandArgs = append(commandArgs, arg) + continue + } + + // Parse flags (arguments starting with -) + if err := c.parseFlag(args, &i); err != nil { + return err + } + } + + // Store parsed arguments + c.args = commandArgs + + // Validate all required flags are present + if err := c.validateRequiredFlags(); err != nil { + fmt.Printf("Error: %v\n\n", err) + c.showHelp() + return err + } + + // Execute action if present + if c.Action != nil { + return c.Action(ctx, c) + } + + // No action defined - show help if we have subcommands + if len(c.Commands) > 0 { + c.showHelp() + } + + return nil +} + +// parseFlag handles parsing of a single flag and its value +// It modifies the index i to skip consumed arguments +func (c *Command) parseFlag(args []string, i *int) error { + arg := args[*i] + + // Remove leading dashes properly + var flagName string + if strings.HasPrefix(arg, "--") { + flagName = arg[2:] // Remove exactly "--" + } else if strings.HasPrefix(arg, "-") { + flagName = arg[1:] // Remove exactly "-" + } else { + return fmt.Errorf("invalid flag format: %s", arg) + } + + // Handle --flag=value format + var flagValue string + var hasValue bool + if eqIndex := strings.Index(flagName, "="); eqIndex != -1 { + flagValue = flagName[eqIndex+1:] + flagName = flagName[:eqIndex] + hasValue = true + } + + // Look up the flag + flag, exists := c.flagMap[flagName] + if !exists { + return fmt.Errorf("unknown flag: %s", flagName) + } + + // Handle boolean flags specially - they don't require values + if bf, ok := flag.(*BoolFlag); ok { + if hasValue { + // --bool-flag=true/false format + return bf.Parse(flagValue) + } else { + // --bool-flag format (implies true) + return bf.Parse("") + } + } + + // For non-boolean flags, we need a value + if !hasValue { + // Value should be in next argument + if *i+1 >= len(args) { + return fmt.Errorf("flag %s requires a value", flagName) + } + *i++ // Move to next argument + flagValue = args[*i] + } + + // Parse the flag value + return flag.Parse(flagValue) +} + +// validateRequiredFlags checks that all required flags have been set +func (c *Command) validateRequiredFlags() error { + for _, flag := range c.Flags { + if flag.Required() && !flag.IsSet() { + return fmt.Errorf("required flag missing: %s", flag.Name()) + } + } + return nil +} diff --git a/go/cmd/cli/commands/deploy/build_docker.go b/go/cmd/cli/commands/deploy/build_docker.go new file mode 100644 index 00000000000..ba664c57072 --- /dev/null +++ b/go/cmd/cli/commands/deploy/build_docker.go @@ -0,0 +1,84 @@ +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/control_plane.go b/go/cmd/cli/commands/deploy/control_plane.go new file mode 100644 index 00000000000..1418c6979f7 --- /dev/null +++ b/go/cmd/cli/commands/deploy/control_plane.go @@ -0,0 +1,237 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "net/http" + "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/otel/logging" +) + +// VersionStatusEvent represents a status change event +type VersionStatusEvent struct { + VersionID string + PreviousStatus ctrlv1.VersionStatus + CurrentStatus ctrlv1.VersionStatus + Version *ctrlv1.Version +} + +// VersionStepEvent represents a step update event +type VersionStepEvent struct { + VersionID string + Step *ctrlv1.VersionStep + Status ctrlv1.VersionStatus +} + +// ControlPlaneClient handles API operations with the control plane +type ControlPlaneClient struct { + client ctrlv1connect.VersionServiceClient + opts *DeployOptions +} + +// NewControlPlaneClient creates a new control plane client +func NewControlPlaneClient(opts *DeployOptions) *ControlPlaneClient { + httpClient := &http.Client{} + client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) + + return &ControlPlaneClient{ + client: client, + opts: opts, + } +} + +// CreateVersion creates a new version in the control plane +func (c *ControlPlaneClient) CreateVersion(ctx context.Context, dockerImage string) (string, error) { + createReq := connect.NewRequest(&ctrlv1.CreateVersionRequest{ + WorkspaceId: c.opts.WorkspaceID, + ProjectId: c.opts.ProjectID, + Branch: c.opts.Branch, + SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, + GitCommitSha: c.opts.Commit, + EnvironmentId: "env_prod", // TODO: Make this configurable + DockerImageTag: dockerImage, + }) + + createReq.Header().Set("Authorization", "Bearer "+c.opts.AuthToken) + + createResp, err := c.client.CreateVersion(ctx, createReq) + if err != nil { + return "", c.handleCreateVersionError(err) + } + + versionId := createResp.Msg.GetVersionId() + if versionId == "" { + return "", fmt.Errorf("empty version ID returned from control plane") + } + + return versionId, nil +} + +// GetVersion retrieves version information from the control plane +func (c *ControlPlaneClient) GetVersion(ctx context.Context, versionId string) (*ctrlv1.Version, error) { + getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ + VersionId: versionId, + }) + getReq.Header().Set("Authorization", "Bearer "+c.opts.AuthToken) + + getResp, err := c.client.GetVersion(ctx, getReq) + if err != nil { + return nil, err + } + + return getResp.Msg.GetVersion(), nil +} + +// PollVersionStatus polls for version changes and calls event handlers +func (c *ControlPlaneClient) PollVersionStatus( + ctx context.Context, + logger logging.Logger, + versionId string, + onStatusChange func(VersionStatusEvent) error, + onStepUpdate func(VersionStepEvent) error, +) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.NewTimer(300 * time.Second) + defer timeout.Stop() + + // Track processed steps by creation time to avoid duplicates + processedSteps := make(map[int64]bool) + lastStatus := ctrlv1.VersionStatus_VERSION_STATUS_UNSPECIFIED + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout.C: + return fmt.Errorf("deployment timeout after 5 minutes") + case <-ticker.C: + version, err := c.GetVersion(ctx, versionId) + if err != nil { + logger.Debug("Failed to get version status", "error", err, "version_id", versionId) + continue + } + + currentStatus := version.GetStatus() + + // Handle version status changes + if currentStatus != lastStatus { + event := VersionStatusEvent{ + VersionID: versionId, + PreviousStatus: lastStatus, + CurrentStatus: currentStatus, + Version: version, + } + + if err := onStatusChange(event); err != nil { + return err + } + lastStatus = currentStatus + } + + // Process new step updates + if err := c.processNewSteps(versionId, version.GetSteps(), processedSteps, currentStatus, onStepUpdate); err != nil { + return err + } + + // Check for completion + if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { + return nil + } + } + } +} + +// processNewSteps processes new deployment steps and calls the event handler +func (c *ControlPlaneClient) processNewSteps( + versionId string, + steps []*ctrlv1.VersionStep, + processedSteps map[int64]bool, + currentStatus ctrlv1.VersionStatus, + onStepUpdate func(VersionStepEvent) error, +) error { + for _, step := range steps { + // Creation timestamp as unique identifier + stepTimestamp := step.GetCreatedAt() + + if processedSteps[stepTimestamp] { + continue // Already processed this step + } + + // Handle step errors first + if step.GetErrorMessage() != "" { + return fmt.Errorf("deployment failed: %s", step.GetErrorMessage()) + } + + // Call step update handler + if step.GetMessage() != "" { + event := VersionStepEvent{ + VersionID: versionId, + Step: step, + Status: currentStatus, + } + + if err := onStepUpdate(event); err != nil { + return err + } + } + + // Mark this step as processed + processedSteps[stepTimestamp] = true + } + return nil +} + +// getFailureMessage extracts failure message from version +func (c *ControlPlaneClient) getFailureMessage(version *ctrlv1.Version) string { + if version.GetErrorMessage() != "" { + return version.GetErrorMessage() + } + + // Check for error in steps + for _, step := range version.GetSteps() { + if step.GetErrorMessage() != "" { + return step.GetErrorMessage() + } + } + + return "Unknown deployment error" +} + +// handleCreateVersionError provides specific error handling for version creation +func (c *ControlPlaneClient) handleCreateVersionError(err error) error { + // 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", c.opts.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", c.opts.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."), + ) +} diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go new file mode 100644 index 00000000000..a286f157fec --- /dev/null +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -0,0 +1,302 @@ +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/deploy/ui.go b/go/cmd/cli/commands/deploy/ui.go new file mode 100644 index 00000000000..12601dd9c93 --- /dev/null +++ b/go/cmd/cli/commands/deploy/ui.go @@ -0,0 +1,159 @@ +package deploy + +import ( + "fmt" + "sync" + "time" +) + +// Color constants +const ( + ColorReset = "\033[0m" + ColorRed = "\033[31m" + ColorGreen = "\033[32m" + ColorYellow = "\033[33m" +) + +var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type UI struct { + mu sync.Mutex + spinning bool + currentStep string + stepSpinning bool +} + +func NewUI() *UI { + return &UI{} +} + +func (ui *UI) Print(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf("%s•%s %s\n", ColorYellow, ColorReset, message) +} + +func (ui *UI) PrintSuccess(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf("%s✓%s %s\n", ColorGreen, ColorReset, message) +} + +func (ui *UI) PrintError(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf("%s✗%s %s\n", ColorRed, ColorReset, message) +} + +func (ui *UI) PrintErrorDetails(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" %s->%s %s\n", ColorRed, ColorReset, message) +} + +func (ui *UI) PrintStepSuccess(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, message) +} + +func (ui *UI) PrintStepError(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" %s✗%s %s\n", ColorRed, ColorReset, message) +} + +func (ui *UI) spinnerLoop(prefix string, messageGetter func() string, isActive func() bool) { + go func() { + frame := 0 + for { + ui.mu.Lock() + if !isActive() { + ui.mu.Unlock() + return + } + message := messageGetter() + fmt.Printf("\r%s%s %s", prefix, spinnerChars[frame%len(spinnerChars)], message) + ui.mu.Unlock() + frame++ + time.Sleep(100 * time.Millisecond) + } + }() +} + +func (ui *UI) StartSpinner(message string) { + ui.mu.Lock() + if ui.spinning { + ui.mu.Unlock() + return + } + ui.spinning = true + spinnerMessage := message + ui.mu.Unlock() + + ui.spinnerLoop("", func() string { return spinnerMessage }, func() bool { return ui.spinning }) +} + +func (ui *UI) StopSpinner(finalMessage string, success bool) { + ui.mu.Lock() + defer ui.mu.Unlock() + if !ui.spinning { + return + } + ui.spinning = false + fmt.Print("\r\033[K") + if success { + fmt.Printf("%s✓%s %s\n", ColorGreen, ColorReset, finalMessage) + } else { + fmt.Printf("%s✗%s %s\n", ColorRed, ColorReset, finalMessage) + } +} + +// Step spinner methods - indented with 2 spaces to show as sub-steps +func (ui *UI) StartStepSpinner(message string) { + ui.mu.Lock() + if ui.stepSpinning { + fmt.Print("\r\033[K") + } + ui.currentStep = message + ui.stepSpinning = true + ui.mu.Unlock() + + ui.spinnerLoop(" ", func() string { return ui.currentStep }, func() bool { return ui.stepSpinning }) +} + +func (ui *UI) CompleteStepAndStartNext(completedMessage, nextMessage string) { + ui.mu.Lock() + // Stop current spinner and show completion + if ui.stepSpinning { + ui.stepSpinning = false + fmt.Print("\r\033[K") + fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, completedMessage) + } + + // Start next step if provided + if nextMessage != "" { + ui.currentStep = nextMessage + ui.stepSpinning = true + ui.mu.Unlock() + + ui.spinnerLoop(" ", func() string { return ui.currentStep }, func() bool { return ui.stepSpinning }) + } else { + ui.mu.Unlock() + } +} + +func (ui *UI) CompleteCurrentStep(message string, success bool) { + ui.mu.Lock() + defer ui.mu.Unlock() + if !ui.stepSpinning { + return + } + ui.stepSpinning = false + fmt.Print("\r\033[K") + if success { + fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, message) + } else { + fmt.Printf(" %s✗%s %s\n", ColorRed, ColorReset, message) + } +} diff --git a/go/cmd/cli/commands/init/init.go b/go/cmd/cli/commands/init/init.go new file mode 100644 index 00000000000..3b5b91f0ed8 --- /dev/null +++ b/go/cmd/cli/commands/init/init.go @@ -0,0 +1,69 @@ +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 new file mode 100644 index 00000000000..643d1c35249 --- /dev/null +++ b/go/cmd/cli/commands/versions/versions.go @@ -0,0 +1,164 @@ +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 new file mode 100644 index 00000000000..d96409f649b --- /dev/null +++ b/go/cmd/cli/main.go @@ -0,0 +1,31 @@ +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) + } +}