Skip to content

Commit 9c40a5f

Browse files
[Autoloop: python-to-go-migration] Iteration 4: Migrate subprocess_env.py and helpers.py to Go
Run: https://github.com/githubnext/apm/actions/runs/25747630390 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 99e6ae1 commit 9c40a5f

5 files changed

Lines changed: 317 additions & 3 deletions

File tree

benchmarks/migration-status.json

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"original_python_lines": 71696,
3-
"migrated_python_lines": 612,
3+
"migrated_python_lines": 827,
44
"migrated_modules": [
55
{
66
"module": "src/apm_cli/constants.py",
@@ -64,8 +64,22 @@
6464
"python_lines": 123,
6565
"status": "migrated",
6666
"notes": "ReadOnlyProjectGuard with snapshot-based mutation detection; stdlib-only"
67+
},
68+
{
69+
"module": "src/apm_cli/utils/subprocess_env.py",
70+
"go_package": "internal/utils/subprocenv",
71+
"python_lines": 84,
72+
"status": "migrated",
73+
"notes": "PyInstaller env restoration; stdlib-only; MapToSlice helper"
74+
},
75+
{
76+
"module": "src/apm_cli/utils/helpers.py",
77+
"go_package": "internal/utils/helpers",
78+
"python_lines": 131,
79+
"status": "migrated",
80+
"notes": "IsToolAvailable, GetAvailablePackageManagers, DetectPlatform, FindPluginJSON"
6781
}
6882
],
69-
"last_updated": "2026-05-12T15:33:35Z",
70-
"iteration": 3
83+
"last_updated": "2026-05-12T16:29:36Z",
84+
"iteration": 4
7185
}

internal/utils/helpers/helpers.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Package helpers provides miscellaneous utility functions for APM.
2+
package helpers
3+
4+
import (
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
)
10+
11+
// IsToolAvailable reports whether a command-line tool can be found on PATH.
12+
func IsToolAvailable(toolName string) bool {
13+
_, err := exec.LookPath(toolName)
14+
return err == nil
15+
}
16+
17+
// GetAvailablePackageManagers returns a map of package manager name -> name
18+
// for every package manager binary found on PATH.
19+
func GetAvailablePackageManagers() map[string]string {
20+
candidates := []string{
21+
// Python
22+
"uv", "pip", "pipx",
23+
// JavaScript
24+
"npm", "yarn", "pnpm",
25+
// System
26+
"brew", // macOS
27+
"apt", // Debian/Ubuntu
28+
"yum", // CentOS/RHEL
29+
"dnf", // Fedora
30+
"apk", // Alpine
31+
"pacman", // Arch
32+
}
33+
out := make(map[string]string)
34+
for _, name := range candidates {
35+
if IsToolAvailable(name) {
36+
out[name] = name
37+
}
38+
}
39+
return out
40+
}
41+
42+
// DetectPlatform returns a normalised platform name: "macos", "linux",
43+
// "windows", or "unknown".
44+
func DetectPlatform() string {
45+
switch runtime.GOOS {
46+
case "darwin":
47+
return "macos"
48+
case "linux":
49+
return "linux"
50+
case "windows":
51+
return "windows"
52+
default:
53+
return "unknown"
54+
}
55+
}
56+
57+
// pluginJSONRelPaths is the ordered list of relative paths where plugin.json
58+
// may live inside a plugin directory.
59+
var pluginJSONRelPaths = []string{
60+
"plugin.json",
61+
filepath.Join(".github", "plugin", "plugin.json"),
62+
filepath.Join(".claude-plugin", "plugin.json"),
63+
filepath.Join(".cursor-plugin", "plugin.json"),
64+
}
65+
66+
// FindPluginJSON searches for plugin.json in the well-known locations inside
67+
// pluginPath and returns the first match. Returns an empty string when not found.
68+
func FindPluginJSON(pluginPath string) string {
69+
for _, rel := range pluginJSONRelPaths {
70+
candidate := filepath.Join(pluginPath, rel)
71+
if fileExists(candidate) {
72+
return candidate
73+
}
74+
}
75+
return ""
76+
}
77+
78+
// fileExists reports whether path refers to a regular file.
79+
func fileExists(path string) bool {
80+
info, err := os.Stat(path)
81+
return err == nil && info.Mode().IsRegular()
82+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package helpers_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/githubnext/apm/internal/utils/helpers"
9+
)
10+
11+
func TestIsToolAvailable(t *testing.T) {
12+
// "sh" or "cat" should exist on any POSIX CI runner.
13+
if !helpers.IsToolAvailable("sh") {
14+
t.Error("expected 'sh' to be found on PATH")
15+
}
16+
if helpers.IsToolAvailable("definitely-not-a-real-binary-xyz") {
17+
t.Error("expected nonexistent tool to return false")
18+
}
19+
}
20+
21+
func TestDetectPlatform(t *testing.T) {
22+
p := helpers.DetectPlatform()
23+
valid := map[string]bool{"macos": true, "linux": true, "windows": true, "unknown": true}
24+
if !valid[p] {
25+
t.Errorf("unexpected platform %q", p)
26+
}
27+
}
28+
29+
func TestGetAvailablePackageManagers(t *testing.T) {
30+
// Just check it returns a map (may be empty in a minimal container).
31+
m := helpers.GetAvailablePackageManagers()
32+
if m == nil {
33+
t.Error("expected non-nil map")
34+
}
35+
}
36+
37+
func TestFindPluginJSON(t *testing.T) {
38+
dir := t.TempDir()
39+
40+
// No plugin.json yet.
41+
if got := helpers.FindPluginJSON(dir); got != "" {
42+
t.Errorf("expected empty, got %q", got)
43+
}
44+
45+
// Create the top-level plugin.json.
46+
pj := filepath.Join(dir, "plugin.json")
47+
if err := os.WriteFile(pj, []byte("{}"), 0o644); err != nil {
48+
t.Fatal(err)
49+
}
50+
if got := helpers.FindPluginJSON(dir); got != pj {
51+
t.Errorf("expected %q, got %q", pj, got)
52+
}
53+
}
54+
55+
func TestFindPluginJSONSubdirs(t *testing.T) {
56+
dir := t.TempDir()
57+
58+
// Create under .github/plugin/
59+
sub := filepath.Join(dir, ".github", "plugin")
60+
if err := os.MkdirAll(sub, 0o755); err != nil {
61+
t.Fatal(err)
62+
}
63+
pj := filepath.Join(sub, "plugin.json")
64+
if err := os.WriteFile(pj, []byte("{}"), 0o644); err != nil {
65+
t.Fatal(err)
66+
}
67+
if got := helpers.FindPluginJSON(dir); got != pj {
68+
t.Errorf("expected %q, got %q", pj, got)
69+
}
70+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Package subprocenv provides environment sanitisation for spawning external
2+
// processes from a PyInstaller-frozen binary.
3+
//
4+
// When APM ships as a PyInstaller --onedir binary the bootloader prepends
5+
// the bundle's _internal directory to LD_LIBRARY_PATH (Linux) and the
6+
// DYLD_* variables (macOS) so that the main Python process can find its own
7+
// shared libraries. Child processes inherit that environment by default,
8+
// which causes system binaries (git, curl, the install script) to resolve
9+
// their dependencies against the bundled libraries. This package centralises
10+
// the restoration logic that mirrors the Python subprocess_env module.
11+
package subprocenv
12+
13+
import (
14+
"os"
15+
"runtime"
16+
"strings"
17+
)
18+
19+
// pyinstallerManagedVars are the library-path variables that PyInstaller's
20+
// bootloader rewrites at launch. Each has a sibling <NAME>_ORIG holding the
21+
// pre-launch value that must be restored before handing the environment to a
22+
// child process.
23+
var pyinstallerManagedVars = []string{
24+
"LD_LIBRARY_PATH", // Linux and most Unixes
25+
"DYLD_LIBRARY_PATH", // macOS dynamic library search path
26+
"DYLD_FRAMEWORK_PATH", // macOS framework search path
27+
}
28+
29+
// isFrozen returns true when the process was started by PyInstaller. This is
30+
// detected by checking for the _MEIPASS environment variable that PyInstaller
31+
// always sets in a frozen binary.
32+
func isFrozen() bool {
33+
_, ok := os.LookupEnv("_MEIPASS")
34+
return ok
35+
}
36+
37+
// ExternalProcessEnv returns an environment map safe for spawning external
38+
// system binaries.
39+
//
40+
// When not running as a PyInstaller-frozen binary the current os.Environ() is
41+
// returned as a fresh map with no modifications.
42+
//
43+
// When frozen, every library-path variable in pyinstallerManagedVars is
44+
// restored from its <NAME>_ORIG sibling. If no _ORIG sibling exists the
45+
// variable is removed entirely so the child does not inherit the bundle's
46+
// _internal path. The _ORIG keys themselves are stripped.
47+
//
48+
// If base is non-nil it is used as the source mapping instead of os.Environ().
49+
func ExternalProcessEnv(base map[string]string) map[string]string {
50+
env := envToMap(base)
51+
52+
if !isFrozen() {
53+
return env
54+
}
55+
56+
for _, key := range pyinstallerManagedVars {
57+
origKey := key + "_ORIG"
58+
if origVal, ok := env[origKey]; ok {
59+
env[key] = origVal
60+
delete(env, origKey)
61+
} else {
62+
delete(env, key)
63+
}
64+
}
65+
return env
66+
}
67+
68+
// envToMap converts a []string slice (KEY=VALUE pairs) or an existing map into
69+
// a fresh map[string]string copy. When base is nil os.Environ() is used.
70+
func envToMap(base map[string]string) map[string]string {
71+
if base != nil {
72+
out := make(map[string]string, len(base))
73+
for k, v := range base {
74+
out[k] = v
75+
}
76+
return out
77+
}
78+
pairs := os.Environ()
79+
out := make(map[string]string, len(pairs))
80+
for _, pair := range pairs {
81+
idx := strings.IndexByte(pair, '=')
82+
if idx < 0 {
83+
out[pair] = ""
84+
continue
85+
}
86+
out[pair[:idx]] = pair[idx+1:]
87+
}
88+
return out
89+
}
90+
91+
// MapToSlice converts a map[string]string into a []string of KEY=VALUE pairs
92+
// suitable for exec.Cmd.Env.
93+
func MapToSlice(env map[string]string) []string {
94+
out := make([]string, 0, len(env))
95+
for k, v := range env {
96+
out = append(out, k+"="+v)
97+
}
98+
return out
99+
}
100+
101+
// IsWindows reports whether the current OS is Windows.
102+
func IsWindows() bool {
103+
return runtime.GOOS == "windows"
104+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package subprocenv_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/githubnext/apm/internal/utils/subprocenv"
7+
)
8+
9+
func TestExternalProcessEnvNoFreeze(t *testing.T) {
10+
// Without _MEIPASS set, ExternalProcessEnv returns a clean copy of the base.
11+
base := map[string]string{
12+
"LD_LIBRARY_PATH": "/bundled/lib",
13+
"LD_LIBRARY_PATH_ORIG": "/usr/lib",
14+
"HOME": "/home/user",
15+
}
16+
env := subprocenv.ExternalProcessEnv(base)
17+
// When not frozen the map is returned as-is (no restoration).
18+
if env["LD_LIBRARY_PATH"] != "/bundled/lib" {
19+
t.Errorf("expected /bundled/lib, got %s", env["LD_LIBRARY_PATH"])
20+
}
21+
}
22+
23+
func TestMapToSlice(t *testing.T) {
24+
env := map[string]string{"FOO": "bar", "BAZ": "qux"}
25+
slice := subprocenv.MapToSlice(env)
26+
if len(slice) != 2 {
27+
t.Errorf("expected 2 entries, got %d", len(slice))
28+
}
29+
seen := map[string]bool{}
30+
for _, s := range slice {
31+
seen[s] = true
32+
}
33+
if !seen["FOO=bar"] || !seen["BAZ=qux"] {
34+
t.Errorf("missing expected entries: %v", slice)
35+
}
36+
}
37+
38+
func TestExternalProcessEnvNilBase(t *testing.T) {
39+
// With nil base we get the real process env -- just verify no panic.
40+
env := subprocenv.ExternalProcessEnv(nil)
41+
if env == nil {
42+
t.Error("expected non-nil env")
43+
}
44+
}

0 commit comments

Comments
 (0)