Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcde802ea2 | ||
|
|
fa000d57c1 |
116 changed files with 6127 additions and 85 deletions
|
|
@ -1,6 +1,6 @@
|
|||
package config
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddConfigCommands)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/config"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addPathCommand(parent *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addSetCommand(parent *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package dev
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
core "forge.lthn.ai/core/go/pkg/framework/core"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
package dev
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"errors"
|
||||
"sort"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"os/exec"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli" // Added
|
||||
"forge.lthn.ai/core/cli/pkg/cli" // Added
|
||||
"forge.lthn.ai/core/go/pkg/i18n" // Added
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
// Added
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"os"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-devops/devops"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-agentic"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/cmd/workspace"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-agentic"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go-scm/git"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
// to a central location for unified documentation builds.
|
||||
package docs
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddDocsCommands)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package docs
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/cmd/workspace"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ package doctor
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ package gitcmd
|
|||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/cmd/dev"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS.
|
||||
package gocmd
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddGoCommands)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/cmd/qa"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package help
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/help"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"os/signal"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/lab"
|
||||
"forge.lthn.ai/core/go/pkg/lab/collector"
|
||||
"forge.lthn.ai/core/go/pkg/lab/handler"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/marketplace"
|
||||
"forge.lthn.ai/core/go/pkg/store"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/marketplace"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package module
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package module
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
package monitor
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package plugin
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
package qa
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/session"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
package setup
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/cmd/workspace"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
package setup
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"os"
|
||||
"sort"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
"golang.org/x/term"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// GitHubLabel represents a label as returned by the GitHub API.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// GitHubBranchProtection represents branch protection rules from the GitHub API.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// GitHubSecurityStatus represents the security settings status of a repository.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// GitHubWebhook represents a webhook as returned by the GitHub API.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package workspace
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddWorkspaceCommands)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
coreio "forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package workspace
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
|
|||
12
go.mod
12
go.mod
|
|
@ -3,12 +3,12 @@ module forge.lthn.ai/core/cli
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f
|
||||
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2 // indirect
|
||||
forge.lthn.ai/core/go-devops v0.0.0-20260221193818-400d8a76901e
|
||||
forge.lthn.ai/core/go-scm v0.0.0-20260221193836-7eb28df79d0b
|
||||
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf // indirect
|
||||
forge.lthn.ai/core/go main
|
||||
forge.lthn.ai/core/go-agentic main
|
||||
forge.lthn.ai/core/go-crypt main // indirect
|
||||
forge.lthn.ai/core/go-devops main
|
||||
forge.lthn.ai/core/go-scm main
|
||||
forge.lthn.ai/core/go-store main // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -1,7 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
// Commands via self-registration (local to CLI)
|
||||
_ "forge.lthn.ai/core/cli/cmd/config"
|
||||
|
|
|
|||
163
pkg/cli/ansi.go
Normal file
163
pkg/cli/ansi.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ANSI escape codes
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiBold = "\033[1m"
|
||||
ansiDim = "\033[2m"
|
||||
ansiItalic = "\033[3m"
|
||||
ansiUnderline = "\033[4m"
|
||||
)
|
||||
|
||||
var (
|
||||
colorEnabled = true
|
||||
colorEnabledMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
// NO_COLOR standard: https://no-color.org/
|
||||
// If NO_COLOR is set (to any value, including empty), disable colors.
|
||||
if _, exists := os.LookupEnv("NO_COLOR"); exists {
|
||||
colorEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// TERM=dumb indicates a terminal without color support.
|
||||
if os.Getenv("TERM") == "dumb" {
|
||||
colorEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// ColorEnabled returns true if ANSI color output is enabled.
|
||||
func ColorEnabled() bool {
|
||||
colorEnabledMu.RLock()
|
||||
defer colorEnabledMu.RUnlock()
|
||||
return colorEnabled
|
||||
}
|
||||
|
||||
// SetColorEnabled enables or disables ANSI color output.
|
||||
// This overrides the NO_COLOR environment variable check.
|
||||
func SetColorEnabled(enabled bool) {
|
||||
colorEnabledMu.Lock()
|
||||
colorEnabled = enabled
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
// AnsiStyle represents terminal text styling.
|
||||
// Use NewStyle() to create, chain methods, call Render().
|
||||
type AnsiStyle struct {
|
||||
bold bool
|
||||
dim bool
|
||||
italic bool
|
||||
underline bool
|
||||
fg string
|
||||
bg string
|
||||
}
|
||||
|
||||
// NewStyle creates a new empty style.
|
||||
func NewStyle() *AnsiStyle {
|
||||
return &AnsiStyle{}
|
||||
}
|
||||
|
||||
// Bold enables bold text.
|
||||
func (s *AnsiStyle) Bold() *AnsiStyle {
|
||||
s.bold = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Dim enables dim text.
|
||||
func (s *AnsiStyle) Dim() *AnsiStyle {
|
||||
s.dim = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Italic enables italic text.
|
||||
func (s *AnsiStyle) Italic() *AnsiStyle {
|
||||
s.italic = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Underline enables underlined text.
|
||||
func (s *AnsiStyle) Underline() *AnsiStyle {
|
||||
s.underline = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Foreground sets foreground color from hex string.
|
||||
func (s *AnsiStyle) Foreground(hex string) *AnsiStyle {
|
||||
s.fg = fgColorHex(hex)
|
||||
return s
|
||||
}
|
||||
|
||||
// Background sets background color from hex string.
|
||||
func (s *AnsiStyle) Background(hex string) *AnsiStyle {
|
||||
s.bg = bgColorHex(hex)
|
||||
return s
|
||||
}
|
||||
|
||||
// Render applies the style to text.
|
||||
// Returns plain text if NO_COLOR is set or colors are disabled.
|
||||
func (s *AnsiStyle) Render(text string) string {
|
||||
if s == nil || !ColorEnabled() {
|
||||
return text
|
||||
}
|
||||
|
||||
var codes []string
|
||||
if s.bold {
|
||||
codes = append(codes, ansiBold)
|
||||
}
|
||||
if s.dim {
|
||||
codes = append(codes, ansiDim)
|
||||
}
|
||||
if s.italic {
|
||||
codes = append(codes, ansiItalic)
|
||||
}
|
||||
if s.underline {
|
||||
codes = append(codes, ansiUnderline)
|
||||
}
|
||||
if s.fg != "" {
|
||||
codes = append(codes, s.fg)
|
||||
}
|
||||
if s.bg != "" {
|
||||
codes = append(codes, s.bg)
|
||||
}
|
||||
|
||||
if len(codes) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
return strings.Join(codes, "") + text + ansiReset
|
||||
}
|
||||
|
||||
// fgColorHex converts a hex string to an ANSI foreground color code.
|
||||
func fgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// bgColorHex converts a hex string to an ANSI background color code.
|
||||
func bgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// hexToRGB converts a hex string to RGB values.
|
||||
func hexToRGB(hex string) (int, int, int) {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
if len(hex) != 6 {
|
||||
return 255, 255, 255
|
||||
}
|
||||
// Use 8-bit parsing since RGB values are 0-255, avoiding integer overflow on 32-bit systems.
|
||||
r, _ := strconv.ParseUint(hex[0:2], 16, 8)
|
||||
g, _ := strconv.ParseUint(hex[2:4], 16, 8)
|
||||
b, _ := strconv.ParseUint(hex[4:6], 16, 8)
|
||||
return int(r), int(g), int(b)
|
||||
}
|
||||
97
pkg/cli/ansi_test.go
Normal file
97
pkg/cli/ansi_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnsiStyle_Render(t *testing.T) {
|
||||
// Ensure colors are enabled for this test
|
||||
SetColorEnabled(true)
|
||||
defer SetColorEnabled(true) // Reset after test
|
||||
|
||||
s := NewStyle().Bold().Foreground("#ff0000")
|
||||
got := s.Render("test")
|
||||
if got == "test" {
|
||||
t.Error("Expected styled output")
|
||||
}
|
||||
if !strings.Contains(got, "test") {
|
||||
t.Error("Output should contain text")
|
||||
}
|
||||
if !strings.Contains(got, "[1m") {
|
||||
t.Error("Output should contain bold code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorEnabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Test enabling
|
||||
SetColorEnabled(true)
|
||||
if !ColorEnabled() {
|
||||
t.Error("ColorEnabled should return true")
|
||||
}
|
||||
|
||||
// Test disabling
|
||||
SetColorEnabled(false)
|
||||
if ColorEnabled() {
|
||||
t.Error("ColorEnabled should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ColorDisabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Disable colors
|
||||
SetColorEnabled(false)
|
||||
|
||||
s := NewStyle().Bold().Foreground("#ff0000")
|
||||
got := s.Render("test")
|
||||
|
||||
// Should return plain text without ANSI codes
|
||||
if got != "test" {
|
||||
t.Errorf("Expected plain 'test', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ColorEnabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable colors
|
||||
SetColorEnabled(true)
|
||||
|
||||
s := NewStyle().Bold()
|
||||
got := s.Render("test")
|
||||
|
||||
// Should contain ANSI codes
|
||||
if !strings.Contains(got, "\033[") {
|
||||
t.Error("Expected ANSI codes when colors enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseASCII_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable first, then UseASCII should disable colors
|
||||
SetColorEnabled(true)
|
||||
UseASCII()
|
||||
if ColorEnabled() {
|
||||
t.Error("UseASCII should disable colors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_NilStyle_Good(t *testing.T) {
|
||||
var s *AnsiStyle
|
||||
got := s.Render("test")
|
||||
if got != "test" {
|
||||
t.Errorf("Nil style should return plain text, got %q", got)
|
||||
}
|
||||
}
|
||||
163
pkg/cli/app.go
Normal file
163
pkg/cli/app.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/crypt/openpgp"
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
"forge.lthn.ai/core/go/pkg/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AppName is the default CLI application name.
|
||||
// Override with WithAppName before calling Main.
|
||||
var AppName = "core"
|
||||
|
||||
// Build-time variables set via ldflags (SemVer 2.0.0):
|
||||
//
|
||||
// go build -ldflags="-X forge.lthn.ai/core/cli/pkg/cli.AppVersion=1.2.0 \
|
||||
// -X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=df94c24 \
|
||||
// -X forge.lthn.ai/core/cli/pkg/cli.BuildDate=2026-02-06 \
|
||||
// -X forge.lthn.ai/core/cli/pkg/cli.BuildPreRelease=dev.8"
|
||||
var (
|
||||
AppVersion = "0.0.0"
|
||||
BuildCommit = "unknown"
|
||||
BuildDate = "unknown"
|
||||
BuildPreRelease = ""
|
||||
)
|
||||
|
||||
// SemVer returns the full SemVer 2.0.0 version string.
|
||||
// - Release: 1.2.0
|
||||
// - Pre-release: 1.2.0-dev.8
|
||||
// - Full: 1.2.0-dev.8+df94c24.20260206
|
||||
func SemVer() string {
|
||||
v := AppVersion
|
||||
if BuildPreRelease != "" {
|
||||
v += "-" + BuildPreRelease
|
||||
}
|
||||
if BuildCommit != "unknown" {
|
||||
v += "+" + BuildCommit
|
||||
if BuildDate != "unknown" {
|
||||
v += "." + BuildDate
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// WithAppName sets the application name used in help text and shell completion.
|
||||
// Call before Main for variant binaries (e.g. "lem", "devops").
|
||||
//
|
||||
// cli.WithAppName("lem")
|
||||
// cli.Main()
|
||||
func WithAppName(name string) {
|
||||
AppName = name
|
||||
}
|
||||
|
||||
// Main initialises and runs the CLI application.
|
||||
// This is the main entry point for the CLI.
|
||||
// Exits with code 1 on error or panic.
|
||||
func Main() {
|
||||
// Recovery from panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("recovered from panic", "error", r, "stack", string(debug.Stack()))
|
||||
Shutdown()
|
||||
Fatal(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialise CLI runtime with services
|
||||
if err := Init(Options{
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
Services: []framework.Option{
|
||||
framework.WithName("i18n", NewI18nService(I18nOptions{})),
|
||||
framework.WithName("log", NewLogService(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
})),
|
||||
framework.WithName("crypt", openpgp.New),
|
||||
framework.WithName("workspace", workspace.New),
|
||||
},
|
||||
}); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
defer Shutdown()
|
||||
|
||||
// Add completion command to the CLI's root
|
||||
RootCmd().AddCommand(newCompletionCmd())
|
||||
|
||||
if err := Execute(); err != nil {
|
||||
code := 1
|
||||
var exitErr *ExitError
|
||||
if As(err, &exitErr) {
|
||||
code = exitErr.Code
|
||||
}
|
||||
Error(err.Error())
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// newCompletionCmd creates the shell completion command using the current AppName.
|
||||
func newCompletionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion script",
|
||||
Long: fmt.Sprintf(`Generate shell completion script for the specified shell.
|
||||
|
||||
To load completions:
|
||||
|
||||
Bash:
|
||||
$ source <(%s completion bash)
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
# Linux:
|
||||
$ %s completion bash > /etc/bash_completion.d/%s
|
||||
# macOS:
|
||||
$ %s completion bash > $(brew --prefix)/etc/bash_completion.d/%s
|
||||
|
||||
Zsh:
|
||||
# If shell completion is not already enabled in your environment,
|
||||
# you will need to enable it. You can execute the following once:
|
||||
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ %s completion zsh > "${fpath[1]}/_%s"
|
||||
|
||||
# You will need to start a new shell for this setup to take effect.
|
||||
|
||||
Fish:
|
||||
$ %s completion fish | source
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ %s completion fish > ~/.config/fish/completions/%s.fish
|
||||
|
||||
PowerShell:
|
||||
PS> %s completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
# To load completions for every new session, run:
|
||||
PS> %s completion powershell > %s.ps1
|
||||
# and source this file from your PowerShell profile.
|
||||
`, AppName, AppName, AppName, AppName, AppName,
|
||||
AppName, AppName, AppName, AppName, AppName,
|
||||
AppName, AppName, AppName),
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
164
pkg/cli/app_test.go
Normal file
164
pkg/cli/app_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestPanicRecovery_Good verifies that the panic recovery mechanism
|
||||
// catches panics and calls the appropriate shutdown and error handling.
|
||||
func TestPanicRecovery_Good(t *testing.T) {
|
||||
t.Run("recovery captures panic value and stack", func(t *testing.T) {
|
||||
var recovered any
|
||||
var capturedStack []byte
|
||||
var shutdownCalled bool
|
||||
|
||||
// Simulate the panic recovery pattern from Main()
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recovered = r
|
||||
capturedStack = debug.Stack()
|
||||
shutdownCalled = true // simulates Shutdown() call
|
||||
}
|
||||
}()
|
||||
|
||||
panic("test panic")
|
||||
}()
|
||||
|
||||
assert.Equal(t, "test panic", recovered)
|
||||
assert.True(t, shutdownCalled, "Shutdown should be called after panic recovery")
|
||||
assert.NotEmpty(t, capturedStack, "Stack trace should be captured")
|
||||
assert.Contains(t, string(capturedStack), "TestPanicRecovery_Good")
|
||||
})
|
||||
|
||||
t.Run("recovery handles error type panics", func(t *testing.T) {
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recovered = r
|
||||
}
|
||||
}()
|
||||
|
||||
panic(fmt.Errorf("error panic"))
|
||||
}()
|
||||
|
||||
err, ok := recovered.(error)
|
||||
assert.True(t, ok, "Recovered value should be an error")
|
||||
assert.Equal(t, "error panic", err.Error())
|
||||
})
|
||||
|
||||
t.Run("recovery handles nil panic gracefully", func(t *testing.T) {
|
||||
recoveryExecuted := false
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recoveryExecuted = true
|
||||
}
|
||||
}()
|
||||
|
||||
// No panic occurs
|
||||
}()
|
||||
|
||||
assert.False(t, recoveryExecuted, "Recovery block should not execute without panic")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPanicRecovery_Bad tests error conditions in panic recovery.
|
||||
func TestPanicRecovery_Bad(t *testing.T) {
|
||||
t.Run("recovery handles concurrent panics", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
recoveryCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mu.Lock()
|
||||
recoveryCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
panic(fmt.Sprintf("panic from goroutine %d", id))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
assert.Equal(t, 3, recoveryCount, "All goroutine panics should be recovered")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPanicRecovery_Ugly tests edge cases in panic recovery.
|
||||
func TestPanicRecovery_Ugly(t *testing.T) {
|
||||
t.Run("recovery handles typed panic values", func(t *testing.T) {
|
||||
type customError struct {
|
||||
code int
|
||||
msg string
|
||||
}
|
||||
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
recovered = recover()
|
||||
}()
|
||||
|
||||
panic(customError{code: 500, msg: "internal error"})
|
||||
}()
|
||||
|
||||
ce, ok := recovered.(customError)
|
||||
assert.True(t, ok, "Should recover custom type")
|
||||
assert.Equal(t, 500, ce.code)
|
||||
assert.Equal(t, "internal error", ce.msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMainPanicRecoveryPattern verifies the exact pattern used in Main().
|
||||
func TestMainPanicRecoveryPattern(t *testing.T) {
|
||||
t.Run("pattern logs error and calls shutdown", func(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
var shutdownCalled bool
|
||||
var fatalErr error
|
||||
|
||||
// Mock implementations
|
||||
mockLogError := func(msg string, args ...any) {
|
||||
fmt.Fprintf(&logBuffer, msg, args...)
|
||||
}
|
||||
mockShutdown := func() {
|
||||
shutdownCalled = true
|
||||
}
|
||||
mockFatal := func(err error) {
|
||||
fatalErr = err
|
||||
}
|
||||
|
||||
// Execute the pattern from Main()
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mockLogError("recovered from panic: %v", r)
|
||||
mockShutdown()
|
||||
mockFatal(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
panic("simulated crash")
|
||||
}()
|
||||
|
||||
assert.Contains(t, logBuffer.String(), "recovered from panic: simulated crash")
|
||||
assert.True(t, shutdownCalled, "Shutdown must be called on panic")
|
||||
assert.NotNil(t, fatalErr, "Fatal must be called with error")
|
||||
assert.Equal(t, "panic: simulated crash", fatalErr.Error())
|
||||
})
|
||||
}
|
||||
91
pkg/cli/check.go
Normal file
91
pkg/cli/check.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CheckBuilder provides fluent API for check results.
|
||||
type CheckBuilder struct {
|
||||
name string
|
||||
status string
|
||||
style *AnsiStyle
|
||||
icon string
|
||||
duration string
|
||||
}
|
||||
|
||||
// Check starts building a check result line.
|
||||
//
|
||||
// cli.Check("audit").Pass()
|
||||
// cli.Check("fmt").Fail().Duration("2.3s")
|
||||
// cli.Check("test").Skip()
|
||||
func Check(name string) *CheckBuilder {
|
||||
return &CheckBuilder{name: name}
|
||||
}
|
||||
|
||||
// Pass marks the check as passed.
|
||||
func (c *CheckBuilder) Pass() *CheckBuilder {
|
||||
c.status = "passed"
|
||||
c.style = SuccessStyle
|
||||
c.icon = Glyph(":check:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Fail marks the check as failed.
|
||||
func (c *CheckBuilder) Fail() *CheckBuilder {
|
||||
c.status = "failed"
|
||||
c.style = ErrorStyle
|
||||
c.icon = Glyph(":cross:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Skip marks the check as skipped.
|
||||
func (c *CheckBuilder) Skip() *CheckBuilder {
|
||||
c.status = "skipped"
|
||||
c.style = DimStyle
|
||||
c.icon = "-"
|
||||
return c
|
||||
}
|
||||
|
||||
// Warn marks the check as warning.
|
||||
func (c *CheckBuilder) Warn() *CheckBuilder {
|
||||
c.status = "warning"
|
||||
c.style = WarningStyle
|
||||
c.icon = Glyph(":warn:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Duration adds duration to the check result.
|
||||
func (c *CheckBuilder) Duration(d string) *CheckBuilder {
|
||||
c.duration = d
|
||||
return c
|
||||
}
|
||||
|
||||
// Message adds a custom message instead of status.
|
||||
func (c *CheckBuilder) Message(msg string) *CheckBuilder {
|
||||
c.status = msg
|
||||
return c
|
||||
}
|
||||
|
||||
// String returns the formatted check line.
|
||||
func (c *CheckBuilder) String() string {
|
||||
icon := c.icon
|
||||
if c.style != nil {
|
||||
icon = c.style.Render(c.icon)
|
||||
}
|
||||
|
||||
status := c.status
|
||||
if c.style != nil && c.status != "" {
|
||||
status = c.style.Render(c.status)
|
||||
}
|
||||
|
||||
if c.duration != "" {
|
||||
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
|
||||
}
|
||||
if status != "" {
|
||||
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
|
||||
}
|
||||
return fmt.Sprintf(" %s %s", icon, c.name)
|
||||
}
|
||||
|
||||
// Print outputs the check result.
|
||||
func (c *CheckBuilder) Print() {
|
||||
fmt.Println(c.String())
|
||||
}
|
||||
49
pkg/cli/check_test.go
Normal file
49
pkg/cli/check_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckBuilder(t *testing.T) {
|
||||
UseASCII() // Deterministic output
|
||||
|
||||
// Pass
|
||||
c := Check("foo").Pass()
|
||||
got := c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Pass")
|
||||
}
|
||||
|
||||
// Fail
|
||||
c = Check("foo").Fail()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Fail")
|
||||
}
|
||||
|
||||
// Skip
|
||||
c = Check("foo").Skip()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Skip")
|
||||
}
|
||||
|
||||
// Warn
|
||||
c = Check("foo").Warn()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Warn")
|
||||
}
|
||||
|
||||
// Duration
|
||||
c = Check("foo").Pass().Duration("1s")
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Duration")
|
||||
}
|
||||
|
||||
// Message
|
||||
c = Check("foo").Message("status")
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Message")
|
||||
}
|
||||
}
|
||||
210
pkg/cli/command.go
Normal file
210
pkg/cli/command.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Type Re-export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Command is the cobra command type.
|
||||
// Re-exported for convenience so packages don't need to import cobra directly.
|
||||
type Command = cobra.Command
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Builders
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// NewCommand creates a new command with a RunE handler.
|
||||
// This is the standard way to create commands that may return errors.
|
||||
//
|
||||
// cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error {
|
||||
// // Build logic
|
||||
// return nil
|
||||
// })
|
||||
func NewCommand(use, short, long string, run func(cmd *Command, args []string) error) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
RunE: run,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewGroup creates a new command group (no RunE).
|
||||
// Use this for parent commands that only contain subcommands.
|
||||
//
|
||||
// devCmd := cli.NewGroup("dev", "Development commands", "")
|
||||
// devCmd.AddCommand(buildCmd, testCmd)
|
||||
func NewGroup(use, short, long string) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewRun creates a new command with a simple Run handler (no error return).
|
||||
// Use when the command cannot fail.
|
||||
//
|
||||
// cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
|
||||
// cli.Println("v1.0.0")
|
||||
// })
|
||||
func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Run: run,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewPassthrough creates a command that passes all arguments (including flags)
|
||||
// to the given function. Used for commands that do their own flag parsing
|
||||
// (e.g. incremental migration from flag.FlagSet to cobra).
|
||||
//
|
||||
// cmd := cli.NewPassthrough("train", "Train a model", func(args []string) {
|
||||
// // args includes all flags: ["--model", "gemma-3-1b", "--epochs", "10"]
|
||||
// fs := flag.NewFlagSet("train", flag.ExitOnError)
|
||||
// // ...
|
||||
// })
|
||||
func NewPassthrough(use, short string, fn func(args []string)) *Command {
|
||||
cmd := NewRun(use, short, "", func(_ *Command, args []string) {
|
||||
fn(args)
|
||||
})
|
||||
cmd.DisableFlagParsing = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// StringFlag adds a string flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var output string
|
||||
// cli.StringFlag(cmd, &output, "output", "o", "", "Output file path")
|
||||
func StringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// BoolFlag adds a boolean flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var verbose bool
|
||||
// cli.BoolFlag(cmd, &verbose, "verbose", "v", false, "Enable verbose output")
|
||||
func BoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().BoolVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().BoolVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// IntFlag adds an integer flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var count int
|
||||
// cli.IntFlag(cmd, &count, "count", "n", 10, "Number of items")
|
||||
func IntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().IntVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().IntVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// StringSliceFlag adds a string slice flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var tags []string
|
||||
// cli.StringSliceFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
|
||||
func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringSliceVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringSliceVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Persistent Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// PersistentStringFlag adds a persistent string flag (inherited by subcommands).
|
||||
func PersistentStringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentBoolFlag adds a persistent boolean flag (inherited by subcommands).
|
||||
func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().BoolVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().BoolVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// WithArgs sets the Args validation function for a command.
|
||||
// Returns the command for chaining.
|
||||
//
|
||||
// cmd := cli.NewCommand("build", "Build", "", run).WithArgs(cobra.ExactArgs(1))
|
||||
func WithArgs(cmd *Command, args cobra.PositionalArgs) *Command {
|
||||
cmd.Args = args
|
||||
return cmd
|
||||
}
|
||||
|
||||
// WithExample sets the Example field for a command.
|
||||
// Returns the command for chaining.
|
||||
func WithExample(cmd *Command, example string) *Command {
|
||||
cmd.Example = example
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ExactArgs returns a PositionalArgs that accepts exactly N arguments.
|
||||
func ExactArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.ExactArgs(n)
|
||||
}
|
||||
|
||||
// MinimumNArgs returns a PositionalArgs that accepts minimum N arguments.
|
||||
func MinimumNArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.MinimumNArgs(n)
|
||||
}
|
||||
|
||||
// MaximumNArgs returns a PositionalArgs that accepts maximum N arguments.
|
||||
func MaximumNArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.MaximumNArgs(n)
|
||||
}
|
||||
|
||||
// NoArgs returns a PositionalArgs that accepts no arguments.
|
||||
func NoArgs() cobra.PositionalArgs {
|
||||
return cobra.NoArgs
|
||||
}
|
||||
|
||||
// ArbitraryArgs returns a PositionalArgs that accepts any arguments.
|
||||
func ArbitraryArgs() cobra.PositionalArgs {
|
||||
return cobra.ArbitraryArgs
|
||||
}
|
||||
50
pkg/cli/commands.go
Normal file
50
pkg/cli/commands.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CommandRegistration is a function that adds commands to the root.
|
||||
type CommandRegistration func(root *cobra.Command)
|
||||
|
||||
var (
|
||||
registeredCommands []CommandRegistration
|
||||
registeredCommandsMu sync.Mutex
|
||||
commandsAttached bool
|
||||
)
|
||||
|
||||
// RegisterCommands registers a function that adds commands to the CLI.
|
||||
// Call this in your package's init() to register commands.
|
||||
//
|
||||
// func init() {
|
||||
// cli.RegisterCommands(AddCommands)
|
||||
// }
|
||||
//
|
||||
// func AddCommands(root *cobra.Command) {
|
||||
// root.AddCommand(myCmd)
|
||||
// }
|
||||
func RegisterCommands(fn CommandRegistration) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
|
||||
// If commands already attached (CLI already running), attach immediately
|
||||
if commandsAttached && instance != nil && instance.root != nil {
|
||||
fn(instance.root)
|
||||
}
|
||||
}
|
||||
|
||||
// attachRegisteredCommands calls all registered command functions.
|
||||
// Called by Init() after creating the root command.
|
||||
func attachRegisteredCommands(root *cobra.Command) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range registeredCommands {
|
||||
fn(root)
|
||||
}
|
||||
commandsAttached = true
|
||||
}
|
||||
185
pkg/cli/commands_test.go
Normal file
185
pkg/cli/commands_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// resetGlobals clears the CLI singleton and command registry for test isolation.
|
||||
func resetGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
// Restore clean state after each test.
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = nil
|
||||
commandsAttached = false
|
||||
registeredCommandsMu.Unlock()
|
||||
if instance != nil {
|
||||
Shutdown()
|
||||
}
|
||||
instance = nil
|
||||
once = sync.Once{}
|
||||
})
|
||||
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = nil
|
||||
commandsAttached = false
|
||||
registeredCommandsMu.Unlock()
|
||||
if instance != nil {
|
||||
Shutdown()
|
||||
}
|
||||
instance = nil
|
||||
once = sync.Once{}
|
||||
}
|
||||
|
||||
// TestRegisterCommands_Good tests the happy path for command registration.
|
||||
func TestRegisterCommands_Good(t *testing.T) {
|
||||
t.Run("registers on startup", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "hello", Short: "Say hello"})
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The "hello" command should be on the root.
|
||||
cmd, _, err := RootCmd().Find([]string{"hello"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", cmd.Use)
|
||||
})
|
||||
|
||||
t.Run("multiple groups compose", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "alpha", Short: "Alpha"})
|
||||
})
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "beta", Short: "Beta"})
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"alpha", "beta"} {
|
||||
cmd, _, err := RootCmd().Find([]string{name})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, name, cmd.Use)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group with subcommands", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
grp := &cobra.Command{Use: "ml", Short: "ML commands"}
|
||||
grp.AddCommand(&cobra.Command{Use: "train", Short: "Train a model"})
|
||||
grp.AddCommand(&cobra.Command{Use: "serve", Short: "Serve a model"})
|
||||
root.AddCommand(grp)
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, _, err := RootCmd().Find([]string{"ml", "train"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "train", cmd.Use)
|
||||
|
||||
cmd, _, err = RootCmd().Find([]string{"ml", "serve"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "serve", cmd.Use)
|
||||
})
|
||||
|
||||
t.Run("executes registered command", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
executed := false
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "ping",
|
||||
Short: "Ping",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
executed = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
RootCmd().SetArgs([]string{"ping"})
|
||||
err = Execute()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, executed, "registered command should have been executed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRegisterCommands_Bad tests expected error conditions.
|
||||
func TestRegisterCommands_Bad(t *testing.T) {
|
||||
t.Run("late registration attaches immediately", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Register after Init — should attach immediately.
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "late", Short: "Late arrival"})
|
||||
})
|
||||
|
||||
cmd, _, err := RootCmd().Find([]string{"late"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "late", cmd.Use)
|
||||
})
|
||||
}
|
||||
|
||||
// TestWithAppName_Good tests the app name override.
|
||||
func TestWithAppName_Good(t *testing.T) {
|
||||
t.Run("overrides root command use", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
WithAppName("lem")
|
||||
defer WithAppName("core") // restore
|
||||
|
||||
err := Init(Options{AppName: AppName})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "lem", RootCmd().Use)
|
||||
})
|
||||
|
||||
t.Run("default is core", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
err := Init(Options{AppName: AppName})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "core", RootCmd().Use)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewPassthrough_Good tests the passthrough command builder.
|
||||
func TestNewPassthrough_Good(t *testing.T) {
|
||||
t.Run("passes all args including flags", func(t *testing.T) {
|
||||
var received []string
|
||||
cmd := NewPassthrough("train", "Train", func(args []string) {
|
||||
received = args
|
||||
})
|
||||
|
||||
cmd.SetArgs([]string{"--model", "gemma", "--epochs", "10"})
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"--model", "gemma", "--epochs", "10"}, received)
|
||||
})
|
||||
|
||||
t.Run("flag parsing is disabled", func(t *testing.T) {
|
||||
cmd := NewPassthrough("run", "Run", func(_ []string) {})
|
||||
assert.True(t, cmd.DisableFlagParsing)
|
||||
})
|
||||
}
|
||||
446
pkg/cli/daemon.go
Normal file
446
pkg/cli/daemon.go
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Mode represents the CLI execution mode.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// ModeInteractive indicates TTY attached with coloured output.
|
||||
ModeInteractive Mode = iota
|
||||
// ModePipe indicates stdout is piped, colours disabled.
|
||||
ModePipe
|
||||
// ModeDaemon indicates headless execution, log-only output.
|
||||
ModeDaemon
|
||||
)
|
||||
|
||||
// String returns the string representation of the Mode.
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
case ModeInteractive:
|
||||
return "interactive"
|
||||
case ModePipe:
|
||||
return "pipe"
|
||||
case ModeDaemon:
|
||||
return "daemon"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetectMode determines the execution mode based on environment.
|
||||
// Checks CORE_DAEMON env var first, then TTY status.
|
||||
func DetectMode() Mode {
|
||||
if os.Getenv("CORE_DAEMON") == "1" {
|
||||
return ModeDaemon
|
||||
}
|
||||
if !IsTTY() {
|
||||
return ModePipe
|
||||
}
|
||||
return ModeInteractive
|
||||
}
|
||||
|
||||
// IsTTY returns true if stdout is a terminal.
|
||||
func IsTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// IsStdinTTY returns true if stdin is a terminal.
|
||||
func IsStdinTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStderrTTY returns true if stderr is a terminal.
|
||||
func IsStderrTTY() bool {
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
// --- PID File Management ---
|
||||
|
||||
// PIDFile manages a process ID file for single-instance enforcement.
|
||||
type PIDFile struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPIDFile creates a PID file manager.
|
||||
func NewPIDFile(path string) *PIDFile {
|
||||
return &PIDFile{path: path}
|
||||
}
|
||||
|
||||
// Acquire writes the current PID to the file.
|
||||
// Returns error if another instance is running.
|
||||
func (p *PIDFile) Acquire() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Check if PID file exists
|
||||
if data, err := io.Local.Read(p.path); err == nil {
|
||||
pid, err := strconv.Atoi(data)
|
||||
if err == nil && pid > 0 {
|
||||
// Check if process is still running
|
||||
if process, err := os.FindProcess(pid); err == nil {
|
||||
if err := process.Signal(syscall.Signal(0)); err == nil {
|
||||
return fmt.Errorf("another instance is running (PID %d)", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
_ = io.Local.Delete(p.path)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if dir := filepath.Dir(p.path); dir != "." {
|
||||
if err := io.Local.EnsureDir(dir); err != nil {
|
||||
return fmt.Errorf("failed to create PID directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write current PID
|
||||
pid := os.Getpid()
|
||||
if err := io.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
|
||||
return fmt.Errorf("failed to write PID file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release removes the PID file.
|
||||
func (p *PIDFile) Release() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return io.Local.Delete(p.path)
|
||||
}
|
||||
|
||||
// Path returns the PID file path.
|
||||
func (p *PIDFile) Path() string {
|
||||
return p.path
|
||||
}
|
||||
|
||||
// --- Health Check Server ---
|
||||
|
||||
// HealthServer provides a minimal HTTP health check endpoint.
|
||||
type HealthServer struct {
|
||||
addr string
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
checks []HealthCheck
|
||||
}
|
||||
|
||||
// HealthCheck is a function that returns nil if healthy.
|
||||
type HealthCheck func() error
|
||||
|
||||
// NewHealthServer creates a health check server.
|
||||
func NewHealthServer(addr string) *HealthServer {
|
||||
return &HealthServer{
|
||||
addr: addr,
|
||||
ready: true,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCheck registers a health check function.
|
||||
func (h *HealthServer) AddCheck(check HealthCheck) {
|
||||
h.mu.Lock()
|
||||
h.checks = append(h.checks, check)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetReady sets the readiness status.
|
||||
func (h *HealthServer) SetReady(ready bool) {
|
||||
h.mu.Lock()
|
||||
h.ready = ready
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start begins serving health check endpoints.
|
||||
// Endpoints:
|
||||
// - /health - liveness probe (always 200 if server is up)
|
||||
// - /ready - readiness probe (200 if ready, 503 if not)
|
||||
func (h *HealthServer) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
checks := h.checks
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, check := range checks {
|
||||
if err := check(); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
ready := h.ready
|
||||
h.mu.Unlock()
|
||||
|
||||
if !ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintln(w, "not ready")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ready")
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", h.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %w", h.addr, err)
|
||||
}
|
||||
|
||||
h.listener = listener
|
||||
h.server = &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := h.server.Serve(listener); err != http.ErrServerClosed {
|
||||
LogError(fmt.Sprintf("health server error: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the health server.
|
||||
func (h *HealthServer) Stop(ctx context.Context) error {
|
||||
if h.server == nil {
|
||||
return nil
|
||||
}
|
||||
return h.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// Addr returns the actual address the server is listening on.
|
||||
// Useful when using port 0 for dynamic port assignment.
|
||||
func (h *HealthServer) Addr() string {
|
||||
if h.listener != nil {
|
||||
return h.listener.Addr().String()
|
||||
}
|
||||
return h.addr
|
||||
}
|
||||
|
||||
// --- Daemon Runner ---
|
||||
|
||||
// DaemonOptions configures daemon mode execution.
|
||||
type DaemonOptions struct {
|
||||
// PIDFile path for single-instance enforcement.
|
||||
// Leave empty to skip PID file management.
|
||||
PIDFile string
|
||||
|
||||
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
|
||||
// Default: 30 seconds.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// HealthAddr is the address for health check endpoints.
|
||||
// Example: ":8080", "127.0.0.1:9000"
|
||||
// Leave empty to disable health checks.
|
||||
HealthAddr string
|
||||
|
||||
// HealthChecks are additional health check functions.
|
||||
HealthChecks []HealthCheck
|
||||
|
||||
// OnReload is called when SIGHUP is received.
|
||||
// Use for config reloading. Leave nil to ignore SIGHUP.
|
||||
OnReload func() error
|
||||
}
|
||||
|
||||
// Daemon manages daemon lifecycle.
|
||||
type Daemon struct {
|
||||
opts DaemonOptions
|
||||
pid *PIDFile
|
||||
health *HealthServer
|
||||
reload chan struct{}
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewDaemon creates a daemon runner with the given options.
|
||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||
if opts.ShutdownTimeout == 0 {
|
||||
opts.ShutdownTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
opts: opts,
|
||||
reload: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
if opts.PIDFile != "" {
|
||||
d.pid = NewPIDFile(opts.PIDFile)
|
||||
}
|
||||
|
||||
if opts.HealthAddr != "" {
|
||||
d.health = NewHealthServer(opts.HealthAddr)
|
||||
for _, check := range opts.HealthChecks {
|
||||
d.health.AddCheck(check)
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Start initialises the daemon (PID file, health server).
|
||||
// Call this after cli.Init().
|
||||
func (d *Daemon) Start() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.running {
|
||||
return fmt.Errorf("daemon already running")
|
||||
}
|
||||
|
||||
// Acquire PID file
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Acquire(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Start health server
|
||||
if d.health != nil {
|
||||
if err := d.health.Start(); err != nil {
|
||||
if d.pid != nil {
|
||||
_ = d.pid.Release()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
d.running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run blocks until the context is cancelled or a signal is received.
|
||||
// Handles graceful shutdown with the configured timeout.
|
||||
func (d *Daemon) Run(ctx context.Context) error {
|
||||
d.mu.Lock()
|
||||
if !d.running {
|
||||
d.mu.Unlock()
|
||||
return fmt.Errorf("daemon not started - call Start() first")
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Wait for context cancellation (from signal handler)
|
||||
<-ctx.Done()
|
||||
|
||||
return d.Stop()
|
||||
}
|
||||
|
||||
// Stop performs graceful shutdown.
|
||||
func (d *Daemon) Stop() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if !d.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
// Create shutdown context with timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Stop health server
|
||||
if d.health != nil {
|
||||
d.health.SetReady(false)
|
||||
if err := d.health.Stop(shutdownCtx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("health server: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Release PID file
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
|
||||
errs = append(errs, fmt.Errorf("pid file: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
d.running = false
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("shutdown errors: %v", errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReady sets the daemon readiness status for health checks.
|
||||
func (d *Daemon) SetReady(ready bool) {
|
||||
if d.health != nil {
|
||||
d.health.SetReady(ready)
|
||||
}
|
||||
}
|
||||
|
||||
// HealthAddr returns the health server address, or empty if disabled.
|
||||
func (d *Daemon) HealthAddr() string {
|
||||
if d.health != nil {
|
||||
return d.health.Addr()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Convenience Functions ---
|
||||
|
||||
// Run blocks until context is cancelled or signal received.
|
||||
// Simple helper for daemon mode without advanced features.
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "myapp"})
|
||||
// defer cli.Shutdown()
|
||||
// cli.Run(cli.Context())
|
||||
func Run(ctx context.Context) error {
|
||||
mustInit()
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// RunWithTimeout wraps Run with a graceful shutdown timeout.
|
||||
// The returned function should be deferred to replace cli.Shutdown().
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "myapp"})
|
||||
// shutdown := cli.RunWithTimeout(30 * time.Second)
|
||||
// defer shutdown()
|
||||
// cli.Run(cli.Context())
|
||||
func RunWithTimeout(timeout time.Duration) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Create done channel for shutdown completion
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Clean shutdown
|
||||
case <-ctx.Done():
|
||||
// Timeout - force exit
|
||||
LogWarn("shutdown timeout exceeded, forcing exit")
|
||||
}
|
||||
}
|
||||
}
|
||||
234
pkg/cli/daemon_test.go
Normal file
234
pkg/cli/daemon_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetectMode(t *testing.T) {
|
||||
t.Run("daemon mode from env", func(t *testing.T) {
|
||||
t.Setenv("CORE_DAEMON", "1")
|
||||
assert.Equal(t, ModeDaemon, DetectMode())
|
||||
})
|
||||
|
||||
t.Run("mode string", func(t *testing.T) {
|
||||
assert.Equal(t, "interactive", ModeInteractive.String())
|
||||
assert.Equal(t, "pipe", ModePipe.String())
|
||||
assert.Equal(t, "daemon", ModeDaemon.String())
|
||||
assert.Equal(t, "unknown", Mode(99).String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPIDFile(t *testing.T) {
|
||||
t.Run("acquire and release", func(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("stale pid file", func(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "stale.pid")
|
||||
|
||||
// Write a stale PID (non-existent process).
|
||||
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
// Should acquire successfully (stale PID removed).
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("creates parent directory", func(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("path getter", func(t *testing.T) {
|
||||
pid := NewPIDFile("/tmp/test.pid")
|
||||
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHealthServer(t *testing.T) {
|
||||
t.Run("health and ready endpoints", func(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0") // Random port
|
||||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
// Health should be OK
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Ready should be OK by default
|
||||
resp, err = http.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Set not ready
|
||||
hs.SetReady(false)
|
||||
|
||||
resp, err = http.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("with health checks", func(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
|
||||
healthy := true
|
||||
hs.AddCheck(func() error {
|
||||
if !healthy {
|
||||
return assert.AnError
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
|
||||
// Should be healthy
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Make unhealthy
|
||||
healthy = false
|
||||
|
||||
resp, err = http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemon(t *testing.T) {
|
||||
t.Run("start and stop", func(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
PIDFile: pidPath,
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
ShutdownTimeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Health server should be running
|
||||
addr := d.HealthAddr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Stop should succeed
|
||||
err = d.Stop()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("double start fails", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
err = d.Start()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already running")
|
||||
})
|
||||
|
||||
t.Run("run without start fails", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := d.Run(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
|
||||
t.Run("set ready", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
addr := d.HealthAddr()
|
||||
|
||||
// Initially ready
|
||||
resp, _ := http.Get("http://" + addr + "/ready")
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Set not ready
|
||||
d.SetReady(false)
|
||||
|
||||
resp, _ = http.Get("http://" + addr + "/ready")
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("no health addr returns empty", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Empty(t, d.HealthAddr())
|
||||
})
|
||||
|
||||
t.Run("default shutdown timeout", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunWithTimeout(t *testing.T) {
|
||||
t.Run("creates shutdown function", func(t *testing.T) {
|
||||
// Just test that it returns a function
|
||||
shutdown := RunWithTimeout(100 * time.Millisecond)
|
||||
assert.NotNil(t, shutdown)
|
||||
})
|
||||
}
|
||||
162
pkg/cli/errors.go
Normal file
162
pkg/cli/errors.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error Creation (replace fmt.Errorf)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Err creates a new error from a format string.
|
||||
// This is a direct replacement for fmt.Errorf.
|
||||
func Err(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Wrap wraps an error with a message.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.Wrap(err, "load config") // "load config: <original error>"
|
||||
func Wrap(err error, msg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
|
||||
// Uses the i18n.ActionFailed function for proper grammar composition.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.WrapVerb(err, "load", "config") // "Failed to load config: <original error>"
|
||||
func WrapVerb(err error, verb, subject string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// WrapAction wraps an error using i18n grammar for "Failed to verb".
|
||||
// Uses the i18n.ActionFailed function for proper grammar composition.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.WrapAction(err, "connect") // "Failed to connect: <original error>"
|
||||
func WrapAction(err error, verb string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, "")
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Is reports whether any error in err's tree matches target.
|
||||
// This is a re-export of errors.Is for convenience.
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// As finds the first error in err's tree that matches target.
|
||||
// This is a re-export of errors.As for convenience.
|
||||
func As(err error, target any) bool {
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// Join returns an error that wraps the given errors.
|
||||
// This is a re-export of errors.Join for convenience.
|
||||
func Join(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ExitError represents an error that should cause the CLI to exit with a specific code.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
if e.Err == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *ExitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Exit creates a new ExitError with the given code and error.
|
||||
// Use this to return an error from a command with a specific exit code.
|
||||
func Exit(code int, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &ExitError{Code: code, Err: err}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fatal Functions (Deprecated - return error from command instead)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fatal prints an error message to stderr, logs it, and exits with code 1.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
func Fatal(err error) {
|
||||
if err != nil {
|
||||
LogError("Fatal error", "err", err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Fatalf prints a formatted error message to stderr, logs it, and exits with code 1.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
func Fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
LogError("Fatal error", "msg", msg)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// FatalWrap prints a wrapped error message to stderr, logs it, and exits with code 1.
|
||||
// Does nothing if err is nil.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
//
|
||||
// cli.FatalWrap(err, "load config") // Prints "✗ load config: <error>" and exits
|
||||
func FatalWrap(err error, msg string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
LogError("Fatal error", "msg", msg, "err", err)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// FatalWrapVerb prints a wrapped error using i18n grammar to stderr, logs it, and exits with code 1.
|
||||
// Does nothing if err is nil.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
//
|
||||
// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: <error>" and exits
|
||||
func FatalWrapVerb(err error, verb, subject string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
358
pkg/cli/frame.go
Normal file
358
pkg/cli/frame.go
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Model is the interface for components that slot into Frame regions.
|
||||
// View receives the allocated width and height and returns rendered text.
|
||||
type Model interface {
|
||||
View(width, height int) string
|
||||
}
|
||||
|
||||
// ModelFunc is a convenience adapter for using a function as a Model.
|
||||
type ModelFunc func(width, height int) string
|
||||
|
||||
// View implements Model.
|
||||
func (f ModelFunc) View(width, height int) string { return f(width, height) }
|
||||
|
||||
// Frame is a live compositional AppShell for TUI.
|
||||
// Uses HLCRF variant strings for region layout — same as the static Layout system,
|
||||
// but with live-updating Model components instead of static strings.
|
||||
//
|
||||
// frame := cli.NewFrame("HCF")
|
||||
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
||||
// frame.Content(myTableModel)
|
||||
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
||||
// frame.Run()
|
||||
type Frame struct {
|
||||
variant string
|
||||
layout *Composite
|
||||
models map[Region]Model
|
||||
history []Model // content region stack for Navigate/Back
|
||||
out io.Writer
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFrame creates a new Frame with the given HLCRF variant string.
|
||||
//
|
||||
// frame := cli.NewFrame("HCF") // header, content, footer
|
||||
// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer
|
||||
func NewFrame(variant string) *Frame {
|
||||
return &Frame{
|
||||
variant: variant,
|
||||
layout: Layout(variant),
|
||||
models: make(map[Region]Model),
|
||||
out: os.Stdout,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Header sets the Header region model.
|
||||
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
||||
|
||||
// Left sets the Left sidebar region model.
|
||||
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
|
||||
|
||||
// Content sets the Content region model.
|
||||
func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f }
|
||||
|
||||
// Right sets the Right sidebar region model.
|
||||
func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
|
||||
|
||||
// Footer sets the Footer region model.
|
||||
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
|
||||
|
||||
func (f *Frame) setModel(r Region, m Model) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.models[r] = m
|
||||
}
|
||||
|
||||
// Navigate replaces the Content region with a new model, pushing the current one
|
||||
// onto the history stack for Back().
|
||||
func (f *Frame) Navigate(m Model) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if current, ok := f.models[RegionContent]; ok {
|
||||
f.history = append(f.history, current)
|
||||
}
|
||||
f.models[RegionContent] = m
|
||||
}
|
||||
|
||||
// Back pops the content history stack, restoring the previous Content model.
|
||||
// Returns false if the history is empty.
|
||||
func (f *Frame) Back() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.history) == 0 {
|
||||
return false
|
||||
}
|
||||
f.models[RegionContent] = f.history[len(f.history)-1]
|
||||
f.history = f.history[:len(f.history)-1]
|
||||
return true
|
||||
}
|
||||
|
||||
// Stop signals the Frame to exit its Run loop.
|
||||
func (f *Frame) Stop() {
|
||||
select {
|
||||
case <-f.done:
|
||||
default:
|
||||
close(f.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
|
||||
// In non-TTY mode, it renders once and returns immediately.
|
||||
func (f *Frame) Run() {
|
||||
if !f.isTTY() {
|
||||
fmt.Fprint(f.out, f.String())
|
||||
return
|
||||
}
|
||||
f.runLive()
|
||||
}
|
||||
|
||||
// RunFor runs the frame for a fixed duration, then stops.
|
||||
// Useful for dashboards that refresh periodically.
|
||||
func (f *Frame) RunFor(d time.Duration) {
|
||||
go func() {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
f.Stop()
|
||||
case <-f.done:
|
||||
}
|
||||
}()
|
||||
f.Run()
|
||||
}
|
||||
|
||||
// String renders the frame as a static string (no ANSI, no live updates).
|
||||
// This is the non-TTY fallback path.
|
||||
func (f *Frame) String() string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
w, h := f.termSize()
|
||||
var sb strings.Builder
|
||||
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
for _, r := range order {
|
||||
if _, exists := f.layout.regions[r]; !exists {
|
||||
continue
|
||||
}
|
||||
m, ok := f.models[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rw, rh := f.regionSize(r, w, h)
|
||||
view := m.View(rw, rh)
|
||||
if view != "" {
|
||||
sb.WriteString(view)
|
||||
if !strings.HasSuffix(view, "\n") {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (f *Frame) isTTY() bool {
|
||||
if file, ok := f.out.(*os.File); ok {
|
||||
return term.IsTerminal(int(file.Fd()))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *Frame) termSize() (int, int) {
|
||||
if file, ok := f.out.(*os.File); ok {
|
||||
w, h, err := term.GetSize(int(file.Fd()))
|
||||
if err == nil {
|
||||
return w, h
|
||||
}
|
||||
}
|
||||
return 80, 24 // sensible default
|
||||
}
|
||||
|
||||
func (f *Frame) regionSize(r Region, totalW, totalH int) (int, int) {
|
||||
// Simple allocation: Header/Footer get 1 line, sidebars get 1/4 width,
|
||||
// Content gets the rest.
|
||||
switch r {
|
||||
case RegionHeader, RegionFooter:
|
||||
return totalW, 1
|
||||
case RegionLeft, RegionRight:
|
||||
return totalW / 4, totalH - 2 // minus header + footer
|
||||
case RegionContent:
|
||||
sideW := 0
|
||||
if _, ok := f.models[RegionLeft]; ok {
|
||||
sideW += totalW / 4
|
||||
}
|
||||
if _, ok := f.models[RegionRight]; ok {
|
||||
sideW += totalW / 4
|
||||
}
|
||||
return totalW - sideW, totalH - 2
|
||||
}
|
||||
return totalW, totalH
|
||||
}
|
||||
|
||||
func (f *Frame) runLive() {
|
||||
// Enter alt-screen.
|
||||
fmt.Fprint(f.out, "\033[?1049h")
|
||||
// Hide cursor.
|
||||
fmt.Fprint(f.out, "\033[?25l")
|
||||
|
||||
defer func() {
|
||||
// Show cursor.
|
||||
fmt.Fprint(f.out, "\033[?25h")
|
||||
// Leave alt-screen.
|
||||
fmt.Fprint(f.out, "\033[?1049l")
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
f.renderFrame()
|
||||
|
||||
select {
|
||||
case <-f.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Frame) renderFrame() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
w, h := f.termSize()
|
||||
|
||||
// Move to top-left.
|
||||
fmt.Fprint(f.out, "\033[H")
|
||||
// Clear screen.
|
||||
fmt.Fprint(f.out, "\033[2J")
|
||||
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
for _, r := range order {
|
||||
if _, exists := f.layout.regions[r]; !exists {
|
||||
continue
|
||||
}
|
||||
m, ok := f.models[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rw, rh := f.regionSize(r, w, h)
|
||||
view := m.View(rw, rh)
|
||||
if view != "" {
|
||||
fmt.Fprint(f.out, view)
|
||||
if !strings.HasSuffix(view, "\n") {
|
||||
fmt.Fprintln(f.out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Built-in Region Components
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// statusLineModel renders a "title key:value key:value" bar.
|
||||
type statusLineModel struct {
|
||||
title string
|
||||
pairs []string
|
||||
}
|
||||
|
||||
// StatusLine creates a header/footer bar with a title and key:value pairs.
|
||||
//
|
||||
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
||||
func StatusLine(title string, pairs ...string) Model {
|
||||
return &statusLineModel{title: title, pairs: pairs}
|
||||
}
|
||||
|
||||
func (s *statusLineModel) View(width, _ int) string {
|
||||
parts := []string{BoldStyle.Render(s.title)}
|
||||
for _, p := range s.pairs {
|
||||
parts = append(parts, DimStyle.Render(p))
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
line = Truncate(line, width)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// keyHintsModel renders keyboard shortcut hints.
|
||||
type keyHintsModel struct {
|
||||
hints []string
|
||||
}
|
||||
|
||||
// KeyHints creates a footer showing keyboard shortcuts.
|
||||
//
|
||||
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
||||
func KeyHints(hints ...string) Model {
|
||||
return &keyHintsModel{hints: hints}
|
||||
}
|
||||
|
||||
func (k *keyHintsModel) View(width, _ int) string {
|
||||
parts := make([]string, len(k.hints))
|
||||
for i, h := range k.hints {
|
||||
parts[i] = DimStyle.Render(h)
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
line = Truncate(line, width)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// breadcrumbModel renders a navigation path.
|
||||
type breadcrumbModel struct {
|
||||
parts []string
|
||||
}
|
||||
|
||||
// Breadcrumb creates a navigation breadcrumb bar.
|
||||
//
|
||||
// frame.Header(cli.Breadcrumb("core", "dev", "health"))
|
||||
func Breadcrumb(parts ...string) Model {
|
||||
return &breadcrumbModel{parts: parts}
|
||||
}
|
||||
|
||||
func (b *breadcrumbModel) View(width, _ int) string {
|
||||
styled := make([]string, len(b.parts))
|
||||
for i, p := range b.parts {
|
||||
if i == len(b.parts)-1 {
|
||||
styled[i] = BoldStyle.Render(p)
|
||||
} else {
|
||||
styled[i] = DimStyle.Render(p)
|
||||
}
|
||||
}
|
||||
line := strings.Join(styled, DimStyle.Render(" > "))
|
||||
if width > 0 {
|
||||
line = Truncate(line, width)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// staticModel wraps a plain string as a Model.
|
||||
type staticModel struct {
|
||||
text string
|
||||
}
|
||||
|
||||
// StaticModel wraps a static string as a Model, for use in Frame regions.
|
||||
func StaticModel(text string) Model {
|
||||
return &staticModel{text: text}
|
||||
}
|
||||
|
||||
func (s *staticModel) View(_, _ int) string {
|
||||
return s.text
|
||||
}
|
||||
207
pkg/cli/frame_test.go
Normal file
207
pkg/cli/frame_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFrame_Good(t *testing.T) {
|
||||
t.Run("static render HCF", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Header(StaticModel("header"))
|
||||
f.Content(StaticModel("content"))
|
||||
f.Footer(StaticModel("footer"))
|
||||
|
||||
out := f.String()
|
||||
assert.Contains(t, out, "header")
|
||||
assert.Contains(t, out, "content")
|
||||
assert.Contains(t, out, "footer")
|
||||
})
|
||||
|
||||
t.Run("region order preserved", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Header(StaticModel("AAA"))
|
||||
f.Content(StaticModel("BBB"))
|
||||
f.Footer(StaticModel("CCC"))
|
||||
|
||||
out := f.String()
|
||||
posA := indexOf(out, "AAA")
|
||||
posB := indexOf(out, "BBB")
|
||||
posC := indexOf(out, "CCC")
|
||||
assert.Less(t, posA, posB, "header before content")
|
||||
assert.Less(t, posB, posC, "content before footer")
|
||||
})
|
||||
|
||||
t.Run("navigate and back", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Header(StaticModel("nav"))
|
||||
f.Content(StaticModel("page-1"))
|
||||
f.Footer(StaticModel("hints"))
|
||||
|
||||
assert.Contains(t, f.String(), "page-1")
|
||||
|
||||
// Navigate to page 2
|
||||
f.Navigate(StaticModel("page-2"))
|
||||
assert.Contains(t, f.String(), "page-2")
|
||||
assert.NotContains(t, f.String(), "page-1")
|
||||
|
||||
// Navigate to page 3
|
||||
f.Navigate(StaticModel("page-3"))
|
||||
assert.Contains(t, f.String(), "page-3")
|
||||
|
||||
// Back to page 2
|
||||
ok := f.Back()
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, f.String(), "page-2")
|
||||
|
||||
// Back to page 1
|
||||
ok = f.Back()
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, f.String(), "page-1")
|
||||
|
||||
// No more history
|
||||
ok = f.Back()
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("empty regions skipped", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Content(StaticModel("only content"))
|
||||
|
||||
out := f.String()
|
||||
assert.Equal(t, "only content\n", out)
|
||||
})
|
||||
|
||||
t.Run("non-TTY run renders once", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
var buf bytes.Buffer
|
||||
f := NewFrame("HCF")
|
||||
f.out = &buf
|
||||
f.Header(StaticModel("h"))
|
||||
f.Content(StaticModel("c"))
|
||||
f.Footer(StaticModel("f"))
|
||||
|
||||
f.Run() // non-TTY, should return immediately
|
||||
assert.Contains(t, buf.String(), "h")
|
||||
assert.Contains(t, buf.String(), "c")
|
||||
assert.Contains(t, buf.String(), "f")
|
||||
})
|
||||
|
||||
t.Run("ModelFunc adapter", func(t *testing.T) {
|
||||
called := false
|
||||
m := ModelFunc(func(w, h int) string {
|
||||
called = true
|
||||
return "dynamic"
|
||||
})
|
||||
|
||||
out := m.View(80, 24)
|
||||
assert.True(t, called)
|
||||
assert.Equal(t, "dynamic", out)
|
||||
})
|
||||
|
||||
t.Run("RunFor exits after duration", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
f := NewFrame("C")
|
||||
f.out = &buf // non-TTY → RunFor renders once and returns
|
||||
f.Content(StaticModel("timed"))
|
||||
|
||||
start := time.Now()
|
||||
f.RunFor(50 * time.Millisecond)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.Less(t, elapsed, 200*time.Millisecond)
|
||||
assert.Contains(t, buf.String(), "timed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrame_Bad(t *testing.T) {
|
||||
t.Run("empty frame", func(t *testing.T) {
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
assert.Equal(t, "", f.String())
|
||||
})
|
||||
|
||||
t.Run("back on empty history", func(t *testing.T) {
|
||||
f := NewFrame("C")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Content(StaticModel("x"))
|
||||
assert.False(t, f.Back())
|
||||
})
|
||||
|
||||
t.Run("invalid variant degrades gracefully", func(t *testing.T) {
|
||||
f := NewFrame("XYZ")
|
||||
f.out = &bytes.Buffer{}
|
||||
// No valid regions, so nothing renders
|
||||
assert.Equal(t, "", f.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatusLine_Good(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
m := StatusLine("core dev", "18 repos", "main")
|
||||
out := m.View(80, 1)
|
||||
assert.Contains(t, out, "core dev")
|
||||
assert.Contains(t, out, "18 repos")
|
||||
assert.Contains(t, out, "main")
|
||||
}
|
||||
|
||||
func TestKeyHints_Good(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
m := KeyHints("↑/↓ navigate", "q quit")
|
||||
out := m.View(80, 1)
|
||||
assert.Contains(t, out, "navigate")
|
||||
assert.Contains(t, out, "quit")
|
||||
}
|
||||
|
||||
func TestBreadcrumb_Good(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
m := Breadcrumb("core", "dev", "health")
|
||||
out := m.View(80, 1)
|
||||
assert.Contains(t, out, "core")
|
||||
assert.Contains(t, out, "dev")
|
||||
assert.Contains(t, out, "health")
|
||||
assert.Contains(t, out, ">")
|
||||
}
|
||||
|
||||
func TestStaticModel_Good(t *testing.T) {
|
||||
m := StaticModel("hello")
|
||||
assert.Equal(t, "hello", m.View(80, 24))
|
||||
}
|
||||
|
||||
// indexOf returns the position of substr in s, or -1 if not found.
|
||||
func indexOf(s, substr string) int {
|
||||
for i := range len(s) - len(substr) + 1 {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
92
pkg/cli/glyph.go
Normal file
92
pkg/cli/glyph.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GlyphTheme defines which symbols to use.
|
||||
type GlyphTheme int
|
||||
|
||||
const (
|
||||
// ThemeUnicode uses standard Unicode symbols.
|
||||
ThemeUnicode GlyphTheme = iota
|
||||
// ThemeEmoji uses Emoji symbols.
|
||||
ThemeEmoji
|
||||
// ThemeASCII uses ASCII fallback symbols.
|
||||
ThemeASCII
|
||||
)
|
||||
|
||||
var currentTheme = ThemeUnicode
|
||||
|
||||
// UseUnicode switches the glyph theme to Unicode.
|
||||
func UseUnicode() { currentTheme = ThemeUnicode }
|
||||
|
||||
// UseEmoji switches the glyph theme to Emoji.
|
||||
func UseEmoji() { currentTheme = ThemeEmoji }
|
||||
|
||||
// UseASCII switches the glyph theme to ASCII and disables colors.
|
||||
func UseASCII() {
|
||||
currentTheme = ThemeASCII
|
||||
SetColorEnabled(false)
|
||||
}
|
||||
|
||||
func glyphMap() map[string]string {
|
||||
switch currentTheme {
|
||||
case ThemeEmoji:
|
||||
return glyphMapEmoji
|
||||
case ThemeASCII:
|
||||
return glyphMapASCII
|
||||
default:
|
||||
return glyphMapUnicode
|
||||
}
|
||||
}
|
||||
|
||||
// Glyph converts a shortcode (e.g. ":check:") to its symbol based on the current theme.
|
||||
func Glyph(code string) string {
|
||||
if sym, ok := glyphMap()[code]; ok {
|
||||
return sym
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func compileGlyphs(x string) string {
|
||||
if x == "" {
|
||||
return ""
|
||||
}
|
||||
input := bytes.NewBufferString(x)
|
||||
output := bytes.NewBufferString("")
|
||||
|
||||
for {
|
||||
r, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if r == ':' {
|
||||
output.WriteString(replaceGlyph(input))
|
||||
} else {
|
||||
output.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func replaceGlyph(input *bytes.Buffer) string {
|
||||
code := bytes.NewBufferString(":")
|
||||
for {
|
||||
r, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
return code.String()
|
||||
}
|
||||
if r == ':' && code.Len() == 1 {
|
||||
return code.String() + replaceGlyph(input)
|
||||
}
|
||||
code.WriteRune(r)
|
||||
if unicode.IsSpace(r) {
|
||||
return code.String()
|
||||
}
|
||||
if r == ':' {
|
||||
return Glyph(code.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
25
pkg/cli/glyph_maps.go
Normal file
25
pkg/cli/glyph_maps.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package cli
|
||||
|
||||
var glyphMapUnicode = map[string]string{
|
||||
":check:": "✓", ":cross:": "✗", ":warn:": "⚠", ":info:": "ℹ",
|
||||
":question:": "?", ":skip:": "○", ":dot:": "●", ":circle:": "◯",
|
||||
":arrow_right:": "→", ":arrow_left:": "←", ":arrow_up:": "↑", ":arrow_down:": "↓",
|
||||
":pointer:": "▶", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
||||
":corner:": "└", ":tee:": "├", ":pending:": "…", ":spinner:": "⠋",
|
||||
}
|
||||
|
||||
var glyphMapEmoji = map[string]string{
|
||||
":check:": "✅", ":cross:": "❌", ":warn:": "⚠️", ":info:": "ℹ️",
|
||||
":question:": "❓", ":skip:": "⏭️", ":dot:": "🔵", ":circle:": "⚪",
|
||||
":arrow_right:": "➡️", ":arrow_left:": "⬅️", ":arrow_up:": "⬆️", ":arrow_down:": "⬇️",
|
||||
":pointer:": "▶️", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
||||
":corner:": "└", ":tee:": "├", ":pending:": "⏳", ":spinner:": "🔄",
|
||||
}
|
||||
|
||||
var glyphMapASCII = map[string]string{
|
||||
":check:": "[OK]", ":cross:": "[FAIL]", ":warn:": "[WARN]", ":info:": "[INFO]",
|
||||
":question:": "[?]", ":skip:": "[SKIP]", ":dot:": "[*]", ":circle:": "[ ]",
|
||||
":arrow_right:": "->", ":arrow_left:": "<-", ":arrow_up:": "^", ":arrow_down:": "v",
|
||||
":pointer:": ">", ":bullet:": "*", ":dash:": "-", ":pipe:": "|",
|
||||
":corner:": "`", ":tee:": "+", ":pending:": "...", ":spinner:": "-",
|
||||
}
|
||||
23
pkg/cli/glyph_test.go
Normal file
23
pkg/cli/glyph_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGlyph(t *testing.T) {
|
||||
UseUnicode()
|
||||
if Glyph(":check:") != "✓" {
|
||||
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
|
||||
}
|
||||
|
||||
UseASCII()
|
||||
if Glyph(":check:") != "[OK]" {
|
||||
t.Errorf("Expected [OK], got %s", Glyph(":check:"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGlyphs(t *testing.T) {
|
||||
UseUnicode()
|
||||
got := compileGlyphs("Status: :check:")
|
||||
if got != "Status: ✓" {
|
||||
t.Errorf("Expected Status: ✓, got %s", got)
|
||||
}
|
||||
}
|
||||
170
pkg/cli/i18n.go
Normal file
170
pkg/cli/i18n.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// I18nService wraps i18n as a Core service.
|
||||
type I18nService struct {
|
||||
*framework.ServiceRuntime[I18nOptions]
|
||||
svc *i18n.Service
|
||||
|
||||
// Collect mode state
|
||||
missingKeys []i18n.MissingKey
|
||||
missingKeysMu sync.Mutex
|
||||
}
|
||||
|
||||
// I18nOptions configures the i18n service.
|
||||
type I18nOptions struct {
|
||||
// Language overrides auto-detection (e.g., "en-GB", "de")
|
||||
Language string
|
||||
// Mode sets the translation mode (Normal, Strict, Collect)
|
||||
Mode i18n.Mode
|
||||
}
|
||||
|
||||
// NewI18nService creates an i18n service factory.
|
||||
func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
svc, err := i18n.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.Language != "" {
|
||||
_ = svc.SetLanguage(opts.Language)
|
||||
}
|
||||
|
||||
// Set mode if specified
|
||||
svc.SetMode(opts.Mode)
|
||||
|
||||
// Set as global default so i18n.T() works everywhere
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
return &I18nService{
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||
svc: svc,
|
||||
missingKeys: make([]i18n.MissingKey, 0),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup initialises the i18n service.
|
||||
func (s *I18nService) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
|
||||
// Register action handler for collect mode
|
||||
if s.svc.Mode() == i18n.ModeCollect {
|
||||
i18n.OnMissingKey(s.handleMissingKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMissingKey accumulates missing keys in collect mode.
|
||||
func (s *I18nService) handleMissingKey(mk i18n.MissingKey) {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = append(s.missingKeys, mk)
|
||||
}
|
||||
|
||||
// MissingKeys returns all missing keys collected in collect mode.
|
||||
// Call this at the end of a QA session to report missing translations.
|
||||
func (s *I18nService) MissingKeys() []i18n.MissingKey {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
result := make([]i18n.MissingKey, len(s.missingKeys))
|
||||
copy(result, s.missingKeys)
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearMissingKeys resets the collected missing keys.
|
||||
func (s *I18nService) ClearMissingKeys() {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = s.missingKeys[:0]
|
||||
}
|
||||
|
||||
// SetMode changes the translation mode.
|
||||
func (s *I18nService) SetMode(mode i18n.Mode) {
|
||||
s.svc.SetMode(mode)
|
||||
|
||||
// Update action handler registration
|
||||
if mode == i18n.ModeCollect {
|
||||
i18n.OnMissingKey(s.handleMissingKey)
|
||||
} else {
|
||||
i18n.OnMissingKey(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
func (s *I18nService) Mode() i18n.Mode {
|
||||
return s.svc.Mode()
|
||||
}
|
||||
|
||||
// Queries for i18n service
|
||||
|
||||
// QueryTranslate requests a translation.
|
||||
type QueryTranslate struct {
|
||||
Key string
|
||||
Args map[string]any
|
||||
}
|
||||
|
||||
func (s *I18nService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||
switch m := q.(type) {
|
||||
case QueryTranslate:
|
||||
return s.svc.T(m.Key, m.Args), true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// T translates a key with optional arguments.
|
||||
func (s *I18nService) T(key string, args ...map[string]any) string {
|
||||
if len(args) > 0 {
|
||||
return s.svc.T(key, args[0])
|
||||
}
|
||||
return s.svc.T(key)
|
||||
}
|
||||
|
||||
// SetLanguage changes the current language.
|
||||
func (s *I18nService) SetLanguage(lang string) {
|
||||
_ = s.svc.SetLanguage(lang)
|
||||
}
|
||||
|
||||
// Language returns the current language.
|
||||
func (s *I18nService) Language() string {
|
||||
return s.svc.Language()
|
||||
}
|
||||
|
||||
// AvailableLanguages returns all available languages.
|
||||
func (s *I18nService) AvailableLanguages() []string {
|
||||
return s.svc.AvailableLanguages()
|
||||
}
|
||||
|
||||
// --- Package-level convenience ---
|
||||
|
||||
// T translates a key using the CLI's i18n service.
|
||||
// Falls back to the global i18n.T if CLI not initialised.
|
||||
func T(key string, args ...map[string]any) string {
|
||||
if instance == nil {
|
||||
// CLI not initialised, use global i18n
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
}
|
||||
return i18n.T(key)
|
||||
}
|
||||
|
||||
svc, err := framework.ServiceFor[*I18nService](instance.core, "i18n")
|
||||
if err != nil {
|
||||
// i18n service not registered, use global
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
}
|
||||
return i18n.T(key)
|
||||
}
|
||||
|
||||
return svc.T(key, args...)
|
||||
}
|
||||
148
pkg/cli/layout.go
Normal file
148
pkg/cli/layout.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Region represents one of the 5 HLCRF regions.
|
||||
type Region rune
|
||||
|
||||
const (
|
||||
// RegionHeader is the top region of the layout.
|
||||
RegionHeader Region = 'H'
|
||||
// RegionLeft is the left sidebar region.
|
||||
RegionLeft Region = 'L'
|
||||
// RegionContent is the main content region.
|
||||
RegionContent Region = 'C'
|
||||
// RegionRight is the right sidebar region.
|
||||
RegionRight Region = 'R'
|
||||
// RegionFooter is the bottom region of the layout.
|
||||
RegionFooter Region = 'F'
|
||||
)
|
||||
|
||||
// Composite represents an HLCRF layout node.
|
||||
type Composite struct {
|
||||
variant string
|
||||
path string
|
||||
regions map[Region]*Slot
|
||||
parent *Composite
|
||||
}
|
||||
|
||||
// Slot holds content for a region.
|
||||
type Slot struct {
|
||||
region Region
|
||||
path string
|
||||
blocks []Renderable
|
||||
child *Composite
|
||||
}
|
||||
|
||||
// Renderable is anything that can be rendered to terminal.
|
||||
type Renderable interface {
|
||||
Render() string
|
||||
}
|
||||
|
||||
// StringBlock is a simple string that implements Renderable.
|
||||
type StringBlock string
|
||||
|
||||
// Render returns the string content.
|
||||
func (s StringBlock) Render() string { return string(s) }
|
||||
|
||||
// Layout creates a new layout from a variant string.
|
||||
func Layout(variant string) *Composite {
|
||||
c, err := ParseVariant(variant)
|
||||
if err != nil {
|
||||
return &Composite{variant: variant, regions: make(map[Region]*Slot)}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseVariant parses a variant string like "H[LC]C[HCF]F".
|
||||
func ParseVariant(variant string) (*Composite, error) {
|
||||
c := &Composite{
|
||||
variant: variant,
|
||||
path: "",
|
||||
regions: make(map[Region]*Slot),
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(variant) {
|
||||
r := Region(variant[i])
|
||||
if !isValidRegion(r) {
|
||||
return nil, fmt.Errorf("invalid region: %c", r)
|
||||
}
|
||||
|
||||
slot := &Slot{region: r, path: string(r)}
|
||||
c.regions[r] = slot
|
||||
i++
|
||||
|
||||
if i < len(variant) && variant[i] == '[' {
|
||||
end := findMatchingBracket(variant, i)
|
||||
if end == -1 {
|
||||
return nil, fmt.Errorf("unmatched bracket at %d", i)
|
||||
}
|
||||
nested, err := ParseVariant(variant[i+1 : end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nested.path = string(r) + "-"
|
||||
nested.parent = c
|
||||
slot.child = nested
|
||||
i = end + 1
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func isValidRegion(r Region) bool {
|
||||
return r == 'H' || r == 'L' || r == 'C' || r == 'R' || r == 'F'
|
||||
}
|
||||
|
||||
func findMatchingBracket(s string, start int) int {
|
||||
depth := 0
|
||||
for i := start; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// H adds content to Header region.
|
||||
func (c *Composite) H(items ...any) *Composite { c.addToRegion(RegionHeader, items...); return c }
|
||||
|
||||
// L adds content to Left region.
|
||||
func (c *Composite) L(items ...any) *Composite { c.addToRegion(RegionLeft, items...); return c }
|
||||
|
||||
// C adds content to Content region.
|
||||
func (c *Composite) C(items ...any) *Composite { c.addToRegion(RegionContent, items...); return c }
|
||||
|
||||
// R adds content to Right region.
|
||||
func (c *Composite) R(items ...any) *Composite { c.addToRegion(RegionRight, items...); return c }
|
||||
|
||||
// F adds content to Footer region.
|
||||
func (c *Composite) F(items ...any) *Composite { c.addToRegion(RegionFooter, items...); return c }
|
||||
|
||||
func (c *Composite) addToRegion(r Region, items ...any) {
|
||||
slot, ok := c.regions[r]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
slot.blocks = append(slot.blocks, toRenderable(item))
|
||||
}
|
||||
}
|
||||
|
||||
func toRenderable(item any) Renderable {
|
||||
switch v := item.(type) {
|
||||
case Renderable:
|
||||
return v
|
||||
case string:
|
||||
return StringBlock(v)
|
||||
default:
|
||||
return StringBlock(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
25
pkg/cli/layout_test.go
Normal file
25
pkg/cli/layout_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVariant(t *testing.T) {
|
||||
c, err := ParseVariant("H[LC]F")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
if _, ok := c.regions[RegionHeader]; !ok {
|
||||
t.Error("Expected Header region")
|
||||
}
|
||||
if _, ok := c.regions[RegionFooter]; !ok {
|
||||
t.Error("Expected Footer region")
|
||||
}
|
||||
|
||||
hSlot := c.regions[RegionHeader]
|
||||
if hSlot.child == nil {
|
||||
t.Error("Header should have child layout")
|
||||
} else {
|
||||
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
|
||||
t.Error("Child should have Left region")
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue