mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
feat(cli): add apply subcommand
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
)
|
||||
|
||||
// NewAdapter returns the `adapter` parent command with a `list` subcommand.
|
||||
func NewAdapter(reg *adapter.Registry) *cobra.Command {
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List registered adapters",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
for _, a := range reg.All() {
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), a.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := &cobra.Command{Use: "adapter", Short: "Adapter introspection"}
|
||||
cmd.AddCommand(list)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
)
|
||||
|
||||
func TestAdapterList(t *testing.T) {
|
||||
reg := adapter.NewRegistry()
|
||||
reg.Register(&detectableAdapter{name: "alpha", detect: true})
|
||||
reg.Register(&detectableAdapter{name: "beta", detect: false})
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := NewAdapter(reg)
|
||||
cmd.SetArgs([]string{"list"})
|
||||
cmd.SetOut(&buf)
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "alpha")
|
||||
assert.Contains(t, out, "beta")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/apply"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/gitx"
|
||||
)
|
||||
|
||||
// NewApply returns the `apply` subcommand. It renders the canonical bundle
|
||||
// to each detected (or selected) harness using the apply pipeline.
|
||||
func NewApply(reg *adapter.Registry) *cobra.Command {
|
||||
var (
|
||||
dryRun bool
|
||||
force bool
|
||||
root string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "apply [harness...]",
|
||||
Short: "Render canonical and propagate to harnesses",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if root == "" {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root = filepath.Join(h, ".config", "harness-sync")
|
||||
}
|
||||
b, err := canonical.Load(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var selected []adapter.Adapter
|
||||
if len(args) == 0 {
|
||||
for _, a := range reg.All() {
|
||||
if a.Detect() {
|
||||
selected = append(selected, a)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, name := range args {
|
||||
a, ok := reg.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown adapter %q", name)
|
||||
}
|
||||
selected = append(selected, a)
|
||||
}
|
||||
}
|
||||
|
||||
opt := apply.Options{
|
||||
Bundle: b,
|
||||
Adapters: selected,
|
||||
Repo: gitx.New(root),
|
||||
DryRun: dryRun,
|
||||
Force: force,
|
||||
}
|
||||
rep, err := apply.Run(opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := "applied"
|
||||
if dryRun {
|
||||
mode = "dry-run"
|
||||
}
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %d written, %d skipped, %d conflicts\n",
|
||||
mode, rep.Written, rep.Skipped, rep.Conflicts); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range rep.Actions {
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %-10s %-12s %s %s\n", a.Adapter, a.Kind, a.Dest, a.Note); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rep.Conflicts > 0 {
|
||||
return fmt.Errorf("%d conflicts; resolve .rej files", rep.Conflicts)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print actions without writing")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "overwrite without 3-way merge")
|
||||
cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
)
|
||||
|
||||
func TestApplyDryRun(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"),
|
||||
[]byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600))
|
||||
|
||||
reg := adapter.NewRegistry()
|
||||
reg.Register(&detectableAdapter{name: "stub", detect: true})
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := NewApply(reg)
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetArgs([]string{"--dry-run", "--root", root})
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
assert.Contains(t, buf.String(), "dry-run")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
)
|
||||
|
||||
// NewDiff returns the `diff` subcommand. It is a shorthand for
|
||||
// `apply --dry-run` and shows pending changes without writing.
|
||||
func NewDiff(reg *adapter.Registry) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "diff [harness...]",
|
||||
Short: "Show pending changes (alias for `apply --dry-run`)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
apply := NewApply(reg)
|
||||
apply.SetOut(cmd.OutOrStdout())
|
||||
apply.SetErr(cmd.ErrOrStderr())
|
||||
apply.SetArgs(append([]string{"--dry-run"}, args...))
|
||||
return apply.Execute()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
)
|
||||
|
||||
func TestDiffInvokesDryRun(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"),
|
||||
[]byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600))
|
||||
|
||||
reg := adapter.NewRegistry()
|
||||
reg.Register(&detectableAdapter{name: "stub", detect: true})
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := NewDiff(reg)
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetArgs([]string{"--root", root})
|
||||
require.NoError(t, cmd.Execute())
|
||||
assert.Contains(t, buf.String(), "dry-run")
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/gitx"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/render"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/ui"
|
||||
)
|
||||
|
||||
// NewInit returns the `init` subcommand: scan for detected harnesses, import
|
||||
// their content into a canonical tree, and produce an initial git commit.
|
||||
func NewInit(reg *adapter.Registry) *cobra.Command {
|
||||
var (
|
||||
root string
|
||||
home string
|
||||
from []string
|
||||
noPrompt bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialise canonical config from existing harnesses",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var err error
|
||||
r, err := resolveRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if home == "" {
|
||||
home, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = os.MkdirAll(r, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := reg.DetectedNames()
|
||||
if len(candidates) == 0 {
|
||||
return fmt.Errorf("no harnesses detected under %s", home)
|
||||
}
|
||||
var picked []string
|
||||
switch {
|
||||
case len(from) > 0:
|
||||
picked = from
|
||||
case noPrompt:
|
||||
picked = candidates
|
||||
default:
|
||||
picked, err = ui.MultiSelect("Import from which harnesses?", candidates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runImport(cmd, reg, r, home, picked)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)")
|
||||
cmd.Flags().StringVar(&home, "home", "", "home dir (default $HOME)")
|
||||
cmd.Flags().StringSliceVar(&from, "from", nil, "import from specific adapter(s), skip prompt")
|
||||
cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "import from all detected adapters without prompting")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, picked []string) error {
|
||||
bundle := &canonical.Bundle{
|
||||
Root: root,
|
||||
Config: canonical.Config{ActiveProfile: "imported"},
|
||||
}
|
||||
seenSkills := map[string]bool{}
|
||||
seenAgents := map[string]bool{}
|
||||
seenMCP := map[string]bool{}
|
||||
var instructions []string
|
||||
|
||||
for _, name := range picked {
|
||||
ad, ok := reg.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown adapter %q", name)
|
||||
}
|
||||
res, err := ad.Import(home)
|
||||
if err != nil {
|
||||
return fmt.Errorf("import %s: %w", name, err)
|
||||
}
|
||||
for _, s := range res.Skills {
|
||||
key := s.Name + "|" + s.Body
|
||||
if seenSkills[key] {
|
||||
continue
|
||||
}
|
||||
seenSkills[key] = true
|
||||
bundle.Skills = append(bundle.Skills, s)
|
||||
}
|
||||
for _, a := range res.Agents {
|
||||
key := a.Name + "|" + a.Body
|
||||
if seenAgents[key] {
|
||||
continue
|
||||
}
|
||||
seenAgents[key] = true
|
||||
bundle.Agents = append(bundle.Agents, a)
|
||||
}
|
||||
for _, m := range res.MCP {
|
||||
if seenMCP[m.Name] {
|
||||
continue
|
||||
}
|
||||
seenMCP[m.Name] = true
|
||||
bundle.MCP.Servers = append(bundle.MCP.Servers, m)
|
||||
}
|
||||
if res.Instructions != "" {
|
||||
instructions = append(instructions, fmt.Sprintf("<!-- from %s -->\n%s", name, res.Instructions))
|
||||
}
|
||||
}
|
||||
bundle.Instructions.Global = strings.Join(instructions, "\n\n")
|
||||
|
||||
if err := writeCanonical(root, bundle, picked); err != nil {
|
||||
return err
|
||||
}
|
||||
repo := gitx.New(root)
|
||||
if !repo.IsRepo() {
|
||||
if err := repo.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo.Configure("harness-sync", "harness-sync@localhost"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := repo.AddAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo.Commit(fmt.Sprintf("import from %s", strings.Join(picked, ", "))); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "imported from %v into %s\n", picked, root)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeCanonical(root string, b *canonical.Bundle, picked []string) error {
|
||||
for _, sub := range []string{"profiles", "skills", "agents", "instructions"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, sub), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
cfgBody, err := render.YAML(map[string]any{
|
||||
"enabled_harnesses": picked,
|
||||
"active_profile": "imported",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(root, "config.yaml"), cfgBody, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profBody, err := render.YAML(canonical.Profile{
|
||||
Name: "imported",
|
||||
Gateway: canonical.Gateway{
|
||||
URL: "https://gateway.local",
|
||||
Token: "dummy",
|
||||
DefaultModel: "claude-sonnet-4-6",
|
||||
},
|
||||
Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "sonnet"}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(root, "profiles", "imported.yaml"), profBody, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(b.MCP.Servers) > 0 {
|
||||
mcpBody, err := render.YAML(b.MCP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(root, "mcp.yaml"), mcpBody, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range b.Skills {
|
||||
path := filepath.Join(root, "skills", s.Name, "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(s.Body), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, a := range b.Agents {
|
||||
path := filepath.Join(root, "agents", a.Name+".md")
|
||||
if err := os.WriteFile(path, []byte(a.Body), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if b.Instructions.Global != "" {
|
||||
if err := os.WriteFile(filepath.Join(root, "instructions", "global.md"),
|
||||
[]byte(b.Instructions.Global), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
)
|
||||
|
||||
type importableAdapter struct {
|
||||
res *adapter.ImportResult
|
||||
name string
|
||||
}
|
||||
|
||||
func (i *importableAdapter) Name() string { return i.name }
|
||||
func (i *importableAdapter) Detect() bool { return true }
|
||||
func (i *importableAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) {
|
||||
return adapter.NewFileSet(), nil
|
||||
}
|
||||
func (i *importableAdapter) Import(_ string) (*adapter.ImportResult, error) { return i.res, nil }
|
||||
|
||||
func TestInitImportWritesCanonical(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
home := t.TempDir()
|
||||
|
||||
reg := adapter.NewRegistry()
|
||||
reg.Register(&importableAdapter{
|
||||
name: "stub",
|
||||
res: &adapter.ImportResult{
|
||||
Skills: []canonical.Skill{{Name: "hi", Body: "---\nname: hi\n---\nhi body", Path: "hi/SKILL.md"}},
|
||||
Instructions: "# global",
|
||||
MCP: []canonical.MCPServer{{Name: "filepuff", Command: "/bin/x"}},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewInit(reg)
|
||||
cmd.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"})
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
assert.FileExists(t, filepath.Join(root, "config.yaml"))
|
||||
assert.FileExists(t, filepath.Join(root, "instructions", "global.md"))
|
||||
assert.FileExists(t, filepath.Join(root, "mcp.yaml"))
|
||||
assert.FileExists(t, filepath.Join(root, "skills", "hi", "SKILL.md"))
|
||||
_, err := os.Stat(filepath.Join(root, ".git"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewProfile returns the `profile` subcommand with `list` and `use`.
|
||||
func NewProfile() *cobra.Command {
|
||||
var root string
|
||||
rootFlag := func(c *cobra.Command) {
|
||||
c.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)")
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available profiles",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
r, err := resolveRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(r, "profiles"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".yaml") {
|
||||
names = append(names, strings.TrimSuffix(e.Name(), ".yaml"))
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), n)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
rootFlag(list)
|
||||
|
||||
use := &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Short: "Set active profile (rewrites config.yaml)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
r, err := resolveRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := args[0]
|
||||
profPath := filepath.Join(r, "profiles", target+".yaml")
|
||||
if _, err := os.Stat(profPath); err != nil {
|
||||
return fmt.Errorf("profile %q not found at %s", target, profPath)
|
||||
}
|
||||
configPath := filepath.Join(r, "config.yaml")
|
||||
existing, _ := os.ReadFile(configPath)
|
||||
updated := setActiveProfile(string(existing), target)
|
||||
return os.WriteFile(configPath, []byte(updated), 0o600)
|
||||
},
|
||||
}
|
||||
rootFlag(use)
|
||||
|
||||
cmd := &cobra.Command{Use: "profile", Short: "Manage profiles"}
|
||||
cmd.AddCommand(list, use)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func setActiveProfile(existing, name string) string {
|
||||
lines := strings.Split(existing, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "active_profile:") {
|
||||
lines[i] = "active_profile: " + name
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(existing, "\n") + "\nactive_profile: " + name + "\n"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProfileList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"), []byte("name: home\n"), 0o600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "work.yaml"), []byte("name: work\n"), 0o600))
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := NewProfile()
|
||||
cmd.SetArgs([]string{"list", "--root", root})
|
||||
cmd.SetOut(&buf)
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "home")
|
||||
assert.Contains(t, out, "work")
|
||||
}
|
||||
|
||||
func TestProfileUse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "work.yaml"), []byte("name: work\n"), 0o600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600))
|
||||
|
||||
cmd := NewProfile()
|
||||
cmd.SetArgs([]string{"use", "work", "--root", root})
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
body, err := os.ReadFile(filepath.Join(root, "config.yaml"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(body), "active_profile: work")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/gitx"
|
||||
)
|
||||
|
||||
// NewRollback returns the `rollback` subcommand. It reverts the last N apply
|
||||
// commits in the canonical repo by calling `git revert` over a range.
|
||||
func NewRollback() *cobra.Command {
|
||||
var root string
|
||||
cmd := &cobra.Command{
|
||||
Use: "rollback [n]",
|
||||
Short: "Revert the last N apply commits in the canonical repo",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
r, err := resolveRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := 1
|
||||
if len(args) == 1 {
|
||||
v, err := strconv.Atoi(args[0])
|
||||
if err != nil || v < 1 {
|
||||
return cmd.Help()
|
||||
}
|
||||
n = v
|
||||
}
|
||||
return gitx.New(r).Revert(n)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&root, "root", "", "canonical root")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRollbackHelp(t *testing.T) {
|
||||
cmd := NewRollback()
|
||||
assert.Equal(t, "rollback [n]", cmd.Use)
|
||||
}
|
||||
+20
-1
@@ -1,7 +1,13 @@
|
||||
// Package cli provides the cobra command tree for harness-sync.
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRoot(version string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
@@ -11,3 +17,16 @@ func NewRoot(version string) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRoot returns the canonical root: the flag value when non-empty,
|
||||
// else $HOME/.config/harness-sync.
|
||||
func resolveRoot(flag string) (string, error) {
|
||||
if flag != "" {
|
||||
return flag, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config", "harness-sync"), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
)
|
||||
|
||||
// NewShow returns the `show` subcommand which prints the files each adapter
|
||||
// would manage if `apply` were run now. Useful for auditing what harness-sync
|
||||
// will write to your system before committing to it.
|
||||
func NewShow(reg *adapter.Registry) *cobra.Command {
|
||||
var (
|
||||
root string
|
||||
all bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "show [harness...]",
|
||||
Short: "List files harness-sync manages for each harness",
|
||||
Long: `Renders the canonical bundle through each adapter and prints
|
||||
the resulting targets without writing anything. By default, shows only detected
|
||||
harnesses; pass --all to include adapters whose harness is not installed, or
|
||||
positional args to select specific harnesses.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
r, err := resolveRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := canonical.Load(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var selected []adapter.Adapter
|
||||
switch {
|
||||
case len(args) > 0:
|
||||
for _, name := range args {
|
||||
a, ok := reg.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown adapter %q", name)
|
||||
}
|
||||
selected = append(selected, a)
|
||||
}
|
||||
case all:
|
||||
selected = reg.All()
|
||||
default:
|
||||
for _, a := range reg.All() {
|
||||
if a.Detect() {
|
||||
selected = append(selected, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
for _, a := range selected {
|
||||
fs, err := a.Render(b)
|
||||
if err != nil {
|
||||
if _, werr := fmt.Fprintf(w, "%s: render error: %v\n", a.Name(), err); werr != nil {
|
||||
return werr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, werr := fmt.Fprintf(w, "%s (%s)\n", a.Name(), detectLabel(a)); werr != nil {
|
||||
return werr
|
||||
}
|
||||
var iterErr error
|
||||
fs.ForEach(func(f adapter.File) {
|
||||
if iterErr != nil {
|
||||
return
|
||||
}
|
||||
switch f.Kind {
|
||||
case adapter.SymlinkDir, adapter.SymlinkFile:
|
||||
_, iterErr = fmt.Fprintf(w, " %-12s %s -> %s\n",
|
||||
f.Kind.String(), short(f.Dest), short(f.SymlinkTarget))
|
||||
case adapter.RenderedFile:
|
||||
_, iterErr = fmt.Fprintf(w, " %-12s %s (%d bytes)\n",
|
||||
f.Kind.String(), short(f.Dest), len(f.Content))
|
||||
}
|
||||
})
|
||||
if iterErr != nil {
|
||||
return iterErr
|
||||
}
|
||||
if _, werr := fmt.Fprintln(w); werr != nil {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "include adapters whose harness is not detected")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func detectLabel(a adapter.Adapter) string {
|
||||
if a.Detect() {
|
||||
return "detected"
|
||||
}
|
||||
return "not detected"
|
||||
}
|
||||
|
||||
// short collapses the user's home dir to "~" for cleaner output.
|
||||
func short(p string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return p
|
||||
}
|
||||
if p == home {
|
||||
return "~"
|
||||
}
|
||||
if len(p) > len(home) && p[:len(home)+1] == home+string(filepath.Separator) {
|
||||
return "~" + p[len(home):]
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
)
|
||||
|
||||
type renderingAdapter struct {
|
||||
name string
|
||||
files []adapter.File
|
||||
detect bool
|
||||
}
|
||||
|
||||
func (r *renderingAdapter) Name() string { return r.name }
|
||||
func (r *renderingAdapter) Detect() bool { return r.detect }
|
||||
func (r *renderingAdapter) Import(_ string) (*adapter.ImportResult, error) {
|
||||
return &adapter.ImportResult{}, nil
|
||||
}
|
||||
func (r *renderingAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) {
|
||||
fs := adapter.NewFileSet()
|
||||
for _, f := range r.files {
|
||||
fs.Add(f)
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
func TestShowPrintsTargets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"),
|
||||
[]byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600))
|
||||
|
||||
reg := adapter.NewRegistry()
|
||||
reg.Register(&renderingAdapter{
|
||||
name: "stub",
|
||||
detect: true,
|
||||
files: []adapter.File{
|
||||
{Dest: "/tmp/out.json", Kind: adapter.RenderedFile, Content: []byte("{}\n")},
|
||||
{Dest: "/tmp/link", Kind: adapter.SymlinkDir, SymlinkTarget: "/canon/skills"},
|
||||
},
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := NewShow(reg)
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetArgs([]string{"--root", root})
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "stub")
|
||||
assert.Contains(t, out, "rendered")
|
||||
assert.Contains(t, out, "symlink-dir")
|
||||
assert.Contains(t, out, "/tmp/out.json")
|
||||
assert.Contains(t, out, "/canon/skills")
|
||||
}
|
||||
|
||||
func TestShowAllIncludesUndetected(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"),
|
||||
[]byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600))
|
||||
|
||||
reg := adapter.NewRegistry()
|
||||
reg.Register(&renderingAdapter{name: "absent", detect: false})
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := NewShow(reg)
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetArgs([]string{"--root", root, "--all"})
|
||||
require.NoError(t, cmd.Execute())
|
||||
|
||||
assert.Contains(t, buf.String(), "absent")
|
||||
assert.Contains(t, buf.String(), "not detected")
|
||||
}
|
||||
Reference in New Issue
Block a user