* fix(docs): respect workspace.yaml packages_dir setting (fixes #46) * fix(workspace): improve config loading logic (CR feedback) - Expand ~ before resolving relative paths in cmd_registry - Handle LoadWorkspaceConfig errors properly - Update Repo.Path when PackagesDir overrides default - Validate workspace config version - Add unit tests for workspace config loading * docs: add comments and increase test coverage (CR feedback) - Add docstrings to exported functions in pkg/cli - Add unit tests for Semantic Output (pkg/cli/output.go) - Add unit tests for CheckBuilder (pkg/cli/check.go) - Add unit tests for IPC Query/Perform (pkg/framework/core) * fix(test): fix panics and failures in php package tests - Fix panic in TestLookupLinuxKit_Bad by mocking paths - Fix assertion errors in TestGetSSLDir_Bad and TestGetPackageInfo_Bad - Fix formatting in test files * fix(test): correct syntax in services_extended_test.go * fix(ci): point coverage workflow to go.mod instead of go.work * fix(ci): build CLI before running coverage * fix(ci): run go generate for updater package in coverage workflow * fix(github): allow dry-run publish without gh CLI authentication Moves validation check after dry-run check so tests can verify dry-run behavior in CI environments.
This commit is contained in:
parent
b02b57e6fb
commit
10277c6094
18 changed files with 442 additions and 46 deletions
8
.github/workflows/coverage.yml
vendored
8
.github/workflows/coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.work'
|
go-version-file: 'go.mod'
|
||||||
|
|
||||||
- name: Setup Task
|
- name: Setup Task
|
||||||
uses: arduino/setup-task@v1
|
uses: arduino/setup-task@v1
|
||||||
|
|
@ -25,6 +25,12 @@ jobs:
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||||
|
|
||||||
|
- name: Build CLI
|
||||||
|
run: |
|
||||||
|
go generate ./pkg/updater/...
|
||||||
|
task cli:build
|
||||||
|
echo "$(pwd)/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Run coverage
|
- name: Run coverage
|
||||||
run: task cov
|
run: task cov
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,17 +100,19 @@ func (s *AnsiStyle) Render(text string) string {
|
||||||
return strings.Join(codes, "") + text + ansiReset
|
return strings.Join(codes, "") + text + ansiReset
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hex color support
|
// fgColorHex converts a hex string to an ANSI foreground color code.
|
||||||
func fgColorHex(hex string) string {
|
func fgColorHex(hex string) string {
|
||||||
r, g, b := hexToRGB(hex)
|
r, g, b := hexToRGB(hex)
|
||||||
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bgColorHex converts a hex string to an ANSI background color code.
|
||||||
func bgColorHex(hex string) string {
|
func bgColorHex(hex string) string {
|
||||||
r, g, b := hexToRGB(hex)
|
r, g, b := hexToRGB(hex)
|
||||||
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hexToRGB converts a hex string to RGB values.
|
||||||
func hexToRGB(hex string) (int, int, int) {
|
func hexToRGB(hex string) (int, int, int) {
|
||||||
hex = strings.TrimPrefix(hex, "#")
|
hex = strings.TrimPrefix(hex, "#")
|
||||||
if len(hex) != 6 {
|
if len(hex) != 6 {
|
||||||
|
|
@ -120,4 +122,4 @@ func hexToRGB(hex string) (int, int, int) {
|
||||||
g, _ := strconv.ParseInt(hex[2:4], 16, 64)
|
g, _ := strconv.ParseInt(hex[2:4], 16, 64)
|
||||||
b, _ := strconv.ParseInt(hex[4:6], 16, 64)
|
b, _ := strconv.ParseInt(hex[4:6], 16, 64)
|
||||||
return int(r), int(g), int(b)
|
return int(r), int(g), int(b)
|
||||||
}
|
}
|
||||||
|
|
@ -88,4 +88,4 @@ func (c *CheckBuilder) String() string {
|
||||||
// Print outputs the check result.
|
// Print outputs the check result.
|
||||||
func (c *CheckBuilder) Print() {
|
func (c *CheckBuilder) Print() {
|
||||||
fmt.Println(c.String())
|
fmt.Println(c.String())
|
||||||
}
|
}
|
||||||
49
pkg/cli/check_test.go
Normal file
49
pkg/cli/check_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCheckBuilder(t *testing.T) {
|
||||||
|
UseASCII() // Deterministic output
|
||||||
|
|
||||||
|
// Pass
|
||||||
|
c := Check("foo").Pass()
|
||||||
|
got := c.String()
|
||||||
|
if got == "" {
|
||||||
|
t.Error("Empty output for Pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail
|
||||||
|
c = Check("foo").Fail()
|
||||||
|
got = c.String()
|
||||||
|
if got == "" {
|
||||||
|
t.Error("Empty output for Fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip
|
||||||
|
c = Check("foo").Skip()
|
||||||
|
got = c.String()
|
||||||
|
if got == "" {
|
||||||
|
t.Error("Empty output for Skip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn
|
||||||
|
c = Check("foo").Warn()
|
||||||
|
got = c.String()
|
||||||
|
if got == "" {
|
||||||
|
t.Error("Empty output for Warn")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
c = Check("foo").Pass().Duration("1s")
|
||||||
|
got = c.String()
|
||||||
|
if got == "" {
|
||||||
|
t.Error("Empty output for Duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message
|
||||||
|
c = Check("foo").Message("status")
|
||||||
|
got = c.String()
|
||||||
|
if got == "" {
|
||||||
|
t.Error("Empty output for Message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,16 +9,24 @@ import (
|
||||||
type GlyphTheme int
|
type GlyphTheme int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// ThemeUnicode uses standard Unicode symbols.
|
||||||
ThemeUnicode GlyphTheme = iota
|
ThemeUnicode GlyphTheme = iota
|
||||||
|
// ThemeEmoji uses Emoji symbols.
|
||||||
ThemeEmoji
|
ThemeEmoji
|
||||||
|
// ThemeASCII uses ASCII fallback symbols.
|
||||||
ThemeASCII
|
ThemeASCII
|
||||||
)
|
)
|
||||||
|
|
||||||
var currentTheme = ThemeUnicode
|
var currentTheme = ThemeUnicode
|
||||||
|
|
||||||
|
// UseUnicode switches the glyph theme to Unicode.
|
||||||
func UseUnicode() { currentTheme = ThemeUnicode }
|
func UseUnicode() { currentTheme = ThemeUnicode }
|
||||||
func UseEmoji() { currentTheme = ThemeEmoji }
|
|
||||||
func UseASCII() { currentTheme = ThemeASCII }
|
// UseEmoji switches the glyph theme to Emoji.
|
||||||
|
func UseEmoji() { currentTheme = ThemeEmoji }
|
||||||
|
|
||||||
|
// UseASCII switches the glyph theme to ASCII.
|
||||||
|
func UseASCII() { currentTheme = ThemeASCII }
|
||||||
|
|
||||||
func glyphMap() map[string]string {
|
func glyphMap() map[string]string {
|
||||||
switch currentTheme {
|
switch currentTheme {
|
||||||
|
|
@ -31,7 +39,7 @@ func glyphMap() map[string]string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Glyph converts a shortcode to its symbol.
|
// Glyph converts a shortcode (e.g. ":check:") to its symbol based on the current theme.
|
||||||
func Glyph(code string) string {
|
func Glyph(code string) string {
|
||||||
if sym, ok := glyphMap()[code]; ok {
|
if sym, ok := glyphMap()[code]; ok {
|
||||||
return sym
|
return sym
|
||||||
|
|
@ -78,4 +86,4 @@ func replaceGlyph(input *bytes.Buffer) string {
|
||||||
return Glyph(code.String())
|
return Glyph(code.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,10 +6,15 @@ import "fmt"
|
||||||
type Region rune
|
type Region rune
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// RegionHeader is the top region of the layout.
|
||||||
RegionHeader Region = 'H'
|
RegionHeader Region = 'H'
|
||||||
|
// RegionLeft is the left sidebar region.
|
||||||
RegionLeft Region = 'L'
|
RegionLeft Region = 'L'
|
||||||
|
// RegionContent is the main content region.
|
||||||
RegionContent Region = 'C'
|
RegionContent Region = 'C'
|
||||||
|
// RegionRight is the right sidebar region.
|
||||||
RegionRight Region = 'R'
|
RegionRight Region = 'R'
|
||||||
|
// RegionFooter is the bottom region of the layout.
|
||||||
RegionFooter Region = 'F'
|
RegionFooter Region = 'F'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,6 +42,7 @@ type Renderable interface {
|
||||||
// StringBlock is a simple string that implements Renderable.
|
// StringBlock is a simple string that implements Renderable.
|
||||||
type StringBlock string
|
type StringBlock string
|
||||||
|
|
||||||
|
// Render returns the string content.
|
||||||
func (s StringBlock) Render() string { return string(s) }
|
func (s StringBlock) Render() string { return string(s) }
|
||||||
|
|
||||||
// Layout creates a new layout from a variant string.
|
// Layout creates a new layout from a variant string.
|
||||||
|
|
@ -138,4 +144,4 @@ func toRenderable(item any) Renderable {
|
||||||
default:
|
default:
|
||||||
return StringBlock(fmt.Sprint(v))
|
return StringBlock(fmt.Sprint(v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
98
pkg/cli/output_test.go
Normal file
98
pkg/cli/output_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func captureOutput(f func()) string {
|
||||||
|
old := os.Stdout
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
f()
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stdout = old
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, r)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemanticOutput(t *testing.T) {
|
||||||
|
UseASCII()
|
||||||
|
|
||||||
|
// Test Success
|
||||||
|
out := captureOutput(func() {
|
||||||
|
Success("done")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Success output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Error
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Error("fail")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Error output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Warn
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Warn("warn")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Warn output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Info
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Info("info")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Info output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Task
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Task("task", "msg")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Task output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Section
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Section("section")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Section output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Hint
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Hint("hint", "msg")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Hint output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Result
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Result(true, "pass")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Result(true) output empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
out = captureOutput(func() {
|
||||||
|
Result(false, "fail")
|
||||||
|
})
|
||||||
|
if out == "" {
|
||||||
|
t.Error("Result(false) output empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,14 +25,14 @@ type RepoDocInfo struct {
|
||||||
func loadRegistry(registryPath string) (*repos.Registry, string, error) {
|
func loadRegistry(registryPath string) (*repos.Registry, string, error) {
|
||||||
var reg *repos.Registry
|
var reg *repos.Registry
|
||||||
var err error
|
var err error
|
||||||
var basePath string
|
var registryDir string
|
||||||
|
|
||||||
if registryPath != "" {
|
if registryPath != "" {
|
||||||
reg, err = repos.LoadRegistry(registryPath)
|
reg, err = repos.LoadRegistry(registryPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||||
}
|
}
|
||||||
basePath = filepath.Dir(registryPath)
|
registryDir = filepath.Dir(registryPath)
|
||||||
} else {
|
} else {
|
||||||
registryPath, err = repos.FindRegistry()
|
registryPath, err = repos.FindRegistry()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -40,14 +40,44 @@ func loadRegistry(registryPath string) (*repos.Registry, string, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||||
}
|
}
|
||||||
basePath = filepath.Dir(registryPath)
|
registryDir = filepath.Dir(registryPath)
|
||||||
} else {
|
} else {
|
||||||
cwd, _ := os.Getwd()
|
cwd, _ := os.Getwd()
|
||||||
reg, err = repos.ScanDirectory(cwd)
|
reg, err = repos.ScanDirectory(cwd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.scan", "directory"))
|
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.scan", "directory"))
|
||||||
}
|
}
|
||||||
basePath = cwd
|
registryDir = cwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load workspace config to respect packages_dir
|
||||||
|
wsConfig, err := repos.LoadWorkspaceConfig(registryDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "workspace config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
basePath := registryDir
|
||||||
|
|
||||||
|
if wsConfig.PackagesDir != "" {
|
||||||
|
pkgDir := wsConfig.PackagesDir
|
||||||
|
|
||||||
|
// Expand ~
|
||||||
|
if strings.HasPrefix(pkgDir, "~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
pkgDir = filepath.Join(home, pkgDir[2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(pkgDir) {
|
||||||
|
pkgDir = filepath.Join(registryDir, pkgDir)
|
||||||
|
}
|
||||||
|
basePath = pkgDir
|
||||||
|
|
||||||
|
// Update repo paths if they were relative to registry
|
||||||
|
// This ensures consistency when packages_dir overrides the default
|
||||||
|
reg.BasePath = basePath
|
||||||
|
for _, repo := range reg.Repos {
|
||||||
|
repo.Path = filepath.Join(basePath, repo.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
77
pkg/framework/core/ipc_test.go
Normal file
77
pkg/framework/core/ipc_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IPCTestQuery struct{ Value string }
|
||||||
|
type IPCTestTask struct{ Value string }
|
||||||
|
|
||||||
|
func TestIPC_Query(t *testing.T) {
|
||||||
|
c, _ := New()
|
||||||
|
|
||||||
|
// No handler
|
||||||
|
res, handled, err := c.QUERY(IPCTestQuery{})
|
||||||
|
assert.False(t, handled)
|
||||||
|
assert.Nil(t, res)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// With handler
|
||||||
|
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||||
|
if tq, ok := q.(IPCTestQuery); ok {
|
||||||
|
return tq.Value + "-response", true, nil
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
res, handled, err = c.QUERY(IPCTestQuery{Value: "test"})
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "test-response", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPC_QueryAll(t *testing.T) {
|
||||||
|
c, _ := New()
|
||||||
|
|
||||||
|
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||||
|
return "h1", true, nil
|
||||||
|
})
|
||||||
|
c.RegisterQuery(func(c *Core, q Query) (any, bool, error) {
|
||||||
|
return "h2", true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
results, err := c.QUERYALL(IPCTestQuery{})
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Len(t, results, 2)
|
||||||
|
assert.Contains(t, results, "h1")
|
||||||
|
assert.Contains(t, results, "h2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPC_Perform(t *testing.T) {
|
||||||
|
c, _ := New()
|
||||||
|
|
||||||
|
c.RegisterTask(func(c *Core, task Task) (any, bool, error) {
|
||||||
|
if tt, ok := task.(IPCTestTask); ok {
|
||||||
|
if tt.Value == "error" {
|
||||||
|
return nil, true, errors.New("task error")
|
||||||
|
}
|
||||||
|
return "done", true, nil
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Success
|
||||||
|
res, handled, err := c.PERFORM(IPCTestTask{Value: "run"})
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "done", res)
|
||||||
|
|
||||||
|
// Error
|
||||||
|
res, handled, err = c.PERFORM(IPCTestTask{Value: "error"})
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, res)
|
||||||
|
}
|
||||||
|
|
@ -349,6 +349,12 @@ func IsPHPProject(dir string) bool {
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commonLinuxKitPaths defines default search locations for linuxkit.
|
||||||
|
var commonLinuxKitPaths = []string{
|
||||||
|
"/usr/local/bin/linuxkit",
|
||||||
|
"/opt/homebrew/bin/linuxkit",
|
||||||
|
}
|
||||||
|
|
||||||
// lookupLinuxKit finds the linuxkit binary.
|
// lookupLinuxKit finds the linuxkit binary.
|
||||||
func lookupLinuxKit() (string, error) {
|
func lookupLinuxKit() (string, error) {
|
||||||
// Check PATH first
|
// Check PATH first
|
||||||
|
|
@ -356,13 +362,7 @@ func lookupLinuxKit() (string, error) {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check common locations
|
for _, p := range commonLinuxKitPaths {
|
||||||
paths := []string{
|
|
||||||
"/usr/local/bin/linuxkit",
|
|
||||||
"/opt/homebrew/bin/linuxkit",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range paths {
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
if _, err := os.Stat(p); err == nil {
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,16 +102,22 @@ func TestIsPHPProject_Container_Bad(t *testing.T) {
|
||||||
|
|
||||||
func TestLookupLinuxKit_Bad(t *testing.T) {
|
func TestLookupLinuxKit_Bad(t *testing.T) {
|
||||||
t.Run("returns error when linuxkit not found", func(t *testing.T) {
|
t.Run("returns error when linuxkit not found", func(t *testing.T) {
|
||||||
// Save original PATH and restore after test
|
// Save original PATH and paths
|
||||||
origPath := os.Getenv("PATH")
|
origPath := os.Getenv("PATH")
|
||||||
defer os.Setenv("PATH", origPath)
|
origCommonPaths := commonLinuxKitPaths
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("PATH", origPath)
|
||||||
|
commonLinuxKitPaths = origCommonPaths
|
||||||
|
}()
|
||||||
|
|
||||||
// Set PATH to empty to ensure linuxkit isn't found
|
// Set PATH to empty and clear common paths
|
||||||
os.Setenv("PATH", "")
|
os.Setenv("PATH", "")
|
||||||
|
commonLinuxKitPaths = []string{}
|
||||||
|
|
||||||
_, err := lookupLinuxKit()
|
_, err := lookupLinuxKit()
|
||||||
assert.Error(t, err)
|
if assert.Error(t, err) {
|
||||||
assert.Contains(t, err.Error(), "linuxkit not found")
|
assert.Contains(t, err.Error(), "linuxkit not found")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func TestReadComposerJSON_Bad(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := readComposerJSON(dir)
|
_, err := readComposerJSON(dir)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to read composer.json")
|
assert.Contains(t, err.Error(), "Failed to read composer.json")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("invalid JSON", func(t *testing.T) {
|
t.Run("invalid JSON", func(t *testing.T) {
|
||||||
|
|
@ -60,7 +60,7 @@ func TestReadComposerJSON_Bad(t *testing.T) {
|
||||||
|
|
||||||
_, err = readComposerJSON(dir)
|
_, err = readComposerJSON(dir)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to parse composer.json")
|
assert.Contains(t, err.Error(), "Failed to parse composer.json")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,10 +104,9 @@ func TestWriteComposerJSON_Bad(t *testing.T) {
|
||||||
|
|
||||||
err := writeComposerJSON("/non/existent/path", raw)
|
err := writeComposerJSON("/non/existent/path", raw)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to write composer.json")
|
assert.Contains(t, err.Error(), "Failed to write composer.json")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRepositories_Good(t *testing.T) {
|
func TestGetRepositories_Good(t *testing.T) {
|
||||||
t.Run("returns empty slice when no repositories", func(t *testing.T) {
|
t.Run("returns empty slice when no repositories", func(t *testing.T) {
|
||||||
raw := make(map[string]json.RawMessage)
|
raw := make(map[string]json.RawMessage)
|
||||||
|
|
@ -149,7 +148,7 @@ func TestGetRepositories_Bad(t *testing.T) {
|
||||||
|
|
||||||
_, err := getRepositories(raw)
|
_, err := getRepositories(raw)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to parse repositories")
|
assert.Contains(t, err.Error(), "Failed to parse repositories")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,17 +211,17 @@ func TestGetPackageInfo_Bad(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, _, err := getPackageInfo(dir)
|
_, _, err := getPackageInfo(dir)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to read package composer.json")
|
assert.Contains(t, err.Error(), "Failed to read package composer.json")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("invalid JSON", func(t *testing.T) {
|
t.Run("invalid JSON", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("invalid{"), 0644)
|
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, _, err = getPackageInfo(dir)
|
_, _, err = getPackageInfo(dir)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to parse package composer.json")
|
assert.Contains(t, err.Error(), "Failed to parse package composer.json")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing name", func(t *testing.T) {
|
t.Run("missing name", func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -99,9 +99,8 @@ func TestBaseService_Logs_Bad(t *testing.T) {
|
||||||
t.Run("returns error when log file doesn't exist", func(t *testing.T) {
|
t.Run("returns error when log file doesn't exist", func(t *testing.T) {
|
||||||
s := &baseService{logPath: "/nonexistent/path/log.log"}
|
s := &baseService{logPath: "/nonexistent/path/log.log"}
|
||||||
_, err := s.Logs(false)
|
_, err := s.Logs(false)
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to open log file")
|
assert.Contains(t, err.Error(), "Failed to open log file")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ func TestGetSSLDir_Bad(t *testing.T) {
|
||||||
opts := SSLOptions{Dir: "/dev/null/cannot/create"}
|
opts := SSLOptions{Dir: "/dev/null/cannot/create"}
|
||||||
_, err := GetSSLDir(opts)
|
_, err := GetSSLDir(opts)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to create SSL directory")
|
assert.Contains(t, err.Error(), "Failed to create SSL directory")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,6 @@ func (p *GitHubPublisher) Name() string {
|
||||||
// Publish publishes the release to GitHub.
|
// Publish publishes the release to GitHub.
|
||||||
// Uses the gh CLI for creating releases and uploading assets.
|
// Uses the gh CLI for creating releases and uploading assets.
|
||||||
func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error {
|
func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error {
|
||||||
// Validate gh CLI is available
|
|
||||||
if err := validateGhCli(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine repository
|
// Determine repository
|
||||||
repo := ""
|
repo := ""
|
||||||
if relCfg != nil {
|
if relCfg != nil {
|
||||||
|
|
@ -49,6 +44,11 @@ func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg
|
||||||
return p.dryRunPublish(release, pubCfg, repo)
|
return p.dryRunPublish(release, pubCfg, repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate gh CLI is available and authenticated for actual publish
|
||||||
|
if err := validateGhCli(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return p.executePublish(ctx, release, pubCfg, repo)
|
return p.executePublish(ctx, release, pubCfg, repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
47
pkg/repos/workspace.go
Normal file
47
pkg/repos/workspace.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkspaceConfig holds workspace-level configuration.
|
||||||
|
type WorkspaceConfig struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Active string `yaml:"active"`
|
||||||
|
PackagesDir string `yaml:"packages_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultWorkspaceConfig returns a config with default values.
|
||||||
|
func DefaultWorkspaceConfig() *WorkspaceConfig {
|
||||||
|
return &WorkspaceConfig{
|
||||||
|
Version: 1,
|
||||||
|
PackagesDir: "./packages",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWorkspaceConfig tries to load workspace.yaml from the given directory's .core subfolder.
|
||||||
|
func LoadWorkspaceConfig(dir string) (*WorkspaceConfig, error) {
|
||||||
|
path := filepath.Join(dir, ".core", "workspace.yaml")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return DefaultWorkspaceConfig(), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read workspace config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := DefaultWorkspaceConfig()
|
||||||
|
if err := yaml.Unmarshal(data, config); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Version != 1 {
|
||||||
|
return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
56
pkg/repos/workspace_test.go
Normal file
56
pkg/repos/workspace_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadWorkspaceConfig_Good(t *testing.T) {
|
||||||
|
// Setup temp dir
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
coreDir := filepath.Join(tmpDir, ".core")
|
||||||
|
err := os.MkdirAll(coreDir, 0755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Write valid config
|
||||||
|
configContent := `
|
||||||
|
version: 1
|
||||||
|
active: core-php
|
||||||
|
packages_dir: ./custom-packages
|
||||||
|
`
|
||||||
|
err = os.WriteFile(filepath.Join(coreDir, "workspace.yaml"), []byte(configContent), 0644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Load
|
||||||
|
cfg, err := LoadWorkspaceConfig(tmpDir)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, cfg.Version)
|
||||||
|
assert.Equal(t, "core-php", cfg.Active)
|
||||||
|
assert.Equal(t, "./custom-packages", cfg.PackagesDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadWorkspaceConfig_Default(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Load non-existent
|
||||||
|
cfg, err := LoadWorkspaceConfig(tmpDir)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, cfg.Version)
|
||||||
|
assert.Equal(t, "./packages", cfg.PackagesDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadWorkspaceConfig_BadVersion(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
coreDir := filepath.Join(tmpDir, ".core")
|
||||||
|
os.MkdirAll(coreDir, 0755)
|
||||||
|
|
||||||
|
configContent := `version: 2`
|
||||||
|
os.WriteFile(filepath.Join(coreDir, "workspace.yaml"), []byte(configContent), 0644)
|
||||||
|
|
||||||
|
_, err := LoadWorkspaceConfig(tmpDir)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unsupported workspace config version")
|
||||||
|
}
|
||||||
|
|
@ -33,21 +33,34 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("registry")), registryPath)
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("registry")), registryPath)
|
||||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.setup.org_label")), reg.Org)
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.setup.org_label")), reg.Org)
|
||||||
|
|
||||||
|
registryDir := filepath.Dir(registryPath)
|
||||||
|
|
||||||
// Determine base path for cloning
|
// Determine base path for cloning
|
||||||
basePath := reg.BasePath
|
basePath := reg.BasePath
|
||||||
if basePath == "" {
|
if basePath == "" {
|
||||||
basePath = "./packages"
|
// Load workspace config to see if packages_dir is set
|
||||||
}
|
wsConfig, err := repos.LoadWorkspaceConfig(registryDir)
|
||||||
// Resolve relative to registry location
|
if err != nil {
|
||||||
if !filepath.IsAbs(basePath) {
|
return fmt.Errorf("failed to load workspace config: %w", err)
|
||||||
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
|
}
|
||||||
|
if wsConfig.PackagesDir != "" {
|
||||||
|
basePath = wsConfig.PackagesDir
|
||||||
|
} else {
|
||||||
|
basePath = "./packages"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand ~
|
// Expand ~
|
||||||
if strings.HasPrefix(basePath, "~/") {
|
if strings.HasPrefix(basePath, "~/") {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
basePath = filepath.Join(home, basePath[2:])
|
basePath = filepath.Join(home, basePath[2:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve relative to registry location
|
||||||
|
if !filepath.IsAbs(basePath) {
|
||||||
|
basePath = filepath.Join(registryDir, basePath)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), basePath)
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), basePath)
|
||||||
|
|
||||||
// Parse type filter
|
// Parse type filter
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue