Skip to content

Commit f77d95e

Browse files
authored
Merge pull request #86 from githubnext/crane/crane-migration-python-to-go-full-apm-cli-rewrite
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite]
2 parents 8b87e58 + 29b3f18 commit f77d95e

28 files changed

Lines changed: 3262 additions & 0 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Package client provides MCP client adapter interfaces and types.
2+
package client
3+
4+
import "errors"
5+
6+
// MCPClientAdapter is the base interface for MCP client adapters.
7+
type MCPClientAdapter interface {
8+
// GetCurrentConfig returns the current MCP configuration.
9+
GetCurrentConfig() (map[string]interface{}, error)
10+
// UpdateConfig updates the MCP configuration.
11+
UpdateConfig(config map[string]interface{}) error
12+
// ConfigureMCPServer adds or updates a single MCP server entry.
13+
ConfigureMCPServer(serverName, packageName string, enabled bool) error
14+
// RemoveMCPServer removes a server entry from the configuration.
15+
RemoveMCPServer(serverName string) error
16+
// GetTargetName returns the adapter's target identifier.
17+
GetTargetName() string
18+
}
19+
20+
// ErrServerNotFound is returned when a server is not in the config.
21+
var ErrServerNotFound = errors.New("MCP server not found in config")
22+
23+
// ErrConfigInvalid is returned for malformed configurations.
24+
var ErrConfigInvalid = errors.New("invalid MCP configuration")
25+
26+
// MCPServerEntry represents a single MCP server configuration entry.
27+
type MCPServerEntry struct {
28+
Command string `json:"command,omitempty"`
29+
Args []string `json:"args,omitempty"`
30+
Env map[string]string `json:"env,omitempty"`
31+
URL string `json:"url,omitempty"`
32+
Type string `json:"type,omitempty"`
33+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package client_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/githubnext/apm/internal/adapters/client"
7+
)
8+
9+
// mockClient satisfies MCPClientAdapter for interface verification.
10+
type mockClient struct{}
11+
12+
func (m *mockClient) GetCurrentConfig() (map[string]interface{}, error) {
13+
return map[string]interface{}{"mcpServers": map[string]interface{}{}}, nil
14+
}
15+
func (m *mockClient) UpdateConfig(config map[string]interface{}) error { return nil }
16+
func (m *mockClient) ConfigureMCPServer(name, pkg string, en bool) error { return nil }
17+
func (m *mockClient) RemoveMCPServer(name string) error { return nil }
18+
func (m *mockClient) GetTargetName() string { return "mock" }
19+
20+
// TestParityMCPClientAdapterInterface verifies the interface type exists.
21+
func TestParityMCPClientAdapterInterface(t *testing.T) {
22+
var _ client.MCPClientAdapter = (*mockClient)(nil)
23+
}
24+
25+
// TestParityMCPServerEntry verifies struct fields.
26+
func TestParityMCPServerEntry(t *testing.T) {
27+
entry := client.MCPServerEntry{
28+
Command: "npx",
29+
Args: []string{"-y", "@modelcontextprotocol/server-filesystem"},
30+
Env: map[string]string{"TOKEN": "${GITHUB_TOKEN}"},
31+
}
32+
if entry.Command != "npx" {
33+
t.Fatalf("unexpected command: %s", entry.Command)
34+
}
35+
if len(entry.Args) != 2 {
36+
t.Fatalf("expected 2 args, got %d", len(entry.Args))
37+
}
38+
}
39+
40+
// TestParityMCPClientErrors verifies sentinel errors are defined.
41+
func TestParityMCPClientErrors(t *testing.T) {
42+
if client.ErrServerNotFound == nil {
43+
t.Fatal("ErrServerNotFound should not be nil")
44+
}
45+
if client.ErrConfigInvalid == nil {
46+
t.Fatal("ErrConfigInvalid should not be nil")
47+
}
48+
}
49+
50+
// TestParityMCPServerEntryURL verifies URL-based (SSE) server config.
51+
func TestParityMCPServerEntryURL(t *testing.T) {
52+
entry := client.MCPServerEntry{
53+
URL: "http://localhost:3000/sse",
54+
Type: "sse",
55+
}
56+
if entry.URL == "" {
57+
t.Fatal("expected non-empty URL")
58+
}
59+
}
60+
61+
// TestParityMCPClientAdapterMethodGetTargetName verifies method via mock.
62+
func TestParityMCPClientAdapterMethodGetTargetName(t *testing.T) {
63+
var c client.MCPClientAdapter = &mockClient{}
64+
if c.GetTargetName() != "mock" {
65+
t.Fatal("unexpected target name")
66+
}
67+
}

