From 66614fe8cfad04baa01cb21033e579f9868fe615 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 15 Aug 2026 22:24:15 +0200 Subject: [PATCH] feat(cli): discover ThothII projects and installations --- tools/tht/cmd/tht/main.go | 48 +++++- tools/tht/cmd/tht/main_test.go | 68 +++++++++ tools/tht/internal/config/discovery.go | 144 ++++++++++++++++++ tools/tht/internal/config/discovery_test.go | 150 +++++++++++++++++++ tools/tht/internal/project/discovery.go | 79 ++++++++++ tools/tht/internal/project/discovery_test.go | 100 +++++++++++++ 6 files changed, 583 insertions(+), 6 deletions(-) create mode 100644 tools/tht/internal/config/discovery.go create mode 100644 tools/tht/internal/config/discovery_test.go create mode 100644 tools/tht/internal/project/discovery.go create mode 100644 tools/tht/internal/project/discovery_test.go diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index df845235..356e02b6 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -22,7 +22,10 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/workspaceops" ) -const usage = `Usage: tht --installation /thothii-installation.yaml +const usage = `Usage: tht [--installation /thothii-installation.yaml] + +When --installation is omitted, tht uses THOTHII_INSTALLATION or discovers one valid +descriptor in the current project tree. Commands: status Show the Compose service state. @@ -78,6 +81,23 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "tht: %s\n\n%s", err, usage) return 2 } + if command == "help" && len(commandArgs) == 0 { + fmt.Fprint(stdout, usage) + return 0 + } + if (command == "version" || command == "setup") && len(commandArgs) == 0 { + return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command)) + } + workingDirectory, err := os.Getwd() + if err != nil { + fmt.Fprintf(stderr, "tht: current directory is unavailable: %s\n", output.Sanitize(err.Error(), nil)) + return 2 + } + installationPath, err = config.Resolve(installationPath, os.Getenv, workingDirectory) + if err != nil { + fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil)) + return 2 + } installation, err := config.Load(installationPath) if err != nil { fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil)) @@ -588,13 +608,29 @@ func piFailure(stderr io.Writer, err error, secretValues []string) int { } func parseArgs(args []string) (string, string, []string, error) { - if len(args) < 3 || args[0] != "--installation" { - return "", "", nil, errors.New("--installation is required before the command") + var installation string + commandArgs := make([]string, 0, len(args)) + for index := 0; index < len(args); index++ { + if args[index] != "--installation" { + commandArgs = append(commandArgs, args[index]) + continue + } + if index+1 >= len(args) { + return "", "", nil, errors.New("--installation requires an absolute path") + } + if installation != "" { + return "", "", nil, errors.New("--installation may be supplied once") + } + installation = args[index+1] + if !filepath.IsAbs(installation) { + return "", "", nil, errors.New("--installation must be an absolute path") + } + index++ } - if !filepath.IsAbs(args[1]) { - return "", "", nil, errors.New("--installation must be an absolute path") + if len(commandArgs) == 0 { + return "", "", nil, errors.New("a command is required") } - return args[1], args[2], args[3:], nil + return installation, commandArgs[0], commandArgs[1:], nil } func logsArgs(args []string) ([]string, error) { diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index 89500648..4e1a6b73 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -74,6 +74,31 @@ func TestRootCommandIdentity(t *testing.T) { } } +func TestBootstrapCommandsDoNotRequireAnInstallationDescriptor(t *testing.T) { + projectRoot := newProjectWithoutInstallation(t) + previousDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(filepath.Join(projectRoot, "frontend")); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if restoreErr := os.Chdir(previousDirectory); restoreErr != nil { + t.Errorf("restore working directory: %v", restoreErr) + } + }) + t.Setenv("THOTHII_INSTALLATION", "") + + for _, args := range [][]string{nil, {"help"}, {"version"}, {"setup"}} { + var stdout, stderr bytes.Buffer + _ = run(context.Background(), args, &stdout, &stderr) + if output := stdout.String() + stderr.String(); strings.Contains(output, "installation descriptor") { + t.Fatalf("run(%v) required a descriptor: %q", args, output) + } + } +} + // Catches interactive configuration prompts that use retired model-only data instead of the // provider, model, and reasoning choices supplied by the dedicated Pi Management API. func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) { @@ -126,6 +151,35 @@ func TestLogsRejectsFollowAndOtherArguments(t *testing.T) { } } +func TestParseArgsMakesInstallationOptionalAndAcceptsOverrideAfterCommand(t *testing.T) { + if installation, command, commandArgs, err := parseArgs([]string{"pi", "status"}); err != nil || installation != "" || command != "pi" || strings.Join(commandArgs, " ") != "status" { + t.Fatalf("implicit parse = %q, %q, %v, %v", installation, command, commandArgs, err) + } + + want := "/tmp/thothii-installation.yaml" + installation, command, commandArgs, err := parseArgs([]string{"pi", "update", "--installation", want}) + if err != nil { + t.Fatal(err) + } + if installation != want || command != "pi" || strings.Join(commandArgs, " ") != "update" { + t.Fatalf("trailing override parse = %q, %q, %v", installation, command, commandArgs) + } +} + +func TestRunPiStatusUsesInstallationEnvironmentWithoutFlag(t *testing.T) { + fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") + fixture.setEnvironment(t) + t.Setenv("THOTHII_INSTALLATION", fixture.installationPath) + + var stdout, stderr bytes.Buffer + if code := run(context.Background(), []string{"pi", "status"}, &stdout, &stderr); code != 0 { + t.Fatalf("run() exit code = %d, stderr = %s", code, stderr.String()) + } + if stdout.String() != "Pi version: 0.80.3\n" { + t.Fatalf("stdout = %q, want image-bundled Pi version", stdout.String()) + } +} + func TestUsageDocumentsClosedConfigureUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) { if strings.Contains(usage, "--follow") { t.Fatal("usage still advertises unbounded log following") @@ -1078,6 +1132,20 @@ type cliFixture struct { envTemplate string } +func newProjectWithoutInstallation(t *testing.T) string { + t.Helper() + root := t.TempDir() + for _, directory := range []string{"backend", "frontend", "harness", "tools", "deploy", ".git"} { + if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "compose.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + return root +} + func newCLIFixture(t *testing.T, envTemplate string) cliFixture { t.Helper() temporaryRoot, err := filepath.EvalSymlinks(os.TempDir()) diff --git a/tools/tht/internal/config/discovery.go b/tools/tht/internal/config/discovery.go new file mode 100644 index 00000000..ad1abd81 --- /dev/null +++ b/tools/tht/internal/config/discovery.go @@ -0,0 +1,144 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/aritmolab/thothii/tools/tht/internal/project" +) + +// Resolve selects one installation descriptor without searching arbitrary descendants. +// Explicit paths and THOTHII_INSTALLATION are authoritative; automatic discovery considers +// only the current directory, its parents, and immediate deploy/* descriptors. +func Resolve(explicit string, environment func(string) string, workingDirectory string) (string, error) { + if explicit != "" { + return validateSelectedPath(explicit, "--installation") + } + if environment != nil { + if value := strings.TrimSpace(environment("THOTHII_INSTALLATION")); value != "" { + return validateSelectedPath(value, "THOTHII_INSTALLATION") + } + } + + friendlyRoot, err := filepath.Abs(workingDirectory) + if err != nil { + return "", errors.New("installation discovery could not resolve the current directory") + } + info, err := os.Stat(friendlyRoot) + if err != nil || !info.IsDir() { + return "", errors.New("installation discovery could not read the current directory") + } + root, err := project.Discover(friendlyRoot) + if err != nil { + return "", fmt.Errorf("installation descriptor not found from %s; run tht setup or use --installation /%s", filepath.Clean(friendlyRoot), installationFileName) + } + start, err := filepath.EvalSymlinks(friendlyRoot) + if err != nil { + return "", errors.New("installation discovery could not resolve the current directory") + } + + candidates := make(map[string]string) + for directory := filepath.Clean(start); ; directory = filepath.Dir(directory) { + for _, path := range discoveryCandidates(directory) { + if hasArtifactsComponent(path) { + continue + } + info, statErr := os.Stat(path) + if statErr != nil || !info.Mode().IsRegular() { + continue + } + if _, loadErr := Load(path); loadErr != nil { + continue + } + canonical := canonicalPath(path) + candidates[canonical] = path + } + if directory == root.Path { + break + } + parent := filepath.Dir(directory) + if parent == directory { + break + } + } + + paths := make([]string, 0, len(candidates)) + for _, path := range candidates { + paths = append(paths, path) + } + sort.Strings(paths) + switch len(paths) { + case 1: + return paths[0], nil + case 0: + return "", fmt.Errorf("installation descriptor not found from %s; run tht setup or use --installation /%s", filepath.Clean(friendlyRoot), installationFileName) + default: + return "", fmt.Errorf("multiple valid installation descriptors found: %s; use --installation /%s", formatCandidates(paths, root.Path), installationFileName) + } +} + +func formatCandidates(paths []string, root string) string { + formatted := make([]string, 0, len(paths)) + for _, path := range paths { + id := filepath.Base(filepath.Dir(path)) + relative, err := filepath.Rel(filepath.Join(root, "deploy"), path) + if err == nil { + parts := strings.Split(filepath.Clean(relative), string(filepath.Separator)) + if len(parts) == 2 && parts[1] == installationFileName { + id = parts[0] + } + } + formatted = append(formatted, fmt.Sprintf("%s: %s", id, path)) + } + return strings.Join(formatted, ", ") +} + +func validateSelectedPath(path, source string) (string, error) { + if !filepath.IsAbs(path) { + return "", fmt.Errorf("%s must be an absolute path", source) + } + path = filepath.Clean(path) + if filepath.Base(path) != installationFileName { + return "", fmt.Errorf("%s must name %s", source, installationFileName) + } + if _, err := Load(path); err != nil { + return "", fmt.Errorf("%s is unavailable: %w", source, err) + } + return path, nil +} + +func discoveryCandidates(directory string) []string { + candidates := []string{filepath.Join(directory, installationFileName)} + deploy := filepath.Join(directory, "deploy") + entries, err := os.ReadDir(deploy) + if err != nil { + return candidates + } + for _, entry := range entries { + if entry.IsDir() { + candidates = append(candidates, filepath.Join(deploy, entry.Name(), installationFileName)) + } + } + sort.Strings(candidates) + return candidates +} + +func canonicalPath(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return filepath.Clean(resolved) + } + return filepath.Clean(path) +} + +func hasArtifactsComponent(path string) bool { + for _, component := range strings.Split(filepath.Clean(path), string(filepath.Separator)) { + if component == ".artifacts" { + return true + } + } + return false +} diff --git a/tools/tht/internal/config/discovery_test.go b/tools/tht/internal/config/discovery_test.go new file mode 100644 index 00000000..6273046b --- /dev/null +++ b/tools/tht/internal/config/discovery_test.go @@ -0,0 +1,150 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveExplicitPathWinsOverEnvironmentAndDiscovery(t *testing.T) { + root := t.TempDir() + writeDiscoverableProject(t, root) + first := writeDiscoverableInstallation(t, root, filepath.Join(root, "first")) + second := writeDiscoverableInstallation(t, root, filepath.Join(root, "second")) + + got, err := Resolve(first, func(string) string { return second }, root) + if err != nil { + t.Fatal(err) + } + if got != first { + t.Fatalf("Resolve() = %q, want explicit %q", got, first) + } +} + +func TestResolveUsesEnvironmentBeforeAutomaticDiscovery(t *testing.T) { + root := t.TempDir() + writeDiscoverableProject(t, root) + writeDiscoverableInstallation(t, root, filepath.Join(root, "deploy", "one")) + environmentPath := writeDiscoverableInstallation(t, root, filepath.Join(root, "deploy", "two")) + + got, err := Resolve("", func(name string) string { + if name == "THOTHII_INSTALLATION" { + return environmentPath + } + return "" + }, root) + if err != nil { + t.Fatal(err) + } + if got != environmentPath { + t.Fatalf("Resolve() = %q, want environment %q", got, environmentPath) + } +} + +func TestResolveFindsDeployDescriptorFromNestedDirectory(t *testing.T) { + root := t.TempDir() + writeDiscoverableProject(t, root) + want := writeDiscoverableInstallation(t, root, filepath.Join(root, "deploy", "psd")) + nested := filepath.Join(root, "tools", "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + got, err := Resolve("", func(string) string { return "" }, nested) + if err != nil { + t.Fatal(err) + } + canonicalWant, err := filepath.EvalSymlinks(want) + if err != nil { + t.Fatal(err) + } + if got != canonicalWant { + t.Fatalf("Resolve() = %q, want canonical %q", got, canonicalWant) + } +} + +func TestResolveIgnoresArtifactsAndRejectsAmbiguousValidCandidates(t *testing.T) { + root := t.TempDir() + writeDiscoverableProject(t, root) + artifacts := writeDiscoverableInstallation(t, root, filepath.Join(root, ".artifacts", "run")) + _ = artifacts + first := writeDiscoverableInstallation(t, root, filepath.Join(root, "deploy", "one")) + second := writeDiscoverableInstallation(t, root, filepath.Join(root, "deploy", "two")) + + _, err := Resolve("", func(string) string { return "" }, root) + if err == nil || !strings.Contains(err.Error(), "multiple") || !strings.Contains(err.Error(), "one") || !strings.Contains(err.Error(), "two") || !strings.Contains(err.Error(), first) || !strings.Contains(err.Error(), second) { + t.Fatalf("Resolve() error = %v, want ambiguity naming valid candidates", err) + } +} + +func TestResolveDoesNotSelectAnArtifactsDescriptor(t *testing.T) { + root := t.TempDir() + writeDiscoverableProject(t, root) + writeDiscoverableInstallation(t, root, filepath.Join(root, ".artifacts", "run")) + + _, err := Resolve("", func(string) string { return "" }, root) + if err == nil || !strings.Contains(err.Error(), "descriptor not found") { + t.Fatalf("Resolve() error = %v, want artifacts candidate excluded", err) + } +} + +func TestResolveReportsNoCandidateWithOverrideGuidance(t *testing.T) { + root := t.TempDir() + writeDiscoverableProject(t, root) + + _, err := Resolve("", func(string) string { return "" }, root) + if err == nil || !strings.Contains(err.Error(), "tht setup") || !strings.Contains(err.Error(), "--installation") { + t.Fatalf("Resolve() error = %v, want setup and override guidance", err) + } +} + +func TestResolveDoesNotSearchAboveTheDiscoveredProjectRoot(t *testing.T) { + container := t.TempDir() + root := filepath.Join(container, "thothii") + writeDiscoverableProject(t, root) + outside := writeDiscoverableInstallation(t, root, container) + + _, err := Resolve("", func(string) string { return "" }, filepath.Join(root, "frontend")) + if err == nil || !strings.Contains(err.Error(), "tht setup") || strings.Contains(err.Error(), outside) { + t.Fatalf("Resolve() error = %v, want project-bounded setup guidance", err) + } +} + +func writeDiscoverableProject(t *testing.T, root string) { + t.Helper() + for _, path := range []string{"backend", "frontend", "harness", "tools", "deploy"} { + if err := os.MkdirAll(filepath.Join(root, path), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "compose.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "deploy", "compose.local.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "operator.env"), nil, 0o600); err != nil { + t.Fatal(err) + } +} + +func writeDiscoverableInstallation(t *testing.T, projectRoot, directory string) string { + t.Helper() + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + envFile := filepath.Join(directory, "operator.env") + if err := os.WriteFile(envFile, nil, 0o600); err != nil { + t.Fatal(err) + } + installationPath := filepath.Join(directory, "thothii-installation.yaml") + contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\n" + if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return installationPath +} diff --git a/tools/tht/internal/project/discovery.go b/tools/tht/internal/project/discovery.go new file mode 100644 index 00000000..b54e4248 --- /dev/null +++ b/tools/tht/internal/project/discovery.go @@ -0,0 +1,79 @@ +// Package project finds a ThothII source checkout from a directory within it. +package project + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// Root identifies one ThothII checkout. Path is resolved for stable identity across symlinks. +type Root struct { + Path string + IsWorktree bool +} + +// Discover walks from start towards the filesystem root until it finds the ThothII source-layout +// contract. A regular .git file identifies a linked Git worktree; a .git directory identifies a +// main checkout. +func Discover(start string) (Root, error) { + if start == "" { + return Root{}, errors.New("ThothII project root could not be discovered from an empty directory") + } + abs, err := filepath.Abs(start) + if err != nil { + return Root{}, errors.New("ThothII project root could not resolve the current directory") + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return Root{}, fmt.Errorf("ThothII project root could not resolve %s", filepath.Clean(abs)) + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return Root{}, fmt.Errorf("ThothII project root could not read %s", filepath.Clean(abs)) + } + + for directory := filepath.Clean(resolved); ; directory = filepath.Dir(directory) { + if isRoot(directory) { + return Root{Path: directory, IsWorktree: isLinkedWorktree(directory)}, nil + } + parent := filepath.Dir(directory) + if parent == directory { + break + } + } + return Root{}, fmt.Errorf("ThothII project root not found from %s", filepath.Clean(abs)) +} + +func isRoot(directory string) bool { + if !isGitMarker(filepath.Join(directory, ".git")) || !isRegularFile(filepath.Join(directory, "compose.yaml")) || !isDirectory(filepath.Join(directory, "deploy")) { + return false + } + for _, name := range []string{"backend", "frontend", "harness", "tools"} { + if !isDirectory(filepath.Join(directory, name)) { + return false + } + } + return true +} + +func isLinkedWorktree(directory string) bool { + info, err := os.Lstat(filepath.Join(directory, ".git")) + return err == nil && info.Mode().IsRegular() +} + +func isGitMarker(path string) bool { + info, err := os.Lstat(path) + return err == nil && (info.IsDir() || info.Mode().IsRegular()) +} + +func isRegularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func isDirectory(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/tools/tht/internal/project/discovery_test.go b/tools/tht/internal/project/discovery_test.go new file mode 100644 index 00000000..cc04cd35 --- /dev/null +++ b/tools/tht/internal/project/discovery_test.go @@ -0,0 +1,100 @@ +package project + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiscoverFindsRepositoryRootFromNestedDirectory(t *testing.T) { + root := writeProjectRoot(t, false) + nested := filepath.Join(root, "tools", "tht", "internal") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + got, err := Discover(nested) + if err != nil { + t.Fatal(err) + } + want := resolvedPath(t, root) + if got.Path != want { + t.Fatalf("Discover() root = %q, want %q", got.Path, want) + } + if got.IsWorktree { + t.Fatal("Discover() marked a main checkout as a worktree") + } +} + +func TestDiscoverRecognizesLinkedGitWorktree(t *testing.T) { + root := writeProjectRoot(t, true) + + got, err := Discover(root) + if err != nil { + t.Fatal(err) + } + want := resolvedPath(t, root) + if got.Path != want { + t.Fatalf("Discover() root = %q, want %q", got.Path, want) + } + if !got.IsWorktree { + t.Fatal("Discover() did not mark a linked checkout as a worktree") + } +} + +func TestDiscoverResolvesSymlinkForRootIdentity(t *testing.T) { + root := writeProjectRoot(t, false) + alias := filepath.Join(t.TempDir(), "checkout") + if err := os.Symlink(root, alias); err != nil { + t.Fatal(err) + } + + got, err := Discover(filepath.Join(alias, "frontend")) + if err != nil { + t.Fatal(err) + } + want := resolvedPath(t, root) + if got.Path != want { + t.Fatalf("Discover() root = %q, want canonical %q", got.Path, want) + } +} + +func TestDiscoverRejectsDirectoriesThatAreNotThothIIProjects(t *testing.T) { + _, err := Discover(t.TempDir()) + if err == nil { + t.Fatal("Discover() error = nil, want root discovery failure") + } +} + +func writeProjectRoot(t *testing.T, linkedWorktree bool) string { + t.Helper() + root := t.TempDir() + for _, directory := range []string{"backend", "frontend", "harness", "tools", "deploy"} { + if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "compose.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + gitPath := filepath.Join(root, ".git") + if linkedWorktree { + if err := os.WriteFile(gitPath, []byte("gitdir: /tmp/thothii-worktree.git\n"), 0o600); err != nil { + t.Fatal(err) + } + return root + } + if err := os.Mkdir(gitPath, 0o755); err != nil { + t.Fatal(err) + } + return root +} + +func resolvedPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + return resolved +}