Skip to content

Commit 0ef7093

Browse files
authored
Merge pull request #91 from githubnext/crane/crane-migration-python-to-go-full-apm-cli-rewrite
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite]
2 parents 23f7da4 + 069400f commit 0ef7093

60 files changed

Lines changed: 7372 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/apm/CUTOVER.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# APM CLI Go Rewrite -- Cutover Plan
2+
3+
This document describes when and how the Go binary replaces the Python
4+
binary as the shipped `apm` command (hard gate 2 of the completion
5+
framework in issue #78).
6+
7+
## Current State
8+
9+
The Go binary (`cmd/apm`) is built in parallel with the Python CLI
10+
(`src/apm_cli/`). The Python CLI is currently the shipped `apm` command
11+
via PyInstaller packaging and `pip install apm-cli`.
12+
13+
The Go CLI currently implements:
14+
- `apm --help` / `apm --version` (full parity with Python)
15+
- `apm init [--yes] [PROJECT_NAME]` (functional, creates apm.yml)
16+
- Per-command `--help` for all 26 commands (golden-file verified)
17+
18+
Remaining commands return a "not yet fully implemented" message.
19+
20+
## Cutover Trigger Conditions
21+
22+
The Go binary becomes the shipped `apm` command when ALL of the following
23+
are true:
24+
25+
1. All 26 commands respond correctly to `--help` (done)
26+
2. The representative command matrix passes functional tests:
27+
`init`, `install`, `update`, `compile`, `pack`, `run`, `audit`,
28+
`policy`, `mcp`, `runtime`, `targets`, `list`, `view`, `cache`,
29+
`deps`, `marketplace`, `uninstall`, `prune`
30+
3. Python-vs-Go parity tests pass for all commands in the matrix
31+
4. `go build ./cmd/apm` produces a single static binary
32+
5. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`)
33+
34+
## Cutover Steps
35+
36+
When conditions are met:
37+
38+
1. Update `pyproject.toml` to add `[project.scripts]` pointing to the
39+
Go binary wrapper OR replace the `apm` entrypoint with a shim that
40+
calls the Go binary.
41+
2. Update `build/apm.spec` (PyInstaller) to be marked deprecated/archived.
42+
3. Update `install.sh` and `install.ps1` to download the Go binary.
43+
4. Tag a release with `goreleaser` (or equivalent) producing platform
44+
binaries.
45+
5. Update `README.md` install instructions to reference the Go binary.
46+
47+
## Python Compatibility Shim
48+
49+
Until all commands are implemented in Go, the Python CLI remains the
50+
authoritative `apm` command. The Go binary is available as `apm-go`
51+
for testing.
52+
53+
The shim removal plan: once the command matrix passes functional tests,
54+
the Python entrypoint is replaced by the Go binary in the same PR that
55+
passes the final parity tests.
56+
57+
## Timeline
58+
59+
Each Crane iteration advances one or more commands. At the current pace
60+
(one iteration every 20 minutes), full command coverage is expected
61+
within ~10 additional iterations.

cmd/apm/apmyml.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// apmyml.go provides a minimal apm.yml parser for the Go CLI rewrite.
2+
// Only the fields needed for read-only CLI commands are parsed.
3+
package main
4+
5+
import (
6+
"bufio"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
// ApmProject holds the parsed apm.yml structure.
14+
type ApmProject struct {
15+
Name string
16+
Version string
17+
Description string
18+
Author string
19+
Targets []string
20+
Scripts map[string]string
21+
Deps []ApmDep
22+
MCPDeps []ApmDep
23+
Marketplaces []ApmMarketplace
24+
}
25+
26+
// ApmDep is a single dependency entry (owner/repo or owner/repo@ref).
27+
type ApmDep struct {
28+
Package string
29+
Ref string
30+
}
31+
32+
// ApmMarketplace is a registered marketplace source.
33+
type ApmMarketplace struct {
34+
Name string
35+
URL string
36+
}
37+
38+
// findApmYML walks up from dir looking for apm.yml.
39+
func findApmYML(dir string) (string, error) {
40+
current := dir
41+
for {
42+
candidate := filepath.Join(current, "apm.yml")
43+
if _, err := os.Stat(candidate); err == nil {
44+
return candidate, nil
45+
}
46+
parent := filepath.Dir(current)
47+
if parent == current {
48+
break
49+
}
50+
current = parent
51+
}
52+
return "", fmt.Errorf("apm.yml not found (searched from %s)", dir)
53+
}
54+
55+
// parseApmYML does a line-by-line best-effort parse of apm.yml.
56+
// It handles simple YAML scalars and list entries only.
57+
func parseApmYML(path string) (*ApmProject, error) {
58+
f, err := os.Open(path)
59+
if err != nil {
60+
return nil, err
61+
}
62+
defer f.Close()
63+
64+
p := &ApmProject{Scripts: map[string]string{}}
65+
scanner := bufio.NewScanner(f)
66+
67+
var section string
68+
var depSection string // "apm" or "mcp"
69+
70+
for scanner.Scan() {
71+
line := scanner.Text()
72+
trimmed := strings.TrimSpace(line)
73+
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
74+
continue
75+
}
76+
77+
// Top-level key detection.
78+
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, "-") {
79+
if idx := strings.Index(trimmed, ":"); idx >= 0 {
80+
key := strings.TrimSpace(trimmed[:idx])
81+
val := strings.TrimSpace(trimmed[idx+1:])
82+
switch key {
83+
case "name":
84+
p.Name = unquote(val)
85+
section = ""
86+
case "version":
87+
p.Version = unquote(val)
88+
section = ""
89+
case "description":
90+
p.Description = unquote(val)
91+
section = ""
92+
case "author":
93+
p.Author = unquote(val)
94+
section = ""
95+
case "targets":
96+
section = "targets"
97+
if val != "" && val != "[]" {
98+
p.Targets = parseInlineList(val)
99+
}
100+
case "scripts":
101+
section = "scripts"
102+
case "dependencies":
103+
section = "dependencies"
104+
depSection = ""
105+
case "marketplace":
106+
section = "marketplace"
107+
default:
108+
section = key
109+
}
110+
continue
111+
}
112+
}
113+
114+
// Section-specific parsing.
115+
indent := len(line) - len(strings.TrimLeft(line, " \t"))
116+
117+
switch section {
118+
case "targets":
119+
if strings.HasPrefix(trimmed, "-") {
120+
val := strings.TrimSpace(trimmed[1:])
121+
if val != "" {
122+
p.Targets = append(p.Targets, unquote(val))
123+
}
124+
}
125+
case "scripts":
126+
if idx := strings.Index(trimmed, ":"); idx >= 0 {
127+
key := strings.TrimSpace(trimmed[:idx])
128+
val := strings.TrimSpace(trimmed[idx+1:])
129+
if key != "" && !strings.HasPrefix(key, "-") {
130+
p.Scripts[key] = unquote(val)
131+
}
132+
}
133+
case "dependencies":
134+
if indent == 2 || indent == 0 {
135+
if strings.HasSuffix(trimmed, ":") {
136+
depSection = strings.TrimSuffix(trimmed, ":")
137+
}
138+
}
139+
if strings.HasPrefix(trimmed, "-") {
140+
val := strings.TrimSpace(trimmed[1:])
141+
if val != "" {
142+
dep := parseDep(unquote(val))
143+
switch depSection {
144+
case "apm":
145+
p.Deps = append(p.Deps, dep)
146+
case "mcp":
147+
p.MCPDeps = append(p.MCPDeps, dep)
148+
}
149+
}
150+
}
151+
case "marketplace":
152+
// Parse marketplace entries (name: URL or - name: url)
153+
if idx := strings.Index(trimmed, ":"); idx >= 0 {
154+
key := strings.TrimSpace(trimmed[:idx])
155+
val := strings.TrimSpace(trimmed[idx+1:])
156+
if key != "" && val != "" && !strings.HasPrefix(key, "-") {
157+
p.Marketplaces = append(p.Marketplaces, ApmMarketplace{Name: key, URL: unquote(val)})
158+
}
159+
}
160+
}
161+
}
162+
return p, scanner.Err()
163+
}
164+
165+
func unquote(s string) string {
166+
s = strings.TrimSpace(s)
167+
if len(s) >= 2 && ((s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')) {
168+
return s[1 : len(s)-1]
169+
}
170+
return s
171+
}
172+
173+
func parseInlineList(s string) []string {
174+
s = strings.TrimPrefix(strings.TrimSuffix(strings.TrimSpace(s), "]"), "[")
175+
var out []string
176+
for _, part := range strings.Split(s, ",") {
177+
v := strings.TrimSpace(part)
178+
if v != "" {
179+
out = append(out, unquote(v))
180+
}
181+
}
182+
return out
183+
}
184+
185+
func parseDep(s string) ApmDep {
186+
parts := strings.SplitN(s, "@", 2)
187+
if len(parts) == 2 {
188+
return ApmDep{Package: parts[0], Ref: parts[1]}
189+
}
190+
return ApmDep{Package: s}
191+
}

0 commit comments

Comments
 (0)