* ci: consolidate duplicate workflows and merge CodeQL configs Remove 17 duplicate workflow files that were split copies of the combined originals. Each family (CI, CodeQL, Coverage, PR Build, Alpha Release) had the same job duplicated across separate push/pull_request/schedule/manual trigger files. Merge codeql.yml and codescan.yml into a single codeql.yml with a language matrix covering go, javascript-typescript, python, and actions — matching the previous default setup coverage. Remaining workflows (one per family): - ci.yml (push + PR + manual) - codeql.yml (push + PR + schedule, all languages) - coverage.yml (push + PR + manual) - alpha-release.yml (push + manual) - pr-build.yml (PR + manual) - release.yml (tag push) - agent-verify.yml, auto-label.yml, auto-project.yml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add collect, config, crypt, plugin packages and fix all lint issues Add four new infrastructure packages with CLI commands: - pkg/config: layered configuration (defaults → file → env → flags) - pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums) - pkg/plugin: plugin system with GitHub-based install/update/remove - pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate) Fix all golangci-lint issues across the entire codebase (~100 errcheck, staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that `core go qa` passes with 0 issues. Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
304 lines
8.2 KiB
Go
304 lines
8.2 KiB
Go
// cmd_apply.go implements safe command/script execution across repos for AI agents.
|
|
//
|
|
// Usage:
|
|
// core dev apply --command="sed -i 's/old/new/g' README.md"
|
|
// core dev apply --script="./scripts/update-version.sh"
|
|
// core dev apply --command="..." --commit --message="chore: update"
|
|
|
|
package dev
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/host-uk/core/pkg/cli"
|
|
core "github.com/host-uk/core/pkg/framework/core"
|
|
"github.com/host-uk/core/pkg/git"
|
|
"github.com/host-uk/core/pkg/i18n"
|
|
"github.com/host-uk/core/pkg/io"
|
|
"github.com/host-uk/core/pkg/repos"
|
|
)
|
|
|
|
// Apply command flags
|
|
var (
|
|
applyCommand string
|
|
applyScript string
|
|
applyRepos string
|
|
applyCommit bool
|
|
applyMessage string
|
|
applyCoAuthor string
|
|
applyDryRun bool
|
|
applyPush bool
|
|
applyContinue bool // Continue on error
|
|
applyYes bool // Skip confirmation prompt
|
|
)
|
|
|
|
// AddApplyCommand adds the 'apply' command to dev.
|
|
func AddApplyCommand(parent *cli.Command) {
|
|
applyCmd := &cli.Command{
|
|
Use: "apply",
|
|
Short: i18n.T("cmd.dev.apply.short"),
|
|
Long: i18n.T("cmd.dev.apply.long"),
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
return runApply()
|
|
},
|
|
}
|
|
|
|
applyCmd.Flags().StringVar(&applyCommand, "command", "", i18n.T("cmd.dev.apply.flag.command"))
|
|
applyCmd.Flags().StringVar(&applyScript, "script", "", i18n.T("cmd.dev.apply.flag.script"))
|
|
applyCmd.Flags().StringVar(&applyRepos, "repos", "", i18n.T("cmd.dev.apply.flag.repos"))
|
|
applyCmd.Flags().BoolVar(&applyCommit, "commit", false, i18n.T("cmd.dev.apply.flag.commit"))
|
|
applyCmd.Flags().StringVarP(&applyMessage, "message", "m", "", i18n.T("cmd.dev.apply.flag.message"))
|
|
applyCmd.Flags().StringVar(&applyCoAuthor, "co-author", "", i18n.T("cmd.dev.apply.flag.co_author"))
|
|
applyCmd.Flags().BoolVar(&applyDryRun, "dry-run", false, i18n.T("cmd.dev.apply.flag.dry_run"))
|
|
applyCmd.Flags().BoolVar(&applyPush, "push", false, i18n.T("cmd.dev.apply.flag.push"))
|
|
applyCmd.Flags().BoolVar(&applyContinue, "continue", false, i18n.T("cmd.dev.apply.flag.continue"))
|
|
applyCmd.Flags().BoolVarP(&applyYes, "yes", "y", false, i18n.T("cmd.dev.apply.flag.yes"))
|
|
|
|
parent.AddCommand(applyCmd)
|
|
}
|
|
|
|
func runApply() error {
|
|
ctx := context.Background()
|
|
|
|
// Validate inputs
|
|
if applyCommand == "" && applyScript == "" {
|
|
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_command"), nil)
|
|
}
|
|
if applyCommand != "" && applyScript != "" {
|
|
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.both_command_script"), nil)
|
|
}
|
|
if applyCommit && applyMessage == "" {
|
|
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.commit_needs_message"), nil)
|
|
}
|
|
|
|
// Validate script exists
|
|
if applyScript != "" {
|
|
if !io.Local.IsFile(applyScript) {
|
|
return core.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
|
|
}
|
|
}
|
|
|
|
// Get target repos
|
|
targetRepos, err := getApplyTargetRepos()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(targetRepos) == 0 {
|
|
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_repos"), nil)
|
|
}
|
|
|
|
// Show plan
|
|
action := applyCommand
|
|
if applyScript != "" {
|
|
action = applyScript
|
|
}
|
|
cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.apply.action")), action)
|
|
cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.apply.targets")), len(targetRepos))
|
|
if applyDryRun {
|
|
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.apply.dry_run_mode")))
|
|
}
|
|
cli.Blank()
|
|
|
|
// Require confirmation unless --yes or --dry-run
|
|
if !applyYes && !applyDryRun {
|
|
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.apply.warning")))
|
|
cli.Blank()
|
|
|
|
if !cli.Confirm(i18n.T("cmd.dev.apply.confirm"), cli.Required()) {
|
|
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.dev.apply.cancelled")))
|
|
return nil
|
|
}
|
|
cli.Blank()
|
|
}
|
|
|
|
var succeeded, skipped, failed int
|
|
|
|
for _, repo := range targetRepos {
|
|
repoName := filepath.Base(repo.Path)
|
|
|
|
if applyDryRun {
|
|
cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName)
|
|
succeeded++
|
|
continue
|
|
}
|
|
|
|
// Step 1: Run command or script
|
|
var cmdErr error
|
|
if applyCommand != "" {
|
|
cmdErr = runCommandInRepo(ctx, repo.Path, applyCommand)
|
|
} else {
|
|
cmdErr = runScriptInRepo(ctx, repo.Path, applyScript)
|
|
}
|
|
|
|
if cmdErr != nil {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, cmdErr)
|
|
failed++
|
|
if !applyContinue {
|
|
return cli.Err("%s", i18n.T("cmd.dev.apply.error.command_failed"))
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Step 2: Check if anything changed
|
|
statuses := git.Status(ctx, git.StatusOptions{
|
|
Paths: []string{repo.Path},
|
|
Names: map[string]string{repo.Path: repoName},
|
|
})
|
|
if len(statuses) == 0 || !statuses[0].IsDirty() {
|
|
cli.Print(" %s %s: %s\n", dimStyle.Render("-"), repoName, i18n.T("cmd.dev.apply.no_changes"))
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
// Step 3: Commit if requested
|
|
if applyCommit {
|
|
commitMsg := applyMessage
|
|
if applyCoAuthor != "" {
|
|
commitMsg += "\n\nCo-Authored-By: " + applyCoAuthor
|
|
}
|
|
|
|
// Stage all changes
|
|
if _, err := gitCommandQuiet(ctx, repo.Path, "add", "-A"); err != nil {
|
|
cli.Print(" %s %s: stage failed: %s\n", errorStyle.Render("x"), repoName, err)
|
|
failed++
|
|
if !applyContinue {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Commit
|
|
if _, err := gitCommandQuiet(ctx, repo.Path, "commit", "-m", commitMsg); err != nil {
|
|
cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err)
|
|
failed++
|
|
if !applyContinue {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Step 4: Push if requested
|
|
if applyPush {
|
|
if err := safePush(ctx, repo.Path); err != nil {
|
|
cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err)
|
|
failed++
|
|
if !applyContinue {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
|
|
succeeded++
|
|
}
|
|
|
|
// Summary
|
|
cli.Blank()
|
|
cli.Print("%s: ", i18n.T("cmd.dev.apply.summary"))
|
|
if succeeded > 0 {
|
|
cli.Print("%s", successStyle.Render(i18n.T("common.count.succeeded", map[string]interface{}{"Count": succeeded})))
|
|
}
|
|
if skipped > 0 {
|
|
if succeeded > 0 {
|
|
cli.Print(", ")
|
|
}
|
|
cli.Print("%s", dimStyle.Render(i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped})))
|
|
}
|
|
if failed > 0 {
|
|
if succeeded > 0 || skipped > 0 {
|
|
cli.Print(", ")
|
|
}
|
|
cli.Print("%s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
|
|
}
|
|
cli.Blank()
|
|
|
|
return nil
|
|
}
|
|
|
|
// getApplyTargetRepos gets repos to apply command to
|
|
func getApplyTargetRepos() ([]*repos.Repo, error) {
|
|
// Load registry
|
|
registryPath, err := repos.FindRegistry()
|
|
if err != nil {
|
|
return nil, core.E("dev.apply", "failed to find registry", err)
|
|
}
|
|
|
|
registry, err := repos.LoadRegistry(registryPath)
|
|
if err != nil {
|
|
return nil, core.E("dev.apply", "failed to load registry", err)
|
|
}
|
|
|
|
// If --repos specified, filter to those
|
|
if applyRepos != "" {
|
|
repoNames := strings.Split(applyRepos, ",")
|
|
nameSet := make(map[string]bool)
|
|
for _, name := range repoNames {
|
|
nameSet[strings.TrimSpace(name)] = true
|
|
}
|
|
|
|
var matched []*repos.Repo
|
|
for _, repo := range registry.Repos {
|
|
if nameSet[repo.Name] {
|
|
matched = append(matched, repo)
|
|
}
|
|
}
|
|
return matched, nil
|
|
}
|
|
|
|
// Return all repos as slice
|
|
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
|
|
func runCommandInRepo(ctx context.Context, repoPath, command string) error {
|
|
// Use shell to execute command
|
|
var cmd *exec.Cmd
|
|
if isWindows() {
|
|
cmd = exec.CommandContext(ctx, "cmd", "/C", command)
|
|
} else {
|
|
cmd = exec.CommandContext(ctx, "sh", "-c", command)
|
|
}
|
|
cmd.Dir = repoPath
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// runScriptInRepo runs a script in a repo directory
|
|
func runScriptInRepo(ctx context.Context, repoPath, scriptPath string) error {
|
|
// Get absolute path to script
|
|
absScript, err := filepath.Abs(scriptPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
if isWindows() {
|
|
cmd = exec.CommandContext(ctx, "cmd", "/C", absScript)
|
|
} else {
|
|
// Execute script directly to honor shebang
|
|
cmd = exec.CommandContext(ctx, absScript)
|
|
}
|
|
cmd.Dir = repoPath
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// isWindows returns true if running on Windows
|
|
func isWindows() bool {
|
|
return os.PathSeparator == '\\'
|
|
}
|