diff --git a/Makefile b/Makefile index c5b4487..4dcecb2 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ SANDBOX_IMAGE := $(REGISTRY):sandbox LAUNCHER_IMAGE := $(REGISTRY):launcher .PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ - test-unit test test-podman test-ocp test-all validate clean help + vet lint test-unit test test-podman test-ocp test-all validate clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -47,6 +47,22 @@ launcher: cli-launcher sandbox/launcher/Dockerfile sandbox/launcher/openshell push-launcher: launcher docker push $(LAUNCHER_IMAGE) +## ── Lint targets ───────────────────────────────────────────────────── + +## Run go vet +vet: + go vet ./... + cd sandbox/launcher && go vet ./... + +## Run golangci-lint (install: https://golangci-lint.run/usage/install/) +lint: + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run ./...; \ + else \ + echo "golangci-lint not installed, running go vet instead"; \ + $(MAKE) vet; \ + fi + ## ── Test targets ───────────────────────────────────────────────────── ## Unit tests only (no live gateway, fast) diff --git a/cmd/deploy.go b/cmd/deploy.go index e371f1c..a244a54 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -48,7 +48,12 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { return cmd } -func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.Runner) error { +func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.Runner) (retErr error) { + defer func() { + if retErr != nil { + fmt.Fprintf(os.Stderr, "\nDeploy failed. Clean up with: harness teardown --k8s\n") + } + }() ctx := context.Background() namespace := k8s.DefaultNamespace() @@ -128,7 +133,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.R if sps := os.Getenv("SANDBOX_PULL_SECRET"); sps != "" { helmArgs = append(helmArgs, "--set", "server.sandboxImagePullSecrets[0].name="+sps) } - if _, err := kc.RunHelm(ctx, helmArgs...); err != nil { + if err := kc.RunHelm(ctx, helmArgs...); err != nil { return fmt.Errorf("helm install failed: %w", err) } @@ -166,7 +171,10 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.R } // Extract mTLS certs - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("determining home directory: %w", err) + } mtlsDir := filepath.Join(home, ".config", "openshell", "gateways", gatewayName, "mtls") for _, field := range []string{"ca.crt", "tls.crt", "tls.key"} { data, err := kc.GetSecretField(ctx, "openshell-client-tls", field) @@ -178,21 +186,26 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.R } } - gw.GatewaySelect(gatewayName) + if err := gw.GatewaySelect(gatewayName); err != nil { + return fmt.Errorf("selecting gateway %s: %w", gatewayName, err) + } status.OKf("%s registered (certs from cluster)", gatewayName) // Wait for gateway to be reachable fmt.Print(" Waiting for gateway...") - for i := range 30 { + var gwReachable bool + for range 30 { if gw.InferenceGet() == nil { + gwReachable = true status.OK("reachable") break } time.Sleep(2 * time.Second) fmt.Print(".") - if i == 29 { - status.Fail("timed out (try: openshell inference get)") - } + } + if !gwReachable { + fmt.Println() + return fmt.Errorf("gateway not reachable after 60s (try: openshell inference get)") } fmt.Println() diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go new file mode 100644 index 0000000..6b377c9 --- /dev/null +++ b/cmd/helpers_test.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +type mockGW struct { + inferenceErr error + providers map[string]bool + providerList []string + providerErr error + createErr error + createCalls int + createOpts []gateway.SandboxCreateOpts + deletedNames []string + gatewayListResult []gateway.GatewayInfo + onGatewayRemove func(string) +} + +func (m *mockGW) InferenceGet() error { return m.inferenceErr } +func (m *mockGW) ProviderGet(name string) error { + if m.providers[name] { + return nil + } + return fmt.Errorf("not found") +} +func (m *mockGW) ProviderList() ([]string, error) { return m.providerList, m.providerErr } +func (m *mockGW) SandboxCreate(opts gateway.SandboxCreateOpts) error { + m.createCalls++ + m.createOpts = append(m.createOpts, opts) + if m.createErr != nil && m.createCalls == 1 { + return m.createErr + } + return nil +} +func (m *mockGW) SandboxDelete(name string) error { + m.deletedNames = append(m.deletedNames, name) + return nil +} +func (m *mockGW) CLIVersion() string { return "openshell v0.0.55" } +func (m *mockGW) CLIPath() string { return "/usr/bin/openshell" } +func (m *mockGW) InferenceModel() string { return "" } +func (m *mockGW) InferenceSet(string, string) error { return nil } +func (m *mockGW) InferenceRemove() error { return nil } +func (m *mockGW) ActiveGateway() string { return "" } +func (m *mockGW) ProviderCreate(string, string, gateway.ProviderCreateOpts) error { return nil } +func (m *mockGW) ProviderDelete(string) error { return nil } +func (m *mockGW) ProviderProfileImport(string) error { return nil } +func (m *mockGW) ProviderProfileDelete(string) error { return nil } +func (m *mockGW) SettingsSet(string, string) error { return nil } +func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } +func (m *mockGW) SandboxConnect(string) error { return nil } +func (m *mockGW) GatewayAdd(string, string, bool) error { return nil } +func (m *mockGW) GatewayRemove(name string) error { + if m.onGatewayRemove != nil { + m.onGatewayRemove(name) + } + return nil +} +func (m *mockGW) GatewayList() ([]gateway.GatewayInfo, error) { + return m.gatewayListResult, nil +} +func (m *mockGW) GatewaySelect(string) error { return nil } + +func setupTestProfile(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + os.WriteFile(filepath.Join(dir, "profiles", "default.toml"), []byte(` +name = "test-agent" +image = "quay.io/test:latest" +command = "claude --bare" +providers = ["github", "vertex-local", "atlassian"] + +[env] +FOO = "bar" +`), 0o644) + return dir +} diff --git a/cmd/new.go b/cmd/new.go index cbae526..8cb2510 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -30,6 +30,9 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { Short: "Create a new sandbox", Long: "Deploy gateway and providers if needed, then create a sandbox from a profile.", RunE: func(cmd *cobra.Command, args []string) error { + if local && remote { + return fmt.Errorf("--local and --remote are mutually exclusive") + } if len(args) > 0 && sandboxName == "" { sandboxName = args[0] } @@ -278,10 +281,11 @@ func newLocal(opts newLocalOpts) error { } // 5. Stage files - harnessUploadDir := "/tmp/openshell" - if err := os.RemoveAll(harnessUploadDir); err != nil { - return fmt.Errorf("cleaning staging dir: %w", err) + harnessUploadDir, err := os.MkdirTemp("", "harness-") + if err != nil { + return fmt.Errorf("creating staging dir: %w", err) } + defer os.RemoveAll(harnessUploadDir) if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil { return fmt.Errorf("staging files: %w", err) } @@ -320,5 +324,5 @@ func newLocal(opts newLocalOpts) error { } time.Sleep(opts.retrySleep) } - return nil + return nil // unreachable but required by compiler } diff --git a/cmd/new_test.go b/cmd/new_test.go index 1c93172..8414140 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -6,86 +6,8 @@ import ( "path/filepath" "strings" "testing" - - "github.com/robbycochran/harness-openshell/internal/gateway" ) -type mockGW struct { - inferenceErr error - providers map[string]bool - providerList []string - providerErr error - createErr error - createCalls int - createOpts []gateway.SandboxCreateOpts - deletedNames []string - gatewayListResult []gateway.GatewayInfo - onGatewayRemove func(string) -} - -func (m *mockGW) InferenceGet() error { return m.inferenceErr } -func (m *mockGW) ProviderGet(name string) error { - if m.providers[name] { - return nil - } - return fmt.Errorf("not found") -} -func (m *mockGW) ProviderList() ([]string, error) { return m.providerList, m.providerErr } -func (m *mockGW) SandboxCreate(opts gateway.SandboxCreateOpts) error { - m.createCalls++ - m.createOpts = append(m.createOpts, opts) - if m.createErr != nil && m.createCalls == 1 { - return m.createErr - } - return nil -} -func (m *mockGW) SandboxDelete(name string) error { - m.deletedNames = append(m.deletedNames, name) - return nil -} -func (m *mockGW) CLIVersion() string { return "openshell v0.0.55" } -func (m *mockGW) CLIPath() string { return "/usr/bin/openshell" } -func (m *mockGW) InferenceModel() string { return "" } -func (m *mockGW) InferenceSet(string, string) error { return nil } -func (m *mockGW) InferenceRemove() error { return nil } -func (m *mockGW) ActiveGateway() string { return "" } -func (m *mockGW) ProviderCreate(string, string, gateway.ProviderCreateOpts) error { return nil } -func (m *mockGW) ProviderDelete(string) error { return nil } -func (m *mockGW) ProviderProfileImport(string) error { return nil } -func (m *mockGW) ProviderProfileDelete(string) error { return nil } -func (m *mockGW) SettingsSet(string, string) error { return nil } -func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } -func (m *mockGW) SandboxConnect(string) error { return nil } -func (m *mockGW) SandboxUpload(string, string, string) error { return nil } -func (m *mockGW) SandboxExec(string, ...string) error { return nil } -func (m *mockGW) GatewayAdd(string, string, bool) error { return nil } -func (m *mockGW) GatewayRemove(name string) error { - if m.onGatewayRemove != nil { - m.onGatewayRemove(name) - } - return nil -} -func (m *mockGW) GatewayList() ([]gateway.GatewayInfo, error) { - return m.gatewayListResult, nil -} -func (m *mockGW) GatewaySelect(string) error { return nil } - -func setupTestProfile(t *testing.T) string { - t.Helper() - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) - os.WriteFile(filepath.Join(dir, "profiles", "default.toml"), []byte(` -name = "test-agent" -image = "quay.io/test:latest" -command = "claude --bare" -providers = ["github", "vertex-local", "atlassian"] - -[env] -FOO = "bar" -`), 0o644) - return dir -} - func TestNewLocal_NoGateway(t *testing.T) { dir := setupTestProfile(t) gw := &mockGW{inferenceErr: fmt.Errorf("connection refused")} diff --git a/cmd/preflight.go b/cmd/preflight.go index 354ff79..ace111b 100644 --- a/cmd/preflight.go +++ b/cmd/preflight.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/spf13/cobra" @@ -13,13 +15,14 @@ func NewPreflightCmd(harnessDir, cli string) *cobra.Command { Use: "preflight", Short: "Check environment prerequisites", RunE: func(cmd *cobra.Command, args []string) error { - // Support subcommands: available, names if len(args) > 0 { switch args[0] { case "available": return preflight.RunAvailable(harnessDir) case "names": return preflight.RunNames(harnessDir) + default: + return fmt.Errorf("unknown preflight subcommand: %s (use 'available' or 'names')", args[0]) } } gw := gateway.New(cli) diff --git a/cmd/providers.go b/cmd/providers.go index fe80bb9..e3c37c2 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -10,7 +10,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/status" - "github.com/robbycochran/harness-openshell/internal/util" "github.com/spf13/cobra" ) @@ -98,7 +97,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool) error project = readADCProject(adcPath) } - if util.FileExists(adcPath) && project != "" { + if fileExists(adcPath) && project != "" { if gw.ProviderGet("vertex-local") != nil { if err := gw.ProviderCreate("vertex-local", "google-vertex-ai", gateway.ProviderCreateOpts{ FromADC: true, @@ -117,7 +116,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool) error return fmt.Errorf("setting inference: %w", err) } status.OKf("inference: model %s", model) - } else if !util.FileExists(adcPath) { + } else if !fileExists(adcPath) { status.Infof("vertex-local: skipped (no ADC file at %s)", adcPath) } else { status.Info("vertex-local: skipped (no project ID — set ANTHROPIC_VERTEX_PROJECT_ID)") @@ -190,6 +189,11 @@ func extractYAMLID(path string) string { return "" } +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + func readADCProject(path string) string { data, err := os.ReadFile(path) if err != nil { diff --git a/cmd/teardown.go b/cmd/teardown.go index 6b97a93..72a5dd5 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -24,9 +24,7 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { Short: "Tear down sandboxes, providers, or k8s resources", RunE: func(cmd *cobra.Command, args []string) error { if !sandboxes && !providers && !k8sFlag { - sandboxes = true - providers = true - k8sFlag = true + return fmt.Errorf("specify at least one of --sandboxes, --providers, or --k8s") } gw := gateway.New(cli) @@ -149,7 +147,7 @@ func teardownK8s(gw gateway.Gateway, kc, clusterRunner k8s.Runner) { // Helm release status.Section("Helm release") - if _, err := kc.RunHelm(ctx, "uninstall", "openshell"); err == nil { + if err := kc.RunHelm(ctx, "uninstall", "openshell"); err == nil { status.Info("Uninstalled") } else { status.Info("Not installed") diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 4c00fbe..8338d92 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -108,18 +108,7 @@ func (c *CLI) ProviderList() ([]string, error) { if err != nil { return nil, err } - var names []string - for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if i == 0 || strings.TrimSpace(line) == "" { - continue // skip header - } - cleaned := ansiRE.ReplaceAllString(line, "") - fields := strings.Fields(cleaned) - if len(fields) > 0 { - names = append(names, fields[0]) - } - } - return names, nil + return parseFirstColumn(out), nil } func (c *CLI) InferenceRemove() error { @@ -139,18 +128,7 @@ func (c *CLI) SandboxList() ([]string, error) { if err != nil { return nil, err } - var names []string - for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - if i == 0 || strings.TrimSpace(line) == "" { - continue - } - cleaned := ansiRE.ReplaceAllString(line, "") - fields := strings.Fields(cleaned) - if len(fields) > 0 { - names = append(names, fields[0]) - } - } - return names, nil + return parseFirstColumn(out), nil } func (c *CLI) GatewayList() ([]GatewayInfo, error) { @@ -243,14 +221,18 @@ func (c *CLI) SandboxConnect(name string) error { return syscall.Exec(path, args, os.Environ()) } -func (c *CLI) SandboxUpload(name, localDir, remotePath string) error { - return c.passthrough("sandbox", "upload", name, localDir, remotePath, "--no-git-ignore") -} - -func (c *CLI) SandboxExec(name string, command ...string) error { - args := []string{"sandbox", "exec", "--name", name, "--"} - args = append(args, command...) - return c.passthrough(args...) +func parseFirstColumn(out []byte) []string { + var names []string + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if i == 0 || strings.TrimSpace(line) == "" { + continue + } + cleaned := ansiRE.ReplaceAllString(line, "") + if fields := strings.Fields(cleaned); len(fields) > 0 { + names = append(names, fields[0]) + } + } + return names } // passthrough runs the CLI with stdin/stdout/stderr connected. diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index b698f1c..26ca80c 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1,38 +1,28 @@ package gateway -// Focused sub-interfaces aligned with OpenShell proto service domains. -// Each consumer imports only the interface it needs, simplifying mocks. - -// ProviderManager handles provider CRUD and profile operations. -type ProviderManager interface { +// Gateway abstracts all openshell CLI operations. +type Gateway interface { + // Providers ProviderGet(name string) error ProviderCreate(name, providerType string, opts ProviderCreateOpts) error ProviderDelete(name string) error ProviderList() ([]string, error) ProviderProfileImport(dir string) error ProviderProfileDelete(id string) error -} -// SandboxManager handles sandbox lifecycle operations. -type SandboxManager interface { + // Sandboxes SandboxList() ([]string, error) SandboxCreate(opts SandboxCreateOpts) error SandboxDelete(name string) error SandboxConnect(name string) error - SandboxUpload(name, localDir, remotePath string) error - SandboxExec(name string, command ...string) error -} -// InferenceConfig handles inference routing configuration. -type InferenceConfig interface { + // Inference InferenceGet() error InferenceModel() string InferenceSet(provider, model string) error InferenceRemove() error -} -// GatewayAdmin handles gateway management, CLI detection, and settings. -type GatewayAdmin interface { + // Gateway management CLIVersion() string CLIPath() string ActiveGateway() string @@ -43,15 +33,6 @@ type GatewayAdmin interface { SettingsSet(key, value string) error } -// Gateway composes all sub-interfaces. Use this when a function needs -// multiple domains. Prefer the narrower interfaces when possible. -type Gateway interface { - ProviderManager - SandboxManager - InferenceConfig - GatewayAdmin -} - type ProviderCreateOpts struct { Credentials []string Configs []string diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go index 0f8ffeb..654c65f 100644 --- a/internal/k8s/kubectl.go +++ b/internal/k8s/kubectl.go @@ -32,7 +32,7 @@ type Runner interface { RunKubectlOpts(ctx context.Context, opts KubectlOpts) (string, error) RunKubectlQuiet(ctx context.Context, args ...string) error RunKubectlPassthrough(ctx context.Context, args ...string) error - RunHelm(ctx context.Context, args ...string) (string, error) + RunHelm(ctx context.Context, args ...string) error RunOC(ctx context.Context, args ...string) error ApplyYAML(ctx context.Context, resources ...map[string]any) error SecretExists(ctx context.Context, name string) bool @@ -79,12 +79,11 @@ func (c *Client) RunKubectlOpts(ctx context.Context, opts KubectlOpts) (string, } var stdout, stderr bytes.Buffer + cmd.Stderr = &stderr if opts.Quiet { cmd.Stdout = io.Discard - cmd.Stderr = io.Discard } else { cmd.Stdout = &stdout - cmd.Stderr = &stderr } lastErr = cmd.Run() @@ -92,18 +91,20 @@ func (c *Client) RunKubectlOpts(ctx context.Context, opts KubectlOpts) (string, return strings.TrimSpace(stdout.String()), nil } - errOutput := stderr.String() + lastErr.Error() + errOutput := stderr.String() + " " + lastErr.Error() if !isTransient(errOutput) { - return "", fmt.Errorf("kubectl %s: %s", strings.Join(opts.Args, " "), strings.TrimSpace(stderr.String())) + return "", fmt.Errorf("kubectl %s: %s", strings.Join(args, " "), strings.TrimSpace(stderr.String())) } if attempt < 2 { delay := time.Duration(1< `+stdinFile+` } data, _ := os.ReadFile(stdinFile) content := string(data) - if !contains(content, "kind: ServiceAccount") { + if !strings.Contains(content, "kind: ServiceAccount") { t.Errorf("YAML missing ServiceAccount: %s", content) } - if !contains(content, "name: test-sa") { + if !strings.Contains(content, "name: test-sa") { t.Errorf("YAML missing name: %s", content) } } @@ -278,15 +279,3 @@ func TestDefaultNamespace_Default(t *testing.T) { } } -func contains(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && s != substr && indexOf(s, substr) >= 0 -} - -func indexOf(s, substr string) int { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - return -1 -} diff --git a/internal/k8s/mock.go b/internal/k8s/mock.go index ba720b4..4c1cf16 100644 --- a/internal/k8s/mock.go +++ b/internal/k8s/mock.go @@ -58,8 +58,9 @@ func (m *MockRunner) RunKubectlPassthrough(_ context.Context, args ...string) er return err } -func (m *MockRunner) RunHelm(_ context.Context, args ...string) (string, error) { - return m.respond(m.record(append([]string{"helm"}, args...)...)) +func (m *MockRunner) RunHelm(_ context.Context, args ...string) error { + _, err := m.respond(m.record(append([]string{"helm"}, args...)...)) + return err } func (m *MockRunner) RunOC(_ context.Context, args ...string) error { diff --git a/internal/preflight/check.go b/internal/preflight/check.go deleted file mode 100644 index 186f3d1..0000000 --- a/internal/preflight/check.go +++ /dev/null @@ -1,217 +0,0 @@ -package preflight - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/status" -) - -func loadEnabledProviders(harnessDir string) ([]Provider, error) { - providersPath := os.Getenv("PROVIDERS_TOML") - if providersPath == "" { - providersPath = filepath.Join(harnessDir, "providers.toml") - } - configPath := os.Getenv("CONFIG_TOML") - if configPath == "" { - configPath = filepath.Join(harnessDir, "openshell.toml") - } - all, err := LoadProviders(providersPath) - if err != nil { - return nil, err - } - config, _ := LoadConfig(configPath) - return EnabledProviders(all, config), nil -} - -func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { - providers, err := loadEnabledProviders(harnessDir) - if err != nil { - return err - } - - hasFailures := false - - // CLI detection - fmt.Println("=== OpenShell CLI ===") - cliPath := gw.CLIPath() - cliFound := cliPath != "" - if !cliFound { - status.Fail("not found on PATH") - hasFailures = true - } else { - ver := gw.CLIVersion() - if ver != "" { - status.OK(ver) - } else { - status.OK("openshell") - } - status.Detail(cliPath) - } - - // Detect active gateway - activeGW := "" - if cliFound { - activeGW = gw.ActiveGateway() - } - isK8s := strings.Contains(activeGW, "-remote-") - - // Gateway check - gwOK := false - if isK8s { - status.Section("K8s gateway") - kubectlPath, _ := exec.LookPath("kubectl") - if kubectlPath == "" { - status.Fail("kubectl not found") - hasFailures = true - } else { - ctx := runOutput("kubectl", "config", "current-context") - if ctx != "" { - status.OKf("Cluster: %s", ctx) - if cliFound { - if gw.InferenceGet() == nil { - gwOK = true - model := gw.InferenceModel() - if model != "" { - status.OKf("Gateway reachable (model: %s)", model) - } else { - status.OK("Gateway reachable") - } - } else { - status.Fail("Gateway unreachable") - } - } - } else { - status.Fail("No cluster (kubectl not configured)") - hasFailures = true - } - } - } else { - status.Section("Podman gateway") - if cliFound { - if gw.InferenceGet() == nil { - gwOK = true - model := gw.InferenceModel() - if model != "" { - status.OKf("Reachable (model: %s)", model) - } else { - status.OK("Reachable") - } - } else { - status.Info("Not running") - } - - podmanPath, _ := exec.LookPath("podman") - if podmanPath != "" { - ver := runOutput("podman", "--version") - status.OKf("Podman: %s", ver) - } else { - status.Fail("Podman not found") - hasFailures = true - } - } else { - status.Info("CLI not available") - } - } - - // Registered providers - if cliFound && gwOK { - gwLabel := "podman" - if isK8s { - gwLabel = "k8s" - } - status.Section(fmt.Sprintf("Registered providers (%s)", gwLabel)) - for _, p := range providers { - if p.Type != "openshell" { - continue - } - if gw.ProviderGet(p.Name) == nil { - status.OK(p.Name) - } else { - status.Failf("%s: not registered — run ./setup-providers.sh", p.Name) - hasFailures = true - } - } - } - - // Provider inputs - status.Section("Provider inputs") - for _, p := range providers { - ok, details := CheckProvider(p) - if ok { - status.OK(p.Name) - } else { - status.Fail(p.Name) - if p.Required { - hasFailures = true - } - } - status.Detail(p.Description) - - for _, d := range details { - status.Sub(d) - } - - if p.Upstream != "" && !ok { - status.Sub(fmt.Sprintf("upstream: %s", p.Upstream)) - } - fmt.Println() - } - - // Summary - status.Summary(!hasFailures) - if hasFailures && strict { - return fmt.Errorf("preflight: required checks failed") - } - return nil -} - -func RunAvailable(harnessDir string) error { - providers, err := loadEnabledProviders(harnessDir) - if err != nil { - return err - } - - var available []string - for _, p := range providers { - if p.Type != "openshell" { - continue - } - ok, _ := CheckProvider(p) - if ok { - available = append(available, p.Name) - } - } - fmt.Println(strings.Join(available, " ")) - return nil -} - -func RunNames(harnessDir string) error { - providers, err := loadEnabledProviders(harnessDir) - if err != nil { - return err - } - - var names []string - for _, p := range providers { - if p.Type == "openshell" { - names = append(names, p.Name) - } - } - fmt.Println(strings.Join(names, " ")) - return nil -} - -func runOutput(name string, args ...string) string { - cmd := exec.Command(name, args...) - cmd.Stderr = nil - out, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 365bc4a..5b281e5 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -1,6 +1,7 @@ package preflight import ( + "context" "encoding/json" "fmt" "os" @@ -10,6 +11,8 @@ import ( "time" "github.com/BurntSushi/toml" + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" ) type Provider struct { @@ -186,19 +189,10 @@ func expandPath(p string) string { return os.ExpandEnv(p) } -func runQuiet(cmd string) bool { - ctx := exec.Command("bash", "-c", cmd) - ctx.Stdout = nil - ctx.Stderr = nil - done := make(chan error, 1) - go func() { done <- ctx.Run() }() - select { - case err := <-done: - return err == nil - case <-time.After(5 * time.Second): - ctx.Process.Kill() - return false - } +func runQuiet(command string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return exec.CommandContext(ctx, "bash", "-c", command).Run() == nil } func pickKeys(m map[string]string, keys ...string) map[string]string { @@ -234,3 +228,201 @@ func formatMeta(m map[string]string) []string { } return parts } + +func loadEnabledProviders(harnessDir string) ([]Provider, error) { + providersPath := os.Getenv("PROVIDERS_TOML") + if providersPath == "" { + providersPath = filepath.Join(harnessDir, "providers.toml") + } + configPath := os.Getenv("CONFIG_TOML") + if configPath == "" { + configPath = filepath.Join(harnessDir, "openshell.toml") + } + all, err := LoadProviders(providersPath) + if err != nil { + return nil, err + } + config, _ := LoadConfig(configPath) + return EnabledProviders(all, config), nil +} + +func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { + providers, err := loadEnabledProviders(harnessDir) + if err != nil { + return err + } + + hasFailures := false + + fmt.Println("=== OpenShell CLI ===") + cliPath := gw.CLIPath() + cliFound := cliPath != "" + if !cliFound { + status.Fail("not found on PATH") + hasFailures = true + } else { + ver := gw.CLIVersion() + if ver != "" { + status.OK(ver) + } else { + status.OK("openshell") + } + status.Detail(cliPath) + } + + activeGW := "" + if cliFound { + activeGW = gw.ActiveGateway() + } + isK8s := strings.Contains(activeGW, "-remote-") + + gwOK := false + if isK8s { + status.Section("K8s gateway") + kubectlPath, _ := exec.LookPath("kubectl") + if kubectlPath == "" { + status.Fail("kubectl not found") + hasFailures = true + } else { + ctx := runOutput("kubectl", "config", "current-context") + if ctx != "" { + status.OKf("Cluster: %s", ctx) + if cliFound { + if gw.InferenceGet() == nil { + gwOK = true + model := gw.InferenceModel() + if model != "" { + status.OKf("Gateway reachable (model: %s)", model) + } else { + status.OK("Gateway reachable") + } + } else { + status.Fail("Gateway unreachable") + } + } + } else { + status.Fail("No cluster (kubectl not configured)") + hasFailures = true + } + } + } else { + status.Section("Podman gateway") + if cliFound { + if gw.InferenceGet() == nil { + gwOK = true + model := gw.InferenceModel() + if model != "" { + status.OKf("Reachable (model: %s)", model) + } else { + status.OK("Reachable") + } + } else { + status.Info("Not running") + } + + podmanPath, _ := exec.LookPath("podman") + if podmanPath != "" { + ver := runOutput("podman", "--version") + status.OKf("Podman: %s", ver) + } else { + status.Fail("Podman not found") + hasFailures = true + } + } else { + status.Info("CLI not available") + } + } + + if cliFound && gwOK { + gwLabel := "podman" + if isK8s { + gwLabel = "k8s" + } + status.Section(fmt.Sprintf("Registered providers (%s)", gwLabel)) + for _, p := range providers { + if p.Type != "openshell" { + continue + } + if gw.ProviderGet(p.Name) == nil { + status.OK(p.Name) + } else { + status.Failf("%s: not registered — run: harness providers", p.Name) + hasFailures = true + } + } + } + + status.Section("Provider inputs") + for _, p := range providers { + ok, details := CheckProvider(p) + if ok { + status.OK(p.Name) + } else { + status.Fail(p.Name) + if p.Required { + hasFailures = true + } + } + status.Detail(p.Description) + + for _, d := range details { + status.Sub(d) + } + + if p.Upstream != "" && !ok { + status.Sub(fmt.Sprintf("upstream: %s", p.Upstream)) + } + fmt.Println() + } + + status.Summary(!hasFailures) + if hasFailures && strict { + return fmt.Errorf("preflight: required checks failed") + } + return nil +} + +func RunAvailable(harnessDir string) error { + providers, err := loadEnabledProviders(harnessDir) + if err != nil { + return err + } + + var available []string + for _, p := range providers { + if p.Type != "openshell" { + continue + } + ok, _ := CheckProvider(p) + if ok { + available = append(available, p.Name) + } + } + fmt.Println(strings.Join(available, " ")) + return nil +} + +func RunNames(harnessDir string) error { + providers, err := loadEnabledProviders(harnessDir) + if err != nil { + return err + } + + var names []string + for _, p := range providers { + if p.Type == "openshell" { + names = append(names, p.Name) + } + } + fmt.Println(strings.Join(names, " ")) + return nil +} + +func runOutput(name string, args ...string) string { + cmd := exec.Command(name, args...) + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/profile/parse.go b/internal/profile/parse.go deleted file mode 100644 index e00ee66..0000000 --- a/internal/profile/parse.go +++ /dev/null @@ -1,29 +0,0 @@ -package profile - -import ( - "fmt" - "path/filepath" - - "github.com/BurntSushi/toml" -) - -// Parse reads a profile TOML file and returns a Config with defaults applied. -func Parse(harnessDir, name string) (*Config, error) { - path := filepath.Join(harnessDir, "profiles", name+".toml") - return ParseFile(path) -} - -// ParseFile reads a profile TOML file by path. -func ParseFile(path string) (*Config, error) { - var cfg Config - if _, err := toml.DecodeFile(path, &cfg); err != nil { - return nil, fmt.Errorf("parsing %s: %w", path, err) - } - if cfg.Name == "" { - cfg.Name = "agent" - } - if cfg.Command == "" { - cfg.Command = "claude --bare" - } - return &cfg, nil -} diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 494a5ac..8e933a3 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -9,10 +9,10 @@ import ( "sort" "strings" + "github.com/BurntSushi/toml" ) -// ProviderChecker checks if a provider is registered. Accepts gateway.ProviderManager -// or any type with a ProviderGet method — decouples profile from the full Gateway interface. +// ProviderChecker checks if a provider is registered. type ProviderChecker interface { ProviderGet(name string) error } @@ -44,11 +44,32 @@ func (c *Config) BuildSandboxEnv() string { sort.Strings(keys) var b strings.Builder for _, k := range keys { - fmt.Fprintf(&b, "export %s=%s\n", k, c.Env[k]) + fmt.Fprintf(&b, "export %s=%q\n", k, c.Env[k]) } return b.String() } +// Parse reads a profile TOML file and returns a Config with defaults applied. +func Parse(harnessDir, name string) (*Config, error) { + path := filepath.Join(harnessDir, "profiles", name+".toml") + return ParseFile(path) +} + +// ParseFile reads a profile TOML file by path. +func ParseFile(path string) (*Config, error) { + var cfg Config + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if cfg.Name == "" { + cfg.Name = "agent" + } + if cfg.Command == "" { + cfg.Command = "claude --bare" + } + return &cfg, nil +} + // ValidateProviders checks which profile providers are registered on the // gateway. Returns the list of registered providers and a list of missing ones. func ValidateProviders(providers []string, gw ProviderChecker) (registered, missing []string) { @@ -70,7 +91,7 @@ func StageHarnessDir(cfg *Config, harnessDir string) error { envContent := cfg.BuildSandboxEnv() if envContent != "" { - if err := os.WriteFile(filepath.Join(harnessDir, "sandbox.env"), []byte(envContent), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(harnessDir, "sandbox.env"), []byte(envContent), 0o600); err != nil { return fmt.Errorf("writing sandbox.env: %w", err) } lines := strings.Count(envContent, "\n") diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 1a249e9..9a28616 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -115,10 +115,10 @@ func TestBuildSandboxEnv(t *testing.T) { if len(lines) != 2 { t.Fatalf("expected 2 lines, got %d: %q", len(lines), env) } - if lines[0] != "export APPLE=a" { - t.Errorf("first line = %q (should be sorted)", lines[0]) + if lines[0] != `export APPLE="a"` { + t.Errorf("first line = %q (should be sorted and quoted)", lines[0]) } - if lines[1] != "export ZEBRA=z" { + if lines[1] != `export ZEBRA="z"` { t.Errorf("second line = %q", lines[1]) } } @@ -145,7 +145,7 @@ func TestStageHarnessDir(t *testing.T) { if err != nil { t.Fatalf("reading sandbox.env: %v", err) } - if !strings.Contains(string(data), "export FOO=bar") { + if !strings.Contains(string(data), `export FOO="bar"`) { t.Errorf("sandbox.env = %q", string(data)) } } diff --git a/internal/status/status.go b/internal/status/status.go index 945d4fe..42a936d 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -23,7 +23,6 @@ func OKf(format string, a ...any) { fmt.Printf(" ✓ "+format+"\n", a...) } func Fail(msg string) { fmt.Println(" ✗ " + msg) } func Failf(format string, a ...any) { fmt.Printf(" ✗ "+format+"\n", a...) } func Warn(msg string) { fmt.Println(" ! " + msg) } -func Warnf(format string, a ...any) { fmt.Printf(" ! "+format+"\n", a...) } func Info(msg string) { fmt.Println(" - " + msg) } func Infof(format string, a ...any) { fmt.Printf(" - "+format+"\n", a...) } func Detail(msg string) { fmt.Println(" " + msg) } diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 473a8b1..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,8 +0,0 @@ -package util - -import "os" - -func FileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} diff --git a/main.go b/main.go index ff312a1..0cd30e5 100644 --- a/main.go +++ b/main.go @@ -51,19 +51,15 @@ func detectHarnessDir() string { if d := os.Getenv("HARNESS_DIR"); d != "" { return d } - ex, err := os.Executable() - if err == nil { - dir := filepath.Dir(ex) - for range 5 { - if _, err := os.Stat(filepath.Join(dir, "profiles", "default.toml")); err == nil { - return dir - } - dir = filepath.Dir(dir) - } + var roots []string + if ex, err := os.Executable(); err == nil { + roots = append(roots, filepath.Dir(ex)) + } + if cwd, err := os.Getwd(); err == nil { + roots = append(roots, cwd) } - cwd, err := os.Getwd() - if err == nil { - dir := cwd + for _, root := range roots { + dir := root for range 5 { if _, err := os.Stat(filepath.Join(dir, "profiles", "default.toml")); err == nil { return dir @@ -71,6 +67,7 @@ func detectHarnessDir() string { dir = filepath.Dir(dir) } } - fmt.Fprintf(os.Stderr, "WARNING: could not detect harness directory (set HARNESS_DIR)\n") - return "." + fmt.Fprintf(os.Stderr, "ERROR: could not detect harness directory — set HARNESS_DIR or run from the project root\n") + os.Exit(1) + return "" }