feat(cli): add version diagnostics and build-aware start

This commit is contained in:
2026-08-15 23:35:55 +02:00
parent 9a8755e4c3
commit 7ac99b4f7e
11 changed files with 989 additions and 306 deletions
+88 -130
View File
@@ -16,11 +16,14 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/output"
"github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/project"
"github.com/aritmolab/thothii/tools/tht/internal/serverops"
"github.com/aritmolab/thothii/tools/tht/internal/service"
"github.com/aritmolab/thothii/tools/tht/internal/setup"
"github.com/aritmolab/thothii/tools/tht/internal/version"
"github.com/aritmolab/thothii/tools/tht/internal/workspaceops"
)
@@ -32,10 +35,11 @@ descriptor in the current project tree.
Commands:
setup [--configure-only] [--installation-id ID] [--profile local|server]
Create or validate the local non-secret installation configuration.
version [--json] Show the host CLI build identity.
status Show the Compose service state.
doctor Validate Docker, Compose, rendered configuration, line endings, volumes, and health.
doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics.
logs Show the latest 200 sanitized service log lines (bounded; no follow mode).
start Start the installation in the background.
start [--build] Start the installation; --build builds current-checkout images first.
stop Stop the installation.
update --check-only Validate the current installation without changing containers.
sessions migrate --yes
@@ -93,6 +97,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if command == "setup" {
return setupCommand(ctx, installationPath, commandArgs, stdout, stderr)
}
if command == "version" {
return versionCommand(commandArgs, stdout, stderr)
}
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
workingDirectory, err := os.Getwd()
@@ -136,10 +143,15 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
result, err = runner.Run(ctx, installation.ComposeArgs(logArgs...), nil)
case "start":
if len(commandArgs) != 0 {
return commandUsageError(stderr, "start does not accept arguments")
build, argumentError := startArgs(commandArgs)
if argumentError != nil {
return commandUsageError(stderr, argumentError.Error())
}
result, err = runner.Run(ctx, installation.ComposeArgs("up", "--detach", "--remove-orphans"), nil)
if err := service.Start(ctx, installation, runner, build); err != nil {
return lifecycleFailure(stderr, err, secretValues)
}
fmt.Fprintln(stdout, "ThothII services are healthy.")
return 0
case "stop":
if len(commandArgs) != 0 {
return commandUsageError(stderr, "stop does not accept arguments")
@@ -151,10 +163,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
case "doctor":
if len(commandArgs) != 0 {
return commandUsageError(stderr, "doctor does not accept arguments")
}
return doctor(ctx, installation, runner, secretValues, stdout, stderr)
return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr)
case "pi":
return piCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
case "sessions":
@@ -762,6 +771,76 @@ func commandUsageError(stderr io.Writer, message string) int {
return 2
}
func versionCommand(args []string, stdout, stderr io.Writer) int {
jsonMode := false
if len(args) == 1 && args[0] == "--json" {
jsonMode = true
} else if len(args) != 0 {
return commandUsageError(stderr, "version accepts only --json")
}
info := version.Current()
if jsonMode {
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(info); err != nil {
fmt.Fprintln(stderr, "tht: version output could not be written")
return 1
}
return 0
}
fmt.Fprintf(stdout, "tht %s\ncommit: %s\nbuilt: %s\nplatform: %s/%s\n", info.Version, info.Commit, info.BuildTime, info.OS, info.Arch)
return 0
}
func startArgs(args []string) (bool, error) {
if len(args) == 0 {
return false, nil
}
if len(args) == 1 && args[0] == "--build" {
return true, nil
}
return false, errors.New("start accepts only --build")
}
func doctorCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, stdout, stderr io.Writer) int {
jsonMode := false
if len(args) == 1 && args[0] == "--json" {
jsonMode = true
} else if len(args) != 0 {
return commandUsageError(stderr, "doctor accepts only --json")
}
report, err := doctor.Run(ctx, installation, runner)
if err != nil {
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
return 1
}
if jsonMode {
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(report); err != nil {
fmt.Fprintln(stderr, "tht: doctor report could not be written")
return 1
}
} else {
for _, check := range report.Checks {
fmt.Fprintf(stdout, "%s: %s", check.Name, check.Status)
if check.Detail != "" {
fmt.Fprintf(stdout, " - %s", check.Detail)
}
fmt.Fprintln(stdout)
}
}
if report.OK {
return 0
}
return 1
}
func lifecycleFailure(stderr io.Writer, err error, secretValues []string) int {
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), secretValues))
return 1
}
func writeResult(result compose.Result, err error, secretValues []string, stdout, stderr io.Writer) int {
if result.Stdout != "" {
fmt.Fprint(stdout, output.Sanitize(result.Stdout, secretValues))
@@ -780,124 +859,3 @@ func writeResult(result compose.Result, err error, secretValues []string, stdout
}
return 1
}
func doctor(ctx context.Context, installation config.Installation, runner compose.Runner, secretValues []string, stdout, stderr io.Writer) int {
checks := [][]string{
{"version", "--format", "{{.Client.Version}}"},
{"compose", "version", "--short"},
installation.ComposeArgs("config", "--quiet"),
installation.ComposeArgs("config", "--format", "json"),
installation.ComposeArgs("ps", "--format", "json"),
}
var renderedConfig, status string
for index, args := range checks {
result, err := runner.Run(ctx, args, nil)
if err != nil {
return writeResult(result, err, secretValues, stdout, stderr)
}
if index == 3 {
renderedConfig = result.Stdout
}
if index == 4 {
status = result.Stdout
}
}
if err := requireLF(installation.ProjectDirectory); err != nil {
fmt.Fprintf(stderr, "tht: %s\n", err)
return 1
}
if err := requireVolumes(renderedConfig); err != nil {
fmt.Fprintf(stderr, "tht: %s\n", err)
return 1
}
if err := requireHealthyServices(status); err != nil {
fmt.Fprintf(stderr, "tht: %s\n", err)
return 1
}
fmt.Fprintln(stdout, "Doctor checks passed.")
return 0
}
func requireLF(root string) error {
return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !requiresLF(entry.Name()) {
return nil
}
contents, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(string(contents), "\r\n") {
return fmt.Errorf("CRLF line endings found in %s", filepath.Base(path))
}
return nil
})
}
func requiresLF(name string) bool {
if name == "Dockerfile" || strings.HasPrefix(name, "Dockerfile.") || strings.HasSuffix(name, ".Dockerfile") {
return true
}
for _, suffix := range []string{".sh", ".yml", ".yaml"} {
if strings.HasSuffix(name, suffix) {
return true
}
}
return false
}
func requireVolumes(renderedConfig string) error {
var document struct {
Volumes map[string]json.RawMessage `json:"volumes"`
}
if err := json.Unmarshal([]byte(renderedConfig), &document); err != nil {
return fmt.Errorf("Compose returned invalid rendered configuration")
}
if len(document.Volumes) == 0 {
return errors.New("rendered Compose configuration declares no volumes")
}
return nil
}
func requireHealthyServices(status string) error {
type serviceStatus struct {
Service string `json:"Service"`
State string `json:"State"`
Health string `json:"Health"`
}
var services []serviceStatus
if err := json.Unmarshal([]byte(status), &services); err != nil {
decoder := json.NewDecoder(strings.NewReader(status))
for {
var service serviceStatus
if err := decoder.Decode(&service); errors.Is(err, io.EOF) {
break
} else if err != nil {
return errors.New("Compose returned invalid service status")
}
services = append(services, service)
}
if len(services) == 0 {
return errors.New("Compose returned invalid service status")
}
}
seen := map[string]bool{}
for _, service := range services {
if service.Service != "core" && service.Service != "frontend" {
continue
}
if service.State != "running" || service.Health != "healthy" {
return fmt.Errorf("%s is not healthy", service.Service)
}
seen[service.Service] = true
}
for _, service := range []string{"core", "frontend"} {
if !seen[service] {
return fmt.Errorf("%s service is not running", service)
}
}
return nil
}