Compare commits
1 commit
dev
...
ax/review-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82ccd30fcc |
54 changed files with 284 additions and 2792 deletions
|
|
@ -26,13 +26,6 @@ func requiredChecks() []check {
|
|||
args: []string{"--version"},
|
||||
versionFlag: "--version",
|
||||
},
|
||||
{
|
||||
name: i18n.T("cmd.doctor.check.go.name"),
|
||||
description: i18n.T("cmd.doctor.check.go.description"),
|
||||
command: "go",
|
||||
args: []string{"version"},
|
||||
versionFlag: "version",
|
||||
},
|
||||
{
|
||||
name: i18n.T("cmd.doctor.check.gh.name"),
|
||||
description: i18n.T("cmd.doctor.check.gh.description"),
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRequiredChecksIncludesGo(t *testing.T) {
|
||||
checks := requiredChecks()
|
||||
|
||||
var found bool
|
||||
for _, c := range checks {
|
||||
if c.command == "go" {
|
||||
found = true
|
||||
assert.Equal(t, "version", c.versionFlag)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, found, "required checks should include the Go compiler")
|
||||
}
|
||||
|
|
@ -160,13 +160,9 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
|
|||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oasdiff/kin-openapi v0.136.1 h1:x1G9doDyPcagCNXDcMK5dt5yAmIgsSCiK7F5gPUiQdM=
|
||||
github.com/oasdiff/kin-openapi v0.136.1/go.mod h1:BMeaLn+GmFJKtHJ31JrgXFt91eZi/q+Og4tr7sq0BzI=
|
||||
github.com/oasdiff/oasdiff v1.12.3 h1:eUzJ/AiyyCY1KwUZPv7fosgDyETacIZbFesJrRz+QdY=
|
||||
github.com/oasdiff/oasdiff v1.12.3/go.mod h1:ApEJGlkuRdrcBgTE4ioicwIM7nzkxPqLPPvcB5AytQ0=
|
||||
github.com/oasdiff/yaml v0.0.1 h1:dPrn0F2PJ7HdzHPndJkArvB2Fw0cwgFdVUKCEkoFuds=
|
||||
github.com/oasdiff/yaml v0.0.1/go.mod h1:r8bgVgpWT5iIN/AgP0GljFvB6CicK+yL1nIAbm+8/QQ=
|
||||
github.com/oasdiff/yaml3 v0.0.1 h1:kReOSraQLTxuuGNX9aNeJ7tcsvUB2MS+iupdUrWe4Z0=
|
||||
github.com/oasdiff/yaml3 v0.0.1/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
package help
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
gohelp "forge.lthn.ai/core/go-help"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func captureOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldOut := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
|
||||
defer func() {
|
||||
os.Stdout = oldOut
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, r)
|
||||
require.NoError(t, err)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func newHelpCommand(t *testing.T) *cli.Command {
|
||||
t.Helper()
|
||||
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, err := root.Find([]string{"help"})
|
||||
require.NoError(t, err)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func searchableHelpQuery(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
catalog := gohelp.DefaultCatalog()
|
||||
for _, candidate := range []string{"configuration", "docs", "search", "topic", "help"} {
|
||||
if _, err := catalog.Get(candidate); err == nil {
|
||||
continue
|
||||
}
|
||||
if len(catalog.Search(candidate)) > 0 {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
t.Skip("no suitable query found with suggestions")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Good(t *testing.T) {
|
||||
cmd := newHelpCommand(t)
|
||||
|
||||
topics := gohelp.DefaultCatalog().List()
|
||||
require.NotEmpty(t, topics)
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
|
||||
assert.Contains(t, out, topics[0].ID)
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search <topic>")
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Good_Serve(t *testing.T) {
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, err := root.Find([]string{"help", "serve"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
oldStart := startHelpServer
|
||||
defer func() { startHelpServer = oldStart }()
|
||||
|
||||
var gotAddr string
|
||||
startHelpServer = func(catalog *gohelp.Catalog, addr string) error {
|
||||
require.NotNil(t, catalog)
|
||||
gotAddr = addr
|
||||
return nil
|
||||
}
|
||||
|
||||
require.NoError(t, cmd.Flags().Set("addr", "127.0.0.1:9090"))
|
||||
err = cmd.RunE(cmd, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "127.0.0.1:9090", gotAddr)
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Good_Search(t *testing.T) {
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, err := root.Find([]string{"help", "search"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
query := searchableHelpQuery(t)
|
||||
require.NoError(t, cmd.Flags().Set("query", query))
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "SEARCH RESULTS")
|
||||
assert.Contains(t, out, query)
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search")
|
||||
}
|
||||
|
||||
func TestRenderSearchResults_Good(t *testing.T) {
|
||||
out := captureOutput(t, func() {
|
||||
err := renderSearchResults([]*gohelp.SearchResult{
|
||||
{
|
||||
Topic: &gohelp.Topic{
|
||||
ID: "config",
|
||||
Title: "Configuration",
|
||||
},
|
||||
Snippet: "Core is configured via environment variables.",
|
||||
},
|
||||
}, "config")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "SEARCH RESULTS")
|
||||
assert.Contains(t, out, "config - Configuration")
|
||||
assert.Contains(t, out, "Core is configured via environment variables.")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search \"config\"")
|
||||
}
|
||||
|
||||
func TestRenderTopicList_Good(t *testing.T) {
|
||||
out := captureOutput(t, func() {
|
||||
err := renderTopicList([]*gohelp.Topic{
|
||||
{
|
||||
ID: "config",
|
||||
Title: "Configuration",
|
||||
Content: "# Configuration\n\nCore is configured via environment variables.\n\nMore details follow.",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
|
||||
assert.Contains(t, out, "config - Configuration")
|
||||
assert.Contains(t, out, "Core is configured via environment variables.")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search <topic>")
|
||||
}
|
||||
|
||||
func TestRenderTopic_Good(t *testing.T) {
|
||||
out := captureOutput(t, func() {
|
||||
renderTopic(&gohelp.Topic{
|
||||
ID: "config",
|
||||
Title: "Configuration",
|
||||
Content: "Core is configured via environment variables.",
|
||||
})
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "Configuration")
|
||||
assert.Contains(t, out, "Core is configured via environment variables.")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search \"config\"")
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Bad(t *testing.T) {
|
||||
t.Run("missing search results", func(t *testing.T) {
|
||||
cmd := newHelpCommand(t)
|
||||
require.NoError(t, cmd.Flags().Set("search", "zzzyyyxxx"))
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no help topics matched")
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help")
|
||||
assert.Contains(t, out, "core help search")
|
||||
})
|
||||
|
||||
t.Run("missing topic without suggestions shows hints", func(t *testing.T) {
|
||||
cmd := newHelpCommand(t)
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, []string{"definitely-not-a-real-topic"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "help topic")
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help")
|
||||
})
|
||||
|
||||
t.Run("missing search query", func(t *testing.T) {
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, findErr := root.Find([]string{"help", "search"})
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
var runErr error
|
||||
out := captureOutput(t, func() {
|
||||
runErr = cmd.RunE(cmd, nil)
|
||||
})
|
||||
require.Error(t, runErr)
|
||||
assert.Contains(t, runErr.Error(), "help search query is required")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help")
|
||||
})
|
||||
|
||||
t.Run("missing topic shows suggestions when available", func(t *testing.T) {
|
||||
query := searchableHelpQuery(t)
|
||||
|
||||
cmd := newHelpCommand(t)
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, []string{query})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "help topic")
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "SEARCH RESULTS")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunPkgInstall_AllowsRepoShorthand_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
targetDir := filepath.Join(tmp, "packages")
|
||||
|
||||
originalGitClone := gitClone
|
||||
t.Cleanup(func() {
|
||||
gitClone = originalGitClone
|
||||
})
|
||||
|
||||
var gotOrg, gotRepo, gotPath string
|
||||
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
|
||||
gotOrg = org
|
||||
gotRepo = repoName
|
||||
gotPath = repoPath
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPkgInstall("core-api", targetDir, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "host-uk", gotOrg)
|
||||
assert.Equal(t, "core-api", gotRepo)
|
||||
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
|
||||
_, err = os.Stat(targetDir)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRunPkgInstall_AllowsExplicitOrgRepo_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
targetDir := filepath.Join(tmp, "packages")
|
||||
|
||||
originalGitClone := gitClone
|
||||
t.Cleanup(func() {
|
||||
gitClone = originalGitClone
|
||||
})
|
||||
|
||||
var gotOrg, gotRepo, gotPath string
|
||||
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
|
||||
gotOrg = org
|
||||
gotRepo = repoName
|
||||
gotPath = repoPath
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPkgInstall("myorg/core-api", targetDir, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "myorg", gotOrg)
|
||||
assert.Equal(t, "core-api", gotRepo)
|
||||
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
|
||||
}
|
||||
|
||||
func TestRunPkgInstall_InvalidRepoFormat_Bad(t *testing.T) {
|
||||
err := runPkgInstall("a/b/c", t.TempDir(), false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid repo format")
|
||||
}
|
||||
|
||||
func TestParsePkgInstallSource_Good(t *testing.T) {
|
||||
t.Run("default org and repo", func(t *testing.T) {
|
||||
org, repo, ref, err := parsePkgInstallSource("core-api")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "host-uk", org)
|
||||
assert.Equal(t, "core-api", repo)
|
||||
assert.Empty(t, ref)
|
||||
})
|
||||
|
||||
t.Run("explicit org and ref", func(t *testing.T) {
|
||||
org, repo, ref, err := parsePkgInstallSource("myorg/core-api@v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "myorg", org)
|
||||
assert.Equal(t, "core-api", repo)
|
||||
assert.Equal(t, "v1.2.3", ref)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunPkgInstall_WithRef_UsesRefClone_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
targetDir := filepath.Join(tmp, "packages")
|
||||
|
||||
originalGitCloneRef := gitCloneRef
|
||||
t.Cleanup(func() {
|
||||
gitCloneRef = originalGitCloneRef
|
||||
})
|
||||
|
||||
var gotOrg, gotRepo, gotPath, gotRef string
|
||||
gitCloneRef = func(_ context.Context, org, repoName, repoPath, ref string) error {
|
||||
gotOrg = org
|
||||
gotRepo = repoName
|
||||
gotPath = repoPath
|
||||
gotRef = ref
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPkgInstall("myorg/core-api@v1.2.3", targetDir, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "myorg", gotOrg)
|
||||
assert.Equal(t, "core-api", gotRepo)
|
||||
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
|
||||
assert.Equal(t, "v1.2.3", gotRef)
|
||||
}
|
||||
|
|
@ -1,350 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-cache"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func capturePkgOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, r)
|
||||
require.NoError(t, err)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func withWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
oldwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(dir))
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldwd))
|
||||
})
|
||||
}
|
||||
|
||||
func writeTestRegistry(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-alpha:
|
||||
type: foundation
|
||||
description: Alpha package
|
||||
core-beta:
|
||||
type: module
|
||||
description: Beta package
|
||||
`) + "\n"
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "repos.yaml"), []byte(registry), 0644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "core-alpha", ".git"), 0755))
|
||||
}
|
||||
|
||||
func gitCommand(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "git %v failed: %s", args, string(out))
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func commitGitRepo(t *testing.T, dir, filename, content, message string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644))
|
||||
gitCommand(t, dir, "add", filename)
|
||||
gitCommand(t, dir, "commit", "-m", message)
|
||||
}
|
||||
|
||||
func setupOutdatedRegistry(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
tmp := t.TempDir()
|
||||
|
||||
remoteDir := filepath.Join(tmp, "remote.git")
|
||||
gitCommand(t, tmp, "init", "--bare", remoteDir)
|
||||
|
||||
seedDir := filepath.Join(tmp, "seed")
|
||||
require.NoError(t, os.MkdirAll(seedDir, 0755))
|
||||
gitCommand(t, seedDir, "init")
|
||||
gitCommand(t, seedDir, "config", "user.email", "test@test.com")
|
||||
gitCommand(t, seedDir, "config", "user.name", "Test")
|
||||
commitGitRepo(t, seedDir, "repo.txt", "v1\n", "initial")
|
||||
gitCommand(t, seedDir, "remote", "add", "origin", remoteDir)
|
||||
gitCommand(t, seedDir, "push", "-u", "origin", "master")
|
||||
|
||||
freshDir := filepath.Join(tmp, "core-fresh")
|
||||
gitCommand(t, tmp, "clone", remoteDir, freshDir)
|
||||
|
||||
staleDir := filepath.Join(tmp, "core-stale")
|
||||
gitCommand(t, tmp, "clone", remoteDir, staleDir)
|
||||
|
||||
commitGitRepo(t, seedDir, "repo.txt", "v2\n", "second")
|
||||
gitCommand(t, seedDir, "push")
|
||||
gitCommand(t, freshDir, "pull", "--ff-only")
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-fresh:
|
||||
type: foundation
|
||||
description: Fresh package
|
||||
core-stale:
|
||||
type: module
|
||||
description: Stale package
|
||||
core-missing:
|
||||
type: module
|
||||
description: Missing package
|
||||
`) + "\n"
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
|
||||
return tmp
|
||||
}
|
||||
|
||||
func TestRunPkgList_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgList("table")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "core-alpha")
|
||||
assert.Contains(t, out, "core-beta")
|
||||
assert.Contains(t, out, "core setup")
|
||||
}
|
||||
|
||||
func TestRunPkgList_JSON(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgList("json")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
var report pkgListReport
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, 2, report.Total)
|
||||
assert.Equal(t, 1, report.Installed)
|
||||
assert.Equal(t, 1, report.Missing)
|
||||
require.Len(t, report.Packages, 2)
|
||||
assert.Equal(t, "core-alpha", report.Packages[0].Name)
|
||||
assert.True(t, report.Packages[0].Installed)
|
||||
assert.Equal(t, filepath.Join(tmp, "core-alpha"), report.Packages[0].Path)
|
||||
assert.Equal(t, "core-beta", report.Packages[1].Name)
|
||||
assert.False(t, report.Packages[1].Installed)
|
||||
}
|
||||
|
||||
func TestRunPkgList_UnsupportedFormat(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
err := runPkgList("yaml")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported format")
|
||||
}
|
||||
|
||||
func TestRunPkgOutdated_JSON(t *testing.T) {
|
||||
tmp := setupOutdatedRegistry(t)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgOutdated("json")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
var report pkgOutdatedReport
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, 3, report.Total)
|
||||
assert.Equal(t, 2, report.Installed)
|
||||
assert.Equal(t, 1, report.Missing)
|
||||
assert.Equal(t, 1, report.Outdated)
|
||||
assert.Equal(t, 1, report.UpToDate)
|
||||
require.Len(t, report.Packages, 3)
|
||||
|
||||
var staleFound, freshFound, missingFound bool
|
||||
for _, pkg := range report.Packages {
|
||||
switch pkg.Name {
|
||||
case "core-stale":
|
||||
staleFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.False(t, pkg.UpToDate)
|
||||
assert.Equal(t, 1, pkg.Behind)
|
||||
case "core-fresh":
|
||||
freshFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.True(t, pkg.UpToDate)
|
||||
assert.Equal(t, 0, pkg.Behind)
|
||||
case "core-missing":
|
||||
missingFound = true
|
||||
assert.False(t, pkg.Installed)
|
||||
assert.False(t, pkg.UpToDate)
|
||||
assert.Equal(t, 0, pkg.Behind)
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, staleFound)
|
||||
assert.True(t, freshFound)
|
||||
assert.True(t, missingFound)
|
||||
}
|
||||
|
||||
func TestRenderPkgSearchResults_ShowsMetadata(t *testing.T) {
|
||||
out := capturePkgOutput(t, func() {
|
||||
renderPkgSearchResults([]ghRepo{
|
||||
{
|
||||
FullName: "host-uk/core-alpha",
|
||||
Name: "core-alpha",
|
||||
Description: "Alpha package",
|
||||
Visibility: "private",
|
||||
StargazerCount: 42,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "host-uk/core-alpha")
|
||||
assert.Contains(t, out, "Alpha package")
|
||||
assert.Contains(t, out, "42 stars")
|
||||
assert.Contains(t, out, "Go")
|
||||
assert.Contains(t, out, "updated 2h ago")
|
||||
}
|
||||
|
||||
func TestRunPkgSearch_RespectsLimitWithCachedResults(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
c, err := cache.New(nil, filepath.Join(tmp, ".core", "cache"), 0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.Set(cache.GitHubReposKey("host-uk"), []ghRepo{
|
||||
{
|
||||
FullName: "host-uk/core-alpha",
|
||||
Name: "core-alpha",
|
||||
Description: "Alpha package",
|
||||
Visibility: "public",
|
||||
UpdatedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
|
||||
StargazerCount: 1,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
},
|
||||
{
|
||||
FullName: "host-uk/core-beta",
|
||||
Name: "core-beta",
|
||||
Description: "Beta package",
|
||||
Visibility: "public",
|
||||
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
|
||||
StargazerCount: 2,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgSearch("host-uk", "*", "", 1, false, "table")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "core-alpha")
|
||||
assert.NotContains(t, out, "core-beta")
|
||||
}
|
||||
|
||||
func TestRunPkgUpdate_NoArgs_UpdatesAll(t *testing.T) {
|
||||
tmp := setupOutdatedRegistry(t)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgUpdate(nil, false, "table")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "updating")
|
||||
assert.Contains(t, out, "core-fresh")
|
||||
assert.Contains(t, out, "core-stale")
|
||||
}
|
||||
|
||||
func TestRunPkgUpdate_JSON(t *testing.T) {
|
||||
tmp := setupOutdatedRegistry(t)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgUpdate(nil, false, "json")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
var report pkgUpdateReport
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, 3, report.Total)
|
||||
assert.Equal(t, 2, report.Installed)
|
||||
assert.Equal(t, 1, report.Missing)
|
||||
assert.Equal(t, 1, report.Updated)
|
||||
assert.Equal(t, 1, report.UpToDate)
|
||||
assert.Equal(t, 0, report.Failed)
|
||||
require.Len(t, report.Packages, 3)
|
||||
|
||||
var updatedFound, upToDateFound, missingFound bool
|
||||
for _, pkg := range report.Packages {
|
||||
switch pkg.Name {
|
||||
case "core-stale":
|
||||
updatedFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.Equal(t, "updated", pkg.Status)
|
||||
case "core-fresh":
|
||||
upToDateFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.Equal(t, "up_to_date", pkg.Status)
|
||||
case "core-missing":
|
||||
missingFound = true
|
||||
assert.False(t, pkg.Installed)
|
||||
assert.Equal(t, "missing", pkg.Status)
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, updatedFound)
|
||||
assert.True(t, upToDateFound)
|
||||
assert.True(t, missingFound)
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ var (
|
|||
dimStyle = cli.DimStyle
|
||||
ghAuthenticated = cli.GhAuthenticated
|
||||
gitClone = cli.GitClone
|
||||
gitCloneRef = clonePackageAtRef
|
||||
)
|
||||
|
||||
// AddPkgCommands adds the 'pkg' command and subcommands for package management.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -14,50 +12,22 @@ import (
|
|||
|
||||
func setupTestRepo(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
|
||||
repoPath := filepath.Join(dir, name)
|
||||
require.NoError(t, os.MkdirAll(repoPath, 0755))
|
||||
|
||||
gitCommand(t, repoPath, "init")
|
||||
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
|
||||
gitCommand(t, repoPath, "config", "user.name", "Test")
|
||||
gitCommand(t, repoPath, "commit", "--allow-empty", "-m", "initial")
|
||||
|
||||
return repoPath
|
||||
cmds := [][]string{
|
||||
{"git", "init"},
|
||||
{"git", "config", "user.email", "test@test.com"},
|
||||
{"git", "config", "user.name", "Test"},
|
||||
{"git", "commit", "--allow-empty", "-m", "initial"},
|
||||
}
|
||||
|
||||
func capturePkgStreams(t *testing.T, fn func()) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
|
||||
rOut, wOut, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
rErr, wErr, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
os.Stdout = wOut
|
||||
os.Stderr = wErr
|
||||
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, wOut.Close())
|
||||
require.NoError(t, wErr.Close())
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
_, err = io.Copy(&stdout, rOut)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(&stderr, rErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
return stdout.String(), stderr.String()
|
||||
for _, c := range cmds {
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
cmd.Dir = repoPath
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "cmd %v failed: %s", c, string(out))
|
||||
}
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func TestCheckRepoSafety_Clean(t *testing.T) {
|
||||
|
|
@ -85,90 +55,38 @@ func TestCheckRepoSafety_Stash(t *testing.T) {
|
|||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "stash-repo")
|
||||
|
||||
// Create a file, add, stash
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644))
|
||||
gitCommand(t, repoPath, "add", ".")
|
||||
gitCommand(t, repoPath, "stash")
|
||||
cmd := exec.Command("git", "add", ".")
|
||||
cmd.Dir = repoPath
|
||||
require.NoError(t, cmd.Run())
|
||||
|
||||
cmd = exec.Command("git", "stash")
|
||||
cmd.Dir = repoPath
|
||||
require.NoError(t, cmd.Run())
|
||||
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
assert.True(t, blocked)
|
||||
|
||||
found := false
|
||||
for _, r := range reasons {
|
||||
if strings.Contains(r, "stash") {
|
||||
if assert.ObjectsAreEqual("stashed", "") || len(r) > 0 {
|
||||
if contains(r, "stash") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected stash warning in reasons: %v", reasons)
|
||||
}
|
||||
|
||||
func TestRunPkgRemove_RemovesRegistryEntry_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "core-alpha")
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
version: 1
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-alpha:
|
||||
type: foundation
|
||||
description: Alpha package
|
||||
core-beta:
|
||||
type: module
|
||||
description: Beta package
|
||||
`) + "\n"
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
|
||||
|
||||
oldwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(tmp))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldwd))
|
||||
})
|
||||
|
||||
require.NoError(t, runPkgRemove("core-alpha", false))
|
||||
|
||||
_, err = os.Stat(repoPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
updated, err := os.ReadFile(filepath.Join(tmp, "repos.yaml"))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(updated), "core-alpha")
|
||||
assert.Contains(t, string(updated), "core-beta")
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
|
||||
}
|
||||
|
||||
func TestRunPkgRemove_Bad_BlockedWarningsGoToStderr(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-alpha:
|
||||
type: foundation
|
||||
description: Alpha package
|
||||
`) + "\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
|
||||
|
||||
repoPath := filepath.Join(tmp, "core-alpha")
|
||||
require.NoError(t, os.MkdirAll(repoPath, 0755))
|
||||
gitCommand(t, repoPath, "init")
|
||||
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
|
||||
gitCommand(t, repoPath, "config", "user.name", "Test")
|
||||
commitGitRepo(t, repoPath, "file.txt", "v1\n", "initial")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "file.txt"), []byte("v2\n"), 0644))
|
||||
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
stdout, stderr := capturePkgStreams(t, func() {
|
||||
err := runPkgRemove("core-alpha", false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unresolved changes")
|
||||
})
|
||||
|
||||
assert.Empty(t, stdout)
|
||||
assert.Contains(t, stderr, "Cannot remove core-alpha")
|
||||
assert.Contains(t, stderr, "uncommitted changes")
|
||||
assert.Contains(t, stderr, "Resolve the issues above or use --force to override.")
|
||||
func containsStr(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolvePkgSearchPattern_Good(t *testing.T) {
|
||||
t.Run("uses flag pattern when set", func(t *testing.T) {
|
||||
got := resolvePkgSearchPattern("core-*", []string{"api"})
|
||||
assert.Equal(t, "core-*", got)
|
||||
})
|
||||
|
||||
t.Run("uses positional pattern when flag is empty", func(t *testing.T) {
|
||||
got := resolvePkgSearchPattern("", []string{"api"})
|
||||
assert.Equal(t, "api", got)
|
||||
})
|
||||
|
||||
t.Run("defaults to wildcard when nothing is provided", func(t *testing.T) {
|
||||
got := resolvePkgSearchPattern("", nil)
|
||||
assert.Equal(t, "*", got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPkgSearchReport_Good(t *testing.T) {
|
||||
repos := []ghRepo{
|
||||
{
|
||||
FullName: "host-uk/core-api",
|
||||
Name: "core-api",
|
||||
Description: "REST API framework",
|
||||
Visibility: "public",
|
||||
UpdatedAt: "2026-03-30T12:00:00Z",
|
||||
StargazerCount: 42,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
report := buildPkgSearchReport("host-uk", "core-*", "api", 50, true, repos)
|
||||
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, "host-uk", report.Org)
|
||||
assert.Equal(t, "core-*", report.Pattern)
|
||||
assert.Equal(t, "api", report.Type)
|
||||
assert.Equal(t, 50, report.Limit)
|
||||
assert.True(t, report.Cached)
|
||||
assert.Equal(t, 1, report.Count)
|
||||
requireRepo := report.Repos
|
||||
if assert.Len(t, requireRepo, 1) {
|
||||
assert.Equal(t, "core-api", requireRepo[0].Name)
|
||||
assert.Equal(t, "host-uk/core-api", requireRepo[0].FullName)
|
||||
assert.Equal(t, "REST API framework", requireRepo[0].Description)
|
||||
assert.Equal(t, "public", requireRepo[0].Visibility)
|
||||
assert.Equal(t, 42, requireRepo[0].StargazerCount)
|
||||
assert.Equal(t, "Go", requireRepo[0].PrimaryLanguage)
|
||||
assert.Equal(t, "2026-03-30T12:00:00Z", requireRepo[0].UpdatedAt)
|
||||
assert.NotEmpty(t, requireRepo[0].Updated)
|
||||
}
|
||||
|
||||
out, err := json.Marshal(report)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"format":"json"`)
|
||||
}
|
||||
|
|
@ -33,5 +33,4 @@ core pkg update core-api
|
|||
|
||||
```bash
|
||||
core pkg outdated
|
||||
core pkg outdated --format json
|
||||
```
|
||||
|
|
|
|||
|
|
@ -60,10 +60,10 @@ core pkg search --refresh
|
|||
|
||||
## pkg install
|
||||
|
||||
Clone a package from GitHub. If you pass only a repo name, `core` assumes the `host-uk` org.
|
||||
Clone a package from GitHub.
|
||||
|
||||
```bash
|
||||
core pkg install [org/]repo [flags]
|
||||
core pkg install <org/repo> [flags]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
|
@ -76,9 +76,6 @@ core pkg install [org/]repo [flags]
|
|||
### Examples
|
||||
|
||||
```bash
|
||||
# Clone from the default host-uk org
|
||||
core pkg install core-api
|
||||
|
||||
# Clone to packages/
|
||||
core pkg install host-uk/core-php
|
||||
|
||||
|
|
@ -101,16 +98,6 @@ core pkg list
|
|||
|
||||
Shows installed status (✓) and description for each package.
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
### JSON Output
|
||||
|
||||
When `--format json` is set, `core pkg list` emits a structured report with package entries, installed state, and summary counts.
|
||||
|
||||
---
|
||||
|
||||
## pkg update
|
||||
|
|
@ -126,7 +113,6 @@ core pkg update [<name>...] [flags]
|
|||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--all` | Update all packages |
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
### Examples
|
||||
|
||||
|
|
@ -136,15 +122,8 @@ core pkg update core-php
|
|||
|
||||
# Update all packages
|
||||
core pkg update --all
|
||||
|
||||
# JSON output for automation
|
||||
core pkg update --format json
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
When `--format json` is set, `core pkg update` emits a structured report with per-package update status and summary totals.
|
||||
|
||||
---
|
||||
|
||||
## pkg outdated
|
||||
|
|
@ -157,16 +136,6 @@ core pkg outdated
|
|||
|
||||
Fetches from remote and shows packages that are behind.
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
### JSON Output
|
||||
|
||||
When `--format json` is set, `core pkg outdated` emits a structured report with package status, behind counts, and summary totals.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ core pkg search [flags]
|
|||
| `--type` | Filter by type in name (mod, services, plug, website) |
|
||||
| `--limit` | Max results (default: 50) |
|
||||
| `--refresh` | Bypass cache and fetch fresh data |
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
@ -41,9 +40,6 @@ core pkg search --refresh
|
|||
|
||||
# Combine filters
|
||||
core pkg search --pattern "core-*" --type mod --limit 20
|
||||
|
||||
# JSON output for automation
|
||||
core pkg search --format json
|
||||
```
|
||||
|
||||
## Output
|
||||
|
|
|
|||
|
|
@ -85,11 +85,6 @@ Persistent flags are inherited by all subcommands:
|
|||
```go
|
||||
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
|
||||
cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode")
|
||||
cli.PersistentIntFlag(parentCmd, &retries, "retries", "r", 3, "Retry count")
|
||||
cli.PersistentInt64Flag(parentCmd, &seed, "seed", "", 0, "Seed value")
|
||||
cli.PersistentFloat64Flag(parentCmd, &ratio, "ratio", "", 1.0, "Scaling ratio")
|
||||
cli.PersistentDurationFlag(parentCmd, &timeout, "timeout", "t", 30*time.Second, "Timeout")
|
||||
cli.PersistentStringSliceFlag(parentCmd, &tags, "tag", "", nil, "Tags")
|
||||
```
|
||||
|
||||
## Args Validation
|
||||
|
|
|
|||
|
|
@ -42,39 +42,6 @@ func runDaemon(cmd *cli.Command, args []string) error {
|
|||
}
|
||||
```
|
||||
|
||||
## Daemon Helper
|
||||
|
||||
Use `cli.NewDaemon()` when you want a helper that writes a PID file and serves
|
||||
basic `/health` and `/ready` probes:
|
||||
|
||||
```go
|
||||
daemon := cli.NewDaemon(cli.DaemonOptions{
|
||||
PIDFile: "/tmp/core.pid",
|
||||
HealthAddr: "127.0.0.1:8080",
|
||||
HealthCheck: func() bool {
|
||||
return true
|
||||
},
|
||||
ReadyCheck: func() bool {
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
if err := daemon.Start(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = daemon.Stop(context.Background())
|
||||
}()
|
||||
```
|
||||
|
||||
`Start()` writes the current process ID to the configured file, and `Stop()`
|
||||
removes it after shutting the probe server down.
|
||||
|
||||
If you need to stop a daemon process from outside its own process tree, use
|
||||
`cli.StopPIDFile(pidFile, timeout)`. It sends `SIGTERM`, waits up to the
|
||||
timeout for exit, escalates to `SIGKILL` if needed, and removes the PID file
|
||||
after the process stops.
|
||||
|
||||
## Shutdown with Timeout
|
||||
|
||||
The daemon stop logic sends SIGTERM and waits up to 30 seconds. If the process has not exited by then, it sends SIGKILL and removes the PID file.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ The framework has three layers:
|
|||
| `TreeNode` | Tree structure with box-drawing connectors |
|
||||
| `TaskTracker` | Concurrent task display with live spinners |
|
||||
| `CheckBuilder` | Fluent API for pass/fail/skip result lines |
|
||||
| `Daemon` | PID file and probe helper for background processes |
|
||||
| `AnsiStyle` | Terminal text styling (bold, dim, colour) |
|
||||
|
||||
## Built-in Services
|
||||
|
|
|
|||
|
|
@ -280,5 +280,4 @@ cli.LogInfo("server started", "port", 8080)
|
|||
cli.LogWarn("slow query", "duration", "3.2s")
|
||||
cli.LogError("connection failed", "err", err)
|
||||
cli.LogSecurity("login attempt", "user", "admin")
|
||||
cli.LogSecurityf("login attempt from %s", username)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -135,12 +135,6 @@ choice := cli.Choose("Select a file:", files,
|
|||
)
|
||||
```
|
||||
|
||||
Enable `cli.Filter()` to let users type a substring and narrow the visible choices before selecting a number:
|
||||
|
||||
```go
|
||||
choice := cli.Choose("Select:", items, cli.Filter[Item]())
|
||||
```
|
||||
|
||||
With a default selection:
|
||||
|
||||
```go
|
||||
|
|
|
|||
|
|
@ -34,19 +34,17 @@ When word-wrap is enabled, the stream tracks the current column position and ins
|
|||
|
||||
## Custom Output Writer
|
||||
|
||||
By default, streams write to the CLI stdout writer (`stdoutWriter()`), so tests can
|
||||
redirect output via `cli.SetStdout` and other callers can provide any `io.Writer`:
|
||||
By default, streams write to `os.Stdout`. Redirect to any `io.Writer`:
|
||||
|
||||
```go
|
||||
var buf strings.Builder
|
||||
stream := cli.NewStream(cli.WithStreamOutput(&buf))
|
||||
// ... write tokens ...
|
||||
stream.Done()
|
||||
result, ok := stream.CapturedOK() // or buf.String()
|
||||
result := stream.Captured() // or buf.String()
|
||||
```
|
||||
|
||||
`Captured()` returns the output as a string when using a `*strings.Builder` or any `fmt.Stringer`.
|
||||
`CapturedOK()` reports whether capture is supported by the configured writer.
|
||||
|
||||
## Reading from `io.Reader`
|
||||
|
||||
|
|
@ -70,15 +68,14 @@ stream.Done()
|
|||
| `Done()` | Signal completion (adds trailing newline if needed) |
|
||||
| `Wait()` | Block until `Done` is called |
|
||||
| `Column()` | Current column position |
|
||||
| `Captured()` | Get output as string (returns `""` if capture is unsupported) |
|
||||
| `CapturedOK()` | Get output and support status |
|
||||
| `Captured()` | Get output as string (requires `*strings.Builder` or `fmt.Stringer` writer) |
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `WithWordWrap(cols)` | Set the word-wrap column width |
|
||||
| `WithStreamOutput(w)` | Set the output writer (default: `stdoutWriter()`) |
|
||||
| `WithStreamOutput(w)` | Set the output writer (default: `os.Stdout`) |
|
||||
|
||||
## Example: LLM Token Streaming
|
||||
|
||||
|
|
|
|||
14
go.mod
14
go.mod
|
|
@ -1,26 +1,25 @@
|
|||
module dappco.re/go/core/cli
|
||||
module forge.lthn.ai/core/cli
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require dappco.re/go/core v0.4.7
|
||||
|
||||
require (
|
||||
dappco.re/go/core/i18n v0.1.7
|
||||
dappco.re/go/core/log v0.0.4
|
||||
forge.lthn.ai/core/go-i18n v0.1.7
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/mattn/go-runewidth v0.0.21
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/term v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.3.3 // indirect
|
||||
dappco.re/go/core/inference v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go v0.3.2 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.7 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
|
|
@ -31,6 +30,7 @@ require (
|
|||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -1,7 +1,7 @@
|
|||
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
|
||||
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
||||
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
||||
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
|
||||
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ const (
|
|||
var (
|
||||
colorEnabled = true
|
||||
colorEnabledMu sync.RWMutex
|
||||
asciiDisabledColors bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
@ -49,18 +48,6 @@ func ColorEnabled() bool {
|
|||
func SetColorEnabled(enabled bool) {
|
||||
colorEnabledMu.Lock()
|
||||
colorEnabled = enabled
|
||||
if enabled {
|
||||
asciiDisabledColors = false
|
||||
}
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
func restoreColorIfASCII() {
|
||||
colorEnabledMu.Lock()
|
||||
if asciiDisabledColors {
|
||||
colorEnabled = true
|
||||
asciiDisabledColors = false
|
||||
}
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ func TestRender_ColorEnabled_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestUseASCII_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable first, then UseASCII should disable colors
|
||||
SetColorEnabled(true)
|
||||
|
|
@ -86,33 +88,7 @@ func TestUseASCII_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUseUnicodeAndEmojiRestoreColorsAfterASCII(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
|
||||
SetColorEnabled(true)
|
||||
UseASCII()
|
||||
if ColorEnabled() {
|
||||
t.Fatal("UseASCII should disable colors")
|
||||
}
|
||||
|
||||
UseUnicode()
|
||||
if !ColorEnabled() {
|
||||
t.Fatal("UseUnicode should restore colors after ASCII mode")
|
||||
}
|
||||
|
||||
UseASCII()
|
||||
if ColorEnabled() {
|
||||
t.Fatal("UseASCII should disable colors again")
|
||||
}
|
||||
|
||||
UseEmoji()
|
||||
if !ColorEnabled() {
|
||||
t.Fatal("UseEmoji should restore colors after ASCII mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_NilStyle_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
var s *AnsiStyle
|
||||
got := s.Render("test")
|
||||
if got != "test" {
|
||||
|
|
@ -121,7 +97,6 @@ func TestRender_NilStyle_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAnsiStyle_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
|
|
@ -142,7 +117,6 @@ func TestAnsiStyle_Bad(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAnsiStyle_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-log"
|
||||
"dappco.re/go/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -34,16 +34,9 @@ var (
|
|||
)
|
||||
|
||||
// SemVer returns the full SemVer 2.0.0 version string.
|
||||
//
|
||||
// Examples:
|
||||
// // Release only:
|
||||
// // AppVersion=1.2.0 -> 1.2.0
|
||||
// cli.AppVersion = "1.2.0"
|
||||
// fmt.Println(cli.SemVer())
|
||||
//
|
||||
// // Pre-release + commit + date:
|
||||
// // AppVersion=1.2.0, BuildPreRelease=dev.8, BuildCommit=df94c24, BuildDate=20260206
|
||||
// // -> 1.2.0-dev.8+df94c24.20260206
|
||||
// - 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 != "" {
|
||||
|
|
@ -71,37 +64,19 @@ func WithAppName(name string) {
|
|||
type LocaleSource = i18n.FSSource
|
||||
|
||||
// WithLocales returns a locale source for use with MainWithLocales.
|
||||
//
|
||||
// Example:
|
||||
// fs := embed.FS{}
|
||||
// locales := cli.WithLocales(fs, "locales")
|
||||
// cli.MainWithLocales([]cli.LocaleSource{locales})
|
||||
func WithLocales(fsys fs.FS, dir string) LocaleSource {
|
||||
return LocaleSource{FS: fsys, Dir: dir}
|
||||
}
|
||||
|
||||
// CommandSetup is a function that registers commands on the CLI after init.
|
||||
//
|
||||
// Example:
|
||||
// cli.Main(
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
type CommandSetup func(c *core.Core)
|
||||
|
||||
// Main initialises and runs the CLI with the framework's built-in translations.
|
||||
//
|
||||
// Example:
|
||||
// cli.WithAppName("core")
|
||||
// cli.Main(config.AddConfigCommands)
|
||||
func Main(commands ...CommandSetup) {
|
||||
MainWithLocales(nil, commands...)
|
||||
}
|
||||
|
||||
// MainWithLocales initialises and runs the CLI with additional translation sources.
|
||||
//
|
||||
// Example:
|
||||
// locales := []cli.LocaleSource{cli.WithLocales(embeddedLocales, "locales")}
|
||||
// cli.MainWithLocales(locales, doctor.AddDoctorCommands)
|
||||
func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
|
||||
// Recovery from panics
|
||||
defer func() {
|
||||
|
|
@ -200,13 +175,13 @@ PowerShell:
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
_ = cmd.Root().GenBashCompletion(stdoutWriter())
|
||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
_ = cmd.Root().GenZshCompletion(stdoutWriter())
|
||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
_ = cmd.Root().GenFishCompletion(stdoutWriter(), true)
|
||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter())
|
||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func (c *CheckBuilder) Fail() *CheckBuilder {
|
|||
func (c *CheckBuilder) Skip() *CheckBuilder {
|
||||
c.status = "skipped"
|
||||
c.style = DimStyle
|
||||
c.icon = Glyph(":skip:")
|
||||
c.icon = "-"
|
||||
return c
|
||||
}
|
||||
|
||||
|
|
@ -64,24 +64,23 @@ func (c *CheckBuilder) Message(msg string) *CheckBuilder {
|
|||
|
||||
// String returns the formatted check line.
|
||||
func (c *CheckBuilder) String() string {
|
||||
icon := compileGlyphs(c.icon)
|
||||
icon := c.icon
|
||||
if c.style != nil {
|
||||
icon = c.style.Render(icon)
|
||||
icon = c.style.Render(c.icon)
|
||||
}
|
||||
|
||||
name := Pad(compileGlyphs(c.name), 20)
|
||||
status := Pad(compileGlyphs(c.status), 10)
|
||||
status := c.status
|
||||
if c.style != nil && c.status != "" {
|
||||
status = c.style.Render(status)
|
||||
status = c.style.Render(c.status)
|
||||
}
|
||||
|
||||
if c.duration != "" {
|
||||
return Sprintf(" %s %s %s %s", icon, name, status, DimStyle.Render(compileGlyphs(c.duration)))
|
||||
return Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
|
||||
}
|
||||
if status != "" {
|
||||
return Sprintf(" %s %s %s", icon, name, status)
|
||||
return Sprintf(" %s %s %s", icon, c.name, status)
|
||||
}
|
||||
return Sprintf(" %s %s", icon, name)
|
||||
return Sprintf(" %s %s", icon, c.name)
|
||||
}
|
||||
|
||||
// Print outputs the check result.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
)
|
||||
|
||||
func TestCheckBuilder_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII() // Deterministic output
|
||||
|
||||
checkResult := Check("database").Pass()
|
||||
|
|
@ -20,7 +19,6 @@ func TestCheckBuilder_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCheckBuilder_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
checkResult := Check("lint").Fail()
|
||||
|
|
@ -43,7 +41,6 @@ func TestCheckBuilder_Bad(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCheckBuilder_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
// Zero-value builder should not panic.
|
||||
|
|
|
|||
|
|
@ -173,32 +173,6 @@ func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []stri
|
|||
}
|
||||
}
|
||||
|
||||
// StringArrayFlag adds a string array flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var tags []string
|
||||
// cli.StringArrayFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
|
||||
func StringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringArrayVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringArrayVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// StringToStringFlag adds a string-to-string map flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var labels map[string]string
|
||||
// cli.StringToStringFlag(cmd, &labels, "label", "l", nil, "Labels to apply")
|
||||
func StringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringToStringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringToStringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Persistent Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -221,69 +195,6 @@ func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, u
|
|||
}
|
||||
}
|
||||
|
||||
// PersistentIntFlag adds a persistent integer flag (inherited by subcommands).
|
||||
func PersistentIntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().IntVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().IntVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentInt64Flag adds a persistent int64 flag (inherited by subcommands).
|
||||
func PersistentInt64Flag(cmd *Command, ptr *int64, name, short string, def int64, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().Int64VarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().Int64Var(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentFloat64Flag adds a persistent float64 flag (inherited by subcommands).
|
||||
func PersistentFloat64Flag(cmd *Command, ptr *float64, name, short string, def float64, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().Float64VarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().Float64Var(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentDurationFlag adds a persistent time.Duration flag (inherited by subcommands).
|
||||
func PersistentDurationFlag(cmd *Command, ptr *time.Duration, name, short string, def time.Duration, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().DurationVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().DurationVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentStringSliceFlag adds a persistent string slice flag (inherited by subcommands).
|
||||
func PersistentStringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringSliceVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringSliceVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentStringArrayFlag adds a persistent string array flag (inherited by subcommands).
|
||||
func PersistentStringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringArrayVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringArrayVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentStringToStringFlag adds a persistent string-to-string map flag (inherited by subcommands).
|
||||
func PersistentStringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringToStringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringToStringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -20,7 +19,6 @@ import (
|
|||
// )
|
||||
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup {
|
||||
return func(c *core.Core) {
|
||||
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
|
||||
if root, ok := c.App().Runtime.(*cobra.Command); ok {
|
||||
register(root)
|
||||
}
|
||||
|
|
@ -29,13 +27,6 @@ func WithCommands(name string, register func(root *Command), localeFS ...fs.FS)
|
|||
}
|
||||
|
||||
// CommandRegistration is a function that adds commands to the CLI root.
|
||||
//
|
||||
// Example:
|
||||
// func addCommands(root *cobra.Command) {
|
||||
// root.AddCommand(cli.NewRun("ping", "Ping API", "", func(cmd *cli.Command, args []string) {
|
||||
// cli.Println("pong")
|
||||
// }))
|
||||
// }
|
||||
type CommandRegistration func(root *cobra.Command)
|
||||
|
||||
var (
|
||||
|
|
@ -51,13 +42,6 @@ var (
|
|||
// func init() {
|
||||
// cli.RegisterCommands(AddCommands, locales.FS)
|
||||
// }
|
||||
//
|
||||
// Example:
|
||||
// cli.RegisterCommands(func(root *cobra.Command) {
|
||||
// root.AddCommand(cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
|
||||
// cli.Println(cli.SemVer())
|
||||
// }))
|
||||
// })
|
||||
func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
|
|
@ -65,7 +49,6 @@ func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
|
|||
root := instance
|
||||
registeredCommandsMu.Unlock()
|
||||
|
||||
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
|
||||
appendLocales(localeFS...)
|
||||
|
||||
// If commands already attached (CLI already running), attach immediately
|
||||
|
|
@ -90,62 +73,19 @@ func appendLocales(localeFS ...fs.FS) {
|
|||
registeredCommandsMu.Unlock()
|
||||
}
|
||||
|
||||
func localeSourcesFromFS(localeFS ...fs.FS) []LocaleSource {
|
||||
sources := make([]LocaleSource, 0, len(localeFS))
|
||||
for _, lfs := range localeFS {
|
||||
if lfs != nil {
|
||||
sources = append(sources, LocaleSource{FS: lfs, Dir: "."})
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func loadLocaleSources(sources ...LocaleSource) {
|
||||
svc := i18n.Default()
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.FS == nil {
|
||||
continue
|
||||
}
|
||||
if err := svc.AddLoader(i18n.NewFSLoader(src.FS, src.Dir)); err != nil {
|
||||
LogDebug("failed to load locale source", "dir", src.Dir, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredLocales returns all locale filesystems registered by command packages.
|
||||
//
|
||||
// Example:
|
||||
// for _, fs := range cli.RegisteredLocales() {
|
||||
// _ = fs
|
||||
// }
|
||||
func RegisteredLocales() []fs.FS {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
if len(registeredLocales) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]fs.FS, len(registeredLocales))
|
||||
copy(out, registeredLocales)
|
||||
return out
|
||||
return registeredLocales
|
||||
}
|
||||
|
||||
// RegisteredCommands returns an iterator over the registered command functions.
|
||||
//
|
||||
// Example:
|
||||
// for attach := range cli.RegisteredCommands() {
|
||||
// _ = attach
|
||||
// }
|
||||
func RegisteredCommands() iter.Seq[CommandRegistration] {
|
||||
return func(yield func(CommandRegistration) bool) {
|
||||
registeredCommandsMu.Lock()
|
||||
snapshot := make([]CommandRegistration, len(registeredCommands))
|
||||
copy(snapshot, registeredCommands)
|
||||
registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range snapshot {
|
||||
defer registeredCommandsMu.Unlock()
|
||||
for _, fn := range registeredCommands {
|
||||
if !yield(fn) {
|
||||
return
|
||||
}
|
||||
|
|
@ -157,12 +97,10 @@ func RegisteredCommands() iter.Seq[CommandRegistration] {
|
|||
// Called by Init() after creating the root command.
|
||||
func attachRegisteredCommands(root *cobra.Command) {
|
||||
registeredCommandsMu.Lock()
|
||||
snapshot := make([]CommandRegistration, len(registeredCommands))
|
||||
copy(snapshot, registeredCommands)
|
||||
commandsAttached = true
|
||||
registeredCommandsMu.Unlock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range snapshot {
|
||||
for _, fn := range registeredCommands {
|
||||
fn(root)
|
||||
}
|
||||
commandsAttached = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,6 @@ import (
|
|||
)
|
||||
|
||||
// Mode represents the CLI execution mode.
|
||||
//
|
||||
// mode := cli.DetectMode()
|
||||
// if mode == cli.ModeDaemon {
|
||||
// cli.LogInfo("running headless")
|
||||
// }
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
|
|
@ -39,11 +34,7 @@ func (m Mode) String() string {
|
|||
}
|
||||
|
||||
// DetectMode determines the execution mode based on environment.
|
||||
//
|
||||
// mode := cli.DetectMode()
|
||||
// // cli.ModeDaemon when CORE_DAEMON=1
|
||||
// // cli.ModePipe when stdout is not a terminal
|
||||
// // cli.ModeInteractive otherwise
|
||||
// Checks CORE_DAEMON env var first, then TTY status.
|
||||
func DetectMode() Mode {
|
||||
if os.Getenv("CORE_DAEMON") == "1" {
|
||||
return ModeDaemon
|
||||
|
|
@ -55,37 +46,17 @@ func DetectMode() Mode {
|
|||
}
|
||||
|
||||
// IsTTY returns true if stdout is a terminal.
|
||||
//
|
||||
// if cli.IsTTY() {
|
||||
// cli.Success("interactive output enabled")
|
||||
// }
|
||||
func IsTTY() bool {
|
||||
if f, ok := stdoutWriter().(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// IsStdinTTY returns true if stdin is a terminal.
|
||||
//
|
||||
// if !cli.IsStdinTTY() {
|
||||
// cli.Warn("input is piped")
|
||||
// }
|
||||
func IsStdinTTY() bool {
|
||||
if f, ok := stdinReader().(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStderrTTY returns true if stderr is a terminal.
|
||||
//
|
||||
// if cli.IsStderrTTY() {
|
||||
// cli.Progress("load", 1, 3, "config")
|
||||
// }
|
||||
func IsStderrTTY() bool {
|
||||
if f, ok := stderrWriter().(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,322 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DaemonOptions configures a background process helper.
|
||||
//
|
||||
// daemon := cli.NewDaemon(cli.DaemonOptions{
|
||||
// PIDFile: "/tmp/core.pid",
|
||||
// HealthAddr: "127.0.0.1:8080",
|
||||
// })
|
||||
type DaemonOptions struct {
|
||||
// PIDFile stores the current process ID on Start and removes it on Stop.
|
||||
PIDFile string
|
||||
|
||||
// HealthAddr binds the HTTP health server.
|
||||
// Pass an empty string to disable the server.
|
||||
HealthAddr string
|
||||
|
||||
// HealthPath serves the liveness probe endpoint.
|
||||
HealthPath string
|
||||
|
||||
// ReadyPath serves the readiness probe endpoint.
|
||||
ReadyPath string
|
||||
|
||||
// HealthCheck reports whether the process is healthy.
|
||||
// Defaults to true when nil.
|
||||
HealthCheck func() bool
|
||||
|
||||
// ReadyCheck reports whether the process is ready to serve traffic.
|
||||
// Defaults to HealthCheck when nil, or true when both are nil.
|
||||
ReadyCheck func() bool
|
||||
}
|
||||
|
||||
// Daemon manages a PID file and optional HTTP health endpoints.
|
||||
//
|
||||
// daemon := cli.NewDaemon(cli.DaemonOptions{PIDFile: "/tmp/core.pid"})
|
||||
// _ = daemon.Start(context.Background())
|
||||
type Daemon struct {
|
||||
opts DaemonOptions
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
addr string
|
||||
started bool
|
||||
}
|
||||
|
||||
var (
|
||||
processNow = time.Now
|
||||
processSleep = time.Sleep
|
||||
processAlive = func(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
processSignal = func(pid int, sig syscall.Signal) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(sig)
|
||||
}
|
||||
processPollInterval = 100 * time.Millisecond
|
||||
processShutdownWait = 30 * time.Second
|
||||
)
|
||||
|
||||
// NewDaemon creates a daemon helper with sensible defaults.
|
||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||
if opts.HealthPath == "" {
|
||||
opts.HealthPath = "/health"
|
||||
}
|
||||
if opts.ReadyPath == "" {
|
||||
opts.ReadyPath = "/ready"
|
||||
}
|
||||
return &Daemon{opts: opts}
|
||||
}
|
||||
|
||||
// Start writes the PID file and starts the health server, if configured.
|
||||
func (d *Daemon) Start(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.started {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.writePIDFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.opts.HealthAddr != "" {
|
||||
if err := d.startHealthServer(ctx); err != nil {
|
||||
_ = d.removePIDFile()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
d.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts down the health server and removes the PID file.
|
||||
func (d *Daemon) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
server := d.server
|
||||
listener := d.listener
|
||||
d.server = nil
|
||||
d.listener = nil
|
||||
d.addr = ""
|
||||
d.started = false
|
||||
d.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
|
||||
if server != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil && !isClosedServerError(err) {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if listener != nil {
|
||||
if err := listener.Close(); err != nil && !isListenerClosedError(err) && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.removePIDFile(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// HealthAddr returns the bound health server address, if running.
|
||||
func (d *Daemon) HealthAddr() string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.addr != "" {
|
||||
return d.addr
|
||||
}
|
||||
return d.opts.HealthAddr
|
||||
}
|
||||
|
||||
// StopPIDFile sends SIGTERM to the process identified by pidFile, waits for it
|
||||
// to exit, escalates to SIGKILL after the timeout, and then removes the file.
|
||||
//
|
||||
// If the PID file does not exist, StopPIDFile returns nil.
|
||||
func StopPIDFile(pidFile string, timeout time.Duration) error {
|
||||
if pidFile == "" {
|
||||
return nil
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = processShutdownWait
|
||||
}
|
||||
|
||||
rawPID, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
pid, err := parsePID(strings.TrimSpace(string(rawPID)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse pid file %q: %w", pidFile, err)
|
||||
}
|
||||
|
||||
if err := processSignal(pid, syscall.SIGTERM); err != nil && !isProcessGone(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
deadline := processNow().Add(timeout)
|
||||
for processAlive(pid) && processNow().Before(deadline) {
|
||||
processSleep(processPollInterval)
|
||||
}
|
||||
|
||||
if processAlive(pid) {
|
||||
if err := processSignal(pid, syscall.SIGKILL); err != nil && !isProcessGone(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
deadline = processNow().Add(processShutdownWait)
|
||||
for processAlive(pid) && processNow().Before(deadline) {
|
||||
processSleep(processPollInterval)
|
||||
}
|
||||
|
||||
if processAlive(pid) {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
}
|
||||
|
||||
return os.Remove(pidFile)
|
||||
}
|
||||
|
||||
func parsePID(raw string) (int, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("empty pid")
|
||||
}
|
||||
pid, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if pid <= 0 {
|
||||
return 0, fmt.Errorf("invalid pid %d", pid)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func isProcessGone(err error) bool {
|
||||
return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH)
|
||||
}
|
||||
|
||||
func (d *Daemon) writePIDFile() error {
|
||||
if d.opts.PIDFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func (d *Daemon) removePIDFile() error {
|
||||
if d.opts.PIDFile == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(d.opts.PIDFile); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) startHealthServer(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
healthCheck := d.opts.HealthCheck
|
||||
if healthCheck == nil {
|
||||
healthCheck = func() bool { return true }
|
||||
}
|
||||
readyCheck := d.opts.ReadyCheck
|
||||
if readyCheck == nil {
|
||||
readyCheck = healthCheck
|
||||
}
|
||||
|
||||
mux.HandleFunc(d.opts.HealthPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeProbe(w, healthCheck())
|
||||
})
|
||||
mux.HandleFunc(d.opts.ReadyPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeProbe(w, readyCheck())
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", d.opts.HealthAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: mux,
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
|
||||
d.listener = listener
|
||||
d.server = server
|
||||
d.addr = listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
err := server.Serve(listener)
|
||||
if err != nil && !isClosedServerError(err) {
|
||||
_ = err
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeProbe(w http.ResponseWriter, ok bool) {
|
||||
if ok {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = io.WriteString(w, "unhealthy\n")
|
||||
}
|
||||
|
||||
func isClosedServerError(err error) bool {
|
||||
return err == nil || err == http.ErrServerClosed
|
||||
}
|
||||
|
||||
func isListenerClosedError(err error) bool {
|
||||
return err == nil || errors.Is(err, net.ErrClosed)
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDaemon_StartStop(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
ready := false
|
||||
|
||||
daemon := NewDaemon(DaemonOptions{
|
||||
PIDFile: pidFile,
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
HealthCheck: func() bool {
|
||||
return true
|
||||
},
|
||||
ReadyCheck: func() bool {
|
||||
return ready
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, daemon.Start(context.Background()))
|
||||
defer func() {
|
||||
require.NoError(t, daemon.Stop(context.Background()))
|
||||
}()
|
||||
|
||||
rawPID, err := os.ReadFile(pidFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, strconv.Itoa(os.Getpid()), strings.TrimSpace(string(rawPID)))
|
||||
|
||||
addr := daemon.HealthAddr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
resp, err := client.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "ok\n", string(body))
|
||||
|
||||
resp, err = client.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
assert.Equal(t, "unhealthy\n", string(body))
|
||||
|
||||
ready = true
|
||||
|
||||
resp, err = client.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "ok\n", string(body))
|
||||
}
|
||||
|
||||
func TestDaemon_StopRemovesPIDFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
|
||||
daemon := NewDaemon(DaemonOptions{PIDFile: pidFile})
|
||||
require.NoError(t, daemon.Start(context.Background()))
|
||||
|
||||
_, err := os.Stat(pidFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, daemon.Stop(context.Background()))
|
||||
|
||||
_, err = os.Stat(pidFile)
|
||||
require.Error(t, err)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestStopPIDFile_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
require.NoError(t, os.WriteFile(pidFile, []byte("1234\n"), 0o644))
|
||||
|
||||
originalSignal := processSignal
|
||||
originalAlive := processAlive
|
||||
originalNow := processNow
|
||||
originalSleep := processSleep
|
||||
originalPoll := processPollInterval
|
||||
originalShutdownWait := processShutdownWait
|
||||
t.Cleanup(func() {
|
||||
processSignal = originalSignal
|
||||
processAlive = originalAlive
|
||||
processNow = originalNow
|
||||
processSleep = originalSleep
|
||||
processPollInterval = originalPoll
|
||||
processShutdownWait = originalShutdownWait
|
||||
})
|
||||
|
||||
var mu sync.Mutex
|
||||
var signals []syscall.Signal
|
||||
processSignal = func(pid int, sig syscall.Signal) error {
|
||||
mu.Lock()
|
||||
signals = append(signals, sig)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
processAlive = func(pid int) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(signals) == 0 {
|
||||
return true
|
||||
}
|
||||
return signals[len(signals)-1] != syscall.SIGTERM
|
||||
}
|
||||
processPollInterval = 0
|
||||
processShutdownWait = 0
|
||||
|
||||
require.NoError(t, StopPIDFile(pidFile, time.Second))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, []syscall.Signal{syscall.SIGTERM}, signals)
|
||||
|
||||
_, err := os.Stat(pidFile)
|
||||
require.Error(t, err)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestStopPIDFile_Bad_Escalates(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
require.NoError(t, os.WriteFile(pidFile, []byte("4321\n"), 0o644))
|
||||
|
||||
originalSignal := processSignal
|
||||
originalAlive := processAlive
|
||||
originalNow := processNow
|
||||
originalSleep := processSleep
|
||||
originalPoll := processPollInterval
|
||||
originalShutdownWait := processShutdownWait
|
||||
t.Cleanup(func() {
|
||||
processSignal = originalSignal
|
||||
processAlive = originalAlive
|
||||
processNow = originalNow
|
||||
processSleep = originalSleep
|
||||
processPollInterval = originalPoll
|
||||
processShutdownWait = originalShutdownWait
|
||||
})
|
||||
|
||||
var mu sync.Mutex
|
||||
var signals []syscall.Signal
|
||||
current := time.Unix(0, 0)
|
||||
processNow = func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return current
|
||||
}
|
||||
processSleep = func(d time.Duration) {
|
||||
mu.Lock()
|
||||
current = current.Add(d)
|
||||
mu.Unlock()
|
||||
}
|
||||
processSignal = func(pid int, sig syscall.Signal) error {
|
||||
mu.Lock()
|
||||
signals = append(signals, sig)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
processAlive = func(pid int) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(signals) == 0 {
|
||||
return true
|
||||
}
|
||||
return signals[len(signals)-1] != syscall.SIGKILL
|
||||
}
|
||||
processPollInterval = 10 * time.Millisecond
|
||||
processShutdownWait = 0
|
||||
|
||||
require.NoError(t, StopPIDFile(pidFile, 15*time.Millisecond))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, signals)
|
||||
}
|
||||
|
|
@ -78,12 +78,6 @@ func Join(errs ...error) error {
|
|||
}
|
||||
|
||||
// ExitError represents an error that should cause the CLI to exit with a specific code.
|
||||
//
|
||||
// err := cli.Exit(2, cli.Err("validation failed"))
|
||||
// var exitErr *cli.ExitError
|
||||
// if cli.As(err, &exitErr) {
|
||||
// cli.Println("exit code:", exitErr.Code)
|
||||
// }
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
|
|
@ -101,8 +95,7 @@ func (e *ExitError) Unwrap() error {
|
|||
}
|
||||
|
||||
// Exit creates a new ExitError with the given code and error.
|
||||
//
|
||||
// return cli.Exit(2, cli.Err("validation failed"))
|
||||
// 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
|
||||
|
|
@ -120,7 +113,7 @@ func Exit(code int, err error) error {
|
|||
func Fatal(err error) {
|
||||
if err != nil {
|
||||
LogError("Fatal error", "err", err)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +124,7 @@ func Fatal(err error) {
|
|||
func Fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
LogError("Fatal error", "msg", msg)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +140,7 @@ func FatalWrap(err error, msg string) {
|
|||
}
|
||||
LogError("Fatal error", "msg", msg, "err", err)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +157,6 @@ func FatalWrapVerb(err error, verb, subject string) {
|
|||
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(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
|
|
@ -61,7 +60,7 @@ func NewFrame(variant string) *Frame {
|
|||
variant: variant,
|
||||
layout: Layout(variant),
|
||||
models: make(map[Region]Model),
|
||||
out: stderrWriter(),
|
||||
out: os.Stdout,
|
||||
done: make(chan struct{}),
|
||||
focused: RegionContent,
|
||||
keyMap: DefaultKeyMap(),
|
||||
|
|
@ -70,15 +69,6 @@ func NewFrame(variant string) *Frame {
|
|||
}
|
||||
}
|
||||
|
||||
// WithOutput sets the destination writer for rendered output.
|
||||
// Pass nil to keep the current writer unchanged.
|
||||
func (f *Frame) WithOutput(out io.Writer) *Frame {
|
||||
if out != nil {
|
||||
f.out = out
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Header sets the Header region model.
|
||||
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
||||
|
||||
|
|
@ -438,7 +428,6 @@ func (f *Frame) String() string {
|
|||
if view == "" {
|
||||
return ""
|
||||
}
|
||||
view = ansi.Strip(view)
|
||||
// Ensure trailing newline for non-TTY consistency
|
||||
if !strings.HasSuffix(view, "\n") {
|
||||
view += "\n"
|
||||
|
|
@ -463,11 +452,12 @@ func (f *Frame) termSize() (int, int) {
|
|||
return 80, 24 // sensible default
|
||||
}
|
||||
|
||||
|
||||
func (f *Frame) runLive() {
|
||||
opts := []tea.ProgramOption{
|
||||
tea.WithAltScreen(),
|
||||
}
|
||||
if f.out != stdoutWriter() {
|
||||
if f.out != os.Stdout {
|
||||
opts = append(opts, tea.WithOutput(f.out))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ func StatusLine(title string, pairs ...string) Model {
|
|||
}
|
||||
|
||||
func (s *statusLineModel) View(width, _ int) string {
|
||||
parts := []string{BoldStyle.Render(compileGlyphs(s.title))}
|
||||
parts := []string{BoldStyle.Render(s.title)}
|
||||
for _, p := range s.pairs {
|
||||
parts = append(parts, DimStyle.Render(compileGlyphs(p)))
|
||||
parts = append(parts, DimStyle.Render(p))
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
|
|
@ -46,7 +46,7 @@ func KeyHints(hints ...string) Model {
|
|||
func (k *keyHintsModel) View(width, _ int) string {
|
||||
parts := make([]string, len(k.hints))
|
||||
for i, h := range k.hints {
|
||||
parts[i] = DimStyle.Render(compileGlyphs(h))
|
||||
parts[i] = DimStyle.Render(h)
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
|
|
@ -70,11 +70,10 @@ func Breadcrumb(parts ...string) Model {
|
|||
func (b *breadcrumbModel) View(width, _ int) string {
|
||||
styled := make([]string, len(b.parts))
|
||||
for i, p := range b.parts {
|
||||
part := compileGlyphs(p)
|
||||
if i == len(b.parts)-1 {
|
||||
styled[i] = BoldStyle.Render(part)
|
||||
styled[i] = BoldStyle.Render(p)
|
||||
} else {
|
||||
styled[i] = DimStyle.Render(part)
|
||||
styled[i] = DimStyle.Render(p)
|
||||
}
|
||||
}
|
||||
line := strings.Join(styled, DimStyle.Render(" > "))
|
||||
|
|
@ -95,5 +94,5 @@ func StaticModel(text string) Model {
|
|||
}
|
||||
|
||||
func (s *staticModel) View(_, _ int) string {
|
||||
return compileGlyphs(s.text)
|
||||
return s.text
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,24 +20,15 @@ const (
|
|||
var currentTheme = ThemeUnicode
|
||||
|
||||
// UseUnicode switches the glyph theme to Unicode.
|
||||
func UseUnicode() {
|
||||
currentTheme = ThemeUnicode
|
||||
restoreColorIfASCII()
|
||||
}
|
||||
func UseUnicode() { currentTheme = ThemeUnicode }
|
||||
|
||||
// UseEmoji switches the glyph theme to Emoji.
|
||||
func UseEmoji() {
|
||||
currentTheme = ThemeEmoji
|
||||
restoreColorIfASCII()
|
||||
}
|
||||
func UseEmoji() { currentTheme = ThemeEmoji }
|
||||
|
||||
// UseASCII switches the glyph theme to ASCII and disables colors.
|
||||
func UseASCII() {
|
||||
currentTheme = ThemeASCII
|
||||
SetColorEnabled(false)
|
||||
colorEnabledMu.Lock()
|
||||
asciiDisabledColors = true
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
func glyphMap() map[string]string {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package cli
|
|||
import "testing"
|
||||
|
||||
func TestGlyph_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseUnicode()
|
||||
if Glyph(":check:") != "✓" {
|
||||
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
|
||||
|
|
@ -16,7 +15,6 @@ func TestGlyph_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGlyph_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Unknown shortcode returns the shortcode unchanged.
|
||||
UseUnicode()
|
||||
got := Glyph(":unknown:")
|
||||
|
|
@ -26,7 +24,6 @@ func TestGlyph_Bad(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGlyph_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Empty shortcode should not panic.
|
||||
got := Glyph("")
|
||||
if got != "" {
|
||||
|
|
@ -35,7 +32,6 @@ func TestGlyph_Ugly(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCompileGlyphs_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseUnicode()
|
||||
got := compileGlyphs("Status: :check:")
|
||||
if got != "Status: ✓" {
|
||||
|
|
@ -44,7 +40,6 @@ func TestCompileGlyphs_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCompileGlyphs_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseUnicode()
|
||||
// Text with no shortcodes should be returned as-is.
|
||||
got := compileGlyphs("no glyphs here")
|
||||
|
|
@ -54,7 +49,6 @@ func TestCompileGlyphs_Bad(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCompileGlyphs_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Empty string should not panic.
|
||||
got := compileGlyphs("")
|
||||
if got != "" {
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
stdin io.Reader = os.Stdin
|
||||
|
||||
stdoutOverride io.Writer
|
||||
stderrOverride io.Writer
|
||||
|
||||
ioMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetStdin overrides the default stdin reader for testing.
|
||||
// Pass nil to restore the real os.Stdin reader.
|
||||
func SetStdin(r io.Reader) {
|
||||
ioMu.Lock()
|
||||
defer ioMu.Unlock()
|
||||
if r == nil {
|
||||
stdin = os.Stdin
|
||||
return
|
||||
}
|
||||
stdin = r
|
||||
}
|
||||
|
||||
// SetStdout overrides the default stdout writer.
|
||||
// Pass nil to restore writes to os.Stdout.
|
||||
func SetStdout(w io.Writer) {
|
||||
ioMu.Lock()
|
||||
defer ioMu.Unlock()
|
||||
stdoutOverride = w
|
||||
}
|
||||
|
||||
// SetStderr overrides the default stderr writer.
|
||||
// Pass nil to restore writes to os.Stderr.
|
||||
func SetStderr(w io.Writer) {
|
||||
ioMu.Lock()
|
||||
defer ioMu.Unlock()
|
||||
stderrOverride = w
|
||||
}
|
||||
|
||||
func stdinReader() io.Reader {
|
||||
ioMu.RLock()
|
||||
defer ioMu.RUnlock()
|
||||
return stdin
|
||||
}
|
||||
|
||||
func stdoutWriter() io.Writer {
|
||||
ioMu.RLock()
|
||||
defer ioMu.RUnlock()
|
||||
if stdoutOverride != nil {
|
||||
return stdoutOverride
|
||||
}
|
||||
return os.Stdout
|
||||
}
|
||||
|
||||
func stderrWriter() io.Writer {
|
||||
ioMu.RLock()
|
||||
defer ioMu.RUnlock()
|
||||
if stderrOverride != nil {
|
||||
return stderrOverride
|
||||
}
|
||||
return os.Stderr
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ type Renderable interface {
|
|||
type StringBlock string
|
||||
|
||||
// Render returns the string content.
|
||||
func (s StringBlock) Render() string { return compileGlyphs(string(s)) }
|
||||
func (s StringBlock) Render() string { return string(s) }
|
||||
|
||||
// Layout creates a new layout from a variant string.
|
||||
func Layout(variant string) *Composite {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
"install_missing": "Install missing tools:",
|
||||
"install_macos": "brew install",
|
||||
"install_macos_cask": "brew install --cask",
|
||||
"install_macos_go": "brew install go",
|
||||
"install_linux_header": "Install on Linux:",
|
||||
"install_linux_go": "sudo apt install golang-go",
|
||||
"install_linux_git": "sudo apt install git",
|
||||
"install_linux_node": "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs",
|
||||
"install_linux_php": "sudo apt install php php-cli php-mbstring php-xml php-curl",
|
||||
|
|
@ -32,7 +30,6 @@
|
|||
"no_repos_yaml": "No repos.yaml found (run from workspace root)",
|
||||
"check": {
|
||||
"git": { "name": "Git", "description": "Version control" },
|
||||
"go": { "name": "Go", "description": "Go compiler" },
|
||||
"docker": { "name": "Docker", "description": "Container runtime" },
|
||||
"node": { "name": "Node.js", "description": "JavaScript runtime" },
|
||||
"php": { "name": "PHP", "description": "PHP interpreter" },
|
||||
|
|
@ -111,10 +108,7 @@
|
|||
"all_up_to_date": "All packages are up to date",
|
||||
"commits_behind": "{{.Count}} commits behind",
|
||||
"update_with": "Update with: core pkg update {{.Name}}",
|
||||
"summary": "{{.Outdated}}/{{.Total}} outdated",
|
||||
"flag": {
|
||||
"format": "Output format: table or json"
|
||||
}
|
||||
"summary": "{{.Outdated}}/{{.Total}} outdated"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
|
|
@ -36,15 +34,3 @@ func LogWarn(msg string, keyvals ...any) { log.Warn(msg, keyvals...) }
|
|||
//
|
||||
// cli.LogError("Fatal error", "err", err)
|
||||
func LogError(msg string, keyvals ...any) { log.Error(msg, keyvals...) }
|
||||
|
||||
// LogSecurity logs a security-sensitive message.
|
||||
//
|
||||
// cli.LogSecurity("login attempt", "user", "admin")
|
||||
func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
|
||||
|
||||
// LogSecurityf logs a formatted security-sensitive message.
|
||||
//
|
||||
// cli.LogSecurityf("login attempt from %s", username)
|
||||
func LogSecurityf(format string, args ...any) {
|
||||
log.Security(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package cli
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
|
|
@ -9,35 +10,35 @@ import (
|
|||
|
||||
// Blank prints an empty line.
|
||||
func Blank() {
|
||||
fmt.Fprintln(stdoutWriter())
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Echo translates a key via i18n.T and prints with newline.
|
||||
// No automatic styling - use Success/Error/Warn/Info for styled output.
|
||||
func Echo(key string, args ...any) {
|
||||
fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...)))
|
||||
fmt.Println(i18n.T(key, args...))
|
||||
}
|
||||
|
||||
// Print outputs formatted text (no newline).
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Print(format string, args ...any) {
|
||||
fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Println outputs formatted text with newline.
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Println(format string, args ...any) {
|
||||
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Text prints arguments like fmt.Println, but handling glyphs.
|
||||
func Text(args ...any) {
|
||||
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...)))
|
||||
fmt.Println(compileGlyphs(fmt.Sprint(args...)))
|
||||
}
|
||||
|
||||
// Success prints a success message with checkmark (green).
|
||||
func Success(msg string) {
|
||||
fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
|
||||
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg))
|
||||
}
|
||||
|
||||
// Successf prints a formatted success message.
|
||||
|
|
@ -48,7 +49,7 @@ func Successf(format string, args ...any) {
|
|||
// Error prints an error message with cross (red) to stderr and logs it.
|
||||
func Error(msg string) {
|
||||
LogError(msg)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
}
|
||||
|
||||
// Errorf prints a formatted error message to stderr and logs it.
|
||||
|
|
@ -85,7 +86,7 @@ func ErrorWrapAction(err error, verb string) {
|
|||
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
|
||||
func Warn(msg string) {
|
||||
LogWarn(msg)
|
||||
fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
|
||||
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg))
|
||||
}
|
||||
|
||||
// Warnf prints a formatted warning message to stderr and logs it.
|
||||
|
|
@ -95,7 +96,7 @@ func Warnf(format string, args ...any) {
|
|||
|
||||
// Info prints an info message with info symbol (blue).
|
||||
func Info(msg string) {
|
||||
fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
|
||||
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg))
|
||||
}
|
||||
|
||||
// Infof prints a formatted info message.
|
||||
|
|
@ -105,33 +106,33 @@ func Infof(format string, args ...any) {
|
|||
|
||||
// Dim prints dimmed text.
|
||||
func Dim(msg string) {
|
||||
fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg)))
|
||||
fmt.Println(DimStyle.Render(msg))
|
||||
}
|
||||
|
||||
// Progress prints a progress indicator that overwrites the current line.
|
||||
// Uses i18n.Progress for gerund form ("Checking...").
|
||||
func Progress(verb string, current, total int, item ...string) {
|
||||
msg := compileGlyphs(i18n.Progress(verb))
|
||||
msg := i18n.Progress(verb)
|
||||
if len(item) > 0 && item[0] != "" {
|
||||
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, compileGlyphs(item[0]))
|
||||
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
|
||||
} else {
|
||||
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
|
||||
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressDone clears the progress line.
|
||||
func ProgressDone() {
|
||||
fmt.Fprint(stderrWriter(), "\033[2K\r")
|
||||
fmt.Print("\033[2K\r")
|
||||
}
|
||||
|
||||
// Label prints a "Label: value" line.
|
||||
func Label(word, value string) {
|
||||
fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
|
||||
fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value)
|
||||
}
|
||||
|
||||
// Scanln reads from stdin.
|
||||
func Scanln(a ...any) (int, error) {
|
||||
return fmt.Fscanln(newReader(), a...)
|
||||
return fmt.Scanln(a...)
|
||||
}
|
||||
|
||||
// Task prints a task header: "[label] message"
|
||||
|
|
@ -139,16 +140,15 @@ func Scanln(a ...any) (int, error) {
|
|||
// cli.Task("php", "Running tests...") // [php] Running tests...
|
||||
// cli.Task("go", i18n.Progress("build")) // [go] Building...
|
||||
func Task(label, message string) {
|
||||
fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
|
||||
fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message)
|
||||
}
|
||||
|
||||
// Section prints a section header: "── SECTION ──"
|
||||
//
|
||||
// cli.Section("audit") // ── AUDIT ──
|
||||
func Section(name string) {
|
||||
dash := Glyph(":dash:")
|
||||
header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash
|
||||
fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header))
|
||||
header := "── " + strings.ToUpper(name) + " ──"
|
||||
fmt.Println(AccentStyle.Render(header))
|
||||
}
|
||||
|
||||
// Hint prints a labelled hint: "label: message"
|
||||
|
|
@ -156,7 +156,7 @@ func Section(name string) {
|
|||
// cli.Hint("install", "composer require vimeo/psalm")
|
||||
// cli.Hint("fix", "core php fmt --fix")
|
||||
func Hint(label, message string) {
|
||||
fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
|
||||
fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message)
|
||||
}
|
||||
|
||||
// Severity prints a severity-styled message.
|
||||
|
|
@ -179,7 +179,7 @@ func Severity(level, message string) {
|
|||
default:
|
||||
style = DimStyle
|
||||
}
|
||||
fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
|
||||
fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message)
|
||||
}
|
||||
|
||||
// Result prints a result line: "✓ message" or "✗ message"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ func captureOutput(f func()) string {
|
|||
}
|
||||
|
||||
func TestSemanticOutput_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
|
@ -53,7 +52,6 @@ func TestSemanticOutput_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestSemanticOutput_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
|
@ -76,7 +74,6 @@ func TestSemanticOutput_Bad(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestSemanticOutput_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
// Severity with various levels should not panic.
|
||||
|
|
|
|||
|
|
@ -5,42 +5,39 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var stdin io.Reader = os.Stdin
|
||||
|
||||
// SetStdin overrides the default stdin reader for testing.
|
||||
func SetStdin(r io.Reader) { stdin = r }
|
||||
|
||||
// newReader wraps stdin in a bufio.Reader if it isn't one already.
|
||||
func newReader() *bufio.Reader {
|
||||
if br, ok := stdinReader().(*bufio.Reader); ok {
|
||||
if br, ok := stdin.(*bufio.Reader); ok {
|
||||
return br
|
||||
}
|
||||
return bufio.NewReader(stdinReader())
|
||||
return bufio.NewReader(stdin)
|
||||
}
|
||||
|
||||
// Prompt asks for text input with a default value.
|
||||
func Prompt(label, defaultVal string) (string, error) {
|
||||
label = compileGlyphs(label)
|
||||
defaultVal = compileGlyphs(defaultVal)
|
||||
if defaultVal != "" {
|
||||
fmt.Fprintf(stderrWriter(), "%s [%s]: ", label, defaultVal)
|
||||
fmt.Printf("%s [%s]: ", label, defaultVal)
|
||||
} else {
|
||||
fmt.Fprintf(stderrWriter(), "%s: ", label)
|
||||
fmt.Printf("%s: ", label)
|
||||
}
|
||||
|
||||
r := newReader()
|
||||
input, err := r.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
if input == "" {
|
||||
if defaultVal != "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
|
|
@ -49,62 +46,46 @@ func Prompt(label, defaultVal string) (string, error) {
|
|||
|
||||
// Select presents numbered options and returns the selected value.
|
||||
func Select(label string, options []string) (string, error) {
|
||||
if len(options) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
|
||||
fmt.Println(label)
|
||||
for i, opt := range options {
|
||||
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
|
||||
fmt.Printf(" %d. %s\n", i+1, opt)
|
||||
}
|
||||
fmt.Fprintf(stderrWriter(), "Choose [1-%d]: ", len(options))
|
||||
fmt.Printf("Choose [1-%d]: ", len(options))
|
||||
|
||||
r := newReader()
|
||||
input, err := r.ReadString('\n')
|
||||
if err != nil && strings.TrimSpace(input) == "" {
|
||||
promptHint("No input received. Selection cancelled.")
|
||||
return "", Wrap(err, "selection cancelled")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(input)
|
||||
n, err := strconv.Atoi(trimmed)
|
||||
n, err := strconv.Atoi(strings.TrimSpace(input))
|
||||
if err != nil || n < 1 || n > len(options) {
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(options)))
|
||||
return "", Err("invalid selection %q: choose a number between 1 and %d", trimmed, len(options))
|
||||
return "", errors.New("invalid selection")
|
||||
}
|
||||
return options[n-1], nil
|
||||
}
|
||||
|
||||
// MultiSelect presents checkboxes (space-separated numbers).
|
||||
func MultiSelect(label string, options []string) ([]string, error) {
|
||||
if len(options) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
|
||||
fmt.Println(label)
|
||||
for i, opt := range options {
|
||||
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
|
||||
fmt.Printf(" %d. %s\n", i+1, opt)
|
||||
}
|
||||
fmt.Fprintf(stderrWriter(), "Choose (space-separated) [1-%d]: ", len(options))
|
||||
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options))
|
||||
|
||||
r := newReader()
|
||||
input, err := r.ReadString('\n')
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if err != nil && trimmed == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selected, parseErr := parseMultiSelection(trimmed, len(options))
|
||||
if parseErr != nil {
|
||||
return nil, Wrap(parseErr, fmt.Sprintf("invalid selection %q", trimmed))
|
||||
var selected []string
|
||||
for _, s := range strings.Fields(input) {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 || n > len(options) {
|
||||
continue
|
||||
}
|
||||
|
||||
selectedOptions := make([]string, 0, len(selected))
|
||||
for _, idx := range selected {
|
||||
selectedOptions = append(selectedOptions, options[idx])
|
||||
selected = append(selected, options[n-1])
|
||||
}
|
||||
return selectedOptions, nil
|
||||
return selected, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ import (
|
|||
)
|
||||
|
||||
// RenderStyle controls how layouts are rendered.
|
||||
//
|
||||
// cli.UseRenderBoxed()
|
||||
// frame := cli.NewFrame("HCF")
|
||||
// fmt.Print(frame.String())
|
||||
type RenderStyle int
|
||||
|
||||
// Render style constants for layout output.
|
||||
|
|
@ -25,23 +21,17 @@ const (
|
|||
var currentRenderStyle = RenderFlat
|
||||
|
||||
// UseRenderFlat sets the render style to flat (no borders).
|
||||
//
|
||||
// cli.UseRenderFlat()
|
||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||
|
||||
// UseRenderSimple sets the render style to simple (--- separators).
|
||||
//
|
||||
// cli.UseRenderSimple()
|
||||
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
||||
|
||||
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
|
||||
//
|
||||
// cli.UseRenderBoxed()
|
||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||
|
||||
// Render outputs the layout to terminal.
|
||||
func (c *Composite) Render() {
|
||||
fmt.Fprint(stdoutWriter(), c.String())
|
||||
fmt.Print(c.String())
|
||||
}
|
||||
|
||||
// String returns the rendered layout.
|
||||
|
|
@ -76,9 +66,9 @@ func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
|
|||
indent := strings.Repeat(" ", depth)
|
||||
switch currentRenderStyle {
|
||||
case RenderBoxed:
|
||||
sb.WriteString(indent + Glyph(":tee:") + strings.Repeat(Glyph(":dash:"), 40) + Glyph(":tee:") + "\n")
|
||||
sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n")
|
||||
case RenderSimple:
|
||||
sb.WriteString(indent + strings.Repeat(Glyph(":dash:"), 40) + "\n")
|
||||
sb.WriteString(indent + strings.Repeat("─", 40) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import (
|
|||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -39,12 +38,6 @@ type runtime struct {
|
|||
}
|
||||
|
||||
// Options configures the CLI runtime.
|
||||
//
|
||||
// Example:
|
||||
// opts := cli.Options{
|
||||
// AppName: "core",
|
||||
// Version: "1.0.0",
|
||||
// }
|
||||
type Options struct {
|
||||
AppName string
|
||||
Version string
|
||||
|
|
@ -58,11 +51,6 @@ type Options struct {
|
|||
|
||||
// Init initialises the global CLI runtime.
|
||||
// Call this once at startup (typically in main.go or cmd.Execute).
|
||||
//
|
||||
// Example:
|
||||
// err := cli.Init(cli.Options{AppName: "core"})
|
||||
// if err != nil { panic(err) }
|
||||
// defer cli.Shutdown()
|
||||
func Init(opts Options) error {
|
||||
var initErr error
|
||||
once.Do(func() {
|
||||
|
|
@ -122,8 +110,6 @@ func Init(opts Options) error {
|
|||
return
|
||||
}
|
||||
|
||||
loadLocaleSources(opts.I18nSources...)
|
||||
|
||||
// Attach registered commands AFTER Core startup so i18n is available
|
||||
attachRegisteredCommands(rootCmd)
|
||||
})
|
||||
|
|
@ -152,98 +138,25 @@ func RootCmd() *cobra.Command {
|
|||
|
||||
// Execute runs the CLI root command.
|
||||
// Returns an error if the command fails.
|
||||
//
|
||||
// Example:
|
||||
// if err := cli.Execute(); err != nil {
|
||||
// cli.Warn("command failed:", "err", err)
|
||||
// }
|
||||
func Execute() error {
|
||||
mustInit()
|
||||
return instance.root.Execute()
|
||||
}
|
||||
|
||||
// Run executes the CLI and watches an external context for cancellation.
|
||||
// If the context is cancelled first, the runtime is shut down and the
|
||||
// command error is returned if execution failed during shutdown.
|
||||
//
|
||||
// Example:
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
// defer cancel()
|
||||
// if err := cli.Run(ctx); err != nil {
|
||||
// cli.Error(err.Error())
|
||||
// }
|
||||
func Run(ctx context.Context) error {
|
||||
mustInit()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- Execute()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
Shutdown()
|
||||
if err := <-errCh; err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// RunWithTimeout returns a shutdown helper that waits for the runtime to stop
|
||||
// for up to timeout before giving up. It is intended for deferred cleanup.
|
||||
//
|
||||
// Example:
|
||||
// stop := cli.RunWithTimeout(5 * time.Second)
|
||||
// defer stop()
|
||||
func RunWithTimeout(timeout time.Duration) func() {
|
||||
return func() {
|
||||
if timeout <= 0 {
|
||||
Shutdown()
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(timeout):
|
||||
// Give up waiting, but let the shutdown goroutine finish in the background.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context returns the CLI's root context.
|
||||
// Cancelled on SIGINT/SIGTERM.
|
||||
//
|
||||
// Example:
|
||||
// if ctx := cli.Context(); ctx != nil {
|
||||
// _ = ctx
|
||||
// }
|
||||
func Context() context.Context {
|
||||
mustInit()
|
||||
return instance.ctx
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the CLI.
|
||||
//
|
||||
// Example:
|
||||
// cli.Shutdown()
|
||||
func Shutdown() {
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
instance.cancel()
|
||||
_ = instance.core.ServiceShutdown(context.WithoutCancel(instance.ctx))
|
||||
_ = instance.core.ServiceShutdown(instance.ctx)
|
||||
}
|
||||
|
||||
// --- Signal Srv (internal) ---
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRun_Good_ReturnsCommandError(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
require.NoError(t, Init(Options{AppName: "test"}))
|
||||
|
||||
RootCmd().AddCommand(NewCommand("boom", "Boom", "", func(_ *Command, _ []string) error {
|
||||
return errors.New("boom")
|
||||
}))
|
||||
RootCmd().SetArgs([]string{"boom"})
|
||||
|
||||
err := Run(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "boom")
|
||||
}
|
||||
|
||||
func TestRun_Good_CancelledContext(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
require.NoError(t, Init(Options{AppName: "test"}))
|
||||
|
||||
RootCmd().AddCommand(NewCommand("wait", "Wait", "", func(_ *Command, _ []string) error {
|
||||
<-Context().Done()
|
||||
return nil
|
||||
}))
|
||||
RootCmd().SetArgs([]string{"wait"})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
time.AfterFunc(25*time.Millisecond, cancel)
|
||||
|
||||
err := Run(ctx)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestRunWithTimeout_Good_ReturnsHelper(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
finished := make(chan struct{})
|
||||
var finishedOnce sync.Once
|
||||
require.NoError(t, Init(Options{
|
||||
AppName: "test",
|
||||
Services: []core.Service{
|
||||
{
|
||||
Name: "slow-stop",
|
||||
OnStop: func() core.Result {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
finishedOnce.Do(func() {
|
||||
close(finished)
|
||||
})
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
start := time.Now()
|
||||
RunWithTimeout(20 * time.Millisecond)()
|
||||
require.Less(t, time.Since(start), 80*time.Millisecond)
|
||||
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown did not complete")
|
||||
}
|
||||
}
|
||||
|
|
@ -3,16 +3,13 @@ package cli
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// StreamOption configures a Stream.
|
||||
//
|
||||
// stream := cli.NewStream(cli.WithWordWrap(80))
|
||||
// stream.Wait()
|
||||
type StreamOption func(*Stream)
|
||||
|
||||
// WithWordWrap sets the word-wrap column width.
|
||||
|
|
@ -20,7 +17,7 @@ func WithWordWrap(cols int) StreamOption {
|
|||
return func(s *Stream) { s.wrap = cols }
|
||||
}
|
||||
|
||||
// WithStreamOutput sets the output writer (default: stdoutWriter()).
|
||||
// WithStreamOutput sets the output writer (default: os.Stdout).
|
||||
func WithStreamOutput(w io.Writer) StreamOption {
|
||||
return func(s *Stream) { s.out = w }
|
||||
}
|
||||
|
|
@ -41,14 +38,13 @@ type Stream struct {
|
|||
wrap int
|
||||
col int // current column position (visible characters)
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewStream creates a streaming text renderer.
|
||||
func NewStream(opts ...StreamOption) *Stream {
|
||||
s := &Stream{
|
||||
out: stdoutWriter(),
|
||||
out: os.Stdout,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
|
|
@ -64,11 +60,11 @@ func (s *Stream) Write(text string) {
|
|||
|
||||
if s.wrap <= 0 {
|
||||
fmt.Fprint(s.out, text)
|
||||
// Track visible width across newlines for Done() trailing-newline logic.
|
||||
// Track column across newlines for Done() trailing-newline logic.
|
||||
if idx := strings.LastIndex(text, "\n"); idx >= 0 {
|
||||
s.col = runewidth.StringWidth(text[idx+1:])
|
||||
s.col = utf8.RuneCountInString(text[idx+1:])
|
||||
} else {
|
||||
s.col += runewidth.StringWidth(text)
|
||||
s.col += utf8.RuneCountInString(text)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -80,14 +76,13 @@ func (s *Stream) Write(text string) {
|
|||
continue
|
||||
}
|
||||
|
||||
rw := runewidth.RuneWidth(r)
|
||||
if rw > 0 && s.col > 0 && s.col+rw > s.wrap {
|
||||
if s.col >= s.wrap {
|
||||
fmt.Fprintln(s.out)
|
||||
s.col = 0
|
||||
}
|
||||
|
||||
fmt.Fprint(s.out, string(r))
|
||||
s.col += rw
|
||||
s.col++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,14 +105,12 @@ func (s *Stream) WriteFrom(r io.Reader) error {
|
|||
|
||||
// Done signals that no more text will arrive.
|
||||
func (s *Stream) Done() {
|
||||
s.once.Do(func() {
|
||||
s.mu.Lock()
|
||||
if s.col > 0 {
|
||||
fmt.Fprintln(s.out) // ensure trailing newline
|
||||
}
|
||||
s.mu.Unlock()
|
||||
close(s.done)
|
||||
})
|
||||
}
|
||||
|
||||
// Wait blocks until Done is called.
|
||||
|
|
@ -132,24 +125,16 @@ func (s *Stream) Column() int {
|
|||
return s.col
|
||||
}
|
||||
|
||||
// Captured returns the stream output as a string when the output writer is
|
||||
// capture-capable. If the writer cannot be captured, it returns an empty string.
|
||||
// Use CapturedOK when you need to distinguish that case.
|
||||
// Captured returns the stream output as a string when using a bytes.Buffer.
|
||||
// Panics if the output writer is not a *strings.Builder or fmt.Stringer.
|
||||
func (s *Stream) Captured() string {
|
||||
out, _ := s.CapturedOK()
|
||||
return out
|
||||
}
|
||||
|
||||
// CapturedOK returns the stream output and whether the configured writer
|
||||
// supports capture.
|
||||
func (s *Stream) CapturedOK() (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sb, ok := s.out.(*strings.Builder); ok {
|
||||
return sb.String(), true
|
||||
return sb.String()
|
||||
}
|
||||
if st, ok := s.out.(fmt.Stringer); ok {
|
||||
return st.String(), true
|
||||
return st.String()
|
||||
}
|
||||
return "", false
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,53 +20,47 @@ func Sprint(args ...any) string {
|
|||
//
|
||||
// label := cli.Styled(cli.AccentStyle, "core dev")
|
||||
func Styled(style *AnsiStyle, text string) string {
|
||||
if style == nil {
|
||||
return compileGlyphs(text)
|
||||
}
|
||||
return style.Render(compileGlyphs(text))
|
||||
return style.Render(text)
|
||||
}
|
||||
|
||||
// Styledf returns formatted text with a style applied.
|
||||
//
|
||||
// header := cli.Styledf(cli.HeaderStyle, "%s v%s", name, version)
|
||||
func Styledf(style *AnsiStyle, format string, args ...any) string {
|
||||
if style == nil {
|
||||
return compileGlyphs(fmt.Sprintf(format, args...))
|
||||
}
|
||||
return style.Render(compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
return style.Render(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// SuccessStr returns a success-styled string without printing it.
|
||||
//
|
||||
// line := cli.SuccessStr("all tests passed")
|
||||
func SuccessStr(msg string) string {
|
||||
return SuccessStyle.Render(Glyph(":check:") + " " + compileGlyphs(msg))
|
||||
return SuccessStyle.Render(Glyph(":check:") + " " + msg)
|
||||
}
|
||||
|
||||
// ErrorStr returns an error-styled string without printing it.
|
||||
//
|
||||
// line := cli.ErrorStr("connection refused")
|
||||
func ErrorStr(msg string) string {
|
||||
return ErrorStyle.Render(Glyph(":cross:") + " " + compileGlyphs(msg))
|
||||
return ErrorStyle.Render(Glyph(":cross:") + " " + msg)
|
||||
}
|
||||
|
||||
// WarnStr returns a warning-styled string without printing it.
|
||||
//
|
||||
// line := cli.WarnStr("deprecated flag")
|
||||
func WarnStr(msg string) string {
|
||||
return WarningStyle.Render(Glyph(":warn:") + " " + compileGlyphs(msg))
|
||||
return WarningStyle.Render(Glyph(":warn:") + " " + msg)
|
||||
}
|
||||
|
||||
// InfoStr returns an info-styled string without printing it.
|
||||
//
|
||||
// line := cli.InfoStr("listening on :8080")
|
||||
func InfoStr(msg string) string {
|
||||
return InfoStyle.Render(Glyph(":info:") + " " + compileGlyphs(msg))
|
||||
return InfoStyle.Render(Glyph(":info:") + " " + msg)
|
||||
}
|
||||
|
||||
// DimStr returns a dim-styled string without printing it.
|
||||
//
|
||||
// line := cli.DimStr("optional: use --verbose for details")
|
||||
func DimStr(msg string) string {
|
||||
return DimStyle.Render(compileGlyphs(msg))
|
||||
return DimStyle.Render(msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
// Tailwind colour palette (hex strings)
|
||||
|
|
@ -72,53 +69,21 @@ var (
|
|||
|
||||
// Truncate shortens a string to max length with ellipsis.
|
||||
func Truncate(s string, max int) string {
|
||||
if max <= 0 || s == "" {
|
||||
return ""
|
||||
}
|
||||
if displayWidth(s) <= max {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 3 {
|
||||
return truncateByWidth(s, max)
|
||||
return s[:max]
|
||||
}
|
||||
return truncateByWidth(s, max-3) + "..."
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
// Pad right-pads a string to width.
|
||||
func Pad(s string, width int) string {
|
||||
if displayWidth(s) >= width {
|
||||
if len(s) >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-displayWidth(s))
|
||||
}
|
||||
|
||||
func displayWidth(s string) int {
|
||||
return runewidth.StringWidth(ansi.Strip(s))
|
||||
}
|
||||
|
||||
func truncateByWidth(s string, max int) string {
|
||||
if max <= 0 || s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
plain := ansi.Strip(s)
|
||||
if displayWidth(plain) <= max {
|
||||
return plain
|
||||
}
|
||||
|
||||
var (
|
||||
width int
|
||||
out strings.Builder
|
||||
)
|
||||
for _, r := range plain {
|
||||
rw := runewidth.RuneWidth(r)
|
||||
if width+rw > max {
|
||||
break
|
||||
}
|
||||
out.WriteRune(r)
|
||||
width += rw
|
||||
}
|
||||
return out.String()
|
||||
return s + strings.Repeat(" ", width-len(s))
|
||||
}
|
||||
|
||||
// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago").
|
||||
|
|
@ -174,13 +139,6 @@ var borderSets = map[BorderStyle]borderSet{
|
|||
BorderDouble: {"╔", "╗", "╚", "╝", "═", "║", "╦", "╩", "╠", "╣", "╬"},
|
||||
}
|
||||
|
||||
var borderSetsASCII = map[BorderStyle]borderSet{
|
||||
BorderNormal: {"+", "+", "+", "+", "-", "|", "+", "+", "+", "+", "+"},
|
||||
BorderRounded: {"+", "+", "+", "+", "-", "|", "+", "+", "+", "+", "+"},
|
||||
BorderHeavy: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"},
|
||||
BorderDouble: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"},
|
||||
}
|
||||
|
||||
// CellStyleFn returns a style based on the cell's raw value.
|
||||
// Return nil to use the table's default CellStyle.
|
||||
type CellStyleFn func(value string) *AnsiStyle
|
||||
|
|
@ -275,7 +233,7 @@ func (t *Table) String() string {
|
|||
|
||||
// Render prints the table to stdout.
|
||||
func (t *Table) Render() {
|
||||
fmt.Fprint(stdoutWriter(), t.String())
|
||||
fmt.Print(t.String())
|
||||
}
|
||||
|
||||
func (t *Table) colCount() int {
|
||||
|
|
@ -291,16 +249,14 @@ func (t *Table) columnWidths() []int {
|
|||
widths := make([]int, cols)
|
||||
|
||||
for i, h := range t.Headers {
|
||||
if w := displayWidth(compileGlyphs(h)); w > widths[i] {
|
||||
widths[i] = w
|
||||
if len(h) > widths[i] {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
}
|
||||
for _, row := range t.Rows {
|
||||
for i, cell := range row {
|
||||
if i < cols {
|
||||
if w := displayWidth(compileGlyphs(cell)); w > widths[i] {
|
||||
widths[i] = w
|
||||
}
|
||||
if i < cols && len(cell) > widths[i] {
|
||||
widths[i] = len(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -367,7 +323,7 @@ func (t *Table) renderPlain() string {
|
|||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
cell := Pad(Truncate(compileGlyphs(h), widths[i]), widths[i])
|
||||
cell := Pad(Truncate(h, widths[i]), widths[i])
|
||||
if t.Style.HeaderStyle != nil {
|
||||
cell = t.Style.HeaderStyle.Render(cell)
|
||||
}
|
||||
|
|
@ -385,7 +341,7 @@ func (t *Table) renderPlain() string {
|
|||
if i < len(row) {
|
||||
val = row[i]
|
||||
}
|
||||
cell := Pad(Truncate(compileGlyphs(val), widths[i]), widths[i])
|
||||
cell := Pad(Truncate(val, widths[i]), widths[i])
|
||||
if style := t.resolveStyle(i, val); style != nil {
|
||||
cell = style.Render(cell)
|
||||
}
|
||||
|
|
@ -398,7 +354,7 @@ func (t *Table) renderPlain() string {
|
|||
}
|
||||
|
||||
func (t *Table) renderBordered() string {
|
||||
b := tableBorderSet(t.borders)
|
||||
b := borderSets[t.borders]
|
||||
widths := t.columnWidths()
|
||||
cols := t.colCount()
|
||||
|
||||
|
|
@ -423,7 +379,7 @@ func (t *Table) renderBordered() string {
|
|||
if i < len(t.Headers) {
|
||||
h = t.Headers[i]
|
||||
}
|
||||
cell := Pad(Truncate(compileGlyphs(h), widths[i]), widths[i])
|
||||
cell := Pad(Truncate(h, widths[i]), widths[i])
|
||||
if t.Style.HeaderStyle != nil {
|
||||
cell = t.Style.HeaderStyle.Render(cell)
|
||||
}
|
||||
|
|
@ -454,7 +410,7 @@ func (t *Table) renderBordered() string {
|
|||
if i < len(row) {
|
||||
val = row[i]
|
||||
}
|
||||
cell := Pad(Truncate(compileGlyphs(val), widths[i]), widths[i])
|
||||
cell := Pad(Truncate(val, widths[i]), widths[i])
|
||||
if style := t.resolveStyle(i, val); style != nil {
|
||||
cell = style.Render(cell)
|
||||
}
|
||||
|
|
@ -479,15 +435,3 @@ func (t *Table) renderBordered() string {
|
|||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func tableBorderSet(style BorderStyle) borderSet {
|
||||
if currentTheme == ThemeASCII {
|
||||
if b, ok := borderSetsASCII[style]; ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
if b, ok := borderSets[style]; ok {
|
||||
return b
|
||||
}
|
||||
return borderSet{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,22 +81,6 @@ func TestTable_Good(t *testing.T) {
|
|||
assert.Contains(t, out, "║")
|
||||
})
|
||||
|
||||
t.Run("ASCII theme uses ASCII borders", func(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
tbl := NewTable("REPO", "STATUS").WithBorders(BorderRounded)
|
||||
tbl.AddRow("core", "clean")
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "+")
|
||||
assert.Contains(t, out, "-")
|
||||
assert.Contains(t, out, "|")
|
||||
assert.NotContains(t, out, "╭")
|
||||
assert.NotContains(t, out, "╮")
|
||||
assert.NotContains(t, out, "│")
|
||||
})
|
||||
|
||||
t.Run("bordered structure", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
|
@ -146,19 +130,6 @@ func TestTable_Good(t *testing.T) {
|
|||
assert.Contains(t, out, "ok")
|
||||
})
|
||||
|
||||
t.Run("glyph shortcodes render in headers and cells", func(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
tbl := NewTable(":check: NAME", "STATUS").
|
||||
WithBorders(BorderRounded)
|
||||
tbl.AddRow("core", ":warn:")
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "[OK] NAME")
|
||||
assert.Contains(t, out, "[WARN]")
|
||||
})
|
||||
|
||||
t.Run("max width truncates", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
|
@ -263,7 +234,6 @@ func TestTruncate_Good(t *testing.T) {
|
|||
assert.Equal(t, "hel...", Truncate("hello world", 6))
|
||||
assert.Equal(t, "hi", Truncate("hi", 6))
|
||||
assert.Equal(t, "he", Truncate("hello", 2))
|
||||
assert.Equal(t, "東", Truncate("東京", 3))
|
||||
}
|
||||
|
||||
func TestTruncate_Ugly(t *testing.T) {
|
||||
|
|
@ -277,21 +247,6 @@ func TestTruncate_Ugly(t *testing.T) {
|
|||
func TestPad_Good(t *testing.T) {
|
||||
assert.Equal(t, "hi ", Pad("hi", 5))
|
||||
assert.Equal(t, "hello", Pad("hello", 3))
|
||||
assert.Equal(t, "東京 ", Pad("東京", 6))
|
||||
}
|
||||
|
||||
func TestStyled_Good_NilStyle(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
assert.Equal(t, "hello [OK]", Styled(nil, "hello :check:"))
|
||||
}
|
||||
|
||||
func TestStyledf_Good_NilStyle(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
assert.Equal(t, "value: [WARN]", Styledf(nil, "value: %s", ":warn:"))
|
||||
}
|
||||
|
||||
func TestPad_Ugly(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ import (
|
|||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Spinner frames for the live tracker.
|
||||
var spinnerFramesUnicode = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
var spinnerFramesASCII = []string{"-", "\\", "|", "/"}
|
||||
// Spinner frames (braille pattern).
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// taskState tracks the lifecycle of a tracked task.
|
||||
type taskState int
|
||||
|
|
@ -89,11 +88,8 @@ type TaskTracker struct {
|
|||
func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
|
||||
return func(yield func(*TrackedTask) bool) {
|
||||
tr.mu.Lock()
|
||||
tasks := make([]*TrackedTask, len(tr.tasks))
|
||||
copy(tasks, tr.tasks)
|
||||
tr.mu.Unlock()
|
||||
|
||||
for _, t := range tasks {
|
||||
defer tr.mu.Unlock()
|
||||
for _, t := range tr.tasks {
|
||||
if !yield(t) {
|
||||
return
|
||||
}
|
||||
|
|
@ -105,11 +101,8 @@ func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
|
|||
func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
|
||||
return func(yield func(string, string) bool) {
|
||||
tr.mu.Lock()
|
||||
tasks := make([]*TrackedTask, len(tr.tasks))
|
||||
copy(tasks, tr.tasks)
|
||||
tr.mu.Unlock()
|
||||
|
||||
for _, t := range tasks {
|
||||
defer tr.mu.Unlock()
|
||||
for _, t := range tr.tasks {
|
||||
name, status, _ := t.snapshot()
|
||||
if !yield(name, status) {
|
||||
return
|
||||
|
|
@ -120,16 +113,7 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
|
|||
|
||||
// NewTaskTracker creates a new parallel task tracker.
|
||||
func NewTaskTracker() *TaskTracker {
|
||||
return &TaskTracker{out: stderrWriter()}
|
||||
}
|
||||
|
||||
// WithOutput sets the destination writer for tracker output.
|
||||
// Pass nil to keep the current writer unchanged.
|
||||
func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker {
|
||||
if out != nil {
|
||||
tr.out = out
|
||||
}
|
||||
return tr
|
||||
return &TaskTracker{out: os.Stdout}
|
||||
}
|
||||
|
||||
// Add registers a task and returns it for goroutine use.
|
||||
|
|
@ -175,8 +159,6 @@ func (tr *TaskTracker) waitStatic() {
|
|||
allDone := true
|
||||
for i, t := range tasks {
|
||||
name, status, state := t.snapshot()
|
||||
name = compileGlyphs(name)
|
||||
status = compileGlyphs(status)
|
||||
if state != taskDone && state != taskFailed {
|
||||
allDone = false
|
||||
continue
|
||||
|
|
@ -208,9 +190,6 @@ func (tr *TaskTracker) waitLive() {
|
|||
for i := range n {
|
||||
tr.renderLine(i, frame)
|
||||
}
|
||||
if n == 0 || tr.allDone() {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
|
@ -241,8 +220,6 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
|
|||
tr.mu.Unlock()
|
||||
|
||||
name, status, state := t.snapshot()
|
||||
name = compileGlyphs(name)
|
||||
status = compileGlyphs(status)
|
||||
nameW := tr.nameWidth()
|
||||
|
||||
var icon string
|
||||
|
|
@ -250,7 +227,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
|
|||
case taskPending:
|
||||
icon = DimStyle.Render(Glyph(":pending:"))
|
||||
case taskRunning:
|
||||
icon = InfoStyle.Render(trackerSpinnerFrame(frame))
|
||||
icon = InfoStyle.Render(spinnerFrames[frame%len(spinnerFrames)])
|
||||
case taskDone:
|
||||
icon = SuccessStyle.Render(Glyph(":check:"))
|
||||
case taskFailed:
|
||||
|
|
@ -267,7 +244,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
|
|||
styledStatus = DimStyle.Render(status)
|
||||
}
|
||||
|
||||
fmt.Fprintf(tr.out, "\033[2K%s %s %s\n", icon, Pad(name, nameW), styledStatus)
|
||||
fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus)
|
||||
}
|
||||
|
||||
func (tr *TaskTracker) nameWidth() int {
|
||||
|
|
@ -275,8 +252,8 @@ func (tr *TaskTracker) nameWidth() int {
|
|||
defer tr.mu.Unlock()
|
||||
w := 0
|
||||
for _, t := range tr.tasks {
|
||||
if nameW := displayWidth(compileGlyphs(t.name)); nameW > w {
|
||||
w = nameW
|
||||
if len(t.name) > w {
|
||||
w = len(t.name)
|
||||
}
|
||||
}
|
||||
return w
|
||||
|
|
@ -327,26 +304,16 @@ func (tr *TaskTracker) String() string {
|
|||
var sb strings.Builder
|
||||
for _, t := range tasks {
|
||||
name, status, state := t.snapshot()
|
||||
name = compileGlyphs(name)
|
||||
status = compileGlyphs(status)
|
||||
icon := Glyph(":pending:")
|
||||
icon := "…"
|
||||
switch state {
|
||||
case taskDone:
|
||||
icon = Glyph(":check:")
|
||||
icon = "✓"
|
||||
case taskFailed:
|
||||
icon = Glyph(":cross:")
|
||||
icon = "✗"
|
||||
case taskRunning:
|
||||
icon = Glyph(":spinner:")
|
||||
icon = "⠋"
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s %s %s\n", icon, Pad(name, nameW), status)
|
||||
fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func trackerSpinnerFrame(frame int) string {
|
||||
frames := spinnerFramesUnicode
|
||||
if currentTheme == ThemeASCII {
|
||||
frames = spinnerFramesASCII
|
||||
}
|
||||
return frames[frame%len(frames)]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,6 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func restoreThemeAndColors(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
prevTheme := currentTheme
|
||||
prevColor := ColorEnabled()
|
||||
t.Cleanup(func() {
|
||||
currentTheme = prevTheme
|
||||
SetColorEnabled(prevColor)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskTracker_Good(t *testing.T) {
|
||||
t.Run("add and complete tasks", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
|
|
@ -121,7 +110,8 @@ func TestTaskTracker_Good(t *testing.T) {
|
|||
|
||||
t.Run("wait completes for non-TTY", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tr := NewTaskTracker().WithOutput(&buf)
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &buf
|
||||
|
||||
task := tr.Add("quick")
|
||||
go func() {
|
||||
|
|
@ -134,17 +124,6 @@ func TestTaskTracker_Good(t *testing.T) {
|
|||
assert.Contains(t, buf.String(), "done")
|
||||
})
|
||||
|
||||
t.Run("WithOutput sets output writer", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tr := NewTaskTracker().WithOutput(&buf)
|
||||
|
||||
tr.Add("quick").Done("done")
|
||||
tr.Wait()
|
||||
|
||||
assert.Contains(t, buf.String(), "quick")
|
||||
assert.Contains(t, buf.String(), "done")
|
||||
})
|
||||
|
||||
t.Run("name width alignment", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
|
@ -156,17 +135,6 @@ func TestTaskTracker_Good(t *testing.T) {
|
|||
assert.Equal(t, 19, w)
|
||||
})
|
||||
|
||||
t.Run("name width counts visible width", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add("東京")
|
||||
tr.Add("repo")
|
||||
|
||||
w := tr.nameWidth()
|
||||
assert.Equal(t, 4, w)
|
||||
})
|
||||
|
||||
t.Run("String output format", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
|
@ -180,68 +148,6 @@ func TestTaskTracker_Good(t *testing.T) {
|
|||
assert.Contains(t, out, "✗")
|
||||
assert.Contains(t, out, "⠋")
|
||||
})
|
||||
|
||||
t.Run("glyph shortcodes render in names and statuses", func(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add(":check: repo").Done("done :warn:")
|
||||
|
||||
out := tr.String()
|
||||
assert.Contains(t, out, "[OK] repo")
|
||||
assert.Contains(t, out, "[WARN]")
|
||||
})
|
||||
|
||||
t.Run("ASCII theme uses ASCII symbols", func(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add("repo-a").Done("clean")
|
||||
tr.Add("repo-b").Fail("dirty")
|
||||
tr.Add("repo-c").Update("pulling")
|
||||
|
||||
out := tr.String()
|
||||
assert.Contains(t, out, "[OK]")
|
||||
assert.Contains(t, out, "[FAIL]")
|
||||
assert.Contains(t, out, "-")
|
||||
assert.NotContains(t, out, "✓")
|
||||
assert.NotContains(t, out, "✗")
|
||||
})
|
||||
|
||||
t.Run("iterators tolerate mutation during iteration", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add("first")
|
||||
tr.Add("second")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for task := range tr.Tasks() {
|
||||
task.Update("visited")
|
||||
}
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
for name, status := range tr.Snapshots() {
|
||||
assert.Equal(t, "visited", status, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskTracker_Bad(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -79,29 +79,24 @@ func (n *TreeNode) String() string {
|
|||
|
||||
// Render prints the tree to stdout.
|
||||
func (n *TreeNode) Render() {
|
||||
fmt.Fprint(stdoutWriter(), n.String())
|
||||
fmt.Print(n.String())
|
||||
}
|
||||
|
||||
func (n *TreeNode) renderLabel() string {
|
||||
label := compileGlyphs(n.label)
|
||||
if n.style != nil {
|
||||
return n.style.Render(label)
|
||||
return n.style.Render(n.label)
|
||||
}
|
||||
return label
|
||||
return n.label
|
||||
}
|
||||
|
||||
func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) {
|
||||
tee := Glyph(":tee:") + Glyph(":dash:") + Glyph(":dash:") + " "
|
||||
corner := Glyph(":corner:") + Glyph(":dash:") + Glyph(":dash:") + " "
|
||||
pipe := Glyph(":pipe:") + " "
|
||||
|
||||
for i, child := range n.children {
|
||||
last := i == len(n.children)-1
|
||||
|
||||
connector := tee
|
||||
next := pipe
|
||||
connector := "├── "
|
||||
next := "│ "
|
||||
if last {
|
||||
connector = corner
|
||||
connector = "└── "
|
||||
next = " "
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,40 +103,6 @@ func TestTree_Good(t *testing.T) {
|
|||
"└── child\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("ASCII theme uses ASCII connectors", func(t *testing.T) {
|
||||
prevTheme := currentTheme
|
||||
prevColor := ColorEnabled()
|
||||
UseASCII()
|
||||
t.Cleanup(func() {
|
||||
currentTheme = prevTheme
|
||||
SetColorEnabled(prevColor)
|
||||
})
|
||||
|
||||
tree := NewTree("core-php")
|
||||
tree.Add("core-tenant").Add("core-bio")
|
||||
tree.Add("core-admin")
|
||||
tree.Add("core-api")
|
||||
|
||||
expected := "core-php\n" +
|
||||
"+-- core-tenant\n" +
|
||||
"| `-- core-bio\n" +
|
||||
"+-- core-admin\n" +
|
||||
"`-- core-api\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("glyph shortcodes render in labels", func(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
tree := NewTree(":check: root")
|
||||
tree.Add(":warn: child")
|
||||
|
||||
out := tree.String()
|
||||
assert.Contains(t, out, "[OK] root")
|
||||
assert.Contains(t, out, "[WARN] child")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTree_Bad(t *testing.T) {
|
||||
|
|
|
|||
278
pkg/cli/utils.go
278
pkg/cli/utils.go
|
|
@ -1,13 +1,14 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-log"
|
||||
|
|
@ -30,10 +31,6 @@ func GhAuthenticated() bool {
|
|||
}
|
||||
|
||||
// ConfirmOption configures Confirm behaviour.
|
||||
//
|
||||
// if cli.Confirm("Proceed?", cli.DefaultYes()) {
|
||||
// cli.Success("continuing")
|
||||
// }
|
||||
type ConfirmOption func(*confirmConfig)
|
||||
|
||||
type confirmConfig struct {
|
||||
|
|
@ -42,14 +39,6 @@ type confirmConfig struct {
|
|||
timeout time.Duration
|
||||
}
|
||||
|
||||
func promptHint(msg string) {
|
||||
fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
func promptWarning(msg string) {
|
||||
fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
||||
func DefaultYes() ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
|
|
@ -93,8 +82,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
opt(cfg)
|
||||
}
|
||||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
// Build the prompt suffix
|
||||
var suffix string
|
||||
if cfg.required {
|
||||
|
|
@ -110,50 +97,37 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
|
||||
}
|
||||
|
||||
reader := newReader()
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Fprintf(stderrWriter(), "%s %s", prompt, suffix)
|
||||
fmt.Printf("%s %s", prompt, suffix)
|
||||
|
||||
var response string
|
||||
var readErr error
|
||||
|
||||
if cfg.timeout > 0 {
|
||||
// Use timeout-based reading
|
||||
resultChan := make(chan string, 1)
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
line, err := reader.ReadString('\n')
|
||||
line, _ := reader.ReadString('\n')
|
||||
resultChan <- line
|
||||
errChan <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case response = <-resultChan:
|
||||
readErr = <-errChan
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
case <-time.After(cfg.timeout):
|
||||
fmt.Fprintln(stderrWriter()) // New line after timeout
|
||||
fmt.Println() // New line after timeout
|
||||
return cfg.defaultYes
|
||||
}
|
||||
} else {
|
||||
line, err := reader.ReadString('\n')
|
||||
readErr = err
|
||||
if err != nil && line == "" {
|
||||
return cfg.defaultYes
|
||||
}
|
||||
response = line
|
||||
response, _ = reader.ReadString('\n')
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if readErr == nil && cfg.required {
|
||||
promptHint("Please enter y or n, then press Enter.")
|
||||
continue
|
||||
}
|
||||
if cfg.required {
|
||||
return cfg.defaultYes
|
||||
continue // Ask again
|
||||
}
|
||||
return cfg.defaultYes
|
||||
}
|
||||
|
|
@ -168,7 +142,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
|
||||
// Invalid response
|
||||
if cfg.required {
|
||||
promptHint("Please enter y or n, then press Enter.")
|
||||
fmt.Println("Please enter 'y' or 'n'")
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -201,8 +175,6 @@ func ConfirmDangerousAction(verb, subject string) bool {
|
|||
}
|
||||
|
||||
// QuestionOption configures Question behaviour.
|
||||
//
|
||||
// name := cli.Question("Project name:", cli.WithDefault("my-app"))
|
||||
type QuestionOption func(*questionConfig)
|
||||
|
||||
type questionConfig struct {
|
||||
|
|
@ -243,28 +215,23 @@ func Question(prompt string, opts ...QuestionOption) string {
|
|||
opt(cfg)
|
||||
}
|
||||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
reader := newReader()
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
// Build prompt with default
|
||||
if cfg.defaultValue != "" {
|
||||
fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
|
||||
fmt.Printf("%s [%s] ", prompt, cfg.defaultValue)
|
||||
} else {
|
||||
fmt.Fprintf(stderrWriter(), "%s ", prompt)
|
||||
fmt.Printf("%s ", prompt)
|
||||
}
|
||||
|
||||
response, err := reader.ReadString('\n')
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
if err != nil && response == "" {
|
||||
return cfg.defaultValue
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if cfg.required {
|
||||
promptHint("Please enter a value, then press Enter.")
|
||||
fmt.Println("Response required")
|
||||
continue
|
||||
}
|
||||
response = cfg.defaultValue
|
||||
|
|
@ -273,7 +240,7 @@ func Question(prompt string, opts ...QuestionOption) string {
|
|||
// Validate if validator provided
|
||||
if cfg.validator != nil {
|
||||
if err := cfg.validator(response); err != nil {
|
||||
promptWarning(fmt.Sprintf("Invalid: %v", err))
|
||||
fmt.Printf("Invalid: %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
|
@ -291,16 +258,12 @@ func QuestionAction(verb, subject string, opts ...QuestionOption) string {
|
|||
}
|
||||
|
||||
// ChooseOption configures Choose behaviour.
|
||||
//
|
||||
// choice := cli.Choose("Pick one:", items, cli.Display(func(v Item) string {
|
||||
// return v.Name
|
||||
// }))
|
||||
type ChooseOption[T any] func(*chooseConfig[T])
|
||||
|
||||
type chooseConfig[T any] struct {
|
||||
displayFn func(T) string
|
||||
defaultN int // 0-based index of default selection
|
||||
filter bool // Enable type-to-filter selection
|
||||
filter bool // Enable fuzzy filtering
|
||||
multi bool // Allow multiple selection
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +282,9 @@ func WithDefaultIndex[T any](idx int) ChooseOption[T] {
|
|||
}
|
||||
|
||||
// Filter enables type-to-filter functionality.
|
||||
// When enabled, typed text narrows the visible options before selection.
|
||||
// Users can type to narrow down the list of options.
|
||||
// Note: This is a hint for interactive UIs; the basic CLI Choose
|
||||
// implementation uses numbered selection which doesn't support filtering.
|
||||
func Filter[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.filter = true
|
||||
|
|
@ -355,77 +320,42 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
|||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
defaultN: -1,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
reader := newReader()
|
||||
visible := make([]int, len(items))
|
||||
for i := range items {
|
||||
visible[i] = i
|
||||
// Display options
|
||||
fmt.Println(prompt)
|
||||
for i, item := range items {
|
||||
marker := " "
|
||||
if i == cfg.defaultN {
|
||||
marker = "*"
|
||||
}
|
||||
allVisible := append([]int(nil), visible...)
|
||||
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
|
||||
|
||||
if cfg.filter {
|
||||
fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible))
|
||||
} else {
|
||||
fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible))
|
||||
}
|
||||
response, err := reader.ReadString('\n')
|
||||
fmt.Printf("Enter number [1-%d]: ", len(items))
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
if err != nil && response == "" {
|
||||
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
|
||||
return items[idx]
|
||||
}
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
// Empty response uses default
|
||||
if response == "" {
|
||||
if cfg.filter && len(visible) != len(allVisible) {
|
||||
visible = append([]int(nil), allVisible...)
|
||||
promptHint("Filter cleared.")
|
||||
continue
|
||||
}
|
||||
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
|
||||
return items[idx]
|
||||
}
|
||||
if cfg.defaultN >= 0 {
|
||||
promptHint("Default selection is not available in the current list. Narrow the list or choose another number.")
|
||||
continue
|
||||
}
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
continue
|
||||
return items[cfg.defaultN]
|
||||
}
|
||||
|
||||
// Parse number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
|
||||
if n >= 1 && n <= len(visible) {
|
||||
return items[visible[n-1]]
|
||||
if n >= 1 && n <= len(items) {
|
||||
return items[n-1]
|
||||
}
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
continue
|
||||
}
|
||||
|
||||
if cfg.filter {
|
||||
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
|
||||
if len(nextVisible) == 0 {
|
||||
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
|
||||
continue
|
||||
}
|
||||
visible = nextVisible
|
||||
continue
|
||||
}
|
||||
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
fmt.Printf("Please enter a number between 1 and %d\n", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,126 +385,51 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
|||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
defaultN: -1,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
reader := newReader()
|
||||
visible := make([]int, len(items))
|
||||
for i := range items {
|
||||
visible[i] = i
|
||||
// Display options
|
||||
fmt.Println(prompt)
|
||||
for i, item := range items {
|
||||
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
|
||||
|
||||
if cfg.filter {
|
||||
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
|
||||
} else {
|
||||
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||
}
|
||||
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Empty response returns no selections.
|
||||
// Empty response returns no selections
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the selection.
|
||||
selected, err := parseMultiSelection(response, len(visible))
|
||||
// Parse the selection
|
||||
selected, err := parseMultiSelection(response, len(items))
|
||||
if err != nil {
|
||||
if cfg.filter && !looksLikeMultiSelectionInput(response) {
|
||||
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
|
||||
if len(nextVisible) == 0 {
|
||||
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
|
||||
continue
|
||||
}
|
||||
visible = nextVisible
|
||||
continue
|
||||
}
|
||||
promptWarning(fmt.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response))
|
||||
fmt.Printf("Invalid selection: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := make([]T, 0, len(selected))
|
||||
for _, idx := range selected {
|
||||
result = append(result, items[visible[idx]])
|
||||
result = append(result, items[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
|
||||
fmt.Fprintln(stderrWriter(), prompt)
|
||||
for i, idx := range visible {
|
||||
marker := " "
|
||||
if defaultN >= 0 && idx == defaultN {
|
||||
marker = "*"
|
||||
}
|
||||
fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
|
||||
}
|
||||
if filter {
|
||||
fmt.Fprintln(stderrWriter(), " (type to filter the list)")
|
||||
}
|
||||
}
|
||||
|
||||
func defaultVisibleIndex(visible []int, defaultN int) (int, bool) {
|
||||
if defaultN < 0 {
|
||||
return 0, false
|
||||
}
|
||||
for _, idx := range visible {
|
||||
if idx == defaultN {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func filterVisible[T any](items []T, visible []int, query string, displayFn func(T) string) []int {
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
if q == "" {
|
||||
return visible
|
||||
}
|
||||
|
||||
filtered := make([]int, 0, len(visible))
|
||||
for _, idx := range visible {
|
||||
if strings.Contains(strings.ToLower(displayFn(items[idx])), q) {
|
||||
filtered = append(filtered, idx)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func looksLikeMultiSelectionInput(input string) bool {
|
||||
hasDigit := false
|
||||
for _, r := range input {
|
||||
switch {
|
||||
case unicode.IsSpace(r), r == '-' || r == ',':
|
||||
continue
|
||||
case unicode.IsDigit(r):
|
||||
hasDigit = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit
|
||||
}
|
||||
|
||||
// parseMultiSelection parses a multi-selection string like "1 3 5", "1,3,5",
|
||||
// or "1-3 5".
|
||||
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
|
||||
// Returns 0-based indices.
|
||||
func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
||||
selected := make(map[int]bool)
|
||||
|
||||
normalized := strings.NewReplacer(",", " ").Replace(input)
|
||||
|
||||
for part := range strings.FieldsSeq(normalized) {
|
||||
for part := range strings.FieldsSeq(input) {
|
||||
// Check for range (e.g., "1-3")
|
||||
if strings.Contains(part, "-") {
|
||||
var rangeParts []string
|
||||
|
|
@ -582,17 +437,17 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
|||
rangeParts = append(rangeParts, p)
|
||||
}
|
||||
if len(rangeParts) != 2 {
|
||||
return nil, Err("invalid range: %s", part)
|
||||
return nil, fmt.Errorf("invalid range: %s", part)
|
||||
}
|
||||
var start, end int
|
||||
if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil {
|
||||
return nil, Err("invalid range start: %s", rangeParts[0])
|
||||
return nil, fmt.Errorf("invalid range start: %s", rangeParts[0])
|
||||
}
|
||||
if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil {
|
||||
return nil, Err("invalid range end: %s", rangeParts[1])
|
||||
return nil, fmt.Errorf("invalid range end: %s", rangeParts[1])
|
||||
}
|
||||
if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end {
|
||||
return nil, Err("range out of bounds: %s", part)
|
||||
return nil, fmt.Errorf("range out of bounds: %s", part)
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
selected[i-1] = true // Convert to 0-based
|
||||
|
|
@ -601,10 +456,10 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
|||
// Single number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
|
||||
return nil, Err("invalid number: %s", part)
|
||||
return nil, fmt.Errorf("invalid number: %s", part)
|
||||
}
|
||||
if n < 1 || n > maxItems {
|
||||
return nil, Err("number out of range: %d", n)
|
||||
return nil, fmt.Errorf("number out of range: %d", n)
|
||||
}
|
||||
selected[n-1] = true // Convert to 0-based
|
||||
}
|
||||
|
|
@ -631,19 +486,9 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt
|
|||
// GitClone clones a GitHub repository to the specified path.
|
||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||
func GitClone(ctx context.Context, org, repo, path string) error {
|
||||
return GitCloneRef(ctx, org, repo, path, "")
|
||||
}
|
||||
|
||||
// GitCloneRef clones a GitHub repository at a specific ref to the specified path.
|
||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||
func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
|
||||
if GhAuthenticated() {
|
||||
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
|
||||
args := []string{"repo", "clone", httpsURL, path}
|
||||
if ref != "" {
|
||||
args = append(args, "--", "--branch", ref, "--single-branch")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "gh", args...)
|
||||
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
|
|
@ -654,12 +499,7 @@ func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
|
|||
}
|
||||
}
|
||||
// Fall back to SSH clone
|
||||
args := []string{"clone"}
|
||||
if ref != "" {
|
||||
args = append(args, "--branch", ref, "--single-branch")
|
||||
}
|
||||
args = append(args, fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.New(strings.TrimSpace(string(output)))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue