diff --git a/VERSION b/VERSION deleted file mode 100644 index a86d3df..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -v0.18.0 diff --git a/cli/cmd/tc/main.go b/cli/cmd/tc/main.go index 6814ec0..58a8a0f 100644 --- a/cli/cmd/tc/main.go +++ b/cli/cmd/tc/main.go @@ -11,7 +11,9 @@ var version = "dev" func main() { if err := cli.Execute(version, os.Stdin, os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) + if !cli.IsHandledError(err) { + fmt.Fprintln(os.Stderr, err) + } os.Exit(1) } } diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index f18d7b5..14a69ef 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -16,6 +16,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" "techulus/cloud-cli/internal/api" "techulus/cloud-cli/internal/auth" @@ -32,6 +33,7 @@ const ( type App struct { Version string + Args []string In io.Reader Out io.Writer Err io.Writer @@ -40,6 +42,29 @@ type App struct { Now func() time.Time IsInteractive func() bool GetCWD func() (string, error) + flags globalFlags +} + +type globalFlags struct { + Agent bool + JSON bool +} + +type handledError struct { + err error +} + +func (e handledError) Error() string { + return e.err.Error() +} + +func (e handledError) Unwrap() error { + return e.err +} + +func IsHandledError(err error) bool { + var handled handledError + return errors.As(err, &handled) } func Execute(version string, in io.Reader, out io.Writer, errOut io.Writer) error { @@ -82,7 +107,17 @@ func (a *App) Execute() error { cmd.SetIn(a.In) cmd.SetOut(a.Out) cmd.SetErr(a.Err) - return cmd.Execute() + if a.Args != nil { + cmd.SetArgs(a.Args) + } + if err := cmd.Execute(); err != nil { + if a.isMachineOutput() { + _ = a.writeError(err) + return handledError{err: err} + } + return err + } + return nil } func (a *App) rootCommand() *cobra.Command { @@ -91,8 +126,26 @@ func (a *App) rootCommand() *cobra.Command { Short: "Techulus Cloud CLI", SilenceUsage: true, SilenceErrors: true, + Annotations: map[string]string{ + "agent_notes": "Use --help --agent on any command for structured command metadata.\nUse --agent for raw JSON data on success; failures are returned as {\"ok\":false,\"error\":\"...\"}.\nUse --json for an ok/data envelope.", + }, } + root.PersistentFlags().BoolVar(&a.flags.Agent, "agent", false, "Agent mode: raw JSON data on success and structured JSON errors") + root.PersistentFlags().BoolVar(&a.flags.JSON, "json", false, "Output an ok/data JSON envelope") + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if a.flags.Agent { + _ = a.writeRaw(agentHelpForCommand(cmd)) + return + } + if a.flags.JSON { + _ = a.writeData(agentHelpForCommand(cmd), "Help") + return + } + defaultHelp(cmd, args) + }) + root.AddCommand(a.authCommand()) root.AddCommand(a.initCommand()) root.AddCommand(a.linkCommand()) @@ -110,6 +163,9 @@ func (a *App) authCommand() *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "Manage CLI authentication", + Annotations: map[string]string{ + "agent_notes": "Most commands require an existing login. Use auth whoami to inspect the saved session.", + }, } cmd.AddCommand(a.authLoginCommand()) cmd.AddCommand(a.authLogoutCommand()) @@ -122,7 +178,13 @@ func (a *App) authLoginCommand() *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Sign in with device login", + Annotations: map[string]string{ + "agent_notes": "Device login requires browser approval and is not fully non-interactive.", + }, RunE: func(cmd *cobra.Command, args []string) error { + if a.isMachineOutput() { + return errors.New("tc auth login requires human browser approval and does not support --agent or --json") + } if host == "" { existing, err := auth.ReadConfig() if err != nil { @@ -150,6 +212,9 @@ func (a *App) authLogoutCommand() *cobra.Command { if err := auth.DeleteConfig(); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(map[string]string{"config": "removed"}, "Signed out") + } output.Section(a.Out, "Signed out") output.Field(a.Out, "Config", "removed") return nil @@ -173,10 +238,17 @@ func (a *App) authWhoamiCommand() *cobra.Command { if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/cli/auth/whoami", nil, nil, &response); err != nil { return err } + result := authWhoamiOutput{ + User: response.User, + Host: config.Host, + } + if a.isMachineOutput() { + return a.writeData(result, "Account") + } output.Section(a.Out, "Account") - output.Field(a.Out, "User", response.User.Email) - output.Field(a.Out, "Name", response.User.Name) - output.Field(a.Out, "Host", config.Host) + output.Field(a.Out, "User", result.User.Email) + output.Field(a.Out, "Name", result.User.Name) + output.Field(a.Out, "Host", result.Host) return nil }, } @@ -186,6 +258,9 @@ func (a *App) initCommand() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Create a starter techulus.yml", + Annotations: map[string]string{ + "agent_notes": "Creates techulus.yml in the current working directory. Fails if techulus.yml already exists.", + }, RunE: func(cmd *cobra.Command, args []string) error { cwd, err := a.GetCWD() if err != nil { @@ -222,6 +297,9 @@ service: if err := os.WriteFile(manifestPath, []byte(starter), 0o644); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(initOutput{Manifest: manifestPath, Next: "tc apply"}, "Manifest created") + } output.Section(a.Out, "Manifest") output.Field(a.Out, "Created", manifestPath) output.Next(a.Out, "tc apply") @@ -235,7 +313,13 @@ func (a *App) linkCommand() *cobra.Command { cmd := &cobra.Command{ Use: "link", Short: "Create techulus.yml from an existing service", + Annotations: map[string]string{ + "agent_notes": "Requires an interactive terminal and does not support --agent or --json. Agents should usually pass --project, --environment, and --service to status/logs instead of linking.", + }, RunE: func(cmd *cobra.Command, args []string) error { + if a.isMachineOutput() { + return errors.New("tc link requires an interactive terminal and does not support --agent or --json") + } if !a.IsInteractive() { return errors.New("tc link requires an interactive terminal") } @@ -303,6 +387,9 @@ func (a *App) applyCommand() *cobra.Command { return &cobra.Command{ Use: "apply", Short: "Apply techulus.yml to the linked service", + Annotations: map[string]string{ + "agent_notes": "Requires techulus.yml in the current directory and sends the full desired manifest to the control plane.", + }, RunE: func(cmd *cobra.Command, args []string) error { config, err := requireConfig() if err != nil { @@ -317,6 +404,9 @@ func (a *App) applyCommand() *cobra.Command { if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/apply", nil, loaded.Manifest, &result); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(result, "Apply") + } printApplyResult(a.Out, result) return nil }, @@ -327,6 +417,9 @@ func (a *App) deployCommand() *cobra.Command { return &cobra.Command{ Use: "deploy", Short: "Deploy the service described by techulus.yml", + Annotations: map[string]string{ + "agent_notes": "Requires techulus.yml in the current directory and queues a deployment for that service.", + }, RunE: func(cmd *cobra.Command, args []string) error { config, err := requireConfig() if err != nil { @@ -341,6 +434,9 @@ func (a *App) deployCommand() *cobra.Command { if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/deploy", nil, loaded.Manifest, &result); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(result, "Deploy") + } output.Section(a.Out, "Deploy") output.Field(a.Out, "Service", output.ShortID(result.ServiceID)) output.Field(a.Out, "Status", output.Status(result.Status)) @@ -354,36 +450,53 @@ func (a *App) deployCommand() *cobra.Command { } func (a *App) statusCommand() *cobra.Command { - return &cobra.Command{ + var target serviceTargetFlags + cmd := &cobra.Command{ Use: "status", Short: "Show service rollout and deployment status", + Annotations: map[string]string{ + "agent_notes": "Without explicit target flags, tc reads techulus.yml from the current directory.\nFor agent use outside a linked directory, pass --project, --environment, and --service together.", + }, RunE: func(cmd *cobra.Command, args []string) error { config, err := requireConfig() if err != nil { return err } - loaded, err := a.ensureManifest() + value, err := a.resolveServiceTarget(target) if err != nil { return err } var status statusResponse client := a.client(config) - query := manifestIdentityQuery(loaded.Manifest) + query := manifestIdentityQuery(value) if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/manifest/status", query, nil, &status); err != nil { return err } - printStatus(a.Out, loaded.Manifest, status) + result := statusOutput{ + Target: serviceTargetFromManifest(value), + Status: status, + } + if a.isMachineOutput() { + return a.writeData(result, "Status") + } + printStatus(a.Out, value, status) return nil }, } + addServiceTargetFlags(cmd, &target) + return cmd } func (a *App) logsCommand() *cobra.Command { var tail int var follow bool + var target serviceTargetFlags cmd := &cobra.Command{ Use: "logs", Short: "Show service logs", + Annotations: map[string]string{ + "agent_notes": "Without explicit target flags, tc reads techulus.yml from the current directory.\nFor agent use outside a linked directory, pass --project, --environment, and --service together.\nIn --agent or --json mode, logs are one-shot JSON output; --follow=true is not supported.", + }, RunE: func(cmd *cobra.Command, args []string) error { if tail < 1 || tail > 1000 { return errors.New("log line count must be between 1 and 1000") @@ -392,19 +505,26 @@ func (a *App) logsCommand() *cobra.Command { if tailChanged && !cmd.Flags().Changed("follow") { follow = false } + if a.isMachineOutput() { + if cmd.Flags().Changed("follow") && follow { + return errors.New("--follow=true is not supported with --agent or --json") + } + follow = false + } config, err := requireConfig() if err != nil { return err } - loaded, err := a.ensureManifest() + value, err := a.resolveServiceTarget(target) if err != nil { return err } - return a.runLogs(cmd.Context(), config, loaded.Manifest, tail, follow) + return a.runLogs(cmd.Context(), config, value, tail, follow) }, } cmd.Flags().IntVarP(&tail, "tail", "n", defaultLogTail, "Number of log lines to fetch") cmd.Flags().BoolVar(&follow, "follow", true, "Continue polling for new log lines") + addServiceTargetFlags(cmd, &target) return cmd } @@ -413,6 +533,9 @@ func (a *App) versionCommand() *cobra.Command { Use: "version", Short: "Print the tc version", RunE: func(cmd *cobra.Command, args []string) error { + if a.isMachineOutput() { + return a.writeData(map[string]string{"version": a.Version}, "Version") + } fmt.Fprintln(a.Out, a.Version) return nil }, @@ -421,7 +544,7 @@ func (a *App) versionCommand() *cobra.Command { func (a *App) completionCommand(root *cobra.Command) *cobra.Command { cmd := &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", + Use: "completion ", Short: "Generate shell completion scripts", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -463,6 +586,202 @@ func (a *App) ensureManifest() (*manifest.Loaded, error) { return nil, fmt.Errorf("invalid techulus.yml: %w", err) } +func (a *App) isMachineOutput() bool { + return a.flags.Agent || a.flags.JSON +} + +func (a *App) writeData(data any, summary string) error { + if a.flags.Agent { + return a.writeRaw(data) + } + return output.OK(a.Out, data, summary) +} + +func (a *App) writeRaw(data any) error { + return output.JSON(a.Out, data) +} + +func (a *App) writeError(err error) error { + return output.Error(a.Out, err) +} + +type agentHelpInfo struct { + Command string `json:"command"` + Path string `json:"path"` + Short string `json:"short"` + Long string `json:"long,omitempty"` + Usage string `json:"usage"` + Notes []string `json:"notes,omitempty"` + Args []agentArg `json:"args,omitempty"` + Subcommands []agentSubcommand `json:"subcommands,omitempty"` + Flags []agentFlag `json:"flags,omitempty"` + InheritedFlags []agentFlag `json:"inherited_flags,omitempty"` +} + +type agentArg struct { + Name string `json:"name"` + Required bool `json:"required"` + Choices []string `json:"choices,omitempty"` +} + +type agentSubcommand struct { + Name string `json:"name"` + Short string `json:"short"` + Path string `json:"path"` +} + +type agentFlag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Type string `json:"type"` + Default string `json:"default"` + Usage string `json:"usage"` +} + +func agentHelpForCommand(cmd *cobra.Command) agentHelpInfo { + info := agentHelpInfo{ + Command: cmd.Name(), + Path: cmd.CommandPath(), + Short: cmd.Short, + Long: cmd.Long, + Usage: cmd.UseLine(), + Args: parseAgentArgs(cmd), + } + if notes := strings.TrimSpace(cmd.Annotations["agent_notes"]); notes != "" { + for _, note := range strings.Split(notes, "\n") { + note = strings.TrimSpace(note) + if note != "" { + info.Notes = append(info.Notes, note) + } + } + } + for _, sub := range cmd.Commands() { + if sub.IsAvailableCommand() || sub.Name() == "help" { + info.Subcommands = append(info.Subcommands, agentSubcommand{ + Name: sub.Name(), + Short: sub.Short, + Path: sub.CommandPath(), + }) + } + } + cmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Name != "help" { + info.Flags = append(info.Flags, agentFlagFor(flag)) + } + }) + cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Name != "help" { + info.InheritedFlags = append(info.InheritedFlags, agentFlagFor(flag)) + } + }) + return info +} + +func agentFlagFor(flag *pflag.Flag) agentFlag { + return agentFlag{ + Name: flag.Name, + Shorthand: flag.Shorthand, + Type: flag.Value.Type(), + Default: flag.DefValue, + Usage: flag.Usage, + } +} + +func parseAgentArgs(cmd *cobra.Command) []agentArg { + fields := strings.Fields(cmd.Use) + if len(fields) <= 1 { + return nil + } + args := make([]agentArg, 0, len(fields)-1) + for _, field := range fields[1:] { + required := strings.HasPrefix(field, "<") || (!strings.HasPrefix(field, "[") && !strings.HasSuffix(field, "]")) + name := strings.Trim(field, "[]<>") + if name == "" || name == "flags" { + continue + } + arg := agentArg{Name: name, Required: required} + if choices := parseAgentArgChoices(name); len(choices) > 0 { + arg.Name = agentChoiceArgName(cmd) + arg.Choices = choices + } + args = append(args, arg) + } + return args +} + +func parseAgentArgChoices(name string) []string { + if !strings.Contains(name, "|") { + return nil + } + parts := strings.Split(name, "|") + choices := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return nil + } + choices = append(choices, part) + } + return choices +} + +func agentChoiceArgName(cmd *cobra.Command) string { + if cmd.Name() == "completion" { + return "shell" + } + return "value" +} + +type serviceTargetFlags struct { + Project string + Environment string + Service string +} + +func addServiceTargetFlags(cmd *cobra.Command, target *serviceTargetFlags) { + cmd.Flags().StringVar(&target.Project, "project", "", "Project name or slug") + cmd.Flags().StringVar(&target.Environment, "environment", "", "Environment name") + cmd.Flags().StringVar(&target.Service, "service", "", "Service name") +} + +func (a *App) resolveServiceTarget(target serviceTargetFlags) (manifest.Manifest, error) { + project := strings.TrimSpace(target.Project) + environment := strings.TrimSpace(target.Environment) + service := strings.TrimSpace(target.Service) + explicitCount := 0 + for _, value := range []string{project, environment, service} { + if value != "" { + explicitCount++ + } + } + if explicitCount == 0 { + loaded, err := a.ensureManifest() + if err != nil { + return manifest.Manifest{}, err + } + return loaded.Manifest, nil + } + if explicitCount != 3 { + return manifest.Manifest{}, errors.New("provide --project, --environment, and --service together") + } + return manifest.Manifest{ + APIVersion: "v1", + Project: project, + Environment: environment, + Service: manifest.Service{ + Name: service, + }, + }, nil +} + +func serviceTargetFromManifest(value manifest.Manifest) serviceTargetOutput { + return serviceTargetOutput{ + Project: value.Project, + Environment: value.Environment, + Service: value.Service.Name, + } +} + func requireConfig() (*auth.Config, error) { config, err := auth.ReadConfig() if err != nil { @@ -585,6 +904,13 @@ func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.M if err != nil { return err } + if a.isMachineOutput() { + return a.writeData(logsOutput{ + Target: serviceTargetFromManifest(value), + LoggingEnabled: result.LoggingEnabled, + Logs: result.Logs, + }, "Logs") + } fmt.Fprintf(a.Out, "%s/%s/%s\n", value.Project, value.Environment, value.Service.Name) if !result.LoggingEnabled { output.Section(a.Out, "Logs") diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index d79cd4c..f7992cf 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -41,6 +42,294 @@ func TestLogsRejectsInvalidTailBeforeConfig(t *testing.T) { } } +func TestAgentHelpOutputsStructuredCommandMetadata(t *testing.T) { + stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "status", "--help", "--agent") + if err != nil { + t.Fatalf("help error = %v\nstderr=%s", err, stderr) + } + var help agentHelpInfo + if err := json.Unmarshal([]byte(stdout), &help); err != nil { + t.Fatalf("decode help: %v\nstdout=%s", err, stdout) + } + if help.Command != "status" || help.Path != "tc status" { + t.Fatalf("help = %#v", help) + } + if !agentFlagsContain(help.Flags, "project") || !agentFlagsContain(help.InheritedFlags, "agent") { + t.Fatalf("flags = %#v inherited = %#v", help.Flags, help.InheritedFlags) + } + if len(help.Notes) == 0 || !strings.Contains(strings.Join(help.Notes, "\n"), "explicit target flags") { + t.Fatalf("notes = %#v", help.Notes) + } +} + +func TestAgentCompletionHelpOutputsChoiceArg(t *testing.T) { + stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "completion", "--help", "--agent") + if err != nil { + t.Fatalf("help error = %v\nstderr=%s", err, stderr) + } + var help agentHelpInfo + if err := json.Unmarshal([]byte(stdout), &help); err != nil { + t.Fatalf("decode help: %v\nstdout=%s", err, stdout) + } + if len(help.Args) != 1 { + t.Fatalf("args = %#v", help.Args) + } + arg := help.Args[0] + if arg.Name != "shell" || !arg.Required { + t.Fatalf("arg = %#v", arg) + } + wantChoices := []string{"bash", "zsh", "fish", "powershell"} + if strings.Join(arg.Choices, ",") != strings.Join(wantChoices, ",") { + t.Fatalf("choices = %#v", arg.Choices) + } +} + +func TestJSONHelpOutputsEnvelope(t *testing.T) { + stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "status", "--help", "--json") + if err != nil { + t.Fatalf("help error = %v\nstderr=%s", err, stderr) + } + var envelope struct { + OK bool `json:"ok"` + Data agentHelpInfo `json:"data"` + Summary string `json:"summary"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if !envelope.OK || envelope.Summary != "Help" { + t.Fatalf("envelope = %#v", envelope) + } + if envelope.Data.Command != "status" || envelope.Data.Path != "tc status" { + t.Fatalf("data = %#v", envelope.Data) + } +} + +func TestAgentStatusOutputsRawJSON(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/status" { + t.Fatalf("path = %s", r.URL.Path) + } + w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--agent", "status", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("status error = %v\nstderr=%s", err, stderr) + } + var raw map[string]any + if err := json.Unmarshal([]byte(stdout), &raw); err != nil { + t.Fatalf("decode raw: %v\nstdout=%s", err, stdout) + } + if _, ok := raw["ok"]; ok { + t.Fatalf("agent output should be raw data, got %s", stdout) + } + target := raw["target"].(map[string]any) + if target["project"] != "app" || raw["status"] == nil { + t.Fatalf("raw = %#v", raw) + } +} + +func TestJSONStatusOutputsEnvelope(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/status" { + t.Fatalf("path = %s", r.URL.Path) + } + w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--json", "status", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("status error = %v\nstderr=%s", err, stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != true || envelope["data"] == nil || envelope["summary"] != "Status" { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestAgentLogsOutputsOneShotJSON(t *testing.T) { + tmp := t.TempDir() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/logs" { + t.Fatalf("path = %s", r.URL.Path) + } + requests++ + w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"hello","timestamp":"2026-01-01T00:00:00Z"}]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--agent", "logs", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("logs error = %v\nstderr=%s", err, stderr) + } + var result logsOutput + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("decode logs: %v\nstdout=%s", err, stdout) + } + if requests != 1 || !result.LoggingEnabled || len(result.Logs) != 1 || result.Logs[0].Message != "hello" { + t.Fatalf("requests=%d result=%#v", requests, result) + } +} + +func TestAgentLogsRejectsFollowTrue(t *testing.T) { + _, _, err := runTestCommand(t, nil, t.TempDir(), "--agent", "logs", "--project", "app", "--environment", "production", "--service", "web", "--follow=true") + if err == nil || !strings.Contains(err.Error(), "--follow=true is not supported") { + t.Fatalf("error = %v", err) + } +} + +func TestExecuteWritesMachineErrorEnvelope(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected API request: %s", r.URL.Path) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestAppExecute(t, server.Client(), tmp, "--json", "status", "--project", "app") + if err == nil { + t.Fatal("expected error") + } + if !IsHandledError(err) { + t.Fatalf("error should be marked handled, got %T %v", err, err) + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "provide --project") { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestHandledErrorUnwrapsOriginalError(t *testing.T) { + base := errors.New("base") + wrapped := handledError{err: base} + if !errors.Is(wrapped, base) { + t.Fatalf("handledError should unwrap original error") + } +} + +func TestAuthLoginRejectsMachineOutputBeforeWritingHumanText(t *testing.T) { + stdout, stderr, err := runTestAppExecute(t, nil, t.TempDir(), "--agent", "auth", "login", "--host", "https://example.com") + if err == nil { + t.Fatal("expected error") + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "requires human browser approval") { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestLinkRejectsMachineOutputWithSpecificMessage(t *testing.T) { + stdout, stderr, err := runTestAppExecute(t, nil, t.TempDir(), "--json", "link") + if err == nil { + t.Fatal("expected error") + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "does not support --agent or --json") { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestStatusUsesExplicitTargetWithoutManifest(t *testing.T) { + tmp := t.TempDir() + var sawStatus bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/status" { + t.Fatalf("path = %s", r.URL.Path) + } + query := r.URL.Query() + if query.Get("project") != "app" || query.Get("environment") != "production" || query.Get("service") != "web" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + sawStatus = true + w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "status", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("status error = %v\nstderr=%s", err, stderr) + } + if !sawStatus || !strings.Contains(stdout, "app/production/web") || !strings.Contains(stdout, "nginx:1.27") { + t.Fatalf("stdout = %s sawStatus=%v", stdout, sawStatus) + } +} + +func TestLogsUsesExplicitTargetWithoutManifest(t *testing.T) { + tmp := t.TempDir() + var sawLogs bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/logs" { + t.Fatalf("path = %s", r.URL.Path) + } + query := r.URL.Query() + if query.Get("project") != "app" || query.Get("environment") != "production" || query.Get("service") != "web" || query.Get("tail") != "10" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + sawLogs = true + w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"hello","timestamp":"2026-01-01T00:00:00Z"}]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "logs", "--project", "app", "--environment", "production", "--service", "web", "--tail", "10", "--follow=false") + if err != nil { + t.Fatalf("logs error = %v\nstderr=%s", err, stderr) + } + if !sawLogs || !strings.Contains(stdout, "app/production/web") || !strings.Contains(stdout, "hello") { + t.Fatalf("stdout = %s sawLogs=%v", stdout, sawLogs) + } +} + +func TestReadOnlyTargetsRejectPartialFlags(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected API request: %s", r.URL.Path) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + _, _, err := runTestCommand(t, server.Client(), tmp, "status", "--project", "app") + if err == nil || !strings.Contains(err.Error(), "provide --project, --environment, and --service together") { + t.Fatalf("status error = %v", err) + } + + _, _, err = runTestCommand(t, server.Client(), tmp, "logs", "--project", "app", "--service", "web", "--follow=false") + if err == nil || !strings.Contains(err.Error(), "provide --project, --environment, and --service together") { + t.Fatalf("logs error = %v", err) + } +} + func TestApplyPostsManifest(t *testing.T) { tmp := t.TempDir() writeTestManifest(t, tmp) @@ -253,6 +542,20 @@ func runTestCommandWithInput(t *testing.T, client *http.Client, cwd string, stdi return stdout.String(), stderr.String(), err } +func runTestAppExecute(t *testing.T, client *http.Client, cwd string, args ...string) (string, string, error) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + app := NewApp("test", strings.NewReader(""), &stdout, &stderr) + app.Args = args + if client != nil { + app.HTTPClient = client + } + app.GetCWD = func() (string, error) { return cwd, nil } + err := app.Execute() + return stdout.String(), stderr.String(), err +} + func writeTestManifest(t *testing.T, dir string) { t.Helper() raw := `apiVersion: v1 @@ -293,3 +596,12 @@ func writeTestConfig(t *testing.T, host string) { t.Fatalf("WriteConfig() error = %v", err) } } + +func agentFlagsContain(flags []agentFlag, name string) bool { + for _, flag := range flags { + if flag.Name == name { + return true + } + } + return false +} diff --git a/cli/internal/cli/types.go b/cli/internal/cli/types.go index f323ff5..c92fcfe 100644 --- a/cli/internal/cli/types.go +++ b/cli/internal/cli/types.go @@ -29,6 +29,16 @@ type exchangeResponse struct { User auth.User `json:"user"` } +type authWhoamiOutput struct { + User auth.User `json:"user"` + Host string `json:"host"` +} + +type initOutput struct { + Manifest string `json:"manifest"` + Next string `json:"next"` +} + type linkServiceTarget struct { ID string `json:"id"` Name string `json:"name"` @@ -115,6 +125,23 @@ type logsResponse struct { Logs []serviceLog `json:"logs"` } +type serviceTargetOutput struct { + Project string `json:"project"` + Environment string `json:"environment"` + Service string `json:"service"` +} + +type statusOutput struct { + Target serviceTargetOutput `json:"target"` + Status statusResponse `json:"status"` +} + +type logsOutput struct { + Target serviceTargetOutput `json:"target"` + LoggingEnabled bool `json:"loggingEnabled"` + Logs []serviceLog `json:"logs"` +} + func countSupportedServices(projects []linkProjectTarget) int { total := 0 for _, project := range projects { diff --git a/cli/internal/output/output.go b/cli/internal/output/output.go index c0589b2..2a49d93 100644 --- a/cli/internal/output/output.go +++ b/cli/internal/output/output.go @@ -1,12 +1,41 @@ package output import ( + "encoding/json" "fmt" "io" "strings" "time" ) +type Envelope struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type ErrorEnvelope struct { + OK bool `json:"ok"` + Error string `json:"error"` +} + +func JSON(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + return encoder.Encode(value) +} + +func OK(w io.Writer, data any, summary string) error { + return JSON(w, Envelope{OK: true, Data: data, Summary: summary}) +} + +func Error(w io.Writer, err error) error { + message := "" + if err != nil { + message = err.Error() + } + return JSON(w, ErrorEnvelope{OK: false, Error: message}) +} + func Section(w io.Writer, title string) { fmt.Fprintf(w, "\n%s\n%s\n", title, strings.Repeat("-", len(title))) } diff --git a/web/actions/members.ts b/web/actions/members.ts index 508d3fd..c237437 100644 --- a/web/actions/members.ts +++ b/web/actions/members.ts @@ -139,8 +139,15 @@ export async function inviteMember(input: { }) { const session = await requireAdminSession(); - const parsed = inviteMemberSchema.parse(input); - const email = parsed.email.toLowerCase(); + const parsed = inviteMemberSchema.safeParse(input); + if (!parsed.success) { + return { + success: false as const, + error: parsed.error.issues[0]?.message ?? "Invalid invitation details", + }; + } + + const email = parsed.data.email.toLowerCase(); const existingUser = await db .select({ id: user.id }) @@ -149,7 +156,10 @@ export async function inviteMember(input: { .limit(1); if (existingUser.length > 0) { - throw new Error("A member with this email already exists"); + return { + success: false as const, + error: "A member with this email already exists", + }; } await db @@ -186,7 +196,7 @@ export async function inviteMember(input: { await db.insert(memberInvitations).values({ id: randomUUID(), email, - role: parsed.role, + role: parsed.data.role, tokenHash: hashInviteToken(token), status: "pending", invitedByUserId: session.user.id, @@ -196,12 +206,12 @@ export async function inviteMember(input: { const emailSent = await sendMemberInviteEmail({ to: email, inviterName: session.user.name, - role: parsed.role, + role: parsed.data.role, inviteUrl, }); revalidatePath("/dashboard/settings"); - return { success: true, inviteUrl, emailSent }; + return { success: true as const, inviteUrl, emailSent }; } export async function revokeInvitation(invitationId: string) { diff --git a/web/app/layout.tsx b/web/app/layout.tsx index d9e029e..4a3f612 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -19,6 +19,13 @@ const ioskeleyMono = localFont({ export const metadata: Metadata = { title: "Techulus Cloud", description: "Stateless container deployment platform", + icons: { + icon: [ + { url: "/favicon.ico" }, + { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + ], + }, }; export const viewport: Viewport = { diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index 5efc13a..f486870 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -133,6 +133,8 @@ export function GlobalSettings({ const [isCheckingUpdates, setIsCheckingUpdates] = useState(false); const [isStartingUpgrade, setIsStartingUpgrade] = useState(false); const [isRefreshingUpgrade, setIsRefreshingUpgrade] = useState(false); + const [controlPlaneUpgradeDialogOpen, setControlPlaneUpgradeDialogOpen] = + useState(false); useEffect(() => { const openedAbout = tab === "about" && previousTabRef.current !== "about"; @@ -260,6 +262,7 @@ export function GlobalSettings({ try { await upgradeControlPlane(targetVersion); toast.success("Control plane upgrade started"); + setControlPlaneUpgradeDialogOpen(false); router.refresh(); } catch (error) { toast.error( @@ -669,7 +672,10 @@ export function GlobalSettings({ )} {updateState?.updateAvailable && updateState.latestVersion && ( - + diff --git a/web/components/settings/member-settings.tsx b/web/components/settings/member-settings.tsx index 90cb086..61a9295 100644 --- a/web/components/settings/member-settings.tsx +++ b/web/components/settings/member-settings.tsx @@ -78,6 +78,11 @@ export function MemberSettings({ initialMembers, initialInvitations }: Props) { setIsInviting(true); try { const result = await inviteMember({ email, role }); + if (!result.success) { + toast.error(result.error); + return; + } + setEmail(""); toast.success( result.emailSent ? "Invitation sent" : "Invitation created",