diff --git a/cli/internal/cli/connect.go b/cli/internal/cli/connect.go index 5c75071..b83979d 100644 --- a/cli/internal/cli/connect.go +++ b/cli/internal/cli/connect.go @@ -86,7 +86,7 @@ func runConnect(ctx context.Context, commandContext *commandContext, kubeClient if !connectedBefore || kube.IsNotFound(err) { return err } - if _, werr := fmt.Fprintf(commandContext.err, "Reconnect to %s/%s failed: %v; retrying in %s...\n", namespace, name, err, backoff); werr != nil { + if _, werr := fmt.Fprintf(commandContext.err, "Reconnect to %s/%s failed: %v; retrying in %s...\n", namespace, name, err, backoff); werr != nil { // ui-passthrough-ok return werr } select { @@ -117,14 +117,14 @@ func runConnect(ctx context.Context, commandContext *commandContext, kubeClient warnRemoveEndpointsFile(commandContext.err, path) // Best-effort status: a clean interrupt must not become a failure // exit code over a stderr write hiccup. - _, _ = fmt.Fprintf(commandContext.err, "Disconnecting from %s/%s.\n", namespace, name) + _, _ = fmt.Fprintf(commandContext.err, "Disconnecting from %s/%s.\n", namespace, name) // ui-passthrough-ok return nil case <-connected.Done(): reason := connected.Err() _ = connected.Close() warnRemoveEndpointsFile(commandContext.err, path) - if _, err := fmt.Fprintf(commandContext.err, "Forwards to %s/%s dropped (%v); re-establishing...\n", namespace, name, reason); err != nil { + if _, err := fmt.Fprintf(commandContext.err, "Forwards to %s/%s dropped (%v); re-establishing...\n", namespace, name, reason); err != nil { // ui-passthrough-ok return err } } diff --git a/cli/internal/cli/devnet.go b/cli/internal/cli/devnet.go index 750d1e0..fa0cfa7 100644 --- a/cli/internal/cli/devnet.go +++ b/cli/internal/cli/devnet.go @@ -111,7 +111,7 @@ func newDevnetDownCommand(commandContext *commandContext) *cobra.Command { return err } - _, err = fmt.Fprintln(commandContext.out, "devnet cluster removed.") + _, err = fmt.Fprintln(commandContext.out, "devnet cluster removed.") // ui-passthrough-ok return err }, } diff --git a/cli/internal/cli/down.go b/cli/internal/cli/down.go index 756e239..7a29ef6 100644 --- a/cli/internal/cli/down.go +++ b/cli/internal/cli/down.go @@ -43,7 +43,7 @@ func newDownCommand(commandContext *commandContext) *cobra.Command { return err } - if _, err := fmt.Fprintf(commandContext.err, "Deleting CardanoNetwork %s/%s...\n", namespace, name); err != nil { + if _, err := fmt.Fprintf(commandContext.err, "Deleting CardanoNetwork %s/%s...\n", namespace, name); err != nil { // ui-passthrough-ok return fmt.Errorf("write delete status: %w", err) } if err := kubeClient.DeleteCardanoNetwork(cmd.Context(), namespace, name); err != nil { @@ -54,7 +54,7 @@ func newDownCommand(commandContext *commandContext) *cobra.Command { if err := kube.WaitGone(cmd.Context(), kubeClient, namespace, name, timeout); err != nil { return err } - if _, err := fmt.Fprintf(commandContext.err, "CardanoNetwork %s/%s is gone.\n", namespace, name); err != nil { + if _, err := fmt.Fprintf(commandContext.err, "CardanoNetwork %s/%s is gone.\n", namespace, name); err != nil { // ui-passthrough-ok return fmt.Errorf("write gone status: %w", err) } } diff --git a/cli/internal/cli/info.go b/cli/internal/cli/info.go index fc2120f..dd46a9d 100644 --- a/cli/internal/cli/info.go +++ b/cli/internal/cli/info.go @@ -47,7 +47,7 @@ func newInfoCommand(commandContext *commandContext) *cobra.Command { if err != nil { return fmt.Errorf("marshal info JSON: %w", err) } - if _, err := fmt.Fprintf(commandContext.out, "%s\n", encoded); err != nil { + if _, err := fmt.Fprintf(commandContext.out, "%s\n", encoded); err != nil { // ui-passthrough-ok return fmt.Errorf("write info JSON: %w", err) } return nil diff --git a/cli/internal/cli/install.go b/cli/internal/cli/install.go index fef44d6..d8fed1a 100644 --- a/cli/internal/cli/install.go +++ b/cli/internal/cli/install.go @@ -160,7 +160,7 @@ func runInstallPlan(ctx context.Context, commandContext *commandContext, install if installed == "" { installed = "none" } - _, err = fmt.Fprintf(commandContext.out, "Plan: %s operator (installed %s -> %s) in namespace %s\n", + _, err = fmt.Fprintf(commandContext.out, "Plan: %s operator (installed %s -> %s) in namespace %s\n", // ui-passthrough-ok actionVerb(decision.Action), installed, decision.TargetVersion, namespace) return err } @@ -196,11 +196,11 @@ func runInstall(ctx context.Context, commandContext *commandContext, report life } if waitReady { - _, err = fmt.Fprintf(commandContext.out, "Operator %s ready in namespace %s\n", state.Version, namespace) + _, err = fmt.Fprintf(commandContext.out, "Operator %s ready in namespace %s\n", state.Version, namespace) // ui-passthrough-ok return err } - _, err = fmt.Fprintf(commandContext.out, "Operator %s installed in namespace %s (not waiting for readiness)\n", state.Version, namespace) + _, err = fmt.Fprintf(commandContext.out, "Operator %s installed in namespace %s (not waiting for readiness)\n", state.Version, namespace) // ui-passthrough-ok return err } diff --git a/cli/internal/cli/list.go b/cli/internal/cli/list.go index 318c5cd..47565e6 100644 --- a/cli/internal/cli/list.go +++ b/cli/internal/cli/list.go @@ -49,7 +49,7 @@ func newListCommand(commandContext *commandContext) *cobra.Command { if err != nil { return fmt.Errorf("marshal list JSON: %w", err) } - if _, err := fmt.Fprintf(commandContext.out, "%s\n", encoded); err != nil { + if _, err := fmt.Fprintf(commandContext.out, "%s\n", encoded); err != nil { // ui-passthrough-ok return fmt.Errorf("write list JSON: %w", err) } return nil diff --git a/cli/internal/cli/orphan.go b/cli/internal/cli/orphan.go index 7c48744..8d32cfd 100644 --- a/cli/internal/cli/orphan.go +++ b/cli/internal/cli/orphan.go @@ -74,5 +74,5 @@ func (commandContext *commandContext) clearManagedStateRecord() { if err := store.Clear(); err != nil { return } - _, _ = fmt.Fprintln(commandContext.err, orphanedStateMessage) + _, _ = fmt.Fprintln(commandContext.err, orphanedStateMessage) // ui-passthrough-ok } diff --git a/cli/internal/cli/purity_test.go b/cli/internal/cli/purity_test.go new file mode 100644 index 0000000..746f6a7 --- /dev/null +++ b/cli/internal/cli/purity_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// rawStreamWrite matches the banned process/raw-stream write forms: any +// os.Std*, any writer-less fmt.Print family, lipgloss.Print*, and any fmt.Fprint +// family targeting the raw commandContext.out/err streams. Helper functions that +// take an io.Writer parameter are intentionally not matched — they receive +// cc.io.Data() once routing lands. Data must reach stdout only through +// cc.io.Data/Encode; the human plane only through cc.io.* helpers. +var rawStreamWrite = regexp.MustCompile( + `os\.Std(out|err|in)\b` + + `|fmt\.Print(f|ln)?\(` + + `|lipgloss\.Print` + + `|fmt\.Fprint(f|ln)?\(\s*commandContext\.(out|err)\b`, +) + +// passthroughPragma is the line-level escape hatch for the run/exec +// byte-transparent passthrough and the wallet_fund os.Stdout swap, plus the +// interim raw writes that later routing phases move onto cc.io. It is a per-line +// pragma (written "// ui-passthrough-ok"), never a whole-file allowlist; the +// bare token is matched so comment spacing does not matter. +const passthroughPragma = "ui-passthrough-ok" + +func TestNoRawStreamWrites(t *testing.T) { + t.Parallel() + + root := purityModuleRoot(t) + dir := filepath.Join(root, "cli", "internal", "cli") + + err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + for n, line := range strings.Split(string(data), "\n") { + // Skip full comment lines so prose mentioning os.Stdout / fmt.Print + // does not trip the guard; trailing-comment pragmas on real write + // lines are still honored below. + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + if !rawStreamWrite.MatchString(line) || strings.Contains(line, passthroughPragma) { + continue + } + rel, _ := filepath.Rel(root, path) + t.Errorf("%s:%d raw stream write; route through cc.io (or tag // %s for passthrough): %s", + rel, n+1, passthroughPragma, strings.TrimSpace(line)) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// purityModuleRoot walks up from the working directory to the first go.mod. +func purityModuleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find module root") + } + dir = parent + } +} diff --git a/cli/internal/cli/run.go b/cli/internal/cli/run.go index 3dcf4af..7d4b352 100644 --- a/cli/internal/cli/run.go +++ b/cli/internal/cli/run.go @@ -56,7 +56,7 @@ to the command instead of being parsed by yacd.`, _ = connected.Close() }() - if _, err := fmt.Fprintf(commandContext.err, "Connected to %s/%s; running %s\n", namespace, name, strings.Join(command, " ")); err != nil { + if _, err := fmt.Fprintf(commandContext.err, "Connected to %s/%s; running %s\n", namespace, name, strings.Join(command, " ")); err != nil { // ui-passthrough-ok return fmt.Errorf("write run status: %w", err) } diff --git a/cli/internal/cli/up.go b/cli/internal/cli/up.go index f6a5ab5..7ec3d23 100644 --- a/cli/internal/cli/up.go +++ b/cli/internal/cli/up.go @@ -77,11 +77,11 @@ func newUpCommand(commandContext *commandContext) *cobra.Command { return fmt.Errorf("write manifest: %w", err) } if len(manifest) == 0 || manifest[len(manifest)-1] != '\n' { - if _, err := fmt.Fprintln(commandContext.out); err != nil { + if _, err := fmt.Fprintln(commandContext.out); err != nil { // ui-passthrough-ok return fmt.Errorf("write manifest newline: %w", err) } } - _, err = fmt.Fprintf(commandContext.err, "Dry run: rendered CardanoNetwork %s/%s; no resources applied.\n", network.Namespace, network.Name) + _, err = fmt.Fprintf(commandContext.err, "Dry run: rendered CardanoNetwork %s/%s; no resources applied.\n", network.Namespace, network.Name) // ui-passthrough-ok return err } @@ -100,18 +100,18 @@ func newUpCommand(commandContext *commandContext) *cobra.Command { return err } commandContext.logger.Debug("Applied CardanoNetwork", "namespace", network.Namespace, "name", network.Name) - if _, err := fmt.Fprintf(commandContext.err, "Applied CardanoNetwork %s/%s.\n", network.Namespace, network.Name); err != nil { + if _, err := fmt.Fprintf(commandContext.err, "Applied CardanoNetwork %s/%s.\n", network.Namespace, network.Name); err != nil { // ui-passthrough-ok return fmt.Errorf("write apply status: %w", err) } if waitReady { - if _, err := fmt.Fprintf(commandContext.err, "Waiting for CardanoNetwork %s/%s to become ready...\n", network.Namespace, network.Name); err != nil { + if _, err := fmt.Fprintf(commandContext.err, "Waiting for CardanoNetwork %s/%s to become ready...\n", network.Namespace, network.Name); err != nil { // ui-passthrough-ok return fmt.Errorf("write wait status: %w", err) } if _, err := kube.WaitReady(cmd.Context(), kubeClient, network.Namespace, network.Name, timeout); err != nil { return err } - if _, err := fmt.Fprintf(commandContext.err, "CardanoNetwork %s/%s is ready.\n", network.Namespace, network.Name); err != nil { + if _, err := fmt.Fprintf(commandContext.err, "CardanoNetwork %s/%s is ready.\n", network.Namespace, network.Name); err != nil { // ui-passthrough-ok return fmt.Errorf("write ready status: %w", err) } } diff --git a/cli/internal/cli/version.go b/cli/internal/cli/version.go index 96545ab..b4cd3a5 100644 --- a/cli/internal/cli/version.go +++ b/cli/internal/cli/version.go @@ -16,7 +16,7 @@ func newVersionCommand(commandContext *commandContext) *cobra.Command { Short: "Print the yacd version", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - _, err := fmt.Fprintf(commandContext.out, "yacd %s (%s) built %s\n", + _, err := fmt.Fprintf(commandContext.out, "yacd %s (%s) built %s\n", // ui-passthrough-ok commandContext.build.Version, commandContext.build.Commit, commandContext.build.Date) return err }, diff --git a/cli/internal/cli/wallet.go b/cli/internal/cli/wallet.go index 136d7be..b8b8061 100644 --- a/cli/internal/cli/wallet.go +++ b/cli/internal/cli/wallet.go @@ -324,7 +324,7 @@ func newWalletRemoveCommand(commandContext *commandContext) *cobra.Command { return err } - _, err = fmt.Fprintf(commandContext.out, "Removed wallet %q from %s/%s.\n", name, walletCtx.namespace, walletCtx.name) + _, err = fmt.Fprintf(commandContext.out, "Removed wallet %q from %s/%s.\n", name, walletCtx.namespace, walletCtx.name) // ui-passthrough-ok return err }, } diff --git a/cli/internal/cli/wallet_fund.go b/cli/internal/cli/wallet_fund.go index f1235c3..8fedd05 100644 --- a/cli/internal/cli/wallet_fund.go +++ b/cli/internal/cli/wallet_fund.go @@ -143,7 +143,7 @@ func (commandContext *commandContext) fundWallet( // One-time notice to stderr so the otherwise-silent poll does not look // hung; it stays off stdout to keep --json output clean and is // best-effort because the transfer is already submitted. - _, _ = fmt.Fprintf(commandContext.err, "Waiting up to %s for %s to confirm on-chain...\n", request.awaitTimeout, result.TxID) + _, _ = fmt.Fprintf(commandContext.err, "Waiting up to %s for %s to confirm on-chain...\n", request.awaitTimeout, result.TxID) // ui-passthrough-ok if err := awaitConfirmation(ctx, confirmer, request.destinationAddress, result.TxID, request.awaitTimeout); err != nil { return fundResult{}, err } @@ -191,8 +191,8 @@ func sourceKeyMaterial(ctx context.Context, kubeClient kube.Client, namespace st // which retain the original streams, so its own output is unaffected. It is // safe because the CLI runs a single command synchronously. func redirectStdoutToStderr() func() { - original := os.Stdout - os.Stdout = os.Stderr + original := os.Stdout // ui-passthrough-ok + os.Stdout = os.Stderr // ui-passthrough-ok - return func() { os.Stdout = original } + return func() { os.Stdout = original } // ui-passthrough-ok } diff --git a/cli/internal/ui/guard_test.go b/cli/internal/ui/guard_test.go new file mode 100644 index 0000000..fa682a1 --- /dev/null +++ b/cli/internal/ui/guard_test.go @@ -0,0 +1,91 @@ +package ui_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// bannedManagerDeps matches any import path that would prove charm — or another +// CLI-only heavy dependency — leaked into the operator manager's import graph. +// The manager builds from ./cmd and must never import cli/internal/ui (the only +// package allowed to depend on charm), so `go list -deps ./cmd` is a structural +// tripwire rather than a per-PR review burden. +var bannedManagerDeps = regexp.MustCompile( + `charm|lipgloss|huh|bubble|ultraviolet|colorprofile|charmbracelet|ogmigo|kugo|internal/cardano/tx`, +) + +func TestManagerImportGraphHasNoCharm(t *testing.T) { + t.Parallel() + + root := moduleRoot(t) + + cmd := exec.Command("go", "list", "-deps", "./cmd") + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go list -deps ./cmd failed: %v\n%s", err, out) + } + + for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { + if bannedManagerDeps.MatchString(line) { + t.Errorf("manager import graph contains banned dependency %q; charm and CLI-only deps must stay out of ./cmd", line) + } + } +} + +// legacyCharmImport matches the pre-v2 charmbracelet module paths the v2 +// migration forbids anywhere under cli/. v2 lives at charm.land/*; importing +// github.com/charmbracelet/(lipgloss|huh|log) would silently pull a v0/v1 API. +var legacyCharmImport = regexp.MustCompile(`github\.com/charmbracelet/(lipgloss|huh|log)`) + +func TestNoLegacyCharmPaths(t *testing.T) { + t.Parallel() + + root := moduleRoot(t) + + err := filepath.WalkDir(filepath.Join(root, "cli"), func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + for n, line := range strings.Split(string(data), "\n") { + if legacyCharmImport.MatchString(line) { + rel, _ := filepath.Rel(root, path) + t.Errorf("%s:%d imports a legacy charmbracelet path; use charm.land/* v2: %s", rel, n+1, strings.TrimSpace(line)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// moduleRoot walks up from the working directory to the first go.mod. +func moduleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find module root") + } + dir = parent + } +}