Compare commits
38 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d08d2eb1fc | ||
|
|
6e278a293a | ||
|
|
c50257fa49 | ||
|
|
93c8eef876 | ||
|
|
ae3935919e | ||
|
|
af9887217a | ||
|
|
c06fd2edfc | ||
|
|
cbf650918a | ||
|
|
6eef0ff234 | ||
|
|
04d8a17dc7 | ||
|
|
0179ddf4f2 | ||
|
|
29cbec8575 | ||
|
|
b5d32ade33 | ||
|
|
24fd01dc26 | ||
|
|
ba08cac5ef | ||
|
|
f3c5fe9a7b | ||
|
|
fa20cb8aa5 | ||
|
|
a4d8aba714 | ||
|
|
b7d70883e9 | ||
|
|
129199a5e0 | ||
| 33968f32bc | |||
|
|
ecb50796b7 | ||
|
|
e4216a12b0 | ||
|
|
339ad743be | ||
| 1d7652cb05 | |||
|
|
c2adc7d9dc | ||
|
|
0f50f98a95 | ||
|
|
b429736097 | ||
|
|
b9d9994a36 | ||
|
|
764f290b34 | ||
|
|
2ef3e48b11 | ||
|
|
4c5e12c9f8 | ||
|
|
cea8624497 | ||
|
|
c364b3083c | ||
|
|
7aa8c7f944 | ||
|
|
34b0c4b5dd | ||
|
|
a2b74a642b | ||
|
|
e0fceb0e2e |
68 changed files with 2927 additions and 837 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
.core/
|
||||||
|
|
|
||||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -13,7 +13,7 @@ go vet ./... # Static analysis
|
||||||
|
|
||||||
## Workspace Context
|
## Workspace Context
|
||||||
|
|
||||||
This module (`forge.lthn.ai/core/go-devops`) is part of a 57-module Go workspace rooted at `/Users/snider/Code/go.work`. The parent framework module `forge.lthn.ai/core/go` (at `../go`) provides core libraries: `core.E` errors, `io.Medium` filesystem abstraction, config, i18n, and logging.
|
This module (`dappco.re/go/core/devops`) is part of a 57-module Go workspace rooted at `/Users/snider/Code/go.work`. The parent framework module `forge.lthn.ai/core/go` (at `../go`) provides core libraries: `core.E` errors, `io.Medium` filesystem abstraction, config, i18n, and logging.
|
||||||
|
|
||||||
Most implementation code (ansible engine, build system, infra clients, release pipeline, devkit, SDK generators) lives in the parent framework. This repo contains CLI commands that wire those packages together, plus deployment integrations and infrastructure playbooks.
|
Most implementation code (ansible engine, build system, infra clients, release pipeline, devkit, SDK generators) lives in the parent framework. This repo contains CLI commands that wire those packages together, plus deployment integrations and infrastructure playbooks.
|
||||||
|
|
||||||
|
|
@ -21,15 +21,16 @@ Most implementation code (ansible engine, build system, infra clients, release p
|
||||||
|
|
||||||
### Package Layout
|
### Package Layout
|
||||||
|
|
||||||
- **`cmd/dev/`** — Multi-repo developer commands registered under `core dev`. The main CLI surface (~4,400 LOC across 21 files).
|
- **`cmd/dev/`** — Multi-repo developer commands registered under `core dev`. The main CLI surface (~4,700 LOC across 21 files).
|
||||||
- **`cmd/deploy/`** — `core deploy servers` — Coolify PaaS server/app listing.
|
- **`cmd/deploy/`** — `core deploy servers` — Coolify PaaS server/app listing.
|
||||||
- **`cmd/docs/`** — `core docs sync` — Documentation sync across the multi-repo workspace.
|
- **`cmd/docs/`** — `core docs sync` — Documentation sync across the multi-repo workspace.
|
||||||
- **`cmd/setup/`** — `core setup repo` — Generate `.core` configuration for a project.
|
- **`cmd/setup/`** — `core setup repo` — Generate `.core` configuration for a project.
|
||||||
- **`cmd/gitcmd/`** — Git helper commands.
|
- **`cmd/gitcmd/`** — Git helper commands (mirrors dev commands under `core git`).
|
||||||
- **`cmd/vanity-import/`** — Vanity import path server (the default build target in `.core/build.yaml`).
|
- **`cmd/vanity-import/`** — Vanity import path server (the default build target in `.core/build.yaml`).
|
||||||
- **`cmd/community/`** — Community-related commands.
|
- **`cmd/community/`** — Community landing page assets.
|
||||||
- **`deploy/coolify/`** — Coolify PaaS API HTTP client.
|
- **`deploy/coolify/`** — Coolify PaaS API HTTP client.
|
||||||
- **`deploy/python/`** — Embedded Python 3.13 runtime wrapper (adds ~50 MB to binary).
|
- **`deploy/python/`** — Embedded Python 3.13 runtime wrapper (adds ~50 MB to binary).
|
||||||
|
- **`locales/`** — Embedded i18n translation files (en.json).
|
||||||
- **`snapshot/`** — `core.json` release manifest generation.
|
- **`snapshot/`** — `core.json` release manifest generation.
|
||||||
- **`playbooks/`** — Ansible YAML playbooks for production infrastructure (Galera, Redis). Executed by the native Go Ansible engine, not `ansible-playbook`.
|
- **`playbooks/`** — Ansible YAML playbooks for production infrastructure (Galera, Redis). Executed by the native Go Ansible engine, not `ansible-playbook`.
|
||||||
|
|
||||||
|
|
@ -81,11 +82,11 @@ Configuration lives in `.core/build.yaml` (targets, ldflags) and `.core/release.
|
||||||
- **Co-Author**: `Co-Authored-By: Virgil <virgil@lethean.io>`
|
- **Co-Author**: `Co-Authored-By: Virgil <virgil@lethean.io>`
|
||||||
- **Licence**: EUPL-1.2
|
- **Licence**: EUPL-1.2
|
||||||
- **Imports**: stdlib → forge.lthn.ai → third-party, each group separated by blank line
|
- **Imports**: stdlib → forge.lthn.ai → third-party, each group separated by blank line
|
||||||
- **Errors**: `core.E()` for contextual errors, or `fmt.Errorf("%w", err)` for wrapping
|
- **Errors**: `log.E(op, msg, err)` from `go-log` for all contextual errors (never `fmt.Errorf` or `errors.New`)
|
||||||
|
|
||||||
## Forge
|
## Forge
|
||||||
|
|
||||||
- **Repo**: `forge.lthn.ai/core/go-devops`
|
- **Repo**: `dappco.re/go/core/devops` (hosted at `forge.lthn.ai/core/go-devops`)
|
||||||
- **Push via SSH**: `git push forge main` (remote: `ssh://git@forge.lthn.ai:2223/core/go-devops.git`)
|
- **Push via SSH**: `git push forge main` (remote: `ssh://git@forge.lthn.ai:2223/core/go-devops.git`)
|
||||||
- **Issues/PRs**: Managed via Forgejo SDK (`code.gitea.io/sdk/gitea`), not GitHub
|
- **Issues/PRs**: Managed via Forgejo SDK (`code.gitea.io/sdk/gitea`), not GitHub
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
|
||||||
|
_ "dappco.re/go/core/devops/locales"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -10,5 +12,6 @@ func init() {
|
||||||
|
|
||||||
// AddDeployCommands registers the 'deploy' command and all subcommands.
|
// AddDeployCommands registers the 'deploy' command and all subcommands.
|
||||||
func AddDeployCommands(root *cli.Command) {
|
func AddDeployCommands(root *cli.Command) {
|
||||||
|
setDeployI18n()
|
||||||
root.AddCommand(Cmd)
|
root.AddCommand(Cmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-devops/deploy/coolify"
|
"dappco.re/go/core/devops/deploy/coolify"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -19,9 +20,12 @@ var (
|
||||||
|
|
||||||
// Cmd is the root deploy command.
|
// Cmd is the root deploy command.
|
||||||
var Cmd = &cli.Command{
|
var Cmd = &cli.Command{
|
||||||
Use: "deploy",
|
Use: "deploy",
|
||||||
Short: i18n.T("cmd.deploy.short"),
|
}
|
||||||
Long: i18n.T("cmd.deploy.long"),
|
|
||||||
|
func setDeployI18n() {
|
||||||
|
Cmd.Short = i18n.T("cmd.deploy.short")
|
||||||
|
Cmd.Long = i18n.T("cmd.deploy.long")
|
||||||
}
|
}
|
||||||
|
|
||||||
var serversCmd = &cli.Command{
|
var serversCmd = &cli.Command{
|
||||||
|
|
@ -266,7 +270,7 @@ func runCall(cmd *cli.Command, args []string) error {
|
||||||
var params map[string]any
|
var params map[string]any
|
||||||
if len(args) > 1 {
|
if len(args) > 1 {
|
||||||
if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil {
|
if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil {
|
||||||
return fmt.Errorf("invalid JSON params: %w", err)
|
return log.E("deploy", "invalid JSON params", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// addAPICommands adds the 'api' command and its subcommands to the given parent command.
|
// addAPICommands adds the 'api' command and its subcommands to the given parent command.
|
||||||
|
|
@ -17,6 +17,6 @@ func addAPICommands(parent *cli.Command) {
|
||||||
// Add the 'sync' command to 'api'
|
// Add the 'sync' command to 'api'
|
||||||
addSyncCommand(apiCmd)
|
addSyncCommand(apiCmd)
|
||||||
|
|
||||||
// TODO: Add the 'test-gen' command to 'api'
|
// Add the 'test-gen' command to 'api'
|
||||||
// addTestGenCommand(apiCmd)
|
addTestGenCommand(apiCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
cmd/dev/cmd_api_testgen.go
Normal file
112
cmd/dev/cmd_api_testgen.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addTestGenCommand(parent *cli.Command) {
|
||||||
|
testGenCmd := &cli.Command{
|
||||||
|
Use: "test-gen",
|
||||||
|
Short: i18n.T("cmd.dev.api.test_gen.short"),
|
||||||
|
Long: i18n.T("cmd.dev.api.test_gen.long"),
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
if err := runTestGen(); err != nil {
|
||||||
|
return cli.Wrap(err, i18n.Label("error"))
|
||||||
|
}
|
||||||
|
cli.Text(i18n.T("i18n.done.sync", "public API tests"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.AddCommand(testGenCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTestGen() error {
|
||||||
|
pkgDir := "pkg"
|
||||||
|
internalDirs, err := coreio.Local.List(pkgDir)
|
||||||
|
if err != nil {
|
||||||
|
return cli.Wrap(err, "failed to read pkg directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range internalDirs {
|
||||||
|
if !dir.IsDir() || dir.Name() == "core" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceName := dir.Name()
|
||||||
|
internalDir := filepath.Join(pkgDir, serviceName)
|
||||||
|
publicDir := serviceName
|
||||||
|
publicTestFile := filepath.Join(publicDir, serviceName+"_test.go")
|
||||||
|
|
||||||
|
if !coreio.Local.Exists(internalDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols, err := getExportedSymbols(internalDir)
|
||||||
|
if err != nil {
|
||||||
|
return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(symbols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := generatePublicAPITestFile(publicDir, publicTestFile, serviceName, symbols); err != nil {
|
||||||
|
return cli.Wrap(err, cli.Sprintf("error generating public API test file for service '%s'", serviceName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAPITestTemplate = `// Code generated by "core dev api test-gen"; DO NOT EDIT.
|
||||||
|
package {{.ServiceName}}
|
||||||
|
|
||||||
|
import (
|
||||||
|
impl "forge.lthn.ai/core/cli/{{.ServiceName}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{{range .Symbols}}
|
||||||
|
{{- if eq .Kind "type"}}
|
||||||
|
type _ = impl.{{.Name}}
|
||||||
|
{{- else if eq .Kind "const"}}
|
||||||
|
const _ = impl.{{.Name}}
|
||||||
|
{{- else if eq .Kind "var"}}
|
||||||
|
var _ = impl.{{.Name}}
|
||||||
|
{{- else if eq .Kind "func"}}
|
||||||
|
var _ = impl.{{.Name}}
|
||||||
|
{{- end}}
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
func generatePublicAPITestFile(dir, path, serviceName string, symbols []symbolInfo) error {
|
||||||
|
if err := coreio.Local.EnsureDir(dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("publicAPITest").Parse(publicAPITestTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
ServiceName string
|
||||||
|
Symbols []symbolInfo
|
||||||
|
}{
|
||||||
|
ServiceName: serviceName,
|
||||||
|
Symbols: symbols,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return coreio.Local.Write(path, buf.String())
|
||||||
|
}
|
||||||
115
cmd/dev/cmd_api_testgen_test.go
Normal file
115
cmd/dev/cmd_api_testgen_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"dappco.re/go/core/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunTestGen_Good(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
originalWD, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chdir(originalWD)
|
||||||
|
})
|
||||||
|
require.NoError(t, os.Chdir(tmpDir))
|
||||||
|
|
||||||
|
serviceDir := filepath.Join(tmpDir, "pkg", "demo")
|
||||||
|
require.NoError(t, io.Local.EnsureDir(serviceDir))
|
||||||
|
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo.go"), `package demo
|
||||||
|
|
||||||
|
type Example struct{}
|
||||||
|
|
||||||
|
const Answer = 42
|
||||||
|
|
||||||
|
var Value = Example{}
|
||||||
|
|
||||||
|
func Run() {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "extra.go"), `package demo
|
||||||
|
|
||||||
|
type Another struct{}
|
||||||
|
|
||||||
|
func Extra() {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo_test.go"), `package demo
|
||||||
|
|
||||||
|
func Ignored() {}
|
||||||
|
`))
|
||||||
|
|
||||||
|
require.NoError(t, runTestGen())
|
||||||
|
|
||||||
|
generatedPath := filepath.Join(tmpDir, "demo", "demo_test.go")
|
||||||
|
content, err := io.Local.Read(generatedPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Contains(t, content, `// Code generated by "core dev api test-gen"; DO NOT EDIT.`)
|
||||||
|
require.Contains(t, content, `package demo`)
|
||||||
|
require.Contains(t, content, `impl "forge.lthn.ai/core/cli/demo"`)
|
||||||
|
require.Contains(t, content, `type _ = impl.Example`)
|
||||||
|
require.Contains(t, content, `type _ = impl.Another`)
|
||||||
|
require.Contains(t, content, `const _ = impl.Answer`)
|
||||||
|
require.Contains(t, content, `var _ = impl.Value`)
|
||||||
|
require.Contains(t, content, `var _ = impl.Run`)
|
||||||
|
require.Contains(t, content, `var _ = impl.Extra`)
|
||||||
|
require.NotContains(t, content, `Ignored`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePublicAPITestFile_Good(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, generatePublicAPITestFile(
|
||||||
|
filepath.Join(tmpDir, "demo"),
|
||||||
|
filepath.Join(tmpDir, "demo", "demo_test.go"),
|
||||||
|
"demo",
|
||||||
|
[]symbolInfo{
|
||||||
|
{Name: "Example", Kind: "type"},
|
||||||
|
{Name: "Answer", Kind: "const"},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
content, err := io.Local.Read(filepath.Join(tmpDir, "demo", "demo_test.go"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.True(t, strings.Contains(content, `type _ = impl.Example`))
|
||||||
|
require.True(t, strings.Contains(content, `const _ = impl.Answer`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetExportedSymbols_Good_MultiFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
serviceDir := filepath.Join(tmpDir, "demo")
|
||||||
|
require.NoError(t, io.Local.EnsureDir(serviceDir))
|
||||||
|
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo.go"), `package demo
|
||||||
|
|
||||||
|
type Example struct{}
|
||||||
|
|
||||||
|
const Answer = 42
|
||||||
|
`))
|
||||||
|
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "extra.go"), `package demo
|
||||||
|
|
||||||
|
var Value = Example{}
|
||||||
|
|
||||||
|
func Run() {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo_test.go"), `package demo
|
||||||
|
|
||||||
|
type Ignored struct{}
|
||||||
|
`))
|
||||||
|
|
||||||
|
symbols, err := getExportedSymbols(serviceDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []symbolInfo{
|
||||||
|
{Name: "Answer", Kind: "const"},
|
||||||
|
{Name: "Example", Kind: "type"},
|
||||||
|
{Name: "Run", Kind: "func"},
|
||||||
|
{Name: "Value", Kind: "var"},
|
||||||
|
}, symbols)
|
||||||
|
}
|
||||||
|
|
@ -12,14 +12,14 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"sort"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/io"
|
||||||
|
core "dappco.re/go/core/log"
|
||||||
|
"dappco.re/go/core/scm/git"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
core "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
"forge.lthn.ai/core/go-io"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Apply command flags
|
// Apply command flags
|
||||||
|
|
@ -235,29 +235,39 @@ func getApplyTargetRepos() ([]*repos.Repo, error) {
|
||||||
return nil, core.E("dev.apply", "failed to load registry", err)
|
return nil, core.E("dev.apply", "failed to load registry", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If --repos specified, filter to those
|
return filterTargetRepos(registry, applyRepos), nil
|
||||||
if applyRepos != "" {
|
}
|
||||||
repoNames := strings.Split(applyRepos, ",")
|
|
||||||
nameSet := make(map[string]bool)
|
|
||||||
for _, name := range repoNames {
|
|
||||||
nameSet[strings.TrimSpace(name)] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var matched []*repos.Repo
|
// filterTargetRepos selects repos by exact name/path or glob pattern.
|
||||||
for _, repo := range registry.Repos {
|
func filterTargetRepos(registry *repos.Registry, selection string) []*repos.Repo {
|
||||||
if nameSet[repo.Name] {
|
repoNames := make([]string, 0, len(registry.Repos))
|
||||||
|
for name := range registry.Repos {
|
||||||
|
repoNames = append(repoNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(repoNames)
|
||||||
|
|
||||||
|
if selection == "" {
|
||||||
|
matched := make([]*repos.Repo, 0, len(repoNames))
|
||||||
|
for _, name := range repoNames {
|
||||||
|
matched = append(matched, registry.Repos[name])
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := splitPatterns(selection)
|
||||||
|
var matched []*repos.Repo
|
||||||
|
|
||||||
|
for _, name := range repoNames {
|
||||||
|
repo := registry.Repos[name]
|
||||||
|
for _, candidate := range patterns {
|
||||||
|
if matchGlob(repo.Name, candidate) || matchGlob(repo.Path, candidate) {
|
||||||
matched = append(matched, repo)
|
matched = append(matched, repo)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return matched, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return all repos as slice
|
return matched
|
||||||
var all []*repos.Repo
|
|
||||||
for _, repo := range registry.Repos {
|
|
||||||
all = append(all, repo)
|
|
||||||
}
|
|
||||||
return all, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCommandInRepo runs a shell command in a repo directory
|
// runCommandInRepo runs a shell command in a repo directory
|
||||||
|
|
|
||||||
39
cmd/dev/cmd_apply_test.go
Normal file
39
cmd/dev/cmd_apply_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterTargetRepos_Good(t *testing.T) {
|
||||||
|
registry := &repos.Registry{
|
||||||
|
Repos: map[string]*repos.Repo{
|
||||||
|
"core-api": &repos.Repo{Name: "core-api", Path: "packages/core-api"},
|
||||||
|
"core-web": &repos.Repo{Name: "core-web", Path: "packages/core-web"},
|
||||||
|
"docs-site": &repos.Repo{Name: "docs-site", Path: "sites/docs"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("exact names", func(t *testing.T) {
|
||||||
|
matched := filterTargetRepos(registry, "core-api,docs-site")
|
||||||
|
require.Len(t, matched, 2)
|
||||||
|
require.Equal(t, "core-api", matched[0].Name)
|
||||||
|
require.Equal(t, "docs-site", matched[1].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("glob patterns", func(t *testing.T) {
|
||||||
|
matched := filterTargetRepos(registry, "core-*,sites/*")
|
||||||
|
require.Len(t, matched, 3)
|
||||||
|
require.Equal(t, "core-api", matched[0].Name)
|
||||||
|
require.Equal(t, "core-web", matched[1].Name)
|
||||||
|
require.Equal(t, "docs-site", matched[2].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all repos when empty", func(t *testing.T) {
|
||||||
|
matched := filterTargetRepos(registry, "")
|
||||||
|
require.Len(t, matched, 3)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,9 @@ package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
|
"dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WorkBundle contains the Core instance for dev work operations.
|
// WorkBundle contains the Core instance for dev work operations.
|
||||||
|
|
@ -16,71 +15,52 @@ type WorkBundle struct {
|
||||||
// WorkBundleOptions configures the work bundle.
|
// WorkBundleOptions configures the work bundle.
|
||||||
type WorkBundleOptions struct {
|
type WorkBundleOptions struct {
|
||||||
RegistryPath string
|
RegistryPath string
|
||||||
AllowEdit bool // Allow agentic to use Write/Edit tools
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorkBundle creates a bundle for dev work operations.
|
// NewWorkBundle creates a bundle for dev work operations.
|
||||||
// Includes: dev (orchestration), git, agentic services.
|
// Includes: dev (orchestration) service.
|
||||||
func NewWorkBundle(opts WorkBundleOptions) (*WorkBundle, error) {
|
func NewWorkBundle(opts WorkBundleOptions) (*WorkBundle, error) {
|
||||||
c, err := core.New(
|
c := core.New()
|
||||||
core.WithService(NewService(ServiceOptions{
|
|
||||||
|
svc := &Service{
|
||||||
|
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{
|
||||||
RegistryPath: opts.RegistryPath,
|
RegistryPath: opts.RegistryPath,
|
||||||
})),
|
}),
|
||||||
core.WithService(git.NewService(git.ServiceOptions{})),
|
|
||||||
core.WithService(agentic.NewService(agentic.ServiceOptions{
|
|
||||||
AllowEdit: opts.AllowEdit,
|
|
||||||
})),
|
|
||||||
core.WithServiceLock(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Service("dev", core.Service{
|
||||||
|
OnStart: func() core.Result {
|
||||||
|
c.RegisterTask(svc.handleTask)
|
||||||
|
return core.Result{OK: true}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
c.LockEnable()
|
||||||
|
c.LockApply()
|
||||||
|
|
||||||
return &WorkBundle{Core: c}, nil
|
return &WorkBundle{Core: c}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initialises the bundle services.
|
// Start initialises the bundle services.
|
||||||
func (b *WorkBundle) Start(ctx context.Context) error {
|
func (b *WorkBundle) Start(ctx context.Context) error {
|
||||||
return b.Core.ServiceStartup(ctx, nil)
|
return resultError(b.Core.ServiceStartup(ctx, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop shuts down the bundle services.
|
// Stop shuts down the bundle services.
|
||||||
func (b *WorkBundle) Stop(ctx context.Context) error {
|
func (b *WorkBundle) Stop(ctx context.Context) error {
|
||||||
return b.Core.ServiceShutdown(ctx)
|
return resultError(b.Core.ServiceShutdown(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusBundle contains the Core instance for status-only operations.
|
// resultError extracts an error from a failed core.Result, returning nil on success.
|
||||||
type StatusBundle struct {
|
func resultError(r core.Result) error {
|
||||||
Core *core.Core
|
if !r.OK {
|
||||||
}
|
if err, ok := r.Value.(error); ok {
|
||||||
|
return err
|
||||||
// StatusBundleOptions configures the status bundle.
|
}
|
||||||
type StatusBundleOptions struct {
|
if r.Value != nil {
|
||||||
RegistryPath string
|
return fmt.Errorf("service operation failed: %v", r.Value)
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("service operation failed")
|
||||||
// NewStatusBundle creates a bundle for status-only operations.
|
|
||||||
// Includes: dev (orchestration), git services. No agentic - commits not available.
|
|
||||||
func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) {
|
|
||||||
c, err := core.New(
|
|
||||||
core.WithService(NewService(ServiceOptions(opts))),
|
|
||||||
core.WithService(git.NewService(git.ServiceOptions{})),
|
|
||||||
// No agentic service - TaskCommit will be unhandled
|
|
||||||
core.WithServiceLock(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return &StatusBundle{Core: c}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start initialises the bundle services.
|
|
||||||
func (b *StatusBundle) Start(ctx context.Context) error {
|
|
||||||
return b.Core.ServiceStartup(ctx, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop shuts down the bundle services.
|
|
||||||
func (b *StatusBundle) Stop(ctx context.Context) error {
|
|
||||||
return b.Core.ServiceShutdown(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CI-specific styles (aliases to shared)
|
// CI-specific styles (aliases to shared)
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
"dappco.re/go/core/scm/git"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
coreio "dappco.re/go/core/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Commit command flags
|
// Commit command flags
|
||||||
|
|
@ -117,7 +117,7 @@ func runCommit(registryPath string, all bool) error {
|
||||||
for _, s := range dirtyRepos {
|
for _, s := range dirtyRepos {
|
||||||
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.committing")), s.Name)
|
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.committing")), s.Name)
|
||||||
|
|
||||||
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
|
if err := doCommit(ctx, s.Path, false); err != nil {
|
||||||
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
|
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -192,7 +192,7 @@ func runCommitSingleRepo(ctx context.Context, repoPath string, all bool) error {
|
||||||
cli.Blank()
|
cli.Blank()
|
||||||
|
|
||||||
// Commit
|
// Commit
|
||||||
if err := claudeCommit(ctx, repoPath, repoName, ""); err != nil {
|
if err := doCommit(ctx, repoPath, false); err != nil {
|
||||||
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
|
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
//
|
//
|
||||||
// API Tools:
|
// API Tools:
|
||||||
// - api sync: Synchronize public service APIs
|
// - api sync: Synchronize public service APIs
|
||||||
|
// - api test-gen: Generate compile-time API test stubs
|
||||||
//
|
//
|
||||||
// Dev Environment (VM management):
|
// Dev Environment (VM management):
|
||||||
// - install: Download dev environment image
|
// - install: Download dev environment image
|
||||||
|
|
@ -33,8 +34,10 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
|
_ "dappco.re/go/core/devops/locales"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -74,6 +77,7 @@ func AddDevCommands(root *cli.Command) {
|
||||||
AddCommitCommand(devCmd)
|
AddCommitCommand(devCmd)
|
||||||
AddPushCommand(devCmd)
|
AddPushCommand(devCmd)
|
||||||
AddPullCommand(devCmd)
|
AddPullCommand(devCmd)
|
||||||
|
AddTagCommand(devCmd)
|
||||||
|
|
||||||
// Safe git operations for AI agents (also available under 'core git')
|
// Safe git operations for AI agents (also available under 'core git')
|
||||||
AddFileSyncCommand(devCmd)
|
AddFileSyncCommand(devCmd)
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,12 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
"dappco.re/go/core/log"
|
||||||
|
"dappco.re/go/core/scm/git"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
"forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// File sync command flags
|
// File sync command flags
|
||||||
|
|
@ -29,6 +29,7 @@ var (
|
||||||
fileSyncCoAuthor string
|
fileSyncCoAuthor string
|
||||||
fileSyncDryRun bool
|
fileSyncDryRun bool
|
||||||
fileSyncPush bool
|
fileSyncPush bool
|
||||||
|
fileSyncYes bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddFileSyncCommand adds the 'sync' command to dev for file syncing.
|
// AddFileSyncCommand adds the 'sync' command to dev for file syncing.
|
||||||
|
|
@ -48,6 +49,7 @@ func AddFileSyncCommand(parent *cli.Command) {
|
||||||
syncCmd.Flags().StringVar(&fileSyncCoAuthor, "co-author", "", i18n.T("cmd.dev.file_sync.flag.co_author"))
|
syncCmd.Flags().StringVar(&fileSyncCoAuthor, "co-author", "", i18n.T("cmd.dev.file_sync.flag.co_author"))
|
||||||
syncCmd.Flags().BoolVar(&fileSyncDryRun, "dry-run", false, i18n.T("cmd.dev.file_sync.flag.dry_run"))
|
syncCmd.Flags().BoolVar(&fileSyncDryRun, "dry-run", false, i18n.T("cmd.dev.file_sync.flag.dry_run"))
|
||||||
syncCmd.Flags().BoolVar(&fileSyncPush, "push", false, i18n.T("cmd.dev.file_sync.flag.push"))
|
syncCmd.Flags().BoolVar(&fileSyncPush, "push", false, i18n.T("cmd.dev.file_sync.flag.push"))
|
||||||
|
syncCmd.Flags().BoolVarP(&fileSyncYes, "yes", "y", false, i18n.T("cmd.dev.file_sync.flag.yes"))
|
||||||
|
|
||||||
_ = syncCmd.MarkFlagRequired("to")
|
_ = syncCmd.MarkFlagRequired("to")
|
||||||
|
|
||||||
|
|
@ -64,23 +66,6 @@ func runFileSync(source string) error {
|
||||||
|
|
||||||
// Validate source exists
|
// Validate source exists
|
||||||
sourceInfo, err := os.Stat(source) // Keep os.Stat for local source check or use coreio? coreio.Local.IsFile is bool.
|
sourceInfo, err := os.Stat(source) // Keep os.Stat for local source check or use coreio? coreio.Local.IsFile is bool.
|
||||||
// If source is local file on disk (not in medium), we can use os.Stat.
|
|
||||||
// But concept is everything is via Medium?
|
|
||||||
// User is running CLI on host. `source` is relative to CWD.
|
|
||||||
// coreio.Local uses absolute path or relative to root (which is "/" by default).
|
|
||||||
// So coreio.Local works.
|
|
||||||
if !coreio.Local.IsFile(source) {
|
|
||||||
// Might be directory
|
|
||||||
// IsFile returns false for directory.
|
|
||||||
}
|
|
||||||
// Let's rely on os.Stat for initial source check to distinguish dir vs file easily if coreio doesn't expose Stat.
|
|
||||||
// coreio doesn't expose Stat.
|
|
||||||
|
|
||||||
// Check using standard os for source determination as we are outside strict sandbox for input args potentially?
|
|
||||||
// But we should use coreio where possible.
|
|
||||||
// coreio.Local.List worked for dirs.
|
|
||||||
// Let's stick to os.Stat for source properties finding as typically allowed for CLI args.
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return log.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]any{"Path": source}), err)
|
return log.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]any{"Path": source}), err)
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +88,16 @@ func runFileSync(source string) error {
|
||||||
}
|
}
|
||||||
cli.Blank()
|
cli.Blank()
|
||||||
|
|
||||||
|
if !fileSyncDryRun && !fileSyncYes {
|
||||||
|
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.file_sync.warning")))
|
||||||
|
cli.Blank()
|
||||||
|
if !cli.Confirm(i18n.T("cmd.dev.file_sync.confirm")) {
|
||||||
|
cli.Text(i18n.T("cli.aborted"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cli.Blank()
|
||||||
|
}
|
||||||
|
|
||||||
var succeeded, skipped, failed int
|
var succeeded, skipped, failed int
|
||||||
|
|
||||||
for _, repo := range targetRepos {
|
for _, repo := range targetRepos {
|
||||||
|
|
@ -219,22 +214,48 @@ func resolveTargetRepos(pattern string) ([]*repos.Repo, error) {
|
||||||
|
|
||||||
// Match pattern against repo names
|
// Match pattern against repo names
|
||||||
var matched []*repos.Repo
|
var matched []*repos.Repo
|
||||||
|
patterns := splitPatterns(pattern)
|
||||||
for _, repo := range registry.Repos {
|
for _, repo := range registry.Repos {
|
||||||
if matchGlob(repo.Name, pattern) || matchGlob(repo.Path, pattern) {
|
for _, candidate := range patterns {
|
||||||
matched = append(matched, repo)
|
if matchGlob(repo.Name, candidate) || matchGlob(repo.Path, candidate) {
|
||||||
|
matched = append(matched, repo)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return matched, nil
|
return matched, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitPatterns normalises comma-separated glob patterns.
|
||||||
|
func splitPatterns(pattern string) []string {
|
||||||
|
raw := strings.Split(pattern, ",")
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
|
||||||
|
for _, p := range raw {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// matchGlob performs simple glob matching with * wildcards
|
// matchGlob performs simple glob matching with * wildcards
|
||||||
func matchGlob(s, pattern string) bool {
|
func matchGlob(s, pattern string) bool {
|
||||||
// Handle exact match
|
// Handle exact match and simple glob patterns.
|
||||||
if s == pattern {
|
if s == pattern {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
matched, err := filepath.Match(pattern, s)
|
||||||
|
if err == nil {
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy wildcard rules for invalid glob patterns.
|
||||||
// Handle * at end
|
// Handle * at end
|
||||||
if strings.HasSuffix(pattern, "*") {
|
if strings.HasSuffix(pattern, "*") {
|
||||||
prefix := strings.TrimSuffix(pattern, "*")
|
prefix := strings.TrimSuffix(pattern, "*")
|
||||||
|
|
|
||||||
40
cmd/dev/cmd_file_sync_test.go
Normal file
40
cmd/dev/cmd_file_sync_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddFileSyncCommand_Good(t *testing.T) {
|
||||||
|
root := &cli.Command{Use: "core"}
|
||||||
|
|
||||||
|
AddDevCommands(root)
|
||||||
|
|
||||||
|
syncCmd, _, err := root.Find([]string{"dev", "sync"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, syncCmd)
|
||||||
|
|
||||||
|
yesFlag := syncCmd.Flags().Lookup("yes")
|
||||||
|
require.NotNil(t, yesFlag)
|
||||||
|
require.Equal(t, "y", yesFlag.Shorthand)
|
||||||
|
|
||||||
|
require.NotNil(t, syncCmd.Flags().Lookup("dry-run"))
|
||||||
|
require.NotNil(t, syncCmd.Flags().Lookup("push"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitPatterns_Good(t *testing.T) {
|
||||||
|
patterns := splitPatterns("packages/core-*, apps/* ,services/*,")
|
||||||
|
require.Equal(t, []string{"packages/core-*", "apps/*", "services/*"}, patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchGlob_Good(t *testing.T) {
|
||||||
|
require.True(t, matchGlob("packages/core-xyz", "packages/core-*"))
|
||||||
|
require.True(t, matchGlob("packages/core-xyz", "*/core-*"))
|
||||||
|
require.True(t, matchGlob("a-b", "a?b"))
|
||||||
|
require.True(t, matchGlob("foo", "foo"))
|
||||||
|
require.False(t, matchGlob("core-other", "packages/*"))
|
||||||
|
require.False(t, matchGlob("abc", "[]"))
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
"dappco.re/go/core/scm/git"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Health command flags
|
// Health command flags
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
"forge.lthn.ai/core/go-io"
|
"dappco.re/go/core/io"
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
log "dappco.re/go/core/log"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Impact-specific styles (aliases to shared)
|
// Impact-specific styles (aliases to shared)
|
||||||
|
|
@ -55,14 +55,14 @@ func runImpact(registryPath string, repoName string) error {
|
||||||
return cli.Wrap(err, "failed to load registry")
|
return cli.Wrap(err, "failed to load registry")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return errors.New(i18n.T("cmd.dev.impact.requires_registry"))
|
return log.E("dev.impact", i18n.T("cmd.dev.impact.requires_registry"), nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check repo exists
|
// Check repo exists
|
||||||
repo, exists := reg.Get(repoName)
|
repo, exists := reg.Get(repoName)
|
||||||
if !exists {
|
if !exists {
|
||||||
return errors.New(i18n.T("error.repo_not_found", map[string]any{"Name": repoName}))
|
return log.E("dev.impact", i18n.T("error.repo_not_found", map[string]any{"Name": repoName}), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reverse dependency graph
|
// Build reverse dependency graph
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Issue-specific styles (aliases to shared)
|
// Issue-specific styles (aliases to shared)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
"dappco.re/go/core/scm/git"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pull command flags
|
// Pull command flags
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
"dappco.re/go/core/scm/git"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Push command flags
|
// Push command flags
|
||||||
|
|
@ -206,7 +206,7 @@ func runPushSingleRepo(ctx context.Context, repoPath string, force bool) error {
|
||||||
// Use edit-enabled commit if only untracked files (may need .gitignore fix)
|
// Use edit-enabled commit if only untracked files (may need .gitignore fix)
|
||||||
var err error
|
var err error
|
||||||
if s.Modified == 0 && s.Staged == 0 && s.Untracked > 0 {
|
if s.Modified == 0 && s.Staged == 0 && s.Untracked > 0 {
|
||||||
err = claudeEditCommit(ctx, repoPath, repoName, "")
|
err = doCommit(ctx, repoPath, true)
|
||||||
} else {
|
} else {
|
||||||
err = runCommitSingleRepo(ctx, repoPath, false)
|
err = runCommitSingleRepo(ctx, repoPath, false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PR-specific styles (aliases to shared)
|
// PR-specific styles (aliases to shared)
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@ import (
|
||||||
"go/parser"
|
"go/parser"
|
||||||
"go/token"
|
"go/token"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli" // Added
|
"dappco.re/go/core/i18n"
|
||||||
"forge.lthn.ai/core/go-i18n" // Added
|
coreio "dappco.re/go/core/io"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
// Added
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
@ -52,15 +55,15 @@ func runSync() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceName := dir.Name()
|
serviceName := dir.Name()
|
||||||
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go")
|
internalDir := filepath.Join(pkgDir, serviceName)
|
||||||
publicDir := serviceName
|
publicDir := serviceName
|
||||||
publicFile := filepath.Join(publicDir, serviceName+".go")
|
publicFile := filepath.Join(publicDir, serviceName+".go")
|
||||||
|
|
||||||
if !coreio.Local.IsFile(internalFile) {
|
if !coreio.Local.Exists(internalDir) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
symbols, err := getExportedSymbols(internalFile)
|
symbols, err := getExportedSymbols(internalDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName))
|
return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName))
|
||||||
}
|
}
|
||||||
|
|
@ -74,23 +77,29 @@ func runSync() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getExportedSymbols(path string) ([]symbolInfo, error) {
|
func getExportedSymbols(path string) ([]symbolInfo, error) {
|
||||||
// ParseFile expects a filename/path and reads it using os.Open by default if content is nil.
|
files, err := listGoFiles(path)
|
||||||
// Since we want to use our Medium abstraction, we should read the file content first.
|
|
||||||
content, err := coreio.Local.Read(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fset := token.NewFileSet()
|
symbolsByName := make(map[string]symbolInfo)
|
||||||
// ParseFile can take content as string (src argument).
|
for _, file := range files {
|
||||||
node, err := parser.ParseFile(fset, path, content, parser.ParseComments)
|
content, err := coreio.Local.Read(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
node, err := parser.ParseFile(fset, file, content, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, obj := range node.Scope.Objects {
|
||||||
|
if !ast.IsExported(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
var symbols []symbolInfo
|
|
||||||
for name, obj := range node.Scope.Objects {
|
|
||||||
if ast.IsExported(name) {
|
|
||||||
kind := "unknown"
|
kind := "unknown"
|
||||||
switch obj.Kind {
|
switch obj.Kind {
|
||||||
case ast.Con:
|
case ast.Con:
|
||||||
|
|
@ -102,14 +111,59 @@ func getExportedSymbols(path string) ([]symbolInfo, error) {
|
||||||
case ast.Typ:
|
case ast.Typ:
|
||||||
kind = "type"
|
kind = "type"
|
||||||
}
|
}
|
||||||
if kind != "unknown" {
|
|
||||||
symbols = append(symbols, symbolInfo{Name: name, Kind: kind})
|
if kind == "unknown" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := symbolsByName[name]; !exists {
|
||||||
|
symbolsByName[name] = symbolInfo{Name: name, Kind: kind}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
symbols := make([]symbolInfo, 0, len(symbolsByName))
|
||||||
|
for _, symbol := range symbolsByName {
|
||||||
|
symbols = append(symbols, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(symbols, func(i, j int) bool {
|
||||||
|
if symbols[i].Name == symbols[j].Name {
|
||||||
|
return symbols[i].Kind < symbols[j].Kind
|
||||||
|
}
|
||||||
|
return symbols[i].Name < symbols[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
return symbols, nil
|
return symbols, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listGoFiles(path string) ([]string, error) {
|
||||||
|
entries, err := coreio.Local.List(path)
|
||||||
|
if err == nil {
|
||||||
|
files := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, filepath.Join(path, name))
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if coreio.Local.IsFile(path) {
|
||||||
|
return []string{path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
const publicAPITemplate = `// package {{.ServiceName}} provides the public API for the {{.ServiceName}} service.
|
const publicAPITemplate = `// package {{.ServiceName}} provides the public API for the {{.ServiceName}} service.
|
||||||
package {{.ServiceName}}
|
package {{.ServiceName}}
|
||||||
|
|
||||||
|
|
|
||||||
310
cmd/dev/cmd_tag.go
Normal file
310
cmd/dev/cmd_tag.go
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag command flags
|
||||||
|
var (
|
||||||
|
tagRegistryPath string
|
||||||
|
tagDryRun bool
|
||||||
|
tagForce bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddTagCommand adds the 'tag' command to the given parent command.
|
||||||
|
func AddTagCommand(parent *cli.Command) {
|
||||||
|
tagCmd := &cli.Command{
|
||||||
|
Use: "tag",
|
||||||
|
Short: i18n.T("cmd.dev.tag.short"),
|
||||||
|
Long: i18n.T("cmd.dev.tag.long"),
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runTag(tagRegistryPath, tagDryRun, tagForce)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tagCmd.Flags().StringVar(&tagRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||||
|
tagCmd.Flags().BoolVar(&tagDryRun, "dry-run", false, i18n.T("cmd.dev.tag.flag.dry_run"))
|
||||||
|
tagCmd.Flags().BoolVarP(&tagForce, "force", "f", false, i18n.T("cmd.dev.tag.flag.force"))
|
||||||
|
|
||||||
|
parent.AddCommand(tagCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagPlan holds the version bump plan for a single repo.
|
||||||
|
type tagPlan struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Current string // current tag (e.g. "v0.3.1")
|
||||||
|
Next string // next tag (e.g. "v0.3.2")
|
||||||
|
IsGoMod bool // whether the repo has a go.mod
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTag(registryPath string, dryRun, force bool) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Load registry
|
||||||
|
reg, _, err := loadRegistryWithConfig(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get topological order (dependencies first)
|
||||||
|
ordered, err := reg.TopologicalOrder()
|
||||||
|
if err != nil {
|
||||||
|
return cli.Wrap(err, "failed to compute dependency order")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build version bump plan
|
||||||
|
var plans []tagPlan
|
||||||
|
|
||||||
|
for _, repo := range ordered {
|
||||||
|
if !repo.Exists() || !repo.IsGitRepo() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := latestTag(ctx, repo.Path)
|
||||||
|
if err != nil || current == "" {
|
||||||
|
current = "v0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := bumpPatch(current)
|
||||||
|
if err != nil {
|
||||||
|
return log.E("dev.tag", fmt.Sprintf("%s: failed to bump version %s", repo.Name, current), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasGoMod := fileExists(filepath.Join(repo.Path, "go.mod"))
|
||||||
|
|
||||||
|
plans = append(plans, tagPlan{
|
||||||
|
Name: repo.Name,
|
||||||
|
Path: repo.Path,
|
||||||
|
Current: current,
|
||||||
|
Next: next,
|
||||||
|
IsGoMod: hasGoMod,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plans) == 0 {
|
||||||
|
cli.Text(i18n.T("cmd.dev.no_git_repos"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show plan
|
||||||
|
cli.Print("\n%s\n\n", cli.TitleStyle.Render("Tag plan (dependency order)"))
|
||||||
|
|
||||||
|
nameWidth := 4
|
||||||
|
for _, p := range plans {
|
||||||
|
if len(p.Name) > nameWidth {
|
||||||
|
nameWidth = len(p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range plans {
|
||||||
|
paddedName := cli.Sprintf("%-*s", nameWidth, p.Name)
|
||||||
|
cli.Print(" %s %s → %s\n",
|
||||||
|
repoNameStyle.Render(paddedName),
|
||||||
|
dimStyle.Render(p.Current),
|
||||||
|
aheadStyle.Render(p.Next),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
cli.Blank()
|
||||||
|
cli.Text("Dry run — no changes made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm unless --force
|
||||||
|
if !force {
|
||||||
|
cli.Blank()
|
||||||
|
if !cli.Confirm(fmt.Sprintf("Tag and push %d repos?", len(plans))) {
|
||||||
|
cli.Text(i18n.T("cli.aborted"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
// Execute: for each repo in dependency order
|
||||||
|
var succeeded, failed int
|
||||||
|
|
||||||
|
for _, p := range plans {
|
||||||
|
cli.Print("%s %s → %s\n", dimStyle.Render("▸"), repoNameStyle.Render(p.Name), aheadStyle.Render(p.Next))
|
||||||
|
|
||||||
|
if p.IsGoMod {
|
||||||
|
// Step 1: GOWORK=off go get -u ./...
|
||||||
|
if err := goGetUpdate(ctx, p.Path); err != nil {
|
||||||
|
cli.Print(" %s go get -u: %s\n", errorStyle.Render("x"), err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: GOWORK=off go mod tidy
|
||||||
|
if err := goModTidy(ctx, p.Path); err != nil {
|
||||||
|
cli.Print(" %s go mod tidy: %s\n", errorStyle.Render("x"), err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Commit go.mod/go.sum if changed
|
||||||
|
if err := commitGoMod(ctx, p.Path, p.Next); err != nil {
|
||||||
|
cli.Print(" %s commit: %s\n", errorStyle.Render("x"), err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Create annotated tag
|
||||||
|
if err := createTag(ctx, p.Path, p.Next); err != nil {
|
||||||
|
cli.Print(" %s tag: %s\n", errorStyle.Render("x"), err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Push commits and tags
|
||||||
|
if err := pushWithTags(ctx, p.Path); err != nil {
|
||||||
|
cli.Print(" %s push: %s\n", errorStyle.Render("x"), err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Print(" %s %s\n", successStyle.Render("v"), p.Next)
|
||||||
|
succeeded++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
cli.Blank()
|
||||||
|
cli.Print("%s", successStyle.Render(fmt.Sprintf("%d tagged", succeeded)))
|
||||||
|
if failed > 0 {
|
||||||
|
cli.Print(", %s", errorStyle.Render(i18n.T("common.count.failed", map[string]any{"Count": failed})))
|
||||||
|
}
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestTag returns the latest semver tag in the repo.
|
||||||
|
func latestTag(ctx context.Context, repoPath string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0", "--match", "v*")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpPatch increments the patch version of a semver tag.
|
||||||
|
// "v0.3.1" → "v0.3.2"
|
||||||
|
func bumpPatch(tag string) (string, error) {
|
||||||
|
v := strings.TrimPrefix(tag, "v")
|
||||||
|
parts := strings.Split(v, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", log.E("dev.tag", fmt.Sprintf("invalid semver: %s", tag), nil)
|
||||||
|
}
|
||||||
|
patch, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return "", log.E("dev.tag", fmt.Sprintf("invalid patch version: %s", parts[2]), nil)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("v%s.%s.%d", parts[0], parts[1], patch+1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// goGetUpdate runs GOWORK=off go get -u ./... in the repo.
|
||||||
|
func goGetUpdate(ctx context.Context, repoPath string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "get", "-u", "./...")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return log.E("dev.tag", strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// goModTidy runs GOWORK=off go mod tidy in the repo.
|
||||||
|
func goModTidy(ctx context.Context, repoPath string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "mod", "tidy")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return log.E("dev.tag", strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitGoMod stages and commits go.mod and go.sum if they have changes.
|
||||||
|
func commitGoMod(ctx context.Context, repoPath, version string) error {
|
||||||
|
// Check if go.mod or go.sum changed (staged or unstaged)
|
||||||
|
diffCmd := exec.CommandContext(ctx, "git", "diff", "--quiet", "go.mod", "go.sum")
|
||||||
|
diffCmd.Dir = repoPath
|
||||||
|
modChanged := diffCmd.Run() != nil
|
||||||
|
|
||||||
|
// Also check for untracked go.sum
|
||||||
|
lsCmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--exclude-standard", "go.sum")
|
||||||
|
lsCmd.Dir = repoPath
|
||||||
|
lsOut, _ := lsCmd.Output()
|
||||||
|
untrackedSum := strings.TrimSpace(string(lsOut)) != ""
|
||||||
|
|
||||||
|
if !modChanged && !untrackedSum {
|
||||||
|
return nil // No changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage go.mod and go.sum
|
||||||
|
addCmd := exec.CommandContext(ctx, "git", "add", "go.mod", "go.sum")
|
||||||
|
addCmd.Dir = repoPath
|
||||||
|
if out, err := addCmd.CombinedOutput(); err != nil {
|
||||||
|
return log.E("dev.tag", "git add: "+strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if anything is actually staged
|
||||||
|
stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--quiet")
|
||||||
|
stagedCmd.Dir = repoPath
|
||||||
|
if stagedCmd.Run() == nil {
|
||||||
|
return nil // Nothing staged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit
|
||||||
|
msg := fmt.Sprintf("chore: sync dependencies for %s\n\nCo-Authored-By: Virgil <virgil@lethean.io>", version)
|
||||||
|
commitCmd := exec.CommandContext(ctx, "git", "commit", "-m", msg)
|
||||||
|
commitCmd.Dir = repoPath
|
||||||
|
if out, err := commitCmd.CombinedOutput(); err != nil {
|
||||||
|
return log.E("dev.tag", "git commit: "+strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTag creates an annotated tag.
|
||||||
|
func createTag(ctx context.Context, repoPath, tag string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "tag", "-a", tag, "-m", tag)
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return log.E("dev.tag", strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushWithTags pushes commits and tags to the remote.
|
||||||
|
// Uses interactive mode to support SSH passphrase prompts.
|
||||||
|
func pushWithTags(ctx context.Context, repoPath string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "push", "--follow-tags")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExists checks if a file exists at the given path.
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,14 @@ package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/io"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-container/devenv"
|
"forge.lthn.ai/core/go-container/devenv"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
"forge.lthn.ai/core/go-io"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// addVMCommands adds the dev environment VM commands to the dev parent command.
|
// addVMCommands adds the dev environment VM commands to the dev parent command.
|
||||||
|
|
@ -119,7 +119,7 @@ func runVMBoot(memory, cpus int, fresh bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !d.IsInstalled() {
|
if !d.IsInstalled() {
|
||||||
return errors.New(i18n.T("cmd.dev.vm.not_installed"))
|
return log.E("dev.vm", i18n.T("cmd.dev.vm.not_installed"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := devenv.DefaultBootOptions()
|
opts := devenv.DefaultBootOptions()
|
||||||
|
|
@ -190,10 +190,13 @@ func runVMStop() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addVMStatusCommand adds the 'devops status' command.
|
// addVMStatusCommand adds the 'dev status' command.
|
||||||
func addVMStatusCommand(parent *cli.Command) {
|
func addVMStatusCommand(parent *cli.Command) {
|
||||||
statusCmd := &cli.Command{
|
statusCmd := &cli.Command{
|
||||||
Use: "vm-status",
|
Use: "status",
|
||||||
|
Aliases: []string{
|
||||||
|
"vm-status",
|
||||||
|
},
|
||||||
Short: i18n.T("cmd.dev.vm.status.short"),
|
Short: i18n.T("cmd.dev.vm.status.short"),
|
||||||
Long: i18n.T("cmd.dev.vm.status.long"),
|
Long: i18n.T("cmd.dev.vm.status.long"),
|
||||||
RunE: func(cmd *cli.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
|
|
||||||
26
cmd/dev/cmd_vm_test.go
Normal file
26
cmd/dev/cmd_vm_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package dev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddVMStatusCommand_Good(t *testing.T) {
|
||||||
|
root := &cli.Command{Use: "core"}
|
||||||
|
|
||||||
|
AddDevCommands(root)
|
||||||
|
|
||||||
|
statusCmd, _, err := root.Find([]string{"dev", "status"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, statusCmd)
|
||||||
|
require.Equal(t, "status", statusCmd.Use)
|
||||||
|
require.Contains(t, statusCmd.Aliases, "vm-status")
|
||||||
|
|
||||||
|
aliasCmd, _, err := root.Find([]string{"dev", "vm-status"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, aliasCmd)
|
||||||
|
require.Equal(t, statusCmd, aliasCmd)
|
||||||
|
}
|
||||||
|
|
@ -3,15 +3,12 @@ package dev
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
|
"dappco.re/go/core/i18n"
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
"dappco.re/go/core/scm/git"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Work command flags
|
// Work command flags
|
||||||
|
|
@ -57,42 +54,30 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
defer func() { _ = bundle.Stop(ctx) }()
|
defer func() { _ = bundle.Stop(ctx) }()
|
||||||
|
|
||||||
// Load registry and get paths
|
// Load registry and get paths
|
||||||
paths, names, err := func() ([]string, map[string]string, error) {
|
reg, _, err := loadRegistryWithConfig(registryPath)
|
||||||
reg, _, err := loadRegistryWithConfig(registryPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
var paths []string
|
|
||||||
names := make(map[string]string)
|
|
||||||
for _, repo := range reg.List() {
|
|
||||||
if repo.IsGitRepo() {
|
|
||||||
paths = append(paths, repo.Path)
|
|
||||||
names[repo.Path] = repo.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths, names, nil
|
|
||||||
}()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
names := make(map[string]string)
|
||||||
|
for _, repo := range reg.List() {
|
||||||
|
if repo.IsGitRepo() {
|
||||||
|
paths = append(paths, repo.Path)
|
||||||
|
names[repo.Path] = repo.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
cli.Text(i18n.T("cmd.dev.no_git_repos"))
|
cli.Text(i18n.T("cmd.dev.no_git_repos"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QUERY git status
|
// Query git status directly
|
||||||
result, handled, err := bundle.Core.QUERY(git.QueryStatus{
|
statuses := git.Status(ctx, git.StatusOptions{
|
||||||
Paths: paths,
|
Paths: paths,
|
||||||
Names: names,
|
Names: names,
|
||||||
})
|
})
|
||||||
if !handled {
|
|
||||||
return cli.Err("git service not available")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
statuses := result.([]git.RepoStatus)
|
|
||||||
|
|
||||||
// Sort by repo name for consistent output
|
// Sort by repo name for consistent output
|
||||||
slices.SortFunc(statuses, func(a, b git.RepoStatus) int {
|
slices.SortFunc(statuses, func(a, b git.RepoStatus) int {
|
||||||
|
|
@ -125,15 +110,7 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
cli.Blank()
|
cli.Blank()
|
||||||
|
|
||||||
for _, s := range dirtyRepos {
|
for _, s := range dirtyRepos {
|
||||||
// PERFORM commit via agentic service
|
err := doCommit(ctx, s.Path, false)
|
||||||
_, handled, err := bundle.Core.PERFORM(agentic.TaskCommit{
|
|
||||||
Path: s.Path,
|
|
||||||
Name: s.Name,
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, "agentic service not available")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -141,12 +118,11 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-QUERY status after commits
|
// Re-query status after commits
|
||||||
result, _, _ = bundle.Core.QUERY(git.QueryStatus{
|
statuses = git.Status(ctx, git.StatusOptions{
|
||||||
Paths: paths,
|
Paths: paths,
|
||||||
Names: names,
|
Names: names,
|
||||||
})
|
})
|
||||||
statuses = result.([]git.RepoStatus)
|
|
||||||
|
|
||||||
// Rebuild ahead repos list
|
// Rebuild ahead repos list
|
||||||
aheadRepos = nil
|
aheadRepos = nil
|
||||||
|
|
@ -187,18 +163,11 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
|
|
||||||
cli.Blank()
|
cli.Blank()
|
||||||
|
|
||||||
// PERFORM push for each repo
|
// Push each repo directly
|
||||||
var divergedRepos []git.RepoStatus
|
var divergedRepos []git.RepoStatus
|
||||||
|
|
||||||
for _, s := range aheadRepos {
|
for _, s := range aheadRepos {
|
||||||
_, handled, err := bundle.Core.PERFORM(git.TaskPush{
|
err := git.Push(ctx, s.Path)
|
||||||
Path: s.Path,
|
|
||||||
Name: s.Name,
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, "git service not available")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if git.IsNonFastForward(err) {
|
if git.IsNonFastForward(err) {
|
||||||
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, i18n.T("cmd.dev.push.diverged"))
|
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, i18n.T("cmd.dev.push.diverged"))
|
||||||
|
|
@ -220,8 +189,8 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
for _, s := range divergedRepos {
|
for _, s := range divergedRepos {
|
||||||
cli.Print(" %s %s...\n", dimStyle.Render("↓"), s.Name)
|
cli.Print(" %s %s...\n", dimStyle.Render("↓"), s.Name)
|
||||||
|
|
||||||
// PERFORM pull
|
// Pull directly
|
||||||
_, _, err := bundle.Core.PERFORM(git.TaskPull{Path: s.Path, Name: s.Name})
|
err := git.Pull(ctx, s.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -229,8 +198,8 @@ func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||||
|
|
||||||
cli.Print(" %s %s...\n", dimStyle.Render("↑"), s.Name)
|
cli.Print(" %s %s...\n", dimStyle.Render("↑"), s.Name)
|
||||||
|
|
||||||
// PERFORM push
|
// Push directly
|
||||||
_, _, err = bundle.Core.PERFORM(git.TaskPush{Path: s.Path, Name: s.Name})
|
err = git.Push(ctx, s.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -318,28 +287,3 @@ func printStatusTable(statuses []git.RepoStatus) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// claudeCommit shells out to claude for committing (legacy helper for other commands)
|
|
||||||
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
|
||||||
prompt := agentic.Prompt("commit")
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// claudeEditCommit shells out to claude with edit permissions (legacy helper)
|
|
||||||
func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
|
||||||
prompt := agentic.Prompt("commit")
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Write,Edit,Glob,Grep")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/io"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
"forge.lthn.ai/core/go-io"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Workflow command flags
|
// Workflow command flags
|
||||||
|
|
@ -117,6 +117,10 @@ func runWorkflowList(registryPath string) error {
|
||||||
for _, wf := range templateWorkflows {
|
for _, wf := range templateWorkflows {
|
||||||
templateSet[wf] = true
|
templateSet[wf] = true
|
||||||
}
|
}
|
||||||
|
templateNames := slices.Sorted(maps.Keys(templateSet))
|
||||||
|
if len(templateNames) > 0 {
|
||||||
|
cli.Print("%s %s\n\n", i18n.T("cmd.dev.workflow.templates"), strings.Join(templateNames, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
// Build table
|
// Build table
|
||||||
headers := []string{i18n.T("cmd.dev.workflow.header.repo")}
|
headers := []string{i18n.T("cmd.dev.workflow.header.repo")}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"maps"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-io"
|
"dappco.re/go/core/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFindWorkflows_Good(t *testing.T) {
|
func TestFindWorkflows_Good(t *testing.T) {
|
||||||
|
|
@ -106,3 +108,21 @@ func TestFindTemplateWorkflow_NotFound(t *testing.T) {
|
||||||
t.Errorf("Expected empty string for non-existent template, got %s", result)
|
t.Errorf("Expected empty string for non-existent template, got %s", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTemplateNames_Good(t *testing.T) {
|
||||||
|
templateSet := map[string]bool{
|
||||||
|
"z.yml": true,
|
||||||
|
"a.yml": true,
|
||||||
|
"m.yml": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
names := slices.Sorted(maps.Keys(templateSet))
|
||||||
|
|
||||||
|
if len(names) != 3 {
|
||||||
|
t.Fatalf("Expected 3 template names, got %d", len(names))
|
||||||
|
}
|
||||||
|
|
||||||
|
if names[0] != "a.yml" || names[1] != "m.yml" || names[2] != "z.yml" {
|
||||||
|
t.Fatalf("Expected sorted template names, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-scm/forge"
|
coreio "dappco.re/go/core/io"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
|
"dappco.re/go/core/scm/forge"
|
||||||
)
|
)
|
||||||
|
|
||||||
// forgeAPIClient creates a Gitea SDK client configured for the Forge instance.
|
// forgeAPIClient creates a Gitea SDK client configured for the Forge instance.
|
||||||
|
|
@ -19,7 +19,7 @@ func forgeAPIClient() (*gitea.Client, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, fmt.Errorf("no Forge API token configured (set FORGE_TOKEN or run: core forge config --token TOKEN)")
|
return nil, log.E("dev.forge", "no Forge API token configured (set FORGE_TOKEN or run: core forge config --token TOKEN)", nil)
|
||||||
}
|
}
|
||||||
return gitea.NewClient(forgeURL, gitea.SetToken(token))
|
return gitea.NewClient(forgeURL, gitea.SetToken(token))
|
||||||
}
|
}
|
||||||
|
|
@ -28,12 +28,12 @@ func forgeAPIClient() (*gitea.Client, error) {
|
||||||
// Falls back to fallbackOrg/repoName if no forge.lthn.ai remote is found.
|
// Falls back to fallbackOrg/repoName if no forge.lthn.ai remote is found.
|
||||||
func forgeRepoIdentity(repoPath, fallbackOrg, repoName string) (owner, repo string) {
|
func forgeRepoIdentity(repoPath, fallbackOrg, repoName string) (owner, repo string) {
|
||||||
configPath := filepath.Join(repoPath, ".git", "config")
|
configPath := filepath.Join(repoPath, ".git", "config")
|
||||||
content, err := os.ReadFile(configPath)
|
content, err := coreio.Local.Read(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fallbackOrg, repoName
|
return fallbackOrg, repoName
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range strings.Split(string(content), "\n") {
|
for _, line := range strings.Split(content, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if !strings.HasPrefix(line, "url = ") {
|
if !strings.HasPrefix(line, "url = ") {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/agent/cmd/workspace"
|
"dappco.re/go/agent/cmd/workspace"
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/io"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
"forge.lthn.ai/core/go-io"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadRegistryWithConfig loads the registry and applies workspace configuration.
|
// loadRegistryWithConfig loads the registry and applies workspace configuration.
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,14 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
"os"
|
||||||
"strings"
|
"os/exec"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
agentic "dappco.re/go/agent/pkg/lifecycle"
|
||||||
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
|
"dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-scm/git"
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tasks for dev service
|
|
||||||
|
|
||||||
// TaskWork runs the full dev workflow: status, commit, push.
|
|
||||||
type TaskWork struct {
|
|
||||||
RegistryPath string
|
|
||||||
StatusOnly bool
|
|
||||||
AutoCommit bool
|
|
||||||
AutoPush bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskStatus displays git status for all repos.
|
|
||||||
type TaskStatus struct {
|
|
||||||
RegistryPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceOptions for configuring the dev service.
|
// ServiceOptions for configuring the dev service.
|
||||||
type ServiceOptions struct {
|
type ServiceOptions struct {
|
||||||
RegistryPath string
|
RegistryPath string
|
||||||
|
|
@ -37,256 +19,24 @@ type Service struct {
|
||||||
*core.ServiceRuntime[ServiceOptions]
|
*core.ServiceRuntime[ServiceOptions]
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a dev service factory.
|
func (s *Service) handleTask(_ *core.Core, _ core.Task) core.Result {
|
||||||
func NewService(opts ServiceOptions) func(*core.Core) (any, error) {
|
return core.Result{}
|
||||||
return func(c *core.Core) (any, error) {
|
|
||||||
return &Service{
|
|
||||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers task handlers.
|
// doCommit shells out to claude for AI-assisted commit.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func doCommit(ctx context.Context, repoPath string, allowEdit bool) error {
|
||||||
s.Core().RegisterTask(s.handleTask)
|
prompt := agentic.Prompt("commit")
|
||||||
return nil
|
|
||||||
}
|
tools := "Bash,Read,Glob,Grep"
|
||||||
|
if allowEdit {
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
tools = "Bash,Read,Write,Edit,Glob,Grep"
|
||||||
switch m := t.(type) {
|
}
|
||||||
case TaskWork:
|
|
||||||
err := s.runWork(m)
|
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", tools)
|
||||||
return nil, true, err
|
cmd.Dir = repoPath
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
case TaskStatus:
|
cmd.Stderr = os.Stderr
|
||||||
err := s.runStatus(m)
|
cmd.Stdin = os.Stdin
|
||||||
return nil, true, err
|
|
||||||
}
|
return cmd.Run()
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) runWork(task TaskWork) error {
|
|
||||||
// Load registry
|
|
||||||
paths, names, err := s.loadRegistry(task.RegistryPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(paths) == 0 {
|
|
||||||
cli.Println("No git repositories found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// QUERY git status
|
|
||||||
result, handled, err := s.Core().QUERY(git.QueryStatus{
|
|
||||||
Paths: paths,
|
|
||||||
Names: names,
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
return cli.Err("git service not available")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
statuses := result.([]git.RepoStatus)
|
|
||||||
|
|
||||||
// Sort by name
|
|
||||||
slices.SortFunc(statuses, func(a, b git.RepoStatus) int {
|
|
||||||
return cmp.Compare(a.Name, b.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Display status table
|
|
||||||
s.printStatusTable(statuses)
|
|
||||||
|
|
||||||
// Collect dirty and ahead repos
|
|
||||||
var dirtyRepos []git.RepoStatus
|
|
||||||
var aheadRepos []git.RepoStatus
|
|
||||||
|
|
||||||
for _, st := range statuses {
|
|
||||||
if st.Error != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if st.IsDirty() {
|
|
||||||
dirtyRepos = append(dirtyRepos, st)
|
|
||||||
}
|
|
||||||
if st.HasUnpushed() {
|
|
||||||
aheadRepos = append(aheadRepos, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-commit dirty repos if requested
|
|
||||||
if task.AutoCommit && len(dirtyRepos) > 0 {
|
|
||||||
cli.Blank()
|
|
||||||
cli.Println("Committing changes...")
|
|
||||||
cli.Blank()
|
|
||||||
|
|
||||||
for _, repo := range dirtyRepos {
|
|
||||||
_, handled, err := s.Core().PERFORM(agentic.TaskCommit{
|
|
||||||
Path: repo.Path,
|
|
||||||
Name: repo.Name,
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
// Agentic service not available - skip silently
|
|
||||||
cli.Print(" - %s: agentic service not available\n", repo.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
cli.Print(" x %s: %s\n", repo.Name, err)
|
|
||||||
} else {
|
|
||||||
cli.Print(" v %s\n", repo.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-query status after commits
|
|
||||||
result, _, _ = s.Core().QUERY(git.QueryStatus{
|
|
||||||
Paths: paths,
|
|
||||||
Names: names,
|
|
||||||
})
|
|
||||||
statuses = result.([]git.RepoStatus)
|
|
||||||
|
|
||||||
// Rebuild ahead repos list
|
|
||||||
aheadRepos = nil
|
|
||||||
for _, st := range statuses {
|
|
||||||
if st.Error == nil && st.HasUnpushed() {
|
|
||||||
aheadRepos = append(aheadRepos, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If status only, we're done
|
|
||||||
if task.StatusOnly {
|
|
||||||
if len(dirtyRepos) > 0 && !task.AutoCommit {
|
|
||||||
cli.Blank()
|
|
||||||
cli.Println("Use --commit flag to auto-commit dirty repos")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push repos with unpushed commits
|
|
||||||
if len(aheadRepos) == 0 {
|
|
||||||
cli.Blank()
|
|
||||||
cli.Println("All repositories are up to date")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.Blank()
|
|
||||||
cli.Print("%d repos with unpushed commits:\n", len(aheadRepos))
|
|
||||||
for _, st := range aheadRepos {
|
|
||||||
cli.Print(" %s: %d commits\n", st.Name, st.Ahead)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !task.AutoPush {
|
|
||||||
cli.Blank()
|
|
||||||
cli.Print("Push all? [y/N] ")
|
|
||||||
var answer string
|
|
||||||
_, _ = cli.Scanln(&answer)
|
|
||||||
if strings.ToLower(answer) != "y" {
|
|
||||||
cli.Println("Aborted")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.Blank()
|
|
||||||
|
|
||||||
// Push each repo
|
|
||||||
for _, st := range aheadRepos {
|
|
||||||
_, handled, err := s.Core().PERFORM(git.TaskPush{
|
|
||||||
Path: st.Path,
|
|
||||||
Name: st.Name,
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
cli.Print(" x %s: git service not available\n", st.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if git.IsNonFastForward(err) {
|
|
||||||
cli.Print(" ! %s: branch has diverged\n", st.Name)
|
|
||||||
} else {
|
|
||||||
cli.Print(" x %s: %s\n", st.Name, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cli.Print(" v %s\n", st.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) runStatus(task TaskStatus) error {
|
|
||||||
paths, names, err := s.loadRegistry(task.RegistryPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(paths) == 0 {
|
|
||||||
cli.Println("No git repositories found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result, handled, err := s.Core().QUERY(git.QueryStatus{
|
|
||||||
Paths: paths,
|
|
||||||
Names: names,
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
return cli.Err("git service not available")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
statuses := result.([]git.RepoStatus)
|
|
||||||
slices.SortFunc(statuses, func(a, b git.RepoStatus) int {
|
|
||||||
return cmp.Compare(a.Name, b.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
s.printStatusTable(statuses)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) loadRegistry(registryPath string) ([]string, map[string]string, error) {
|
|
||||||
reg, _, err := loadRegistryWithConfig(registryPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var paths []string
|
|
||||||
names := make(map[string]string)
|
|
||||||
|
|
||||||
for _, repo := range reg.List() {
|
|
||||||
if repo.IsGitRepo() {
|
|
||||||
paths = append(paths, repo.Path)
|
|
||||||
names[repo.Path] = repo.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths, names, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) printStatusTable(statuses []git.RepoStatus) {
|
|
||||||
// Calculate column widths
|
|
||||||
nameWidth := 4 // "Repo"
|
|
||||||
for _, st := range statuses {
|
|
||||||
if len(st.Name) > nameWidth {
|
|
||||||
nameWidth = len(st.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print header
|
|
||||||
cli.Print("%-*s %8s %9s %6s %5s\n",
|
|
||||||
nameWidth, "Repo", "Modified", "Untracked", "Staged", "Ahead")
|
|
||||||
|
|
||||||
// Print separator
|
|
||||||
cli.Text(strings.Repeat("-", nameWidth+2+10+11+8+7))
|
|
||||||
|
|
||||||
// Print rows
|
|
||||||
for _, st := range statuses {
|
|
||||||
if st.Error != nil {
|
|
||||||
cli.Print("%-*s error: %s\n", nameWidth, st.Name, st.Error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cli.Print("%-*s %8d %9d %6d %5d\n",
|
|
||||||
nameWidth, st.Name,
|
|
||||||
st.Modified, st.Untracked, st.Staged, st.Ahead)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@
|
||||||
// to a central location for unified documentation builds.
|
// to a central location for unified documentation builds.
|
||||||
package docs
|
package docs
|
||||||
|
|
||||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
import (
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
|
||||||
|
_ "dappco.re/go/core/devops/locales"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cli.RegisterCommands(AddDocsCommands)
|
cli.RegisterCommands(AddDocsCommands)
|
||||||
|
|
@ -16,5 +20,6 @@ func init() {
|
||||||
|
|
||||||
// AddDocsCommands registers the 'docs' command and all subcommands.
|
// AddDocsCommands registers the 'docs' command and all subcommands.
|
||||||
func AddDocsCommands(root *cli.Command) {
|
func AddDocsCommands(root *cli.Command) {
|
||||||
|
setDocsI18n()
|
||||||
root.AddCommand(docsCmd)
|
root.AddCommand(docsCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package docs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style and utility aliases from shared
|
// Style and utility aliases from shared
|
||||||
|
|
@ -19,9 +19,16 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
var docsCmd = &cli.Command{
|
var docsCmd = &cli.Command{
|
||||||
Use: "docs",
|
Use: "docs",
|
||||||
Short: i18n.T("cmd.docs.short"),
|
}
|
||||||
Long: i18n.T("cmd.docs.long"),
|
|
||||||
|
func setDocsI18n() {
|
||||||
|
docsCmd.Short = i18n.T("cmd.docs.short")
|
||||||
|
docsCmd.Long = i18n.T("cmd.docs.long")
|
||||||
|
docsListCmd.Short = i18n.T("cmd.docs.list.short")
|
||||||
|
docsListCmd.Long = i18n.T("cmd.docs.list.long")
|
||||||
|
docsSyncCmd.Short = i18n.T("cmd.docs.sync.short")
|
||||||
|
docsSyncCmd.Long = i18n.T("cmd.docs.sync.long")
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,14 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flag variable for list command
|
// Flag variable for list command
|
||||||
var docsListRegistryPath string
|
var docsListRegistryPath string
|
||||||
|
|
||||||
var docsListCmd = &cli.Command{
|
var docsListCmd = &cli.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: i18n.T("cmd.docs.list.short"),
|
|
||||||
Long: i18n.T("cmd.docs.list.long"),
|
|
||||||
RunE: func(cmd *cli.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
return runDocsList(docsListRegistryPath)
|
return runDocsList(docsListRegistryPath)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/agent/cmd/workspace"
|
"dappco.re/go/agent/cmd/workspace"
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/io"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
"forge.lthn.ai/core/go-io"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RepoDocInfo holds documentation info for a repo
|
// RepoDocInfo holds documentation info for a repo
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/io"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
"forge.lthn.ai/core/go-io"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flag variables for sync command
|
// Flag variables for sync command
|
||||||
|
|
@ -21,9 +21,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
var docsSyncCmd = &cli.Command{
|
var docsSyncCmd = &cli.Command{
|
||||||
Use: "sync",
|
Use: "sync",
|
||||||
Short: i18n.T("cmd.docs.sync.short"),
|
|
||||||
Long: i18n.T("cmd.docs.sync.long"),
|
|
||||||
RunE: func(cmd *cli.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
return runDocsSync(docsSyncRegistryPath, docsSyncOutputDir, docsSyncDryRun, docsSyncTarget)
|
return runDocsSync(docsSyncRegistryPath, docsSyncOutputDir, docsSyncDryRun, docsSyncTarget)
|
||||||
},
|
},
|
||||||
|
|
@ -142,9 +140,7 @@ func runPHPSync(reg *repos.Registry, basePath string, outputDir string, dryRun b
|
||||||
repoOutDir := filepath.Join(outputDir, outName)
|
repoOutDir := filepath.Join(outputDir, outName)
|
||||||
|
|
||||||
// Clear existing directory (recursively)
|
// Clear existing directory (recursively)
|
||||||
_ = io.Local.DeleteAll(repoOutDir)
|
if err := resetOutputDir(repoOutDir); err != nil {
|
||||||
|
|
||||||
if err := io.Local.EnsureDir(repoOutDir); err != nil {
|
|
||||||
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -277,6 +273,7 @@ func runZensicalSync(reg *repos.Registry, basePath string, outputDir string, dry
|
||||||
|
|
||||||
cli.Blank()
|
cli.Blank()
|
||||||
var synced int
|
var synced int
|
||||||
|
repoLoop:
|
||||||
for _, info := range docsInfo {
|
for _, info := range docsInfo {
|
||||||
section, folder := zensicalOutputName(info.Name)
|
section, folder := zensicalOutputName(info.Name)
|
||||||
|
|
||||||
|
|
@ -285,6 +282,11 @@ func runZensicalSync(reg *repos.Registry, basePath string, outputDir string, dry
|
||||||
destDir = filepath.Join(destDir, folder)
|
destDir = filepath.Join(destDir, folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := resetOutputDir(destDir); err != nil {
|
||||||
|
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
weight := 10
|
weight := 10
|
||||||
docsDir := filepath.Join(info.Path, "docs")
|
docsDir := filepath.Join(info.Path, "docs")
|
||||||
for _, f := range info.DocsFiles {
|
for _, f := range info.DocsFiles {
|
||||||
|
|
@ -297,9 +299,8 @@ func runZensicalSync(reg *repos.Registry, basePath string, outputDir string, dry
|
||||||
weight += 10
|
weight += 10
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Readme != "" && folder != "" {
|
if info.Readme != "" {
|
||||||
dst := filepath.Join(destDir, "index.md")
|
if err := copyZensicalReadme(info.Readme, destDir); err != nil {
|
||||||
if err := copyWithFrontMatter(info.Readme, dst, 1); err != nil {
|
|
||||||
cli.Print(" %s README: %s\n", errorStyle.Render("✗"), err)
|
cli.Print(" %s README: %s\n", errorStyle.Render("✗"), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -307,6 +308,10 @@ func runZensicalSync(reg *repos.Registry, basePath string, outputDir string, dry
|
||||||
if len(info.KBFiles) > 0 {
|
if len(info.KBFiles) > 0 {
|
||||||
suffix := strings.TrimPrefix(info.Name, "go-")
|
suffix := strings.TrimPrefix(info.Name, "go-")
|
||||||
kbDestDir := filepath.Join(outputDir, "kb", suffix)
|
kbDestDir := filepath.Join(outputDir, "kb", suffix)
|
||||||
|
if err := resetOutputDir(kbDestDir); err != nil {
|
||||||
|
cli.Print(" %s KB: %s\n", errorStyle.Render("✗"), err)
|
||||||
|
continue repoLoop
|
||||||
|
}
|
||||||
kbDir := filepath.Join(info.Path, "KB")
|
kbDir := filepath.Join(info.Path, "KB")
|
||||||
kbWeight := 10
|
kbWeight := 10
|
||||||
for _, f := range info.KBFiles {
|
for _, f := range info.KBFiles {
|
||||||
|
|
@ -328,10 +333,24 @@ func runZensicalSync(reg *repos.Registry, basePath string, outputDir string, dry
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyZensicalReadme copies a repository README to index.md in the target directory.
|
||||||
|
func copyZensicalReadme(src, destDir string) error {
|
||||||
|
dst := filepath.Join(destDir, "index.md")
|
||||||
|
return copyWithFrontMatter(src, dst, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetOutputDir clears and recreates a target directory before copying files into it.
|
||||||
|
func resetOutputDir(dir string) error {
|
||||||
|
if err := io.Local.DeleteAll(dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return io.Local.EnsureDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
// goHelpOutputName maps repo name to output folder name for go-help.
|
// goHelpOutputName maps repo name to output folder name for go-help.
|
||||||
func goHelpOutputName(repoName string) string {
|
func goHelpOutputName(repoName string) string {
|
||||||
if repoName == "core" {
|
if repoName == "core" {
|
||||||
return "cli"
|
return "go"
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(repoName, "core-") {
|
if strings.HasPrefix(repoName, "core-") {
|
||||||
return strings.TrimPrefix(repoName, "core-")
|
return strings.TrimPrefix(repoName, "core-")
|
||||||
|
|
@ -390,9 +409,7 @@ func runGoHelpSync(reg *repos.Registry, basePath string, outputDir string, dryRu
|
||||||
repoOutDir := filepath.Join(outputDir, outName)
|
repoOutDir := filepath.Join(outputDir, outName)
|
||||||
|
|
||||||
// Clear existing directory
|
// Clear existing directory
|
||||||
_ = io.Local.DeleteAll(repoOutDir)
|
if err := resetOutputDir(repoOutDir); err != nil {
|
||||||
|
|
||||||
if err := io.Local.EnsureDir(repoOutDir); err != nil {
|
|
||||||
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
cmd/docs/cmd_sync_test.go
Normal file
80
cmd/docs/cmd_sync_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyZensicalReadme_Good(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
src := filepath.Join(srcDir, "README.md")
|
||||||
|
if err := os.WriteFile(src, []byte("# Hello\n\nBody text.\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyZensicalReadme(src, destDir); err != nil {
|
||||||
|
t.Fatalf("copy README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := filepath.Join(destDir, "index.md")
|
||||||
|
data, err := os.ReadFile(output)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read output index.md: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
|
t.Fatalf("expected Hugo front matter at start, got: %q", content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "title: \"README\"") {
|
||||||
|
t.Fatalf("expected README title in front matter, got: %q", content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "Body text.") {
|
||||||
|
t.Fatalf("expected README body to be preserved, got: %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetOutputDir_ClearsExistingFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
stale := filepath.Join(dir, "stale.md")
|
||||||
|
if err := os.WriteFile(stale, []byte("old content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write stale file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resetOutputDir(dir); err != nil {
|
||||||
|
t.Fatalf("reset output dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected stale file to be removed, got err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat output dir: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Fatalf("expected output dir to exist as a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoHelpOutputName_Good(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"core": "go",
|
||||||
|
"core-admin": "admin",
|
||||||
|
"core-api": "api",
|
||||||
|
"go-example": "go-example",
|
||||||
|
"custom-repo": "custom-repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := goHelpOutputName(input); got != want {
|
||||||
|
t.Fatalf("goHelpOutputName(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
package gitcmd
|
package gitcmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/go-devops/cmd/dev"
|
"dappco.re/go/core/devops/cmd/dev"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,11 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/agent/cmd/workspace"
|
"dappco.re/go/agent/cmd/workspace"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
coreio "dappco.re/go/core/io"
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
log "dappco.re/go/core/log"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runSetupOrchestrator decides between registry mode and bootstrap mode.
|
// runSetupOrchestrator decides between registry mode and bootstrap mode.
|
||||||
|
|
@ -46,7 +47,7 @@ func runSetupOrchestrator(registryPath, only string, dryRun, all bool, projectNa
|
||||||
func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error {
|
func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error {
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get working directory: %w", err)
|
return log.E("setup.bootstrap", "failed to get working directory", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.bootstrap_mode"))
|
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.bootstrap_mode"))
|
||||||
|
|
@ -56,7 +57,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
|
||||||
// Check if current directory is empty
|
// Check if current directory is empty
|
||||||
empty, err := isDirEmpty(cwd)
|
empty, err := isDirEmpty(cwd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check directory: %w", err)
|
return log.E("setup.bootstrap", "failed to check directory", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if empty {
|
if empty {
|
||||||
|
|
@ -71,7 +72,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
|
||||||
// Offer choice: setup working directory or create package
|
// Offer choice: setup working directory or create package
|
||||||
choice, err := promptSetupChoice()
|
choice, err := promptSetupChoice()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get choice: %w", err)
|
return log.E("setup.bootstrap", "failed to get choice", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if choice == "setup" {
|
if choice == "setup" {
|
||||||
|
|
@ -88,7 +89,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
|
||||||
} else {
|
} else {
|
||||||
projectName, err = promptProjectName(defaultOrg)
|
projectName, err = promptProjectName(defaultOrg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get project name: %w", err)
|
return log.E("setup.bootstrap", "failed to get project name", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +99,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
|
||||||
|
|
||||||
if !dryRun {
|
if !dryRun {
|
||||||
if err := coreio.Local.EnsureDir(targetDir); err != nil {
|
if err := coreio.Local.EnsureDir(targetDir); err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
return log.E("setup.bootstrap", "failed to create directory", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +111,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
|
||||||
|
|
||||||
if !dryRun {
|
if !dryRun {
|
||||||
if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil {
|
if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil {
|
||||||
return fmt.Errorf("failed to clone %s: %w", devopsRepo, err)
|
return log.E("setup.bootstrap", fmt.Sprintf("failed to clone %s", devopsRepo), err)
|
||||||
}
|
}
|
||||||
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), devopsRepo, i18n.T("cmd.setup.cloned"))
|
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), devopsRepo, i18n.T("cmd.setup.cloned"))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -130,7 +131,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam
|
||||||
|
|
||||||
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
|
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load registry from %s: %w", devopsRepo, err)
|
return log.E("setup.bootstrap", fmt.Sprintf("failed to load registry from %s", devopsRepo), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override base path to target directory
|
// Override base path to target directory
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ func DefaultCIConfig() *CIConfig {
|
||||||
return &CIConfig{
|
return &CIConfig{
|
||||||
Tap: "host-uk/tap",
|
Tap: "host-uk/tap",
|
||||||
Formula: "core",
|
Formula: "core",
|
||||||
ScoopBucket: "https://https://forge.lthn.ai/core/scoop-bucket.git",
|
ScoopBucket: "https://forge.lthn.ai/core/scoop-bucket.git",
|
||||||
ChocolateyPkg: "core-cli",
|
ChocolateyPkg: "core-cli",
|
||||||
Repository: "host-uk/core",
|
Repository: "host-uk/core",
|
||||||
DefaultVersion: "dev",
|
DefaultVersion: "dev",
|
||||||
|
|
|
||||||
64
cmd/setup/cmd_ci_test.go
Normal file
64
cmd/setup/cmd_ci_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func captureStdout(t *testing.T, fn func() error) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
_ = r.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Stdout = w
|
||||||
|
defer func() {
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
}()
|
||||||
|
|
||||||
|
outC := make(chan string, 1)
|
||||||
|
errC := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, copyErr := io.Copy(&buf, r)
|
||||||
|
errC <- copyErr
|
||||||
|
outC <- buf.String()
|
||||||
|
}()
|
||||||
|
|
||||||
|
runErr := fn()
|
||||||
|
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
require.NoError(t, <-errC)
|
||||||
|
out := <-outC
|
||||||
|
|
||||||
|
return out, runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCIConfig_Good(t *testing.T) {
|
||||||
|
cfg := DefaultCIConfig()
|
||||||
|
|
||||||
|
require.Equal(t, "host-uk/tap", cfg.Tap)
|
||||||
|
require.Equal(t, "core", cfg.Formula)
|
||||||
|
require.Equal(t, "https://forge.lthn.ai/core/scoop-bucket.git", cfg.ScoopBucket)
|
||||||
|
require.Equal(t, "core-cli", cfg.ChocolateyPkg)
|
||||||
|
require.Equal(t, "host-uk/core", cfg.Repository)
|
||||||
|
require.Equal(t, "dev", cfg.DefaultVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputPowershellInstall_Good(t *testing.T) {
|
||||||
|
out, err := captureStdout(t, func() error {
|
||||||
|
return outputPowershellInstall(DefaultCIConfig(), "dev")
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, out, `scoop bucket add host-uk $ScoopBucket`)
|
||||||
|
require.NotContains(t, out, `https://https://forge.lthn.ai/core/scoop-bucket.git`)
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,9 @@ package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
|
||||||
|
_ "dappco.re/go/core/devops/locales"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -33,5 +36,7 @@ func init() {
|
||||||
|
|
||||||
// AddSetupCommands registers the 'setup' command and all subcommands.
|
// AddSetupCommands registers the 'setup' command and all subcommands.
|
||||||
func AddSetupCommands(root *cli.Command) {
|
func AddSetupCommands(root *cli.Command) {
|
||||||
|
setupCmd.Short = i18n.T("cmd.setup.short")
|
||||||
|
setupCmd.Long = i18n.T("cmd.setup.long")
|
||||||
AddSetupCommand(root)
|
AddSetupCommand(root)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@
|
||||||
package setup
|
package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
coreio "dappco.re/go/core/io"
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
log "dappco.re/go/core/log"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHub command flags
|
// GitHub command flags
|
||||||
|
|
@ -69,12 +69,12 @@ func addGitHubCommand(parent *cli.Command) {
|
||||||
func runGitHubSetup() error {
|
func runGitHubSetup() error {
|
||||||
// Check gh is available
|
// Check gh is available
|
||||||
if _, err := exec.LookPath("gh"); err != nil {
|
if _, err := exec.LookPath("gh"); err != nil {
|
||||||
return errors.New(i18n.T("error.gh_not_found"))
|
return log.E("setup.github", i18n.T("error.gh_not_found"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check gh is authenticated
|
// Check gh is authenticated
|
||||||
if !cli.GhAuthenticated() {
|
if !cli.GhAuthenticated() {
|
||||||
return errors.New(i18n.T("cmd.setup.github.error.not_authenticated"))
|
return log.E("setup.github", i18n.T("cmd.setup.github.error.not_authenticated"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find registry
|
// Find registry
|
||||||
|
|
@ -118,14 +118,14 @@ func runGitHubSetup() error {
|
||||||
|
|
||||||
// Reject conflicting flags
|
// Reject conflicting flags
|
||||||
if ghRepo != "" && ghAll {
|
if ghRepo != "" && ghAll {
|
||||||
return errors.New(i18n.T("cmd.setup.github.error.conflicting_flags"))
|
return log.E("setup.github", i18n.T("cmd.setup.github.error.conflicting_flags"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ghRepo != "" {
|
if ghRepo != "" {
|
||||||
// Single repo mode
|
// Single repo mode
|
||||||
repo, ok := reg.Get(ghRepo)
|
repo, ok := reg.Get(ghRepo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New(i18n.T("error.repo_not_found", map[string]any{"Name": ghRepo}))
|
return log.E("setup.github", i18n.T("error.repo_not_found", map[string]any{"Name": ghRepo}), nil)
|
||||||
}
|
}
|
||||||
reposToProcess = []*repos.Repo{repo}
|
reposToProcess = []*repos.Repo{repo}
|
||||||
} else if ghAll {
|
} else if ghAll {
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,19 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/agent/cmd/workspace"
|
"dappco.re/go/agent/cmd/workspace"
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// runRegistrySetup loads a registry from path and runs setup.
|
// runRegistrySetup loads a registry from path and runs setup.
|
||||||
func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error {
|
func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error {
|
||||||
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
|
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load registry: %w", err)
|
return log.E("setup.registry", "failed to load registry", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check workspace config for default_only if no filter specified
|
// Check workspace config for default_only if no filter specified
|
||||||
|
|
@ -82,7 +83,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
// Ensure base path exists
|
// Ensure base path exists
|
||||||
if !dryRun {
|
if !dryRun {
|
||||||
if err := coreio.Local.EnsureDir(basePath); err != nil {
|
if err := coreio.Local.EnsureDir(basePath); err != nil {
|
||||||
return fmt.Errorf("failed to create packages directory: %w", err)
|
return log.E("setup.registry", "failed to create packages directory", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +100,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
if useWizard {
|
if useWizard {
|
||||||
selected, err := runPackageWizard(reg, typeFilter)
|
selected, err := runPackageWizard(reg, typeFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wizard error: %w", err)
|
return log.E("setup.registry", "wizard error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build set of selected repos
|
// Build set of selected repos
|
||||||
|
|
@ -227,7 +228,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
buildCmd.Stdout = os.Stdout
|
buildCmd.Stdout = os.Stdout
|
||||||
buildCmd.Stderr = os.Stderr
|
buildCmd.Stderr = os.Stderr
|
||||||
if err := buildCmd.Run(); err != nil {
|
if err := buildCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.run", "build"), err)
|
return log.E("setup.registry", i18n.T("i18n.fail.run", "build"), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +250,7 @@ func gitClone(ctx context.Context, org, repo, path string) error {
|
||||||
// Only fall through to SSH if it's an auth error
|
// Only fall through to SSH if it's an auth error
|
||||||
if !strings.Contains(errStr, "Permission denied") &&
|
if !strings.Contains(errStr, "Permission denied") &&
|
||||||
!strings.Contains(errStr, "could not read") {
|
!strings.Contains(errStr, "could not read") {
|
||||||
return fmt.Errorf("%s", errStr)
|
return log.E("setup.registry", errStr, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,7 +259,7 @@ func gitClone(ctx context.Context, org, repo, path string) error {
|
||||||
cmd := exec.CommandContext(ctx, "git", "clone", url, path)
|
cmd := exec.CommandContext(ctx, "git", "clone", url, path)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
return log.E("setup.registry", strings.TrimSpace(string(output)), nil)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,42 @@ package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
coreio "dappco.re/go/core/io"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var repoDryRun bool
|
||||||
|
|
||||||
|
// addRepoCommand adds the 'repo' subcommand to generate .core configuration.
|
||||||
|
func addRepoCommand(parent *cli.Command) {
|
||||||
|
repoCmd := &cli.Command{
|
||||||
|
Use: "repo",
|
||||||
|
Short: i18n.T("cmd.setup.repo.short"),
|
||||||
|
Long: i18n.T("cmd.setup.repo.long"),
|
||||||
|
Args: cli.ExactArgs(0),
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return log.E("setup.repo", "failed to get working directory", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runRepoSetup(cwd, repoDryRun)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
repoCmd.Flags().BoolVar(&repoDryRun, "dry-run", false, i18n.T("cmd.setup.flag.dry_run"))
|
||||||
|
|
||||||
|
parent.AddCommand(repoCmd)
|
||||||
|
}
|
||||||
|
|
||||||
// runRepoSetup sets up the current repository with .core/ configuration.
|
// runRepoSetup sets up the current repository with .core/ configuration.
|
||||||
func runRepoSetup(repoPath string, dryRun bool) error {
|
func runRepoSetup(repoPath string, dryRun bool) error {
|
||||||
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.repo.setting_up"), repoPath)
|
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.repo.setting_up"), repoPath)
|
||||||
|
|
@ -28,7 +56,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
|
||||||
coreDir := filepath.Join(repoPath, ".core")
|
coreDir := filepath.Join(repoPath, ".core")
|
||||||
if !dryRun {
|
if !dryRun {
|
||||||
if err := coreio.Local.EnsureDir(coreDir); err != nil {
|
if err := coreio.Local.EnsureDir(coreDir); err != nil {
|
||||||
return fmt.Errorf("failed to create .core directory: %w", err)
|
return log.E("setup.repo", "failed to create .core directory", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +83,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
|
||||||
for filename, content := range configs {
|
for filename, content := range configs {
|
||||||
configPath := filepath.Join(coreDir, filename)
|
configPath := filepath.Join(coreDir, filename)
|
||||||
if err := coreio.Local.Write(configPath, content); err != nil {
|
if err := coreio.Local.Write(configPath, content); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", filename, err)
|
return log.E("setup.repo", fmt.Sprintf("failed to write %s", filename), err)
|
||||||
}
|
}
|
||||||
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), i18n.T("cmd.setup.repo.created"), configPath)
|
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), i18n.T("cmd.setup.repo.created"), configPath)
|
||||||
}
|
}
|
||||||
|
|
@ -72,12 +100,12 @@ func detectProjectType(path string) string {
|
||||||
if coreio.Local.IsFile(filepath.Join(path, "go.mod")) {
|
if coreio.Local.IsFile(filepath.Join(path, "go.mod")) {
|
||||||
return "go"
|
return "go"
|
||||||
}
|
}
|
||||||
if coreio.Local.IsFile(filepath.Join(path, "composer.json")) {
|
|
||||||
return "php"
|
|
||||||
}
|
|
||||||
if coreio.Local.IsFile(filepath.Join(path, "package.json")) {
|
if coreio.Local.IsFile(filepath.Join(path, "package.json")) {
|
||||||
return "node"
|
return "node"
|
||||||
}
|
}
|
||||||
|
if coreio.Local.IsFile(filepath.Join(path, "composer.json")) {
|
||||||
|
return "php"
|
||||||
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,23 +295,46 @@ func detectGitHubRepo() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSpace(string(output))
|
return parseGitHubRepoURL(strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
// Handle SSH format: git@github.com:owner/repo.git
|
// parseGitHubRepoURL extracts owner/repo from a GitHub remote URL.
|
||||||
if strings.HasPrefix(url, "git@github.com:") {
|
//
|
||||||
repo := strings.TrimPrefix(url, "git@github.com:")
|
// Supports the common remote formats used by git:
|
||||||
repo = strings.TrimSuffix(repo, ".git")
|
// - git@github.com:owner/repo.git
|
||||||
return repo
|
// - ssh://git@github.com/owner/repo.git
|
||||||
|
// - https://github.com/owner/repo.git
|
||||||
|
// - git://github.com/owner/repo.git
|
||||||
|
func parseGitHubRepoURL(remote string) string {
|
||||||
|
remote = strings.TrimSpace(remote)
|
||||||
|
if remote == "" {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle HTTPS format: https://github.com/owner/repo.git
|
// Handle SSH-style scp syntax first.
|
||||||
if strings.Contains(url, "github.com/") {
|
if strings.HasPrefix(remote, "git@github.com:") {
|
||||||
parts := strings.Split(url, "github.com/")
|
repo := strings.TrimPrefix(remote, "git@github.com:")
|
||||||
if len(parts) == 2 {
|
return strings.TrimSuffix(repo, ".git")
|
||||||
repo := strings.TrimSuffix(parts[1], ".git")
|
}
|
||||||
|
|
||||||
|
if parsed, err := url.Parse(remote); err == nil && parsed.Host != "" {
|
||||||
|
host := strings.TrimPrefix(parsed.Hostname(), "www.")
|
||||||
|
if host == "github.com" {
|
||||||
|
repo := strings.TrimPrefix(parsed.Path, "/")
|
||||||
|
repo = strings.TrimSuffix(repo, ".git")
|
||||||
|
repo = strings.TrimSuffix(repo, "/")
|
||||||
return repo
|
return repo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(remote, "github.com/") {
|
||||||
|
parts := strings.SplitN(remote, "github.com/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
repo := strings.TrimPrefix(parts[1], "/")
|
||||||
|
repo = strings.TrimSuffix(repo, ".git")
|
||||||
|
return strings.TrimSuffix(repo, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
cmd/setup/cmd_repo_test.go
Normal file
52
cmd/setup/cmd_repo_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunRepoSetup_CreatesCoreConfigs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, runRepoSetup(dir, false))
|
||||||
|
|
||||||
|
for _, name := range []string{"build.yaml", "release.yaml", "test.yaml"} {
|
||||||
|
path := filepath.Join(dir, ".core", name)
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
require.NoErrorf(t, err, "expected %s to exist", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectProjectType_PrefersPackageOverComposer(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}\n"), 0o644))
|
||||||
|
|
||||||
|
require.Equal(t, "node", detectProjectType(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGitHubRepoURL_Good(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"git@github.com:owner/repo.git": "owner/repo",
|
||||||
|
"ssh://git@github.com/owner/repo.git": "owner/repo",
|
||||||
|
"https://github.com/owner/repo.git": "owner/repo",
|
||||||
|
"git://github.com/owner/repo.git": "owner/repo",
|
||||||
|
"https://www.github.com/owner/repo": "owner/repo",
|
||||||
|
"git@github.com:owner/nested/repo.git": "owner/nested/repo",
|
||||||
|
"ssh://git@github.com/owner/nested/repo/": "owner/nested/repo",
|
||||||
|
"ssh://git@github.com:443/owner/repo.git": "owner/repo",
|
||||||
|
"https://example.com/owner/repo.git": "",
|
||||||
|
"git@bitbucket.org:owner/repo.git": "",
|
||||||
|
" ssh://git@github.com/owner/repo.git ": "owner/repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
for remote, expected := range cases {
|
||||||
|
t.Run(remote, func(t *testing.T) {
|
||||||
|
require.Equal(t, expected, parseGitHubRepoURL(remote))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
package setup
|
package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style aliases from shared package
|
// Style aliases from shared package
|
||||||
|
|
@ -33,9 +33,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
var setupCmd = &cli.Command{
|
var setupCmd = &cli.Command{
|
||||||
Use: "setup",
|
Use: "setup",
|
||||||
Short: i18n.T("cmd.setup.short"),
|
|
||||||
Long: i18n.T("cmd.setup.long"),
|
|
||||||
RunE: func(cmd *cli.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
return runSetupOrchestrator(registryPath, only, dryRun, all, name, build)
|
return runSetupOrchestrator(registryPath, only, dryRun, all, name, build)
|
||||||
},
|
},
|
||||||
|
|
@ -53,6 +51,7 @@ func initSetupFlags() {
|
||||||
// AddSetupCommand adds the 'setup' command to the given parent command.
|
// AddSetupCommand adds the 'setup' command to the given parent command.
|
||||||
func AddSetupCommand(root *cli.Command) {
|
func AddSetupCommand(root *cli.Command) {
|
||||||
initSetupFlags()
|
initSetupFlags()
|
||||||
|
addRepoCommand(setupCmd)
|
||||||
addGitHubCommand(setupCmd)
|
addGitHubCommand(setupCmd)
|
||||||
root.AddCommand(setupCmd)
|
root.AddCommand(setupCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,6 +39,9 @@ func promptProjectName(defaultName string) (string, error) {
|
||||||
// runPackageWizard presents an interactive multi-select UI for package selection.
|
// runPackageWizard presents an interactive multi-select UI for package selection.
|
||||||
func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string, error) {
|
func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string, error) {
|
||||||
allRepos := reg.List()
|
allRepos := reg.List()
|
||||||
|
if len(preselectedTypes) > 0 {
|
||||||
|
allRepos = filterReposByTypes(allRepos, preselectedTypes)
|
||||||
|
}
|
||||||
|
|
||||||
// Build options
|
// Build options
|
||||||
var options []string
|
var options []string
|
||||||
|
|
@ -57,6 +60,10 @@ func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string,
|
||||||
options = append(options, label)
|
options = append(options, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(options) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.package_selection")))
|
fmt.Println(cli.TitleStyle.Render(i18n.T("cmd.setup.wizard.package_selection")))
|
||||||
fmt.Println(i18n.T("cmd.setup.wizard.selection_hint"))
|
fmt.Println(i18n.T("cmd.setup.wizard.selection_hint"))
|
||||||
|
|
||||||
|
|
@ -87,6 +94,33 @@ func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string,
|
||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterReposByTypes(repoList []*repos.Repo, allowedTypes []string) []*repos.Repo {
|
||||||
|
if len(allowedTypes) == 0 {
|
||||||
|
return repoList
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := make(map[string]struct{}, len(allowedTypes))
|
||||||
|
for _, repoType := range allowedTypes {
|
||||||
|
if repoType == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allowed[repoType] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return repoList
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*repos.Repo, 0, len(repoList))
|
||||||
|
for _, repo := range repoList {
|
||||||
|
if _, ok := allowed[repo.Type]; ok {
|
||||||
|
filtered = append(filtered, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
// confirmClone asks for confirmation before cloning.
|
// confirmClone asks for confirmation before cloning.
|
||||||
func confirmClone(count int, target string) (bool, error) {
|
func confirmClone(count int, target string) (bool, error) {
|
||||||
confirmed := cli.Confirm(i18n.T("cmd.setup.wizard.confirm_clone", map[string]any{"Count": count, "Target": target}))
|
confirmed := cli.Confirm(i18n.T("cmd.setup.wizard.confirm_clone", map[string]any{"Count": count, "Target": target}))
|
||||||
|
|
|
||||||
34
cmd/setup/cmd_wizard_test.go
Normal file
34
cmd/setup/cmd_wizard_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"dappco.re/go/core/scm/repos"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterReposByTypes_Good(t *testing.T) {
|
||||||
|
reposList := []*repos.Repo{
|
||||||
|
{Name: "foundation-a", Type: "foundation"},
|
||||||
|
{Name: "module-a", Type: "module"},
|
||||||
|
{Name: "product-a", Type: "product"},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterReposByTypes(reposList, []string{"module", "product"})
|
||||||
|
|
||||||
|
require.Len(t, filtered, 2)
|
||||||
|
require.Equal(t, "module-a", filtered[0].Name)
|
||||||
|
require.Equal(t, "product-a", filtered[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterReposByTypes_EmptyFilter_Good(t *testing.T) {
|
||||||
|
reposList := []*repos.Repo{
|
||||||
|
{Name: "foundation-a", Type: "foundation"},
|
||||||
|
{Name: "module-a", Type: "module"},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterReposByTypes(reposList, nil)
|
||||||
|
|
||||||
|
require.Len(t, filtered, 2)
|
||||||
|
require.Equal(t, reposList, filtered)
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,8 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
coreio "dappco.re/go/core/io"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,7 +68,7 @@ type SecurityConfig struct {
|
||||||
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
|
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
|
||||||
data, err := coreio.Local.Read(path)
|
data, err := coreio.Local.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, log.E("setup.github_config", "failed to read config file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand environment variables before parsing
|
// Expand environment variables before parsing
|
||||||
|
|
@ -75,7 +76,7 @@ func LoadGitHubConfig(path string) (*GitHubConfig, error) {
|
||||||
|
|
||||||
var config GitHubConfig
|
var config GitHubConfig
|
||||||
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
|
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
return nil, log.E("setup.github_config", "failed to parse config file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set defaults
|
// Set defaults
|
||||||
|
|
@ -131,7 +132,7 @@ func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
||||||
if coreio.Local.IsFile(specifiedPath) {
|
if coreio.Local.IsFile(specifiedPath) {
|
||||||
return specifiedPath, nil
|
return specifiedPath, nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("config file not found: %s", specifiedPath)
|
return "", log.E("setup.github_config", fmt.Sprintf("config file not found: %s", specifiedPath), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search in common locations (using filepath.Join for OS-portable paths)
|
// Search in common locations (using filepath.Join for OS-portable paths)
|
||||||
|
|
@ -146,26 +147,26 @@ func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("github.yaml not found in %s/.core/ or %s/", registryDir, registryDir)
|
return "", log.E("setup.github_config", fmt.Sprintf("github.yaml not found in %s/.core/ or %s/", registryDir, registryDir), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks the configuration for errors.
|
// Validate checks the configuration for errors.
|
||||||
func (c *GitHubConfig) Validate() error {
|
func (c *GitHubConfig) Validate() error {
|
||||||
if c.Version != 1 {
|
if c.Version != 1 {
|
||||||
return fmt.Errorf("unsupported config version: %d (expected 1)", c.Version)
|
return log.E("setup.github_config", fmt.Sprintf("unsupported config version: %d (expected 1)", c.Version), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate labels
|
// Validate labels
|
||||||
for i, label := range c.Labels {
|
for i, label := range c.Labels {
|
||||||
if label.Name == "" {
|
if label.Name == "" {
|
||||||
return fmt.Errorf("label %d: name is required", i+1)
|
return log.E("setup.github_config", fmt.Sprintf("label %d: name is required", i+1), nil)
|
||||||
}
|
}
|
||||||
if label.Color == "" {
|
if label.Color == "" {
|
||||||
return fmt.Errorf("label %q: color is required", label.Name)
|
return log.E("setup.github_config", fmt.Sprintf("label %q: color is required", label.Name), nil)
|
||||||
}
|
}
|
||||||
// Validate color format (hex without #)
|
// Validate color format (hex without #)
|
||||||
if !isValidHexColor(label.Color) {
|
if !isValidHexColor(label.Color) {
|
||||||
return fmt.Errorf("label %q: invalid color %q (expected 6-digit hex without #)", label.Name, label.Color)
|
return log.E("setup.github_config", fmt.Sprintf("label %q: invalid color %q (expected 6-digit hex without #)", label.Name, label.Color), nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,14 +177,14 @@ func (c *GitHubConfig) Validate() error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(wh.Events) == 0 {
|
if len(wh.Events) == 0 {
|
||||||
return fmt.Errorf("webhook %q: at least one event is required", name)
|
return log.E("setup.github_config", fmt.Sprintf("webhook %q: at least one event is required", name), nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate branch protection
|
// Validate branch protection
|
||||||
for i, bp := range c.BranchProtection {
|
for i, bp := range c.BranchProtection {
|
||||||
if bp.Branch == "" {
|
if bp.Branch == "" {
|
||||||
return fmt.Errorf("branch_protection %d: branch is required", i+1)
|
return log.E("setup.github_config", fmt.Sprintf("branch_protection %d: branch is required", i+1), nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"dappco.re/go/core/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChangeType indicates the type of change being made.
|
// ChangeType indicates the type of change being made.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHubBranchProtection represents branch protection rules from the GitHub API.
|
// GitHubBranchProtection represents branch protection rules from the GitHub API.
|
||||||
|
|
@ -68,7 +69,7 @@ type RequiredConversationResolution struct {
|
||||||
func GetBranchProtection(repoFullName, branch string) (*GitHubBranchProtection, error) {
|
func GetBranchProtection(repoFullName, branch string) (*GitHubBranchProtection, error) {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return nil, fmt.Errorf("invalid repo format: %s", repoFullName)
|
return nil, log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch)
|
endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch)
|
||||||
|
|
@ -101,7 +102,7 @@ func GetBranchProtection(repoFullName, branch string) (*GitHubBranchProtection,
|
||||||
func SetBranchProtection(repoFullName, branch string, config BranchProtectionConfig) error {
|
func SetBranchProtection(repoFullName, branch string, config BranchProtectionConfig) error {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return fmt.Errorf("invalid repo format: %s", repoFullName)
|
return log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the protection payload
|
// Build the protection payload
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHubSecurityStatus represents the security settings status of a repository.
|
// GitHubSecurityStatus represents the security settings status of a repository.
|
||||||
|
|
@ -46,7 +47,7 @@ type SecurityFeature struct {
|
||||||
func GetSecuritySettings(repoFullName string) (*GitHubSecurityStatus, error) {
|
func GetSecuritySettings(repoFullName string) (*GitHubSecurityStatus, error) {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return nil, fmt.Errorf("invalid repo format: %s", repoFullName)
|
return nil, log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
status := &GitHubSecurityStatus{}
|
status := &GitHubSecurityStatus{}
|
||||||
|
|
@ -102,7 +103,7 @@ func GetSecuritySettings(repoFullName string) (*GitHubSecurityStatus, error) {
|
||||||
func EnableDependabotAlerts(repoFullName string) error {
|
func EnableDependabotAlerts(repoFullName string) error {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return fmt.Errorf("invalid repo format: %s", repoFullName)
|
return log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", parts[0], parts[1])
|
endpoint := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", parts[0], parts[1])
|
||||||
|
|
@ -118,7 +119,7 @@ func EnableDependabotAlerts(repoFullName string) error {
|
||||||
func EnableDependabotSecurityUpdates(repoFullName string) error {
|
func EnableDependabotSecurityUpdates(repoFullName string) error {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return fmt.Errorf("invalid repo format: %s", repoFullName)
|
return log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1])
|
endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1])
|
||||||
|
|
@ -134,7 +135,7 @@ func EnableDependabotSecurityUpdates(repoFullName string) error {
|
||||||
func DisableDependabotSecurityUpdates(repoFullName string) error {
|
func DisableDependabotSecurityUpdates(repoFullName string) error {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return fmt.Errorf("invalid repo format: %s", repoFullName)
|
return log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1])
|
endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1])
|
||||||
|
|
@ -150,7 +151,7 @@ func DisableDependabotSecurityUpdates(repoFullName string) error {
|
||||||
func UpdateSecurityAndAnalysis(repoFullName string, secretScanning, pushProtection bool) error {
|
func UpdateSecurityAndAnalysis(repoFullName string, secretScanning, pushProtection bool) error {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return fmt.Errorf("invalid repo format: %s", repoFullName)
|
return log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the payload
|
// Build the payload
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
log "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHubWebhook represents a webhook as returned by the GitHub API.
|
// GitHubWebhook represents a webhook as returned by the GitHub API.
|
||||||
|
|
@ -35,7 +36,7 @@ type GitHubWebhookConfig struct {
|
||||||
func ListWebhooks(repoFullName string) ([]GitHubWebhook, error) {
|
func ListWebhooks(repoFullName string) ([]GitHubWebhook, error) {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return nil, fmt.Errorf("invalid repo format: %s", repoFullName)
|
return nil, log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("repos/%s/%s/hooks", parts[0], parts[1])
|
endpoint := fmt.Sprintf("repos/%s/%s/hooks", parts[0], parts[1])
|
||||||
|
|
@ -65,7 +66,7 @@ func ListWebhooks(repoFullName string) ([]GitHubWebhook, error) {
|
||||||
func CreateWebhook(repoFullName string, name string, config WebhookConfig) error {
|
func CreateWebhook(repoFullName string, name string, config WebhookConfig) error {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return fmt.Errorf("invalid repo format: %s", repoFullName)
|
return log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the webhook payload
|
// Build the webhook payload
|
||||||
|
|
@ -108,7 +109,7 @@ func CreateWebhook(repoFullName string, name string, config WebhookConfig) error
|
||||||
func UpdateWebhook(repoFullName string, hookID int, config WebhookConfig) error {
|
func UpdateWebhook(repoFullName string, hookID int, config WebhookConfig) error {
|
||||||
parts := strings.Split(repoFullName, "/")
|
parts := strings.Split(repoFullName, "/")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return fmt.Errorf("invalid repo format: %s", repoFullName)
|
return log.E("setup.github", fmt.Sprintf("invalid repo format: %s", repoFullName), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ package coolify
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-devops/deploy/python"
|
log "dappco.re/go/core/log"
|
||||||
|
|
||||||
|
"dappco.re/go/core/devops/deploy/python"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client wraps the Python CoolifyClient for Go usage.
|
// Client wraps the Python CoolifyClient for Go usage.
|
||||||
|
|
@ -42,15 +42,15 @@ func DefaultConfig() Config {
|
||||||
// NewClient creates a new Coolify client.
|
// NewClient creates a new Coolify client.
|
||||||
func NewClient(cfg Config) (*Client, error) {
|
func NewClient(cfg Config) (*Client, error) {
|
||||||
if cfg.BaseURL == "" {
|
if cfg.BaseURL == "" {
|
||||||
return nil, errors.New("COOLIFY_URL not set")
|
return nil, log.E("coolify", "COOLIFY_URL not set", nil)
|
||||||
}
|
}
|
||||||
if cfg.APIToken == "" {
|
if cfg.APIToken == "" {
|
||||||
return nil, errors.New("COOLIFY_TOKEN not set")
|
return nil, log.E("coolify", "COOLIFY_TOKEN not set", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Python runtime
|
// Initialize Python runtime
|
||||||
if err := python.Init(); err != nil {
|
if err := python.Init(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize Python: %w", err)
|
return nil, log.E("coolify", "failed to initialize Python", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
|
|
@ -73,11 +73,11 @@ func (c *Client) Call(ctx context.Context, operationID string, params map[string
|
||||||
// Generate and run Python script
|
// Generate and run Python script
|
||||||
script, err := python.CoolifyScript(c.baseURL, c.apiToken, operationID, params)
|
script, err := python.CoolifyScript(c.baseURL, c.apiToken, operationID, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate script: %w", err)
|
return nil, log.E("coolify", "failed to generate script", err)
|
||||||
}
|
}
|
||||||
output, err := python.RunScript(ctx, script)
|
output, err := python.RunScript(ctx, script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("API call %s failed: %w", operationID, err)
|
return nil, log.E("coolify", "API call "+operationID+" failed", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON result
|
// Parse JSON result
|
||||||
|
|
@ -88,7 +88,7 @@ func (c *Client) Call(ctx context.Context, operationID string, params map[string
|
||||||
if err2 := json.Unmarshal([]byte(output), &arrResult); err2 == nil {
|
if err2 := json.Unmarshal([]byte(output), &arrResult); err2 == nil {
|
||||||
return map[string]any{"result": arrResult}, nil
|
return map[string]any{"result": arrResult}, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to parse response: %w (output: %s)", err, output)
|
return nil, log.E("coolify", "failed to parse response (output: "+output+")", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"github.com/kluctl/go-embed-python/python"
|
"github.com/kluctl/go-embed-python/python"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -63,9 +63,9 @@ func RunScript(ctx context.Context, code string, args ...string) (string, error)
|
||||||
// Run with context
|
// Run with context
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try to get stderr for better error message
|
// Include stderr in the error message for better diagnostics
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
return "", log.E("python", "run script", fmt.Errorf("%w: %s", err, string(exitErr.Stderr)))
|
return "", log.E("python", "run script: "+string(exitErr.Stderr), err)
|
||||||
}
|
}
|
||||||
return "", log.E("python", "run script", err)
|
return "", log.E("python", "run script", err)
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ func RunModule(ctx context.Context, module string, args ...string) (string, erro
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", log.E("python", fmt.Sprintf("run module %s", module), err)
|
return "", log.E("python", "run module "+module, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(output), nil
|
return string(output), nil
|
||||||
|
|
|
||||||
323
devkit/coverage.go
Normal file
323
devkit/coverage.go
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
package devkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoveragePackage describes coverage for a single package or directory.
|
||||||
|
type CoveragePackage struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
CoveredStatements int `json:"covered_statements"`
|
||||||
|
TotalStatements int `json:"total_statements"`
|
||||||
|
Coverage float64 `json:"coverage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverageSnapshot captures a point-in-time view of coverage across packages.
|
||||||
|
type CoverageSnapshot struct {
|
||||||
|
CapturedAt time.Time `json:"captured_at"`
|
||||||
|
Packages []CoveragePackage `json:"packages"`
|
||||||
|
Total CoveragePackage `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverageDelta describes how a single package changed between snapshots.
|
||||||
|
type CoverageDelta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Previous float64 `json:"previous"`
|
||||||
|
Current float64 `json:"current"`
|
||||||
|
Delta float64 `json:"delta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverageComparison summarises the differences between two coverage snapshots.
|
||||||
|
type CoverageComparison struct {
|
||||||
|
Regressions []CoverageDelta `json:"regressions"`
|
||||||
|
Improvements []CoverageDelta `json:"improvements"`
|
||||||
|
NewPackages []CoveragePackage `json:"new_packages"`
|
||||||
|
Removed []CoveragePackage `json:"removed"`
|
||||||
|
TotalDelta float64 `json:"total_delta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverageStore persists coverage snapshots to disk.
|
||||||
|
type CoverageStore struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type coverageBucket struct {
|
||||||
|
covered int
|
||||||
|
total int
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverProfileLineRE = regexp.MustCompile(`^(.+?):\d+\.\d+,\d+\.\d+\s+(\d+)\s+(\d+)$`)
|
||||||
|
var coverOutputLineRE = regexp.MustCompile(`^(?:ok|\?)?\s*(\S+)\s+.*coverage:\s+([0-9]+(?:\.[0-9]+)?)% of statements$`)
|
||||||
|
|
||||||
|
// NewCoverageStore creates a store backed by the given file path.
|
||||||
|
func NewCoverageStore(path string) *CoverageStore {
|
||||||
|
return &CoverageStore{path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append stores a new snapshot, creating the parent directory if needed.
|
||||||
|
func (s *CoverageStore) Append(snapshot CoverageSnapshot) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots, err := s.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.CapturedAt = snapshot.CapturedAt.UTC()
|
||||||
|
snapshots = append(snapshots, snapshot)
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(snapshots, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(s.path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads all snapshots from disk.
|
||||||
|
func (s *CoverageStore) Load() ([]CoverageSnapshot, error) {
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(string(data))) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshots []CoverageSnapshot
|
||||||
|
if err := json.Unmarshal(data, &snapshots); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return snapshots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest returns the newest snapshot in the store.
|
||||||
|
func (s *CoverageStore) Latest() (CoverageSnapshot, error) {
|
||||||
|
snapshots, err := s.Load()
|
||||||
|
if err != nil {
|
||||||
|
return CoverageSnapshot{}, err
|
||||||
|
}
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
return CoverageSnapshot{}, fmt.Errorf("coverage store is empty")
|
||||||
|
}
|
||||||
|
return snapshots[len(snapshots)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCoverProfile parses go test -coverprofile output into a coverage snapshot.
|
||||||
|
func ParseCoverProfile(data string) (CoverageSnapshot, error) {
|
||||||
|
if strings.TrimSpace(data) == "" {
|
||||||
|
return CoverageSnapshot{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
packages := make(map[string]*coverageBucket)
|
||||||
|
total := coverageBucket{}
|
||||||
|
|
||||||
|
for _, rawLine := range strings.Split(strings.TrimSpace(data), "\n") {
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" || strings.HasPrefix(line, "mode:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
match := coverProfileLineRE.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
return CoverageSnapshot{}, fmt.Errorf("invalid cover profile line: %s", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.ToSlash(match[1])
|
||||||
|
stmts, err := strconv.Atoi(match[2])
|
||||||
|
if err != nil {
|
||||||
|
return CoverageSnapshot{}, err
|
||||||
|
}
|
||||||
|
count, err := strconv.Atoi(match[3])
|
||||||
|
if err != nil {
|
||||||
|
return CoverageSnapshot{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := path.Dir(file)
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
b := packages[dir]
|
||||||
|
if b == nil {
|
||||||
|
b = &coverageBucket{}
|
||||||
|
packages[dir] = b
|
||||||
|
}
|
||||||
|
b.total += stmts
|
||||||
|
total.total += stmts
|
||||||
|
if count > 0 {
|
||||||
|
b.covered += stmts
|
||||||
|
total.covered += stmts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshotFromBuckets(packages, total), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCoverOutput parses human-readable go test -cover output into a snapshot.
|
||||||
|
func ParseCoverOutput(output string) (CoverageSnapshot, error) {
|
||||||
|
if strings.TrimSpace(output) == "" {
|
||||||
|
return CoverageSnapshot{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
packages := make(map[string]*CoveragePackage)
|
||||||
|
var total CoveragePackage
|
||||||
|
|
||||||
|
for _, rawLine := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
match := coverOutputLineRE.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := match[1]
|
||||||
|
coverage, err := strconv.ParseFloat(match[2], 64)
|
||||||
|
if err != nil {
|
||||||
|
return CoverageSnapshot{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg := &CoveragePackage{
|
||||||
|
Name: name,
|
||||||
|
Coverage: coverage,
|
||||||
|
}
|
||||||
|
packages[name] = pkg
|
||||||
|
|
||||||
|
total.Coverage += coverage
|
||||||
|
total.TotalStatements++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(packages) == 0 {
|
||||||
|
return CoverageSnapshot{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := CoverageSnapshot{
|
||||||
|
CapturedAt: time.Now().UTC(),
|
||||||
|
Packages: make([]CoveragePackage, 0, len(packages)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pkg := range packages {
|
||||||
|
snapshot.Packages = append(snapshot.Packages, *pkg)
|
||||||
|
}
|
||||||
|
sort.Slice(snapshot.Packages, func(i, j int) bool {
|
||||||
|
return snapshot.Packages[i].Name < snapshot.Packages[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshot.Total.Name = "total"
|
||||||
|
if total.TotalStatements > 0 {
|
||||||
|
snapshot.Total.Coverage = total.Coverage / float64(total.TotalStatements)
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareCoverage compares two snapshots and reports regressions and improvements.
|
||||||
|
func CompareCoverage(previous, current CoverageSnapshot) CoverageComparison {
|
||||||
|
prevPackages := coverageMap(previous.Packages)
|
||||||
|
currPackages := coverageMap(current.Packages)
|
||||||
|
|
||||||
|
comparison := CoverageComparison{
|
||||||
|
NewPackages: make([]CoveragePackage, 0),
|
||||||
|
Removed: make([]CoveragePackage, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, curr := range currPackages {
|
||||||
|
prev, ok := prevPackages[name]
|
||||||
|
if !ok {
|
||||||
|
comparison.NewPackages = append(comparison.NewPackages, curr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := curr.Coverage - prev.Coverage
|
||||||
|
change := CoverageDelta{
|
||||||
|
Name: name,
|
||||||
|
Previous: prev.Coverage,
|
||||||
|
Current: curr.Coverage,
|
||||||
|
Delta: delta,
|
||||||
|
}
|
||||||
|
if delta < 0 {
|
||||||
|
comparison.Regressions = append(comparison.Regressions, change)
|
||||||
|
} else if delta > 0 {
|
||||||
|
comparison.Improvements = append(comparison.Improvements, change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, prev := range prevPackages {
|
||||||
|
if _, ok := currPackages[name]; !ok {
|
||||||
|
comparison.Removed = append(comparison.Removed, prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortCoverageComparison(&comparison)
|
||||||
|
comparison.TotalDelta = current.Total.Coverage - previous.Total.Coverage
|
||||||
|
return comparison
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotFromBuckets(packages map[string]*coverageBucket, total coverageBucket) CoverageSnapshot {
|
||||||
|
snapshot := CoverageSnapshot{
|
||||||
|
CapturedAt: time.Now().UTC(),
|
||||||
|
Packages: make([]CoveragePackage, 0, len(packages)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, b := range packages {
|
||||||
|
snapshot.Packages = append(snapshot.Packages, coverageAverage(name, b.covered, b.total))
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(snapshot.Packages, func(i, j int) bool {
|
||||||
|
return snapshot.Packages[i].Name < snapshot.Packages[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshot.Total = coverageAverage("total", total.covered, total.total)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverageAverage(name string, covered, total int) CoveragePackage {
|
||||||
|
pkg := CoveragePackage{
|
||||||
|
Name: name,
|
||||||
|
CoveredStatements: covered,
|
||||||
|
TotalStatements: total,
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
pkg.Coverage = float64(covered) / float64(total) * 100
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverageMap(packages []CoveragePackage) map[string]CoveragePackage {
|
||||||
|
result := make(map[string]CoveragePackage, len(packages))
|
||||||
|
for _, pkg := range packages {
|
||||||
|
result[pkg.Name] = pkg
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortCoverageComparison(comparison *CoverageComparison) {
|
||||||
|
sort.Slice(comparison.Regressions, func(i, j int) bool {
|
||||||
|
return comparison.Regressions[i].Name < comparison.Regressions[j].Name
|
||||||
|
})
|
||||||
|
sort.Slice(comparison.Improvements, func(i, j int) bool {
|
||||||
|
return comparison.Improvements[i].Name < comparison.Improvements[j].Name
|
||||||
|
})
|
||||||
|
sort.Slice(comparison.NewPackages, func(i, j int) bool {
|
||||||
|
return comparison.NewPackages[i].Name < comparison.NewPackages[j].Name
|
||||||
|
})
|
||||||
|
sort.Slice(comparison.Removed, func(i, j int) bool {
|
||||||
|
return comparison.Removed[i].Name < comparison.Removed[j].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
108
devkit/coverage_test.go
Normal file
108
devkit/coverage_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
package devkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCoverProfile_Good(t *testing.T) {
|
||||||
|
snapshot, err := ParseCoverProfile(`mode: set
|
||||||
|
github.com/acme/project/foo/foo.go:1.1,3.1 2 1
|
||||||
|
github.com/acme/project/foo/bar.go:1.1,4.1 3 0
|
||||||
|
github.com/acme/project/baz/baz.go:1.1,2.1 4 4
|
||||||
|
`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Packages, 2)
|
||||||
|
require.Equal(t, "github.com/acme/project/baz", snapshot.Packages[0].Name)
|
||||||
|
require.Equal(t, "github.com/acme/project/foo", snapshot.Packages[1].Name)
|
||||||
|
require.InDelta(t, 100.0, snapshot.Packages[0].Coverage, 0.0001)
|
||||||
|
require.InDelta(t, 40.0, snapshot.Packages[1].Coverage, 0.0001)
|
||||||
|
require.InDelta(t, 66.6667, snapshot.Total.Coverage, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCoverProfile_Bad(t *testing.T) {
|
||||||
|
_, err := ParseCoverProfile("mode: set\nbroken line")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCoverOutput_Good(t *testing.T) {
|
||||||
|
snapshot, err := ParseCoverOutput(`ok github.com/acme/project/foo 0.123s coverage: 75.0% of statements
|
||||||
|
ok github.com/acme/project/bar 0.456s coverage: 50.0% of statements
|
||||||
|
`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Packages, 2)
|
||||||
|
require.Equal(t, "github.com/acme/project/bar", snapshot.Packages[0].Name)
|
||||||
|
require.Equal(t, "github.com/acme/project/foo", snapshot.Packages[1].Name)
|
||||||
|
require.InDelta(t, 62.5, snapshot.Total.Coverage, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareCoverage_Good(t *testing.T) {
|
||||||
|
previous := CoverageSnapshot{
|
||||||
|
Packages: []CoveragePackage{
|
||||||
|
{Name: "pkg/a", Coverage: 90.0},
|
||||||
|
{Name: "pkg/b", Coverage: 80.0},
|
||||||
|
},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 85.0},
|
||||||
|
}
|
||||||
|
current := CoverageSnapshot{
|
||||||
|
Packages: []CoveragePackage{
|
||||||
|
{Name: "pkg/a", Coverage: 87.5},
|
||||||
|
{Name: "pkg/b", Coverage: 82.0},
|
||||||
|
{Name: "pkg/c", Coverage: 100.0},
|
||||||
|
},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 89.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
comparison := CompareCoverage(previous, current)
|
||||||
|
require.Len(t, comparison.Regressions, 1)
|
||||||
|
require.Len(t, comparison.Improvements, 1)
|
||||||
|
require.Len(t, comparison.NewPackages, 1)
|
||||||
|
require.Empty(t, comparison.Removed)
|
||||||
|
require.Equal(t, "pkg/a", comparison.Regressions[0].Name)
|
||||||
|
require.Equal(t, "pkg/b", comparison.Improvements[0].Name)
|
||||||
|
require.Equal(t, "pkg/c", comparison.NewPackages[0].Name)
|
||||||
|
require.InDelta(t, 4.0, comparison.TotalDelta, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverageStore_Good(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewCoverageStore(filepath.Join(dir, "coverage.json"))
|
||||||
|
|
||||||
|
first := CoverageSnapshot{
|
||||||
|
CapturedAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
|
||||||
|
Packages: []CoveragePackage{{Name: "pkg/a", Coverage: 80.0}},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 80.0},
|
||||||
|
}
|
||||||
|
second := CoverageSnapshot{
|
||||||
|
CapturedAt: time.Date(2026, 4, 1, 11, 0, 0, 0, time.UTC),
|
||||||
|
Packages: []CoveragePackage{{Name: "pkg/a", Coverage: 82.5}},
|
||||||
|
Total: CoveragePackage{Name: "total", Coverage: 82.5},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, store.Append(first))
|
||||||
|
require.NoError(t, store.Append(second))
|
||||||
|
|
||||||
|
snapshots, err := store.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 2)
|
||||||
|
require.Equal(t, first.CapturedAt, snapshots[0].CapturedAt)
|
||||||
|
require.Equal(t, second.CapturedAt, snapshots[1].CapturedAt)
|
||||||
|
|
||||||
|
latest, err := store.Latest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, second.CapturedAt, latest.CapturedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverageStore_Bad(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "coverage.json")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("{"), 0o600))
|
||||||
|
|
||||||
|
store := NewCoverageStore(path)
|
||||||
|
_, err := store.Load()
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
109
devkit/scan_secrets.go
Normal file
109
devkit/scan_secrets.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
package devkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var scanSecretsRunner = runGitleaksDetect
|
||||||
|
|
||||||
|
// ScanSecrets runs gitleaks against the supplied directory and parses the CSV report.
|
||||||
|
func ScanSecrets(dir string) ([]Finding, error) {
|
||||||
|
output, err := scanSecretsRunner(dir)
|
||||||
|
findings, parseErr := parseGitleaksCSV(output)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
if err != nil && len(findings) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return findings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGitleaksDetect(dir string) ([]byte, error) {
|
||||||
|
bin, err := exec.LookPath("gitleaks")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(context.Background(), bin,
|
||||||
|
"detect",
|
||||||
|
"--no-banner",
|
||||||
|
"--no-color",
|
||||||
|
"--no-git",
|
||||||
|
"--source", dir,
|
||||||
|
"--report-format", "csv",
|
||||||
|
"--report-path", "-",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd.Output()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGitleaksCSV(data []byte) ([]Finding, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := csv.NewReader(strings.NewReader(string(data)))
|
||||||
|
reader.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
rows, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
header := make(map[string]int, len(rows[0]))
|
||||||
|
for idx, name := range rows[0] {
|
||||||
|
header[normalizeCSVHeader(name)] = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
var findings []Finding
|
||||||
|
for _, row := range rows[1:] {
|
||||||
|
finding := Finding{
|
||||||
|
Path: csvField(row, header, "file", "path"),
|
||||||
|
Line: csvIntField(row, header, "startline", "line"),
|
||||||
|
Column: csvIntField(row, header, "startcolumn", "column"),
|
||||||
|
Rule: csvField(row, header, "ruleid", "rule", "name"),
|
||||||
|
Snippet: csvField(row, header, "match", "secret", "description", "message"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if finding.Snippet == "" {
|
||||||
|
finding.Snippet = csvField(row, header, "filename")
|
||||||
|
}
|
||||||
|
findings = append(findings, finding)
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCSVHeader(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(name, "_", ""), " ", "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func csvField(row []string, header map[string]int, names ...string) string {
|
||||||
|
for _, name := range names {
|
||||||
|
if idx, ok := header[name]; ok && idx < len(row) {
|
||||||
|
return strings.TrimSpace(row[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func csvIntField(row []string, header map[string]int, names ...string) int {
|
||||||
|
value := csvField(row, header, names...)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
64
devkit/scan_secrets_test.go
Normal file
64
devkit/scan_secrets_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package devkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScanSecrets_Good(t *testing.T) {
|
||||||
|
originalRunner := scanSecretsRunner
|
||||||
|
t.Cleanup(func() {
|
||||||
|
scanSecretsRunner = originalRunner
|
||||||
|
})
|
||||||
|
|
||||||
|
scanSecretsRunner = func(dir string) ([]byte, error) {
|
||||||
|
require.Equal(t, "/tmp/project", dir)
|
||||||
|
return []byte(`RuleID,File,StartLine,StartColumn,Description,Match
|
||||||
|
github-token,config.yml,12,4,GitHub token detected,ghp_exampletoken1234567890
|
||||||
|
aws-access-key-id,creds.txt,7,1,AWS access key detected,AKIA1234567890ABCDEF
|
||||||
|
`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, err := ScanSecrets("/tmp/project")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, findings, 2)
|
||||||
|
|
||||||
|
require.Equal(t, "github-token", findings[0].Rule)
|
||||||
|
require.Equal(t, "config.yml", findings[0].Path)
|
||||||
|
require.Equal(t, 12, findings[0].Line)
|
||||||
|
require.Equal(t, 4, findings[0].Column)
|
||||||
|
require.Equal(t, "ghp_exampletoken1234567890", findings[0].Snippet)
|
||||||
|
|
||||||
|
require.Equal(t, "aws-access-key-id", findings[1].Rule)
|
||||||
|
require.Equal(t, "creds.txt", findings[1].Path)
|
||||||
|
require.Equal(t, 7, findings[1].Line)
|
||||||
|
require.Equal(t, 1, findings[1].Column)
|
||||||
|
require.Equal(t, "AKIA1234567890ABCDEF", findings[1].Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSecrets_ReportsFindingsOnExitError(t *testing.T) {
|
||||||
|
originalRunner := scanSecretsRunner
|
||||||
|
t.Cleanup(func() {
|
||||||
|
scanSecretsRunner = originalRunner
|
||||||
|
})
|
||||||
|
|
||||||
|
scanSecretsRunner = func(dir string) ([]byte, error) {
|
||||||
|
return []byte(`rule_id,file,start_line,start_column,description,match
|
||||||
|
token,test.txt,3,2,Token detected,secret-value
|
||||||
|
`), errors.New("exit status 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, err := ScanSecrets("/tmp/project")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, findings, 1)
|
||||||
|
require.Equal(t, "token", findings[0].Rule)
|
||||||
|
require.Equal(t, 3, findings[0].Line)
|
||||||
|
require.Equal(t, 2, findings[0].Column)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGitleaksCSV_Bad(t *testing.T) {
|
||||||
|
_, err := parseGitleaksCSV([]byte("rule_id,file,start_line\nunterminated,\"broken"))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
154
devkit/secret.go
Normal file
154
devkit/secret.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package devkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding describes a secret-like match discovered while scanning source files.
|
||||||
|
type Finding struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Column int `json:"column"`
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var secretRules = []struct {
|
||||||
|
name string
|
||||||
|
match *regexp.Regexp
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "aws-access-key-id",
|
||||||
|
match: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "github-token",
|
||||||
|
match: regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9_]{20,}\b`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic-secret-assignment",
|
||||||
|
match: regexp.MustCompile(`(?i)\b(?:api[_-]?key|client[_-]?secret|secret|token|password)\b\s*[:=]\s*["']?([A-Za-z0-9._\-+/]{8,})["']?`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipDirs = map[string]struct{}{
|
||||||
|
".git": {},
|
||||||
|
"vendor": {},
|
||||||
|
"node_modules": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var textExts = map[string]struct{}{
|
||||||
|
".go": {},
|
||||||
|
".md": {},
|
||||||
|
".txt": {},
|
||||||
|
".json": {},
|
||||||
|
".yaml": {},
|
||||||
|
".yml": {},
|
||||||
|
".toml": {},
|
||||||
|
".env": {},
|
||||||
|
".ini": {},
|
||||||
|
".cfg": {},
|
||||||
|
".conf": {},
|
||||||
|
".sh": {},
|
||||||
|
".tf": {},
|
||||||
|
".tfvars": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanDir recursively scans a directory for secret-like patterns.
|
||||||
|
func ScanDir(root string) ([]Finding, error) {
|
||||||
|
var findings []Finding
|
||||||
|
|
||||||
|
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := d.Name()
|
||||||
|
if d.IsDir() {
|
||||||
|
if _, ok := skipDirs[name]; ok || strings.HasPrefix(name, ".") && path != root {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isTextCandidate(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileFindings, err := scanFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
findings = append(findings, fileFindings...)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFile(path string) ([]Finding, error) {
|
||||||
|
data, err := fileRead(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) == 0 || bytes.IndexByte(data, 0) >= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var findings []Finding
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
lineNo := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
|
line := scanner.Text()
|
||||||
|
matchedSpecific := false
|
||||||
|
for _, rule := range secretRules {
|
||||||
|
if rule.name == "generic-secret-assignment" && matchedSpecific {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if loc := rule.match.FindStringIndex(line); loc != nil {
|
||||||
|
findings = append(findings, Finding{
|
||||||
|
Path: path,
|
||||||
|
Line: lineNo,
|
||||||
|
Column: loc[0] + 1,
|
||||||
|
Rule: rule.name,
|
||||||
|
Snippet: strings.TrimSpace(line),
|
||||||
|
})
|
||||||
|
if rule.name != "generic-secret-assignment" {
|
||||||
|
matchedSpecific = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTextCandidate(name string) bool {
|
||||||
|
if ext := strings.ToLower(filepath.Ext(name)); ext != "" {
|
||||||
|
_, ok := textExts[ext]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
// Allow extension-less files such as Makefile, LICENSE, and .env.
|
||||||
|
switch name {
|
||||||
|
case "Makefile", "Dockerfile", "LICENSE", "README", "CLAUDE.md":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(name, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileRead is factored out for tests.
|
||||||
|
var fileRead = func(path string) ([]byte, error) {
|
||||||
|
return os.ReadFile(path)
|
||||||
|
}
|
||||||
57
devkit/secret_test.go
Normal file
57
devkit/secret_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package devkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScanDir_Good(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yml"), []byte(`
|
||||||
|
api_key: "ghp_abcdefghijklmnopqrstuvwxyz1234"
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(root, "nested"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "nested", "creds.txt"), []byte("access_key = AKIA1234567890ABCDEF\n"), 0o600))
|
||||||
|
|
||||||
|
findings, err := ScanDir(root)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, findings, 2)
|
||||||
|
|
||||||
|
require.Equal(t, "github-token", findings[0].Rule)
|
||||||
|
require.Equal(t, 2, findings[0].Line)
|
||||||
|
require.Equal(t, "config.yml", filepath.Base(findings[0].Path))
|
||||||
|
|
||||||
|
require.Equal(t, "aws-access-key-id", findings[1].Rule)
|
||||||
|
require.Equal(t, 1, findings[1].Line)
|
||||||
|
require.Equal(t, "creds.txt", filepath.Base(findings[1].Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanDir_SkipsBinaryAndIgnoredDirs(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, ".git", "config"), []byte("token=ghp_abcdefghijklmnopqrstuvwxyz1234"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "blob.bin"), []byte{0, 1, 2, 3, 4}, 0o600))
|
||||||
|
|
||||||
|
findings, err := ScanDir(root)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanDir_ReportsGenericAssignments(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "secrets.env"), []byte("client_secret: abcdefghijklmnop\n"), 0o600))
|
||||||
|
|
||||||
|
findings, err := ScanDir(root)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, findings, 1)
|
||||||
|
require.Equal(t, "generic-secret-assignment", findings[0].Rule)
|
||||||
|
require.Equal(t, 1, findings[0].Line)
|
||||||
|
require.Equal(t, 1, findings[0].Column)
|
||||||
|
}
|
||||||
86
go.mod
86
go.mod
|
|
@ -1,109 +1,81 @@
|
||||||
module forge.lthn.ai/core/go-devops
|
module dappco.re/go/core/devops
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.23.2
|
code.gitea.io/sdk/gitea v0.23.2
|
||||||
forge.lthn.ai/core/cli v0.1.0
|
dappco.re/go/core v0.4.7
|
||||||
forge.lthn.ai/core/agent v0.1.0
|
dappco.re/go/agent v0.3.3
|
||||||
forge.lthn.ai/core/go-ansible v0.1.0
|
dappco.re/go/core/i18n v0.1.7
|
||||||
forge.lthn.ai/core/config v0.1.0
|
dappco.re/go/core/io v0.1.7
|
||||||
forge.lthn.ai/core/go-container v0.1.0
|
dappco.re/go/core/log v0.0.4
|
||||||
forge.lthn.ai/core/go-infra v0.1.0
|
dappco.re/go/core/scm v0.3.6
|
||||||
forge.lthn.ai/core/go-i18n v0.1.0
|
dappco.re/go/core/cli v0.3.7
|
||||||
forge.lthn.ai/core/go-io v0.0.3
|
dappco.re/go/core/container v0.1.7
|
||||||
forge.lthn.ai/core/go-log v0.0.1
|
|
||||||
forge.lthn.ai/core/go-scm v0.1.0
|
|
||||||
github.com/Snider/Borg v0.2.0
|
|
||||||
github.com/getkin/kin-openapi v0.133.0
|
|
||||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1
|
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1
|
||||||
github.com/leaanthony/debme v1.2.1
|
|
||||||
github.com/leaanthony/gosod v1.0.4
|
|
||||||
github.com/oasdiff/oasdiff v1.11.10
|
|
||||||
github.com/spf13/cobra v1.10.2
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/text v0.35.0
|
||||||
golang.org/x/term v0.40.0
|
|
||||||
golang.org/x/text v0.34.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.123.0 // indirect
|
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
|
||||||
forge.lthn.ai/core/go v0.1.0 // indirect
|
dappco.re/go/core/config v0.1.8 // indirect
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0 // indirect
|
dappco.re/go/core/inference v0.1.6 // indirect
|
||||||
forge.lthn.ai/core/go-inference v0.0.2 // indirect
|
dappco.re/go/core/store v0.1.9 // indirect
|
||||||
forge.lthn.ai/core/go-store v0.1.3 // indirect
|
github.com/42wim/httpsig v1.2.3 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
|
||||||
github.com/TwiN/go-color v1.4.1 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.13.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mailru/easyjson v0.9.1 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/spf13/viper v1.21.0 // indirect
|
github.com/spf13/viper v1.21.0 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
|
||||||
github.com/tidwall/match v1.2.0 // indirect
|
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
|
||||||
github.com/wI2L/jsondiff v0.7.0 // indirect
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
|
||||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
modernc.org/libc v1.68.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.46.1 // indirect
|
modernc.org/sqlite v1.47.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
245
go.sum
245
go.sum
|
|
@ -1,30 +1,69 @@
|
||||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||||
forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM=
|
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
||||||
forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc=
|
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
|
||||||
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
|
dappco.re/go/agent v0.3.3 h1:hVF+ExuJ/WHuQjEdje6bSUPcUpy6jUscVl9fiuV8l74=
|
||||||
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
|
dappco.re/go/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
|
||||||
forge.lthn.ai/core/go-agentic v0.1.0 h1:48tZbzJFpbcuUAm50emAzMrZWNITMvKNYGNrsDNWMdI=
|
dappco.re/go/agent v0.9.0 h1:ZfQTyUWa7YXznGLQZG9r7njwWThfLfsdIkOXJWboqZc=
|
||||||
forge.lthn.ai/core/go-agentic v0.1.0/go.mod h1:6a5D+dt0bShNbYqjNBaMQGBELX0vYkj3gIZ1afMfXFo=
|
dappco.re/go/agent v0.9.0/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
|
dappco.re/go/agent v0.10.0-alpha.1 h1:hZEm4lAqjP6wgsxelYETdMUhGTHdIBpH8hJTMO58GPA=
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
|
dappco.re/go/agent v0.10.0-alpha.1/go.mod h1:jiShGsIfHS7b7rJXMdb30K+wKL8Kx8w/VUrLNDYRbCo=
|
||||||
forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI=
|
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
|
||||||
forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
|
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
forge.lthn.ai/core/go-inference v0.0.2 h1:aHjBkYyLKxLr9tbO4AvzzV/lsZueGq/jeo33SLh113k=
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
forge.lthn.ai/core/go-inference v0.0.2/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI=
|
dappco.re/go/core/i18n v0.1.7 h1:JhJeptA/I42c7GhmtJDPDlvhO8Y3izQ82wpaXCy/XZ0=
|
||||||
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
|
dappco.re/go/core/i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||||
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo=
|
||||||
forge.lthn.ai/core/go-scm v0.1.0 h1:kpL2aGxhMxsLQoobuNLJbI6uMcsecMOh/8AAmIB9Mjc=
|
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||||
forge.lthn.ai/core/go-scm v0.1.0/go.mod h1:QrSFTqkBS/KgFiNrVngrY8nEwS0u41BjUAu/IEpXiRI=
|
dappco.re/go/core/inference v0.2.0/go.mod h1:YLYk/FxWACGehXpHCTa/t7hFl9uvAoq83QYSBakNNlc=
|
||||||
forge.lthn.ai/core/go-store v0.1.3 h1:CSVTRdsOXm2pl+FCs12fHOc9eM88DcZRY6HghN98w/I=
|
dappco.re/go/core/io v0.1.7 h1:tYyOnNFQcF//mqDLTNjBu4PV/CBizW7hm2ZnwdQQi40=
|
||||||
forge.lthn.ai/core/go-store v0.1.3/go.mod h1:op+ftjAqYskPv4OGvHZQf7/DLiRnFIdT0XCQTKR/GjE=
|
dappco.re/go/core/io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
dappco.re/go/core/io v0.3.0-alpha.1 h1:xTWrlk72qG0+aIyP5+Telp2nmFF0GG0EBFyVrOiBtec=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
dappco.re/go/core/io v0.3.0-alpha.1/go.mod h1:1/DWfw8U9ARKQobFJ7KhsNw2lvJGnQr/vi4Pmqxps6s=
|
||||||
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
|
dappco.re/go/core/log v0.0.4 h1:qy54NYLh9nA4Kvo6XBsuAdyDD5jRc9PVnJLz9R0LiBw=
|
||||||
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
|
dappco.re/go/core/log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
|
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
|
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||||
|
dappco.re/go/core/scm v0.3.6 h1:QUHaaPggP0+zfg7y4Q+BChQaVjx6PW+LKkOzcWYPpZ0=
|
||||||
|
dappco.re/go/core/scm v0.3.6/go.mod h1:IWFIYDfRH0mtRdqY5zV06l/RkmkPpBM6FcbKWhg1Qa8=
|
||||||
|
dappco.re/go/core/scm v0.5.0-alpha.1 h1:/LDH7lhVkogqJMxs3w6qmx87RuoHf3nGBNb5El2YQCg=
|
||||||
|
dappco.re/go/core/scm v0.5.0-alpha.1/go.mod h1:qj/tAPMefuQ9HR5Sb+6qZTuaFNbvTOAhedsXHcal1qU=
|
||||||
|
dappco.re/go/core/store v0.2.0/go.mod h1:QQGJiruayjna3nywbf0N2gcO502q/oEkPoSpBpSKbLM=
|
||||||
|
forge.lthn.ai/core/agent v0.3.3 h1:lGpoD5OgvdJ5z+qofw8fBWkDB186QM7I2jjXEbtzSdA=
|
||||||
|
forge.lthn.ai/core/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
|
||||||
|
forge.lthn.ai/core/agent v0.9.0 h1:O43ncyGmEKapB2kjxEzGODqOOMMT5IyZsotXieqmZGo=
|
||||||
|
forge.lthn.ai/core/agent v0.9.0/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
|
||||||
|
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
||||||
|
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
||||||
|
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
|
||||||
|
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
|
||||||
|
forge.lthn.ai/core/config v0.2.0-alpha.1 h1:lhxmnESx+iplLV7aqORbdOodQPSGoBk86oIxPyCXjmc=
|
||||||
|
forge.lthn.ai/core/config v0.2.0-alpha.1/go.mod h1:AIm7VlO/h4s1LmGSn0HZb+RqAbhmZFJppVGivcsJmGE=
|
||||||
|
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-container v0.1.7 h1:+/6NIu7OWyK2LSi2obnFF5fVpWhKiWduMiEkmnbZS6U=
|
||||||
|
forge.lthn.ai/core/go-container v0.1.7/go.mod h1:k0z4yhfZC05bYB5ANdpDFC3AcefnOWJvosXSBSydjs4=
|
||||||
|
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.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
|
||||||
|
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||||
|
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||||
|
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||||
|
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
|
||||||
|
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
||||||
|
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
|
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
|
forge.lthn.ai/core/go-scm v0.2.0 h1:TvDyCzw0HWzXjmqe6uPc46nPaRzc7MPGswmwZt0CmXo=
|
||||||
|
forge.lthn.ai/core/go-scm v0.2.0/go.mod h1:Q/PV2FbqDlWnAOsXAd1pgSiHOlRCPW4HcPmOt8Z9H+E=
|
||||||
|
forge.lthn.ai/core/go-scm v0.3.6 h1:LFNx8Fs82mrpxro/MPUM6tMiD4DqPmdu83UknXztQjc=
|
||||||
|
forge.lthn.ai/core/go-scm v0.3.6/go.mod h1:IWFIYDfRH0mtRdqY5zV06l/RkmkPpBM6FcbKWhg1Qa8=
|
||||||
|
forge.lthn.ai/core/go-store v0.1.6 h1:7T+K5cciXOaWRxge0WnGkt0PcK3epliWBa1G2FLEuac=
|
||||||
|
forge.lthn.ai/core/go-store v0.1.6/go.mod h1:/2vqaAn+HgGU14N29B+vIfhjIsBzy7RC+AluI6BIUKI=
|
||||||
|
forge.lthn.ai/core/go-store v0.1.9 h1:DGO2sUo2i/csWbhw7zxU7oyGF2FJT72/8w47GhZ1joM=
|
||||||
|
forge.lthn.ai/core/go-store v0.1.9/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk=
|
||||||
|
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||||
|
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
|
@ -35,8 +74,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
|
@ -49,13 +88,11 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
|
@ -66,34 +103,26 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
|
||||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
|
||||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
|
||||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
|
||||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||||
|
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
|
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
|
||||||
|
|
@ -102,28 +131,14 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
|
||||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
|
||||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
|
||||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
|
||||||
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
|
|
||||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
|
||||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
|
||||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
|
@ -132,17 +147,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
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/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/oasdiff/oasdiff v1.11.10 h1:4I9VrktUoHmwydkJqVOC7Bd6BXKu9dc4UUP3PIu1VjM=
|
|
||||||
github.com/oasdiff/oasdiff v1.11.10/go.mod h1:GXARzmqBKN8lZHsTQD35ZM41ePbu6JdAZza4sRMeEKg=
|
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||||
|
|
@ -156,8 +162,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
|
@ -169,82 +175,67 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
|
||||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
|
||||||
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
|
||||||
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
|
||||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
|
@ -253,8 +244,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|
|
||||||
15
locales/embed.go
Normal file
15
locales/embed.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Package locales embeds translation files for the go-devops module.
|
||||||
|
package locales
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"dappco.re/go/core/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.json
|
||||||
|
var FS embed.FS
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
i18n.RegisterLocales(FS, ".")
|
||||||
|
}
|
||||||
503
locales/en.json
Normal file
503
locales/en.json
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
{
|
||||||
|
"cmd": {
|
||||||
|
"deploy": {
|
||||||
|
"short": "Manage deployments via Coolify",
|
||||||
|
"long": "Manage deployments, servers, applications and services via the Coolify API."
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"short": "Multi-repo development workflows",
|
||||||
|
"long": "Development workflow commands for managing multiple repositories.\n\nIncludes git operations, forge integration, CI status, and dev environment management.",
|
||||||
|
"api": {
|
||||||
|
"short": "API synchronisation tools",
|
||||||
|
"test_gen": {
|
||||||
|
"short": "Generate public API test stubs",
|
||||||
|
"long": "Scan internal service packages and generate compile-time tests for their public API wrappers."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"short": "Quick health check across all repos",
|
||||||
|
"long": "Show a one-line summary of repository health: total repos, dirty count, push/pull status and errors.",
|
||||||
|
"repos": "repos",
|
||||||
|
"to_push": "to push",
|
||||||
|
"to_pull": "to pull",
|
||||||
|
"dirty_label": "Dirty:",
|
||||||
|
"ahead_label": "Ahead:",
|
||||||
|
"behind_label": "Behind:",
|
||||||
|
"errors": "errors",
|
||||||
|
"errors_label": "Errors:",
|
||||||
|
"more": "(+{{.Count}} more)",
|
||||||
|
"flag": {
|
||||||
|
"verbose": "Show per-repo details"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"work": {
|
||||||
|
"short": "Combined status, commit and push workflow",
|
||||||
|
"long": "Show status table for all repos, optionally commit dirty repos and push unpushed commits in a single workflow.",
|
||||||
|
"flag": {
|
||||||
|
"status": "Show status table only (no commit or push)",
|
||||||
|
"commit": "Auto-commit dirty repos before pushing"
|
||||||
|
},
|
||||||
|
"table_modified": "Modified",
|
||||||
|
"table_untracked": "Untracked",
|
||||||
|
"table_staged": "Staged",
|
||||||
|
"table_ahead": "Ahead",
|
||||||
|
"all_up_to_date": "All repos are up to date.",
|
||||||
|
"use_commit_flag": "Tip: use --commit to auto-commit dirty repos",
|
||||||
|
"error_prefix": "error:"
|
||||||
|
},
|
||||||
|
"commit": {
|
||||||
|
"short": "Claude-assisted commit for dirty repos",
|
||||||
|
"long": "Generate commit messages with Claude for repositories that have uncommitted changes.\n\nRuns in single-repo mode when inside a git repo, or multi-repo mode with a registry.",
|
||||||
|
"flag": {
|
||||||
|
"all": "Commit all dirty repos without prompting"
|
||||||
|
},
|
||||||
|
"committing": "Committing..."
|
||||||
|
},
|
||||||
|
"push": {
|
||||||
|
"short": "Push repos with unpushed commits",
|
||||||
|
"long": "Push all repositories that have commits ahead of remote.\n\nHandles diverged branches by offering to pull and retry.",
|
||||||
|
"flag": {
|
||||||
|
"force": "Push without confirmation"
|
||||||
|
},
|
||||||
|
"confirm": "Push?",
|
||||||
|
"confirm_push": "Push {{.Commits}} commit(s) across {{.Repos}} repo(s)?",
|
||||||
|
"all_up_to_date": "All repos are up to date.",
|
||||||
|
"done_pushed": "{{.Count}} pushed",
|
||||||
|
"diverged": "branch has diverged",
|
||||||
|
"diverged_help": "Some repos have diverged from remote. This usually means someone pushed while you were working.",
|
||||||
|
"pull_and_retry": "Pull and retry push?",
|
||||||
|
"uncommitted_changes_commit": "Uncommitted changes found. Commit first?"
|
||||||
|
},
|
||||||
|
"pull": {
|
||||||
|
"short": "Pull repos that are behind remote",
|
||||||
|
"long": "Pull latest changes for repositories that are behind their remote tracking branch.",
|
||||||
|
"flag": {
|
||||||
|
"all": "Pull all repos, not just those behind"
|
||||||
|
},
|
||||||
|
"pulling": "pulling",
|
||||||
|
"pulling_repos": "Pulling {{.Count}} repo(s)...",
|
||||||
|
"all_up_to_date": "All repos are up to date.",
|
||||||
|
"done_pulled": "{{.Count}} pulled",
|
||||||
|
"repos_behind": "{{.Count}} repo(s) behind remote",
|
||||||
|
"commits_behind": "{{.Count}} commit(s) behind"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"short": "Tag and push all repos in dependency order",
|
||||||
|
"long": "Bump patch version, update Go dependencies, tag and push all repos in topological order.",
|
||||||
|
"flag": {
|
||||||
|
"dry_run": "Preview the tag plan without making changes",
|
||||||
|
"force": "Tag without confirmation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"impact": {
|
||||||
|
"short": "Analyse dependency impact of a repo",
|
||||||
|
"long": "Show which repositories are affected when a given repo changes, including direct and transitive dependents.",
|
||||||
|
"analysis_for": "Impact analysis for",
|
||||||
|
"direct_dependents": "{{.Count}} direct dependent(s)",
|
||||||
|
"transitive_dependents": "{{.Count}} transitive dependent(s)",
|
||||||
|
"no_dependents": "{{.Name}} has no dependents",
|
||||||
|
"changes_affect": "Changes to {{.Repo}} affect {{.Affected}} of {{.Total}} repos",
|
||||||
|
"requires_registry": "Impact analysis requires a repos.yaml registry"
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"short": "List open issues across repos",
|
||||||
|
"long": "Fetch and display open issues from the Forge API across all registered repositories.",
|
||||||
|
"flag": {
|
||||||
|
"assignee": "Filter by assignee username",
|
||||||
|
"limit": "Maximum issues per repo"
|
||||||
|
},
|
||||||
|
"open_issues": "{{.Count}} open issue(s)",
|
||||||
|
"no_issues": "No open issues found."
|
||||||
|
},
|
||||||
|
"reviews": {
|
||||||
|
"short": "List pull requests needing review",
|
||||||
|
"long": "Show open pull requests with review status across all registered repositories.",
|
||||||
|
"flag": {
|
||||||
|
"all": "Include draft PRs",
|
||||||
|
"author": "Filter by PR author"
|
||||||
|
},
|
||||||
|
"open_prs": "{{.Count}} open PR(s)",
|
||||||
|
"no_prs": "No open pull requests found.",
|
||||||
|
"approved": "{{.Count}} approved",
|
||||||
|
"changes_requested": "{{.Count}} changes requested",
|
||||||
|
"draft": "[draft]",
|
||||||
|
"status_approved": "approved",
|
||||||
|
"status_changes": "changes requested",
|
||||||
|
"status_pending": "pending review"
|
||||||
|
},
|
||||||
|
"ci": {
|
||||||
|
"short": "Check CI workflow status",
|
||||||
|
"long": "Show CI/CD pipeline status for all repos, with pass/fail counts and optional branch filtering.",
|
||||||
|
"flag": {
|
||||||
|
"branch": "Branch to check (default: main)",
|
||||||
|
"failed": "Show only failed workflows"
|
||||||
|
},
|
||||||
|
"passing": "{{.Count}} passing",
|
||||||
|
"failing": "{{.Count}} failing",
|
||||||
|
"repos_checked": "{{.Count}} repos checked",
|
||||||
|
"no_ci": "{{.Count}} no CI"
|
||||||
|
},
|
||||||
|
"apply": {
|
||||||
|
"short": "Run a command across repos",
|
||||||
|
"long": "Execute a shell command or script in every repo directory, optionally committing and pushing changes.\n\nDesigned for safe batch operations by AI agents.",
|
||||||
|
"action": "Action",
|
||||||
|
"cancelled": "Cancelled.",
|
||||||
|
"confirm": "Proceed?",
|
||||||
|
"dry_run_mode": "[dry-run] No changes will be made",
|
||||||
|
"no_changes": "no changes",
|
||||||
|
"summary": "Summary",
|
||||||
|
"targets": "Targets",
|
||||||
|
"warning": "This will run the command in each repo directory.",
|
||||||
|
"flag": {
|
||||||
|
"command": "Shell command to run in each repo",
|
||||||
|
"script": "Script file to run in each repo",
|
||||||
|
"repos": "Comma-separated list of repo names, paths, or glob patterns to target",
|
||||||
|
"commit": "Commit changes after running",
|
||||||
|
"message": "Commit message (required with --commit)",
|
||||||
|
"push": "Push after committing",
|
||||||
|
"co_author": "Co-author for commits",
|
||||||
|
"dry_run": "Preview without making changes",
|
||||||
|
"yes": "Skip confirmation prompt",
|
||||||
|
"continue": "Continue on error instead of stopping"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"no_command": "Either --command or --script is required",
|
||||||
|
"both_command_script": "Cannot use both --command and --script",
|
||||||
|
"no_repos": "No target repos found",
|
||||||
|
"command_failed": "Command failed — stopping (use --continue to skip failures)",
|
||||||
|
"commit_needs_message": "--commit requires --message"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"file_sync": {
|
||||||
|
"short": "Sync a file or directory to repos",
|
||||||
|
"long": "Copy a file or directory to matching repos, optionally committing and pushing the changes.\n\nDesigned for safe file distribution by AI agents.",
|
||||||
|
"source": "Source",
|
||||||
|
"targets": "Targets",
|
||||||
|
"warning": "This will copy files into each target repo.",
|
||||||
|
"confirm": "Sync these repos?",
|
||||||
|
"dry_run_mode": "[dry-run] No changes will be made",
|
||||||
|
"no_changes": "no changes",
|
||||||
|
"summary": "Summary",
|
||||||
|
"flag": {
|
||||||
|
"to": "Target repo pattern (e.g. core-*)",
|
||||||
|
"message": "Commit message (omit to leave uncommitted)",
|
||||||
|
"push": "Push after committing",
|
||||||
|
"co_author": "Co-author for commits",
|
||||||
|
"dry_run": "Preview without making changes",
|
||||||
|
"yes": "Skip confirmation prompt"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"source_not_found": "Source not found: {{.Path}}",
|
||||||
|
"no_targets": "No target repos matched the pattern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"short": "Synchronise public API surfaces",
|
||||||
|
"long": "Scan internal service packages and regenerate public API wrappers to keep them in sync."
|
||||||
|
},
|
||||||
|
"workflow": {
|
||||||
|
"short": "Manage CI workflow files",
|
||||||
|
"long": "List and synchronise CI/CD workflow files across all registered repositories.",
|
||||||
|
"no_workflows": "No workflow files found in any repo.",
|
||||||
|
"synced": "synced",
|
||||||
|
"up_to_date": "up to date",
|
||||||
|
"would_sync": "would sync",
|
||||||
|
"dry_run_mode": "[dry-run] No changes will be made",
|
||||||
|
"run_without_dry_run": "Run without --dry-run to apply changes.",
|
||||||
|
"template_not_found": "Template workflow not found: {{.File}}",
|
||||||
|
"read_template_error": "Failed to read template workflow",
|
||||||
|
"failed_count": "{{.Count}} failed",
|
||||||
|
"skipped_count": "{{.Count}} skipped",
|
||||||
|
"synced_count": "{{.Count}} synced",
|
||||||
|
"would_sync_count": "{{.Count}} would sync",
|
||||||
|
"templates": "Templates",
|
||||||
|
"header": {
|
||||||
|
"repo": "Repo"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"short": "Show workflow matrix across repos",
|
||||||
|
"long": "Display a table showing which CI/CD workflow files exist in each repository."
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"short": "Copy a workflow template to all repos",
|
||||||
|
"long": "Synchronise a workflow template file into every registered repository's .github/workflows/ directory.",
|
||||||
|
"flag": {
|
||||||
|
"dry_run": "Preview sync without writing files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scanning_label": "Scanning...",
|
||||||
|
"no_git_repos": "No git repositories found.",
|
||||||
|
"no_changes": "No changes to commit.",
|
||||||
|
"repos_with_changes": "{{.Count}} repo(s) with changes",
|
||||||
|
"modified": "{{.Count}} modified",
|
||||||
|
"untracked": "{{.Count}} untracked",
|
||||||
|
"staged": "{{.Count}} staged",
|
||||||
|
"committed": "committed",
|
||||||
|
"committing": "Committing",
|
||||||
|
"confirm_claude_commit": "Commit with Claude?",
|
||||||
|
"done_succeeded": "{{.Count}} succeeded",
|
||||||
|
"status": {
|
||||||
|
"clean": "clean"
|
||||||
|
},
|
||||||
|
"vm": {
|
||||||
|
"boot": {
|
||||||
|
"short": "Start the dev environment VM",
|
||||||
|
"long": "Boot the Parallels dev environment virtual machine with configurable memory and CPU.",
|
||||||
|
"flag": {
|
||||||
|
"memory": "Memory in MB (default: auto)",
|
||||||
|
"cpus": "Number of CPUs (default: auto)",
|
||||||
|
"fresh": "Discard existing state and boot fresh"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"short": "Download the dev environment image",
|
||||||
|
"long": "Download and install the Parallels dev environment VM image."
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"short": "Mount project and start dev server",
|
||||||
|
"long": "Mount the current project directory into the VM and start the development server.",
|
||||||
|
"flag": {
|
||||||
|
"port": "Port to expose (default: auto)",
|
||||||
|
"path": "Project path to mount"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"short": "Open a shell in the dev VM",
|
||||||
|
"long": "Open an interactive shell session inside the running dev environment VM.",
|
||||||
|
"flag": {
|
||||||
|
"console": "Use serial console instead of SSH"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stop": {
|
||||||
|
"short": "Stop the dev environment VM",
|
||||||
|
"long": "Gracefully shut down the running Parallels dev environment VM."
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"short": "Check for and apply VM updates",
|
||||||
|
"long": "Check if a newer dev environment image is available and optionally download it.",
|
||||||
|
"flag": {
|
||||||
|
"apply": "Download and apply the update"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"short": "Check dev VM status",
|
||||||
|
"long": "Show installation and runtime status of the Parallels dev environment VM."
|
||||||
|
},
|
||||||
|
"claude": {
|
||||||
|
"short": "Start sandboxed Claude session in VM",
|
||||||
|
"long": "Launch a Claude Code session inside the dev VM with project files mounted and optional authentication.",
|
||||||
|
"flag": {
|
||||||
|
"no_auth": "Skip authentication forwarding",
|
||||||
|
"model": "Model to use for the Claude session",
|
||||||
|
"auth": "Authentication flags to pass through"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"short": "Run tests in the dev environment",
|
||||||
|
"long": "Execute the test suite inside the dev environment VM with the current project mounted.",
|
||||||
|
"flag": {
|
||||||
|
"name": "Run only tests matching this name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"already_installed": "Dev environment already installed.",
|
||||||
|
"check_updates": "Check for updates with {{.Command}}",
|
||||||
|
"downloading": "Downloading dev environment image...",
|
||||||
|
"progress_label": "Progress:",
|
||||||
|
"installed_in": "Installed in {{.Duration}}",
|
||||||
|
"start_with": "Start with {{.Command}}",
|
||||||
|
"not_installed": "Dev environment not installed. Run 'core dev install' first.",
|
||||||
|
"config_label": "Config:",
|
||||||
|
"config_value": "{{.Memory}}MB RAM, {{.CPUs}} CPUs",
|
||||||
|
"booting": "Booting dev environment...",
|
||||||
|
"running": "Dev environment running",
|
||||||
|
"connect_with": "Connect with {{.Command}}",
|
||||||
|
"ssh_port": "SSH port:",
|
||||||
|
"not_running": "Dev environment is not running.",
|
||||||
|
"stopping": "Stopping dev environment...",
|
||||||
|
"status_title": "Dev Environment Status",
|
||||||
|
"installed_label": "Installed:",
|
||||||
|
"installed_yes": "Yes",
|
||||||
|
"installed_no": "No",
|
||||||
|
"install_with": "Install with {{.Command}}",
|
||||||
|
"container_label": "Container:",
|
||||||
|
"memory_label": "Memory:",
|
||||||
|
"cpus_label": "CPUs:",
|
||||||
|
"uptime_label": "Uptime:",
|
||||||
|
"latest_label": "Latest:",
|
||||||
|
"up_to_date": "Dev environment is up to date.",
|
||||||
|
"update_available": "Update available!",
|
||||||
|
"run_to_update": "Run {{.Command}} to update.",
|
||||||
|
"stopping_current": "Stopping current VM...",
|
||||||
|
"downloading_update": "Downloading update...",
|
||||||
|
"updated_in": "Updated in {{.Duration}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"short": "Documentation management",
|
||||||
|
"long": "List, scan and synchronise documentation across all registered repositories.",
|
||||||
|
"list": {
|
||||||
|
"short": "List documentation coverage",
|
||||||
|
"long": "Show a table of documentation files (README, CLAUDE.md, CHANGELOG, docs/) for each repo.",
|
||||||
|
"coverage_summary": "{{.WithDocs}} with docs, {{.WithoutDocs}} without",
|
||||||
|
"header": {
|
||||||
|
"readme": "README",
|
||||||
|
"claude": "CLAUDE",
|
||||||
|
"changelog": "CHANGELOG",
|
||||||
|
"docs": "Docs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"short": "Sync docs to a central location",
|
||||||
|
"long": "Copy documentation from each repo's docs/ directory into a central output directory.",
|
||||||
|
"flag": {
|
||||||
|
"dry_run": "Preview sync without writing files",
|
||||||
|
"output": "Output directory for synced docs"
|
||||||
|
},
|
||||||
|
"confirm": "Sync documentation?",
|
||||||
|
"dry_run_notice": "Dry run — no files written.",
|
||||||
|
"files_count": "({{.Count}} file(s))",
|
||||||
|
"repos_with_docs": "{{.Count}} repo(s) with docs",
|
||||||
|
"synced_packages": "{{.Count}} package(s) synced",
|
||||||
|
"total_summary": "{{.Files}} files from {{.Repos}} repos → {{.Output}}",
|
||||||
|
"no_docs_found": "No documentation found in any repo."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"short": "Git workflow commands",
|
||||||
|
"long": "Git operations for single or multi-repo workflows.\n\nIncludes status, commit, push, pull, and safe batch operations for AI agents."
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"short": "Set up workspace and clone packages",
|
||||||
|
"long": "Bootstrap a new workspace or clone packages from a repos.yaml registry.\n\nIn bootstrap mode (no registry), clones the devops repo first, then offers a package wizard.\nIn registry mode, clones all or selected packages into the packages directory.",
|
||||||
|
"flag": {
|
||||||
|
"all": "Clone all packages without prompting",
|
||||||
|
"build": "Run build after cloning",
|
||||||
|
"dry_run": "Preview what would be cloned",
|
||||||
|
"name": "Project directory name (bootstrap mode)",
|
||||||
|
"only": "Filter by repo type (e.g. foundation,module,product)",
|
||||||
|
"registry": "Path to repos.yaml"
|
||||||
|
},
|
||||||
|
"bootstrap_mode": "No registry found — entering bootstrap mode",
|
||||||
|
"cloning_current_dir": "Cloning into current directory",
|
||||||
|
"creating_project_dir": "Creating project directory",
|
||||||
|
"cloned": "cloned",
|
||||||
|
"would_clone": "Would clone",
|
||||||
|
"already_exists": "already exists",
|
||||||
|
"would_load_registry": "Would load registry from",
|
||||||
|
"org_label": "Organisation:",
|
||||||
|
"to_clone": "{{.Count}} to clone",
|
||||||
|
"exist": "{{.Count}} exist",
|
||||||
|
"nothing_to_clone": "Nothing to clone — all repos already exist.",
|
||||||
|
"would_clone_list": "Would clone:",
|
||||||
|
"cancelled": "Cancelled.",
|
||||||
|
"done": "done",
|
||||||
|
"cloned_count": "{{.Count}} cloned",
|
||||||
|
"already_exist_count": "{{.Count}} already exist",
|
||||||
|
"wizard": {
|
||||||
|
"git_repo_title": "Git Repository Detected",
|
||||||
|
"what_to_do": "This directory is already a git repository. What would you like to do?",
|
||||||
|
"project_name_title": "Project Name",
|
||||||
|
"project_name_desc": "Enter a name for the project directory",
|
||||||
|
"package_selection": "Package Selection",
|
||||||
|
"selection_hint": "Use space to select, enter to confirm",
|
||||||
|
"select_packages": "Select packages to clone",
|
||||||
|
"confirm_clone": "Clone {{.Count}} package(s) to {{.Target}}?"
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"short": "Generate .core config for a repo",
|
||||||
|
"long": "Detect the current project type and generate .core/build.yaml, release.yaml, and test.yaml for the repository.",
|
||||||
|
"setting_up": "Setting up repo",
|
||||||
|
"detected_type": "Detected project type",
|
||||||
|
"would_create": "Would create",
|
||||||
|
"created": "Created"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"short": "Configure GitHub repo settings",
|
||||||
|
"long": "Apply standardised GitHub settings (labels, webhooks, branch protection, security) to repos.",
|
||||||
|
"flag": {
|
||||||
|
"repo": "Target a specific repo",
|
||||||
|
"all": "Apply all settings",
|
||||||
|
"labels": "Sync issue labels",
|
||||||
|
"webhooks": "Sync webhooks",
|
||||||
|
"protection": "Sync branch protection rules",
|
||||||
|
"security": "Sync security settings",
|
||||||
|
"check": "Check current settings (dry run)",
|
||||||
|
"config": "Path to GitHub config file"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"not_authenticated": "GitHub CLI (gh) is not authenticated. Run 'gh auth login' first.",
|
||||||
|
"config_not_found": "GitHub config file not found",
|
||||||
|
"conflicting_flags": "Cannot use --check with modification flags"
|
||||||
|
},
|
||||||
|
"dry_run_mode": "[dry-run] Checking current settings",
|
||||||
|
"no_repos_specified": "No repos specified.",
|
||||||
|
"usage_hint": "Use --repo=<name> or --all to target repos.",
|
||||||
|
"run_without_check": "Run without --check to apply changes.",
|
||||||
|
"no_changes": "No changes needed",
|
||||||
|
"repos_checked": "Repos checked",
|
||||||
|
"all_up_to_date": "All repos are up to date",
|
||||||
|
"repos_with_changes": "Repos with changes",
|
||||||
|
"to_create": "To create",
|
||||||
|
"to_update": "To update",
|
||||||
|
"to_delete": "To delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"flag": {
|
||||||
|
"registry": "Path to repos.yaml registry",
|
||||||
|
"verbose": "Show detailed output"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"commits": "{{.Count}} commit(s)",
|
||||||
|
"failed": "{{.Count}} failed",
|
||||||
|
"files": "{{.Count}} file(s)",
|
||||||
|
"pending": "{{.Count}} pending",
|
||||||
|
"repos_unpushed": "{{.Count}} repo(s) with unpushed commits",
|
||||||
|
"skipped": "{{.Count}} skipped",
|
||||||
|
"succeeded": "{{.Count}} succeeded"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"clean": "clean",
|
||||||
|
"dirty": "dirty",
|
||||||
|
"synced": "synced",
|
||||||
|
"up_to_date": "up to date",
|
||||||
|
"cloning": "Cloning",
|
||||||
|
"running": "Running",
|
||||||
|
"stopped": "Stopped"
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"abort": "Aborted."
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"checking_updates": "Checking for updates...",
|
||||||
|
"checking": "Checking..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"aborted": "Aborted."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"gh_not_found": "GitHub CLI (gh) not found. Install it from https://cli.github.com/",
|
||||||
|
"registry_not_found": "Registry (repos.yaml) not found. Run from a workspace directory or use --registry.",
|
||||||
|
"repo_not_found": "Repository not found: {{.Name}}"
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"count": {
|
||||||
|
"failed": "{{.Count}} failed"
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"sync": "Sync complete."
|
||||||
|
},
|
||||||
|
"fail": {
|
||||||
|
"load": "Failed to load {{.Name}}",
|
||||||
|
"run": "Failed to run {{.Name}}",
|
||||||
|
"scan": "Failed to scan {{.Name}}"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"check": "Checking",
|
||||||
|
"fetch": "Fetching"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,11 @@ package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-scm/manifest"
|
log "dappco.re/go/core/log"
|
||||||
|
|
||||||
|
"dappco.re/go/core/scm/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Snapshot is the frozen release manifest written as core.json.
|
// Snapshot is the frozen release manifest written as core.json.
|
||||||
|
|
@ -35,7 +36,7 @@ func Generate(m *manifest.Manifest, commit, tag string) ([]byte, error) {
|
||||||
// GenerateAt creates a core.json snapshot with an explicit build timestamp.
|
// GenerateAt creates a core.json snapshot with an explicit build timestamp.
|
||||||
func GenerateAt(m *manifest.Manifest, commit, tag string, built time.Time) ([]byte, error) {
|
func GenerateAt(m *manifest.Manifest, commit, tag string, built time.Time) ([]byte, error) {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return nil, errors.New("snapshot: manifest is nil")
|
return nil, log.E("snapshot", "manifest is nil", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
snap := Snapshot{
|
snap := Snapshot{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-scm/manifest"
|
"dappco.re/go/core/scm/manifest"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue