diff --git a/pkg/dev/cmd_apply.go b/pkg/dev/cmd_apply.go new file mode 100644 index 0000000..ac03eb9 --- /dev/null +++ b/pkg/dev/cmd_apply.go @@ -0,0 +1,289 @@ +// 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" + "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/git" + "github.com/host-uk/core/pkg/i18n" + "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 +) + +// 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")) + + parent.AddCommand(applyCmd) +} + +func runApply() error { + ctx := context.Background() + + // Validate inputs + if applyCommand == "" && applyScript == "" { + return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.no_command"), nil) + } + if applyCommand != "" && applyScript != "" { + return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.both_command_script"), nil) + } + if applyCommit && applyMessage == "" { + return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.commit_needs_message"), nil) + } + + // Validate script exists + if applyScript != "" { + if _, err := os.Stat(applyScript); err != nil { + return errors.E("dev.apply", "script not found: "+applyScript, err) + } + } + + // Get target repos + targetRepos, err := getApplyTargetRepos() + if err != nil { + return err + } + + if len(targetRepos) == 0 { + return errors.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() + + 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, errors.E("dev.apply", "failed to find registry", err) + } + + registry, err := repos.LoadRegistry(registryPath) + if err != nil { + return nil, errors.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 == '\\' +} diff --git a/pkg/dev/cmd_dev.go b/pkg/dev/cmd_dev.go index c79d8b8..2cbe57d 100644 --- a/pkg/dev/cmd_dev.go +++ b/pkg/dev/cmd_dev.go @@ -75,6 +75,10 @@ func AddDevCommands(root *cli.Command) { addPushCommand(devCmd) addPullCommand(devCmd) + // Safe git operations for AI agents + addFileSyncCommand(devCmd) + addApplyCommand(devCmd) + // GitHub integration addIssuesCommand(devCmd) addReviewsCommand(devCmd) diff --git a/pkg/dev/cmd_file_sync.go b/pkg/dev/cmd_file_sync.go new file mode 100644 index 0000000..6dbd8a7 --- /dev/null +++ b/pkg/dev/cmd_file_sync.go @@ -0,0 +1,350 @@ +// cmd_file_sync.go implements safe file synchronization across repos for AI agents. +// +// Usage: +// core dev sync workflow.yml --to="packages/core-*" +// core dev sync .github/workflows/ --to="packages/core-*" --message="feat: add CI" +// core dev sync config.yaml --to="packages/core-*" --dry-run + +package dev + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/git" + "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/repos" +) + +// File sync command flags +var ( + fileSyncTo string + fileSyncMessage string + fileSyncCoAuthor string + fileSyncDryRun bool + fileSyncPush bool +) + +// addFileSyncCommand adds the 'sync' command to dev for file syncing. +func addFileSyncCommand(parent *cli.Command) { + syncCmd := &cli.Command{ + Use: "sync ", + Short: i18n.T("cmd.dev.file_sync.short"), + Long: i18n.T("cmd.dev.file_sync.long"), + Args: cli.MinimumNArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runFileSync(args[0]) + }, + } + + syncCmd.Flags().StringVar(&fileSyncTo, "to", "", i18n.T("cmd.dev.file_sync.flag.to")) + syncCmd.Flags().StringVarP(&fileSyncMessage, "message", "m", "", i18n.T("cmd.dev.file_sync.flag.message")) + 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(&fileSyncPush, "push", false, i18n.T("cmd.dev.file_sync.flag.push")) + + _ = syncCmd.MarkFlagRequired("to") + + parent.AddCommand(syncCmd) +} + +func runFileSync(source string) error { + ctx := context.Background() + + // Security: Reject path traversal attempts + if strings.Contains(source, "..") { + return errors.E("dev.sync", "path traversal not allowed", nil) + } + + // Validate source exists + sourceInfo, err := os.Stat(source) + if err != nil { + return errors.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err) + } + + // Find target repos + targetRepos, err := resolveTargetRepos(fileSyncTo) + if err != nil { + return err + } + + if len(targetRepos) == 0 { + return cli.Err("%s", i18n.T("cmd.dev.file_sync.error.no_targets")) + } + + // Show plan + cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.source")), source) + cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.targets")), len(targetRepos)) + if fileSyncDryRun { + cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.file_sync.dry_run_mode"))) + } + cli.Blank() + + var succeeded, skipped, failed int + + for _, repo := range targetRepos { + repoName := filepath.Base(repo.Path) + + if fileSyncDryRun { + cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName) + succeeded++ + continue + } + + // Step 1: Pull latest (safe sync) + if err := safePull(ctx, repo.Path); err != nil { + cli.Print(" %s %s: pull failed: %s\n", errorStyle.Render("x"), repoName, err) + failed++ + continue + } + + // Step 2: Copy file(s) + destPath := filepath.Join(repo.Path, source) + if sourceInfo.IsDir() { + if err := copyDir(source, destPath); err != nil { + cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err) + failed++ + continue + } + } else { + if err := copyFile(source, destPath); err != nil { + cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err) + failed++ + continue + } + } + + // Step 3: 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.file_sync.no_changes")) + skipped++ + continue + } + + // Step 4: Commit if message provided + if fileSyncMessage != "" { + commitMsg := fileSyncMessage + if fileSyncCoAuthor != "" { + commitMsg += "\n\nCo-Authored-By: " + fileSyncCoAuthor + } + + if err := gitAddCommit(ctx, repo.Path, source, commitMsg); err != nil { + cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err) + failed++ + continue + } + + // Step 5: Push if requested + if fileSyncPush { + if err := safePush(ctx, repo.Path); err != nil { + cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err) + failed++ + continue + } + } + } + + cli.Print(" %s %s\n", successStyle.Render("v"), repoName) + succeeded++ + } + + // Summary + cli.Blank() + cli.Print("%s: ", i18n.T("cmd.dev.file_sync.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 +} + +// resolveTargetRepos resolves the --to pattern to actual repos +func resolveTargetRepos(pattern string) ([]*repos.Repo, error) { + // Load registry + registryPath, err := repos.FindRegistry() + if err != nil { + return nil, errors.E("dev.sync", "failed to find registry", err) + } + + registry, err := repos.LoadRegistry(registryPath) + if err != nil { + return nil, errors.E("dev.sync", "failed to load registry", err) + } + + // Match pattern against repo names + var matched []*repos.Repo + for _, repo := range registry.Repos { + if matchGlob(repo.Name, pattern) || matchGlob(repo.Path, pattern) { + matched = append(matched, repo) + } + } + + return matched, nil +} + +// matchGlob performs simple glob matching with * wildcards +func matchGlob(s, pattern string) bool { + // Handle exact match + if s == pattern { + return true + } + + // Handle * at end + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + return strings.HasPrefix(s, prefix) + } + + // Handle * at start + if strings.HasPrefix(pattern, "*") { + suffix := strings.TrimPrefix(pattern, "*") + return strings.HasSuffix(s, suffix) + } + + // Handle * in middle + if strings.Contains(pattern, "*") { + parts := strings.SplitN(pattern, "*", 2) + return strings.HasPrefix(s, parts[0]) && strings.HasSuffix(s, parts[1]) + } + + return false +} + +// safePull pulls with rebase, handling errors gracefully +func safePull(ctx context.Context, path string) error { + // Check if we have upstream + _, err := gitCommandQuiet(ctx, path, "rev-parse", "--abbrev-ref", "@{u}") + if err != nil { + // No upstream set, skip pull + return nil + } + + return git.Pull(ctx, path) +} + +// safePush pushes with automatic pull-rebase on rejection +func safePush(ctx context.Context, path string) error { + err := git.Push(ctx, path) + if err == nil { + return nil + } + + // If non-fast-forward, try pull and push again + if git.IsNonFastForward(err) { + if pullErr := git.Pull(ctx, path); pullErr != nil { + return pullErr + } + return git.Push(ctx, path) + } + + return err +} + +// gitAddCommit stages and commits a file/directory +func gitAddCommit(ctx context.Context, repoPath, filePath, message string) error { + // Stage the file(s) + if _, err := gitCommandQuiet(ctx, repoPath, "add", filePath); err != nil { + return err + } + + // Commit + _, err := gitCommandQuiet(ctx, repoPath, "commit", "-m", message) + return err +} + +// gitCommandQuiet runs a git command without output +func gitCommandQuiet(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + + output, err := cmd.CombinedOutput() + if err != nil { + return "", cli.Err("%s", strings.TrimSpace(string(output))) + } + return string(output), nil +} + +// copyFile copies a single file +func copyFile(src, dst string) error { + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + srcInfo, err := srcFile.Stat() + if err != nil { + return err + } + + dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} + +// copyDir recursively copies a directory +func copyDir(src, dst string) error { + srcInfo, err := os.Stat(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil { + return err + } + + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index 4936055..e03cd79 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -185,7 +185,45 @@ "sync.short": "Synchronizes public service APIs with internal implementations", "vm.short": "Dev environment commands", "vm.not_installed": "dev environment not installed (run 'core dev install' first)", - "vm.not_running": "Dev environment is not running" + "vm.not_running": "Dev environment is not running", + "file_sync.short": "Sync files across repos (agent-safe)", + "file_sync.long": "Safely sync files or directories across multiple repositories with automatic pull/commit/push. Designed for AI agents to avoid common git pitfalls.", + "file_sync.flag.to": "Target repos pattern (e.g., packages/core-*)", + "file_sync.flag.message": "Commit message for the sync", + "file_sync.flag.co_author": "Co-author for commit (e.g., 'Name ')", + "file_sync.flag.dry_run": "Show what would be done without making changes", + "file_sync.flag.push": "Push after committing", + "file_sync.source": "Source", + "file_sync.targets": "Targets", + "file_sync.summary": "Summary", + "file_sync.no_changes": "no changes", + "file_sync.dry_run_mode": "(dry run)", + "file_sync.error.source_not_found": "Source not found: {{.Path}}", + "file_sync.error.no_targets": "No target repos matched the pattern", + "file_sync.error.no_registry": "No repos.yaml found", + "apply.short": "Run command or script across repos (agent-safe)", + "apply.long": "Run a command or script across multiple repositories with optional commit and push. Designed for AI agents to safely apply changes at scale.", + "apply.flag.command": "Shell command to run in each repo", + "apply.flag.script": "Script file to run in each repo", + "apply.flag.repos": "Comma-separated list of repo names (default: all)", + "apply.flag.commit": "Commit changes after running", + "apply.flag.message": "Commit message (required with --commit)", + "apply.flag.co_author": "Co-author for commit", + "apply.flag.dry_run": "Show what would be done without making changes", + "apply.flag.push": "Push after committing", + "apply.flag.continue": "Continue on error instead of stopping", + "apply.action": "Action", + "apply.targets": "Targets", + "apply.summary": "Summary", + "apply.no_changes": "no changes", + "apply.dry_run_mode": "(dry run)", + "apply.error.no_command": "Either --command or --script is required", + "apply.error.both_command_script": "Cannot use both --command and --script", + "apply.error.commit_needs_message": "--commit requires --message", + "apply.error.script_not_found": "Script not found: {{.Path}}", + "apply.error.no_repos": "No repos found", + "apply.error.no_registry": "No repos.yaml found", + "apply.error.command_failed": "Command failed (use --continue to skip failures)" }, "docs": { "short": "Documentation management", @@ -425,6 +463,11 @@ }, "hint": { "fix_deps": "Update dependencies to fix vulnerabilities" + }, + "count": { + "succeeded": "{{.Count}} succeeded", + "failed": "{{.Count}} failed", + "skipped": "{{.Count}} skipped" } }, "error": {