Compare commits

...

19 commits
v0.2.0 ... dev

Author SHA1 Message Date
Snider
d08d2eb1fc fix: migrate module paths from forge.lthn.ai to dappco.re
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:12 +01:00
Virgil
6e278a293a feat(dev): show workflow templates in list
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:48:48 +00:00
Virgil
c50257fa49 fix(docs): align gohelp repo mapping
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:11:00 +00:00
Virgil
93c8eef876 feat(dev): support glob targets in apply command
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:02:04 +00:00
Virgil
ae3935919e feat(dev): support glob patterns in file-sync targets
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 08:13:14 +00:00
Virgil
af9887217a fix(setup): broaden GitHub remote parsing
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 07:25:34 +00:00
Virgil
c06fd2edfc feat(dev): add confirmation to file sync
Add a confirmation gate and --yes bypass to core dev sync so batch file distribution follows the same safety model as the other AI-agent commands.
2026-04-01 07:16:56 +00:00
Virgil
cbf650918a feat(devkit): add coverage trending helpers
Implement coverage profile and output parsing, snapshot comparison, and a JSON-backed coverage store.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 07:10:00 +00:00
Virgil
6eef0ff234 fix(docs): clear zensical sync output 2026-04-01 07:03:38 +00:00
Virgil
04d8a17dc7 fix(setup): prefer node project detection over php
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:54:39 +00:00
Virgil
0179ddf4f2 feat(setup): add repo subcommand
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:49:34 +00:00
Virgil
29cbec8575 fix(docs): sync zensical readmes to index
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:41:38 +00:00
Virgil
b5d32ade33 fix(setup): apply type filter in package wizard
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:34:22 +00:00
Virgil
24fd01dc26 fix(setup): repair scoop bucket default
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:26:16 +00:00
Virgil
ba08cac5ef feat(dev): expose vm status command
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:19:31 +00:00
Virgil
f3c5fe9a7b feat(dev): scan full service packages for API stubs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:08:17 +00:00
Virgil
fa20cb8aa5 feat(devkit): add gitleaks-backed secret scanning
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:00:25 +00:00
Virgil
a4d8aba714 feat(devkit): add secret scanning
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 05:37:28 +00:00
Virgil
b7d70883e9 feat(dev): add api test-gen command
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 05:10:56 +00:00
35 changed files with 1759 additions and 119 deletions

View file

@ -1,8 +1,8 @@
package dev package dev
import ( import (
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// 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
View 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())
}

View 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)
}

View file

@ -12,14 +12,14 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "sort"
"forge.lthn.ai/core/cli/pkg/cli"
core "dappco.re/go/core/log"
"dappco.re/go/core/scm/git"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"dappco.re/go/core/io" "dappco.re/go/core/io"
core "dappco.re/go/core/log"
"dappco.re/go/core/scm/git"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// 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) // filterTargetRepos selects repos by exact name/path or glob pattern.
func filterTargetRepos(registry *repos.Registry, selection string) []*repos.Repo {
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 { for _, name := range repoNames {
nameSet[strings.TrimSpace(name)] = true matched = append(matched, registry.Repos[name])
}
return matched
} }
patterns := splitPatterns(selection)
var matched []*repos.Repo var matched []*repos.Repo
for _, repo := range registry.Repos {
if nameSet[repo.Name] { 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
View 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)
})
}

View file

@ -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,8 @@
package dev package dev
import ( import (
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
_ "dappco.re/go/core/devops/locales" _ "dappco.re/go/core/devops/locales"
) )

View file

@ -14,12 +14,12 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/scm/git"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
"dappco.re/go/core/log" "dappco.re/go/core/log"
"dappco.re/go/core/scm/git"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// 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 {
if matchGlob(repo.Name, candidate) || matchGlob(repo.Path, candidate) {
matched = append(matched, repo) 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, "*")

View 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", "[]"))
}

View file

@ -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"
"dappco.re/go/core/i18n" // Added
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/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. if err != nil {
content, err := coreio.Local.Read(path) return nil, err
}
symbolsByName := make(map[string]symbolInfo)
for _, file := range files {
content, err := coreio.Local.Read(file)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fset := token.NewFileSet() fset := token.NewFileSet()
// ParseFile can take content as string (src argument). node, err := parser.ParseFile(fset, file, content, parser.ParseComments)
node, err := parser.ParseFile(fset, path, content, parser.ParseComments)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var symbols []symbolInfo
for name, obj := range node.Scope.Objects { for name, obj := range node.Scope.Objects {
if ast.IsExported(name) { if !ast.IsExported(name) {
continue
}
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}}

View file

@ -5,11 +5,11 @@ import (
"os" "os"
"time" "time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-container/devenv"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"dappco.re/go/core/io" "dappco.re/go/core/io"
log "dappco.re/go/core/log" log "dappco.re/go/core/log"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-container/devenv"
) )
// addVMCommands adds the dev environment VM commands to the dev parent command. // addVMCommands adds the dev environment VM commands to the dev parent command.
@ -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
View 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)
}

View file

@ -7,10 +7,10 @@ import (
"slices" "slices"
"strings" "strings"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"dappco.re/go/core/io" "dappco.re/go/core/io"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// 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")}

View file

@ -1,7 +1,9 @@
package dev package dev
import ( import (
"maps"
"path/filepath" "path/filepath"
"slices"
"testing" "testing"
"dappco.re/go/core/io" "dappco.re/go/core/io"
@ -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)
}
}

View file

@ -6,10 +6,10 @@ import (
"strings" "strings"
"dappco.re/go/agent/cmd/workspace" "dappco.re/go/agent/cmd/workspace"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"dappco.re/go/core/io" "dappco.re/go/core/io"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// loadRegistryWithConfig loads the registry and applies workspace configuration. // loadRegistryWithConfig loads the registry and applies workspace configuration.

View file

@ -5,8 +5,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"dappco.re/go/core"
agentic "dappco.re/go/agent/pkg/lifecycle" agentic "dappco.re/go/agent/pkg/lifecycle"
"dappco.re/go/core"
) )
// ServiceOptions for configuring the dev service. // ServiceOptions for configuring the dev service.

View file

@ -7,10 +7,10 @@ import (
"strings" "strings"
"dappco.re/go/agent/cmd/workspace" "dappco.re/go/agent/cmd/workspace"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"dappco.re/go/core/io" "dappco.re/go/core/io"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// RepoDocInfo holds documentation info for a repo // RepoDocInfo holds documentation info for a repo

View file

@ -6,10 +6,10 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"dappco.re/go/core/io" "dappco.re/go/core/io"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// Flag variables for sync command // Flag variables for sync command
@ -140,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
} }
@ -275,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)
@ -283,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 {
@ -295,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)
} }
} }
@ -305,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 {
@ -326,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-")
@ -388,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
View 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)
}
}
}

View file

@ -6,8 +6,8 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"forge.lthn.ai/core/cli/pkg/cli"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
"forge.lthn.ai/core/cli/pkg/cli"
"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
View 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`)
}

View file

@ -14,11 +14,11 @@ import (
"strings" "strings"
"dappco.re/go/agent/cmd/workspace" "dappco.re/go/agent/cmd/workspace"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
log "dappco.re/go/core/log" log "dappco.re/go/core/log"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// runRegistrySetup loads a registry from path and runs setup. // runRegistrySetup loads a registry from path and runs setup.

View file

@ -8,6 +8,8 @@ package setup
import ( import (
"fmt" "fmt"
"net/url"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@ -15,8 +17,33 @@ import (
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
log "dappco.re/go/core/log" 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)
@ -73,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"
} }
@ -268,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 ""
} }

View 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))
})
}
}

View file

@ -2,8 +2,8 @@
package setup package setup
import ( import (
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
) )
// Style aliases from shared package // Style aliases from shared package
@ -51,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)
} }

View file

@ -7,9 +7,9 @@ import (
"os" "os"
"slices" "slices"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/i18n" "dappco.re/go/core/i18n"
"dappco.re/go/core/scm/repos" "dappco.re/go/core/scm/repos"
"forge.lthn.ai/core/cli/pkg/cli"
"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}))

View 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)
}

323
devkit/coverage.go Normal file
View 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
View 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
View 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
}

View 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
View 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
View 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)
}

10
go.mod
View file

@ -10,8 +10,8 @@ require (
dappco.re/go/core/io v0.1.7 dappco.re/go/core/io v0.1.7
dappco.re/go/core/log v0.0.4 dappco.re/go/core/log v0.0.4
dappco.re/go/core/scm v0.3.6 dappco.re/go/core/scm v0.3.6
forge.lthn.ai/core/cli v0.3.7 dappco.re/go/core/cli v0.3.7
forge.lthn.ai/core/go-container v0.1.7 dappco.re/go/core/container v0.1.7
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/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/term v0.41.0 golang.org/x/term v0.41.0
@ -21,9 +21,9 @@ require (
require ( require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
forge.lthn.ai/core/config v0.1.8 // indirect dappco.re/go/core/config v0.1.8 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect dappco.re/go/core/inference v0.1.6 // indirect
forge.lthn.ai/core/go-store v0.1.9 // indirect dappco.re/go/core/store v0.1.9 // indirect
github.com/42wim/httpsig v1.2.3 // indirect github.com/42wim/httpsig v1.2.3 // 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

36
go.sum
View file

@ -2,14 +2,44 @@ code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
dappco.re/go/agent v0.3.3 h1:hVF+ExuJ/WHuQjEdje6bSUPcUpy6jUscVl9fiuV8l74=
dappco.re/go/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
dappco.re/go/agent v0.9.0 h1:ZfQTyUWa7YXznGLQZG9r7njwWThfLfsdIkOXJWboqZc=
dappco.re/go/agent v0.9.0/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
dappco.re/go/agent v0.10.0-alpha.1 h1:hZEm4lAqjP6wgsxelYETdMUhGTHdIBpH8hJTMO58GPA=
dappco.re/go/agent v0.10.0-alpha.1/go.mod h1:jiShGsIfHS7b7rJXMdb30K+wKL8Kx8w/VUrLNDYRbCo=
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.1.7 h1:JhJeptA/I42c7GhmtJDPDlvhO8Y3izQ82wpaXCy/XZ0=
dappco.re/go/core/i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
dappco.re/go/core/i18n v0.2.1 h1:BeEThqNmQxFoGHY95jSlawq8+RmJBEz4fZ7D7eRQSJo=
dappco.re/go/core/i18n v0.2.1/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/inference v0.2.0/go.mod h1:YLYk/FxWACGehXpHCTa/t7hFl9uvAoq83QYSBakNNlc=
dappco.re/go/core/io v0.1.7 h1:tYyOnNFQcF//mqDLTNjBu4PV/CBizW7hm2ZnwdQQi40=
dappco.re/go/core/io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
dappco.re/go/core/io v0.3.0-alpha.1 h1:xTWrlk72qG0+aIyP5+Telp2nmFF0GG0EBFyVrOiBtec=
dappco.re/go/core/io v0.3.0-alpha.1/go.mod h1:1/DWfw8U9ARKQobFJ7KhsNw2lvJGnQr/vi4Pmqxps6s=
dappco.re/go/core/log v0.0.4 h1:qy54NYLh9nA4Kvo6XBsuAdyDD5jRc9PVnJLz9R0LiBw=
dappco.re/go/core/log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
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 h1:lGpoD5OgvdJ5z+qofw8fBWkDB186QM7I2jjXEbtzSdA=
forge.lthn.ai/core/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw= 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 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= 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 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.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 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= 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 h1:+/6NIu7OWyK2LSi2obnFF5fVpWhKiWduMiEkmnbZS6U=
@ -18,12 +48,18 @@ 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-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 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.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 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= 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 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= 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 h1:LFNx8Fs82mrpxro/MPUM6tMiD4DqPmdu83UknXztQjc=
forge.lthn.ai/core/go-scm v0.3.6/go.mod h1:IWFIYDfRH0mtRdqY5zV06l/RkmkPpBM6FcbKWhg1Qa8= 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 h1:DGO2sUo2i/csWbhw7zxU7oyGF2FJT72/8w47GhZ1joM=
forge.lthn.ai/core/go-store v0.1.9/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk= 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 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=

View file

@ -8,7 +8,11 @@
"short": "Multi-repo development workflows", "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.", "long": "Development workflow commands for managing multiple repositories.\n\nIncludes git operations, forge integration, CI status, and dev environment management.",
"api": { "api": {
"short": "API synchronisation tools" "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": { "health": {
"short": "Quick health check across all repos", "short": "Quick health check across all repos",
@ -147,7 +151,7 @@
"flag": { "flag": {
"command": "Shell command to run in each repo", "command": "Shell command to run in each repo",
"script": "Script file to run in each repo", "script": "Script file to run in each repo",
"repos": "Comma-separated list of repo names to target", "repos": "Comma-separated list of repo names, paths, or glob patterns to target",
"commit": "Commit changes after running", "commit": "Commit changes after running",
"message": "Commit message (required with --commit)", "message": "Commit message (required with --commit)",
"push": "Push after committing", "push": "Push after committing",
@ -169,6 +173,8 @@
"long": "Copy a file or directory to matching repos, optionally committing and pushing the changes.\n\nDesigned for safe file distribution by AI agents.", "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", "source": "Source",
"targets": "Targets", "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", "dry_run_mode": "[dry-run] No changes will be made",
"no_changes": "no changes", "no_changes": "no changes",
"summary": "Summary", "summary": "Summary",
@ -177,7 +183,8 @@
"message": "Commit message (omit to leave uncommitted)", "message": "Commit message (omit to leave uncommitted)",
"push": "Push after committing", "push": "Push after committing",
"co_author": "Co-author for commits", "co_author": "Co-author for commits",
"dry_run": "Preview without making changes" "dry_run": "Preview without making changes",
"yes": "Skip confirmation prompt"
}, },
"error": { "error": {
"source_not_found": "Source not found: {{.Path}}", "source_not_found": "Source not found: {{.Path}}",
@ -203,6 +210,7 @@
"skipped_count": "{{.Count}} skipped", "skipped_count": "{{.Count}} skipped",
"synced_count": "{{.Count}} synced", "synced_count": "{{.Count}} synced",
"would_sync_count": "{{.Count}} would sync", "would_sync_count": "{{.Count}} would sync",
"templates": "Templates",
"header": { "header": {
"repo": "Repo" "repo": "Repo"
}, },
@ -396,6 +404,14 @@
"select_packages": "Select packages to clone", "select_packages": "Select packages to clone",
"confirm_clone": "Clone {{.Count}} package(s) to {{.Target}}?" "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": { "github": {
"short": "Configure GitHub repo settings", "short": "Configure GitHub repo settings",
"long": "Apply standardised GitHub settings (labels, webhooks, branch protection, security) to repos.", "long": "Apply standardised GitHub settings (labels, webhooks, branch protection, security) to repos.",
@ -425,12 +441,6 @@
"to_create": "To create", "to_create": "To create",
"to_update": "To update", "to_update": "To update",
"to_delete": "To delete" "to_delete": "To delete"
},
"repo": {
"setting_up": "Setting up repo",
"detected_type": "Detected project type",
"would_create": "Would create",
"created": "Created"
} }
} }
}, },