internal/adapters/pm/pm.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Package pm provides MCP package manager adapter interfaces.
2+
package pm
3+
4+
import "errors"
5+
6+
// MCPPackageManagerAdapter is the base interface for MCP package managers.
7+
type MCPPackageManagerAdapter interface {
8+
// Install installs an MCP package.
9+
Install(packageName string, version string) error
10+
// Uninstall removes an installed MCP package.
11+
Uninstall(packageName string) error
12+
// ListInstalled lists all installed MCP packages.
13+
ListInstalled() ([]string, error)
14+
// Search queries available MCP packages.
15+
Search(query string) ([]string, error)
16+
}
17+
18+
// ErrPackageNotFound is returned when a package is not installed.
19+
var ErrPackageNotFound = errors.New("MCP package not found")
20+
21+
// ErrInstallFailed is returned when package installation fails.
22+
var ErrInstallFailed = errors.New("MCP package install failed")

internal/adapters/pm/pm_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package pm_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/githubnext/apm/internal/adapters/pm"
7+
)
8+
9+
type mockPM struct{}
10+
11+
func (m *mockPM) Install(name, ver string) error { return nil }
12+
func (m *mockPM) Uninstall(name string) error { return nil }
13+
func (m *mockPM) ListInstalled() ([]string, error) { return []string{"pkg-a"}, nil }
14+
func (m *mockPM) Search(q string) ([]string, error) { return []string{"pkg-a", "pkg-b"}, nil }
15+
16+
// TestParityPMAdapterInterface verifies the interface type exists.
17+
func TestParityPMAdapterInterface(t *testing.T) {
18+
var _ pm.MCPPackageManagerAdapter = (*mockPM)(nil)
19+
}
20+
21+
// TestParityPMErrors verifies sentinel errors are defined.
22+
func TestParityPMErrors(t *testing.T) {
23+
if pm.ErrPackageNotFound == nil {
24+
t.Fatal("ErrPackageNotFound should not be nil")
25+
}
26+
if pm.ErrInstallFailed == nil {
27+
t.Fatal("ErrInstallFailed should not be nil")
28+
}
29+
}
30+
31+
// TestParityPMListInstalled verifies list via mock.
32+
func TestParityPMListInstalled(t *testing.T) {
33+
var p pm.MCPPackageManagerAdapter = &mockPM{}
34+
pkgs, err := p.ListInstalled()
35+
if err != nil {
36+
t.Fatalf("unexpected error: %v", err)
37+
}
38+
if len(pkgs) != 1 || pkgs[0] != "pkg-a" {
39+
t.Fatalf("unexpected packages: %v", pkgs)
40+
}
41+
}

internal/commands/commands.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Package commands provides CLI command types and helpers for the APM Go rewrite.
2+
package commands
3+
4+
// CommandContext carries shared state for all commands.
5+
type CommandContext struct {
6+
// ConfigPath overrides the default config file path.
7+
ConfigPath string
8+
// Verbose enables verbose output.
9+
Verbose bool
10+
// Global applies operations globally rather than per-project.
11+
Global bool
12+
// DryRun prints what would be done without making changes.
13+
DryRun bool
14+
}
15+
16+
// CommandResult is the result of executing a CLI command.
17+
type CommandResult struct {
18+
// ExitCode is the process exit code (0 = success).
19+
ExitCode int
20+
// Output contains the command's stdout.
21+
Output string
22+
// Error contains any error message.
23+
Error string
24+
}
25+
26+
// NewCommandContext returns a CommandContext with default values.
27+
func NewCommandContext() *CommandContext {
28+
return &CommandContext{}
29+
}
30+
31+
// IsSuccess returns true when ExitCode == 0.
32+
func (r *CommandResult) IsSuccess() bool {
33+
return r.ExitCode == 0
34+
}

internal/commands/commands_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package commands_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/githubnext/apm/internal/commands"
7+
)
8+
9+
// TestParityCommandContextFields verifies field parity with Python CommandContext.
10+
func TestParityCommandContextFields(t *testing.T) {
11+
ctx := commands.NewCommandContext()
12+
if ctx.Verbose {
13+
t.Fatal("expected Verbose=false")
14+
}
15+
if ctx.Global {
16+
t.Fatal("expected Global=false")
17+
}
18+
if ctx.DryRun {
19+
t.Fatal("expected DryRun=false")
20+
}
21+
}
22+
23+
// TestParityCommandContextConfigPath verifies ConfigPath field.
24+
func TestParityCommandContextConfigPath(t *testing.T) {
25+
ctx := &commands.CommandContext{ConfigPath: "/tmp/apm.yml"}
26+
if ctx.ConfigPath != "/tmp/apm.yml" {
27+
t.Fatalf("unexpected ConfigPath: %s", ctx.ConfigPath)
28+
}
29+
}
30+
31+
// TestParityCommandResultSuccess verifies IsSuccess.
32+
func TestParityCommandResultSuccess(t *testing.T) {
33+
r := &commands.CommandResult{ExitCode: 0}
34+
if !r.IsSuccess() {
35+
t.Fatal("expected IsSuccess for exit code 0")
36+
}
37+
}
38+
39+
// TestParityCommandResultFailure verifies failure detection.
40+
func TestParityCommandResultFailure(t *testing.T) {
41+
r := &commands.CommandResult{ExitCode: 1, Error: "something failed"}
42+
if r.IsSuccess() {
43+
t.Fatal("expected !IsSuccess for exit code 1")
44+
}
45+
}
46+
47+
// TestParityCommandResultOutput verifies Output field.
48+
func TestParityCommandResultOutput(t *testing.T) {
49+
r := &commands.CommandResult{ExitCode: 0, Output: "[+] Done"}
50+
if r.Output != "[+] Done" {
51+
t.Fatalf("unexpected output: %s", r.Output)
52+
}
53+
}
54+
55+
// TestParityNewCommandContextDefaults verifies zero values.
56+
func TestParityNewCommandContextDefaults(t *testing.T) {
57+
ctx := commands.NewCommandContext()
58+
if ctx == nil {
59+
t.Fatal("NewCommandContext returned nil")
60+
}
61+
if ctx.ConfigPath != "" {
62+
t.Fatalf("expected empty ConfigPath, got %s", ctx.ConfigPath)
63+
}
64+
}
65+
66+
// TestParityCommandContextVerboseFlag verifies setting verbose.
67+
func TestParityCommandContextVerboseFlag(t *testing.T) {
68+
ctx := commands.NewCommandContext()
69+
ctx.Verbose = true
70+
if !ctx.Verbose {
71+
t.Fatal("expected Verbose=true after set")
72+
}
73+
}
74+
75+
// TestParityCommandContextDryRunFlag verifies DryRun field.
76+
func TestParityCommandContextDryRunFlag(t *testing.T) {
77+
ctx := &commands.CommandContext{DryRun: true}
78+
if !ctx.DryRun {
79+
t.Fatal("expected DryRun=true")
80+
}
81+
}
82+
83+
// TestParityCommandContextGlobalFlag verifies Global field.
84+
func TestParityCommandContextGlobalFlag(t *testing.T) {
85+
ctx := &commands.CommandContext{Global: true}
86+
if !ctx.Global {
87+
t.Fatal("expected Global=true")
88+
}
89+
}
90+
91+
// TestParityCommandResultErrorField verifies Error field.
92+
func TestParityCommandResultErrorField(t *testing.T) {
93+
r := &commands.CommandResult{ExitCode: 2, Error: "permission denied"}
94+
if r.Error != "permission denied" {
95+
t.Fatalf("unexpected error: %s", r.Error)
96+
}
97+
}
98+
99+
// TestParityCommandResultExitCode verifies exit code field.
100+
func TestParityCommandResultExitCode(t *testing.T) {
101+
r := &commands.CommandResult{ExitCode: 42}
102+
if r.ExitCode != 42 {
103+
t.Fatalf("unexpected exit code: %d", r.ExitCode)
104+
}
105+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Package compilation provides compilation pipeline types and utilities for APM.
2+
package compilation
3+
4+
import (
5+
"crypto/sha256"
6+
"fmt"
7+
"strings"
8+
)
9+
10+
// BuildIDPlaceholder is the sentinel string inserted into compiled outputs.
11+
const BuildIDPlaceholder = "<!-- Build ID: __BUILD_ID__ -->"
12+
13+
// ConstitutionMarkerBegin marks the start of an injected constitution block.
14+
const ConstitutionMarkerBegin = "<!-- SPEC-KIT CONSTITUTION: BEGIN -->"
15+
16+
// ConstitutionMarkerEnd marks the end of an injected constitution block.
17+
const ConstitutionMarkerEnd = "<!-- SPEC-KIT CONSTITUTION: END -->"
18+
19+
// ConstitutionRelativePath is the repo-root-relative path to the constitution file.
20+
const ConstitutionRelativePath = ".specify/memory/constitution.md"
21+
22+
// StabilizeBuildID replaces BuildIDPlaceholder in content with a deterministic
23+
// 12-char SHA256 hash computed over the content with the placeholder line removed.
24+
//
25+
// Idempotent: returns content unchanged if no placeholder is present.
26+
// Preserves a trailing newline when the input has one.
27+
func StabilizeBuildID(content string) string {
28+
lines := strings.Split(content, "\n")
29+
30+
// Preserve trailing newline: splitlines leaves an empty last element
31+
// if content ends with "\n". We track it but exclude from hash input.
32+
trailingNL := strings.HasSuffix(content, "\n")
33+
34+
idx := -1
35+
for i, line := range lines {
36+
if line == BuildIDPlaceholder {
37+
idx = i
38+
break
39+
}
40+
}
41+
if idx == -1 {
42+
return content
43+
}
44+
45+
// Build hash input from all lines except the placeholder.
46+
hashLines := make([]string, 0, len(lines)-1)
47+
for i, line := range lines {
48+
if i != idx {
49+
hashLines = append(hashLines, line)
50+
}
51+
}
52+
// Remove the empty trailing element that split adds for "\n"-terminated content.
53+
if trailingNL && len(hashLines) > 0 && hashLines[len(hashLines)-1] == "" {
54+
hashLines = hashLines[:len(hashLines)-1]
55+
}
56+
h := sha256.Sum256([]byte(strings.Join(hashLines, "\n")))
57+
buildID := fmt.Sprintf("%x", h)[:12]
58+
59+
lines[idx] = fmt.Sprintf("<!-- Build ID: %s -->", buildID)
60+
61+
result := strings.Join(lines, "\n")
62+
if trailingNL && !strings.HasSuffix(result, "\n") {
63+
result += "\n"
64+
}
65+
return result
66+
}

0 commit comments

Comments
 (0)