Skip to content

Commit c077236

Browse files
committed
feat: support local installs
1 parent 2a82c26 commit c077236

File tree

2 files changed

+381
-98
lines changed

2 files changed

+381
-98
lines changed

pkg/loop/cmd/loopinstall/install.go

Lines changed: 144 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ var execCommand = func(cmd *exec.Cmd) error {
2222
}
2323

2424
// mergeOrReplaceEnvVars merges new environment variables into an existing slice,
25-
// replacing any existing variables with the same key
25+
// replacing any existing variables with the same key.
2626
func mergeOrReplaceEnvVars(existing []string, newVars []string) []string {
2727
result := make([]string, len(existing))
2828
copy(result, existing)
@@ -53,15 +53,40 @@ func mergeOrReplaceEnvVars(existing []string, newVars []string) []string {
5353
return result
5454
}
5555

56-
func determineModuleDirectory(goPrivate, fullModulePath string) (string, error) {
56+
// determineModuleDirectory locates the directory to build from.
57+
// - Local path (absolute or "./relative"): resolve and return the directory (no download).
58+
func determineModuleDirectoryLocal(pluginKey, moduleURI string) (string, error) {
59+
log.Printf("%s - resolving local module path %q", pluginKey, moduleURI)
60+
abs, err := filepath.Abs(moduleURI)
61+
if err != nil {
62+
return "", fmt.Errorf("failed to resolve local module path %q: %w", moduleURI, err)
63+
}
64+
info, err := os.Stat(abs)
65+
if err != nil {
66+
return "", fmt.Errorf("local module path %q not accessible: %w", abs, err)
67+
}
68+
if !info.IsDir() {
69+
return "", fmt.Errorf("local module path %q is not a directory", abs)
70+
}
71+
return abs, nil
72+
}
73+
74+
// determineModuleDirectory locates the directory to build from.
75+
// - Remote module path (e.g., "github.com/org/repo@ref"): use `go mod download -json` to get a module cache dir.
76+
func determineModuleDirectoryRemote(pluginKey, moduleURI, gitRef, goPrivate string) (string, error) {
77+
fullModulePath := moduleURI
78+
if gitRef != "" {
79+
fullModulePath = fmt.Sprintf("%s@%s", moduleURI, gitRef)
80+
}
81+
log.Printf("%s - downloading remote module %s", pluginKey, fullModulePath)
82+
5783
cmd := exec.Command("go", "mod", "download", "-json", fullModulePath)
5884
var out bytes.Buffer
5985
cmd.Stdout = &out
6086
cmd.Stderr = os.Stderr
6187

6288
if goPrivate != "" {
6389
// Inherit the current environment and override GOPRIVATE.
64-
// Note: Not really sure why this is needed - tried to simplify existing logic
6590
cmd.Env = append(os.Environ(), "GOPRIVATE="+goPrivate)
6691
}
6792

@@ -81,13 +106,18 @@ func determineModuleDirectory(goPrivate, fullModulePath string) (string, error)
81106
return result.Dir, nil
82107
}
83108

84-
func determineGoFlags(defaultGoFlags, pluginGoFlags string) ([]string, error) {
109+
// determineGoFlags resolves go build flags in priority order:
110+
// 1) CL_PLUGIN_GOFLAGS env var (overrides config)
111+
// 2) defaults.GoFlags
112+
// 3) plugin-specific goflags appended
113+
// It validates flags if any are present.
114+
func determineGoFlags(pluginKey, defaultGoFlags, pluginGoFlags string) ([]string, error) {
85115
var flags []string
86116
parser := shellwords.NewParser()
87117

88118
// Determine base flags
89119
if envGoFlags := os.Getenv("CL_PLUGIN_GOFLAGS"); envGoFlags != "" {
90-
log.Printf("Overriding config's default goflags with CL_PLUGIN_GOFLAGS env var: %s", envGoFlags)
120+
log.Printf("%s - overriding config's default goflags with CL_PLUGIN_GOFLAGS env var: %s", pluginKey, envGoFlags)
91121
f, err := parser.Parse(envGoFlags)
92122
if err != nil {
93123
return nil, err
@@ -120,155 +150,170 @@ func determineGoFlags(defaultGoFlags, pluginGoFlags string) ([]string, error) {
120150
return flags, nil
121151
}
122152

123-
func determineInstallArg(installPath, moduleURI string) string {
124-
// Determine the actual argument for 'go install' based on installPath and moduleURI.
125-
// - installPath is the user-provided path from YAML (no environment variable expansion).
126-
// - moduleURI is the URI of the module being downloaded and installed (no environment variable expansion).
127-
// The 'go install' command will be run with cmd.Dir set to the root of the downloaded moduleURI.
128-
// Therefore, installArg must be "." or a path starting with "./" relative to the module root.
153+
// determineInstallArg computes the argument passed to `go build` given we're changing cmd.Dir.
154+
// For remote modules, we keep the legacy behavior.
155+
// For local moduleURIs, we compute a relative path from the module root to the installPath
156+
// so the resulting arg is "." or "./sub/package".
157+
func determineInstallArg(installPath, moduleURI string, isLocal bool) string {
158+
cleanInstallPath := filepath.Clean(installPath)
159+
cleanModuleURI := filepath.Clean(moduleURI)
160+
161+
// Local modules
162+
if isLocal {
163+
// 1 - If building the module root
164+
if cleanInstallPath == cleanModuleURI || cleanInstallPath == "." {
165+
return "."
166+
}
167+
// 2 - If installPath is inside the module root, return "./<rel>"
168+
if rel, err := filepath.Rel(cleanModuleURI, cleanInstallPath); err == nil && rel != "" && !strings.HasPrefix(rel, "..") {
169+
rel = filepath.ToSlash(rel)
170+
if rel == "." {
171+
return "."
172+
}
173+
return "./" + rel
174+
}
175+
176+
// 3 - If installPath is already relative to the module root, normalize "./" prefix
177+
if !filepath.IsAbs(cleanInstallPath) {
178+
cleanInstallPath = filepath.ToSlash(cleanInstallPath)
179+
if cleanInstallPath == "." || strings.HasPrefix(cleanInstallPath, "./") {
180+
return cleanInstallPath
181+
}
182+
return "./" + strings.TrimLeft(cleanInstallPath, "/")
183+
}
184+
185+
// Absolute path outside module root: still give a relative-looking arg;
186+
// cmd.Dir will be set to module root so Go expects package paths like "./x/y".
187+
return "./" + filepath.ToSlash(strings.TrimLeft(cleanInstallPath, string(filepath.Separator)))
188+
}
129189

130-
// Case 1: installPath is the moduleURI itself. Install the module root.
190+
// Remote modules
191+
// 1 - installPath is the module root itself.
131192
if installPath == moduleURI {
132193
return "."
133194
}
134195

135-
// Case 2: installPath is a sub-package of moduleURI (e.g., "moduleURI/cmd/plugin").
196+
// 2 - installPath is a sub-package of moduleURI.
136197
if after, ok := strings.CutPrefix(installPath, moduleURI+"/"); ok {
137-
// Extract the relative path and prefix with "./".
138-
relativePath := after
139-
cleanedRelativePath := strings.TrimLeft(relativePath, "/") // Handles "moduleURI///subpath"
140-
if cleanedRelativePath == "" || cleanedRelativePath == "." { // Handles "moduleURI/" or "moduleURI/."
198+
cleanedRelativePath := strings.TrimLeft(after, "/")
199+
if cleanedRelativePath == "" || cleanedRelativePath == "." {
141200
return "."
142201
}
143-
144-
// cleanedRelativePath is like "cmd/plugin" or "sub/../pkg". Prepend "./".
145202
return "./" + cleanedRelativePath
146203
}
147204

148-
// Case 3: installPath is not moduleURI and not a sub-package of moduleURI.
149-
// Assumed to be:
150-
// a) A path already relative to the module root (e.g., "cmd/plugin", "./cmd/plugin", ".").
151-
// b) A full path to a different module (e.g., "github.com/other/mod").
152-
// For (b), prefixing with "./" when cmd.Dir is set is problematic but replicates prior behavior if any.
153-
154-
// Simple case
205+
// 3 - other inputs; normalize to a "./" path.
155206
if installPath == "." {
156207
return "."
157208
}
158-
159-
// Already correctly formatted (e.g., "./cmd/plugin", "./sub/../pkg")
160209
if strings.HasPrefix(installPath, "./") {
161210
return installPath
162211
}
163-
164-
// Needs "./" prefix. Handles "cmd/plugin", "/cmd/plugin", "github.com/other/mod".
165212
return "./" + strings.TrimLeft(installPath, "/")
166213
}
167214

168-
// downloadAndInstallPlugin downloads and installs a single plugin
215+
// downloadAndInstallPlugin downloads (if remote) and builds the plugin.
216+
// For local moduleURIs (absolute or "./relative"), we skip network download,
217+
// ignore gitRef (with a log message), and build directly from the local dir.
169218
func downloadAndInstallPlugin(pluginType string, pluginIdx int, plugin PluginDef, defaults DefaultsConfig) error {
219+
pluginKey := fmt.Sprintf("%s[%d]", pluginType, pluginIdx)
170220
if !isPluginEnabled(plugin) {
171-
log.Printf("Skipping disabled plugin %s[%d]", pluginType, pluginIdx)
221+
log.Printf("%s - skipping disabled plugin", pluginKey)
172222
return nil
173223
}
174224

175225
// Validate inputs
176226
if err := plugin.Validate(); err != nil {
177-
return fmt.Errorf("plugin input validation failed: %w", err)
227+
return fmt.Errorf("%s - plugin input validation failed: %w", pluginKey, err)
178228
}
179229

180230
moduleURI := plugin.ModuleURI
181231
gitRef := plugin.GitRef
182232
installPath := plugin.InstallPath
183233

184-
// Full module path with git reference
185-
fullModulePath := moduleURI
186-
if gitRef != "" {
187-
fullModulePath = fmt.Sprintf("%s@%s", moduleURI, gitRef)
188-
}
189-
190-
log.Printf("Installing plugin %s[%d] from %s", pluginType, pluginIdx, fullModulePath)
191-
192-
// Get GOPRIVATE environment variable
193234
goPrivate := os.Getenv("GOPRIVATE")
194235

195-
// Download the module and get its directory
196-
moduleDir, err := determineModuleDirectory(goPrivate, fullModulePath)
236+
// Determine the directory to run `go build` in.
237+
isLocal := filepath.IsAbs(moduleURI) || strings.HasPrefix(moduleURI, "."+string(filepath.Separator))
238+
moduleDir, err := func() (string, error) {
239+
if isLocal {
240+
return determineModuleDirectoryLocal(pluginKey, moduleURI)
241+
}
242+
return determineModuleDirectoryRemote(pluginKey, moduleURI, gitRef, goPrivate)
243+
}()
197244
if err != nil {
198-
return fmt.Errorf("failed to determine module directory: %w", err)
245+
return fmt.Errorf("%s - failed to determine module directory: %w", pluginKey, err)
246+
}
247+
if moduleDir == "" {
248+
return fmt.Errorf("%s - empty module directory resolved", pluginKey)
199249
}
200250

201-
// Build env vars from defaults, environment variable, and plugin-specific settings
251+
log.Printf("%s - installing plugin from %s", pluginKey, moduleDir)
252+
253+
// Build env vars from defaults, environment variable, and plugin-specific settings.
202254
envVars := defaults.EnvVars
203255
if envEnvVars := os.Getenv("CL_PLUGIN_ENVVARS"); envEnvVars != "" {
204256
envVars = mergeOrReplaceEnvVars(envVars, strings.Fields(envEnvVars))
205257
}
206-
207-
// Merge plugin-specific env vars
208258
if len(plugin.EnvVars) != 0 {
209259
envVars = mergeOrReplaceEnvVars(envVars, plugin.EnvVars)
210260
}
211261

212-
// Install the plugin
213-
{
214-
installArg := determineInstallArg(installPath, moduleURI)
215-
216-
binaryName := filepath.Base(installArg)
217-
if binaryName == "." {
218-
binaryName = filepath.Base(moduleURI)
219-
}
220-
221-
// Determine output directory
222-
outputDir := os.Getenv("GOBIN")
223-
if outputDir == "" {
224-
gopath := os.Getenv("GOPATH")
225-
if gopath == "" {
226-
gopath = filepath.Join(os.Getenv("HOME"), "go")
227-
}
228-
outputDir = filepath.Join(gopath, "bin")
229-
}
230-
231-
outputPath := filepath.Join(outputDir, binaryName)
262+
// Compute build target relative to module root ('.' or './subpkg').
263+
installArg := determineInstallArg(installPath, moduleURI, isLocal)
232264

233-
// Build goflags
234-
goflags, err := determineGoFlags(defaults.GoFlags, plugin.Flags)
235-
if err != nil {
236-
return fmt.Errorf("validation failed: %w", err)
237-
}
265+
// Derive output binary name. When arg is ".", use the module/repo (or local dir) name.
266+
binaryName := filepath.Base(installArg)
267+
if binaryName == "." {
268+
binaryName = filepath.Base(filepath.Clean(moduleURI))
269+
}
238270

239-
args := []string{"build", "-o", outputPath}
240-
if len(goflags) != 0 {
241-
args = append(args, goflags...)
271+
// Determine output directory (GOBIN, or GOPATH/bin, or $HOME/go/bin).
272+
outputDir := os.Getenv("GOBIN")
273+
if outputDir == "" {
274+
gopath := os.Getenv("GOPATH")
275+
if gopath == "" {
276+
gopath = filepath.Join(os.Getenv("HOME"), "go")
242277
}
243-
args = append(args, installArg)
278+
outputDir = filepath.Join(gopath, "bin")
279+
}
280+
outputPath := filepath.Join(outputDir, binaryName)
244281

245-
cmd := exec.Command("go", args...)
246-
cmd.Dir = moduleDir
247-
cmd.Stdout = os.Stdout
248-
cmd.Stderr = os.Stderr
282+
// Build goflags
283+
goflags, err := determineGoFlags(pluginKey, defaults.GoFlags, plugin.Flags)
284+
if err != nil {
285+
return fmt.Errorf("%s - goflags validation failed: %w", pluginKey, err)
286+
}
249287

250-
// Start with all current environment variables
251-
cmd.Env = os.Environ()
288+
// Assemble `go build` command.
289+
args := []string{"build", "-o", outputPath}
290+
if len(goflags) != 0 {
291+
args = append(args, goflags...)
292+
}
293+
args = append(args, installArg)
252294

253-
// Set GOPRIVATE environment variable if provided
254-
if goPrivate != "" {
255-
cmd.Env = mergeOrReplaceEnvVars(cmd.Env, []string{"GOPRIVATE=" + goPrivate})
256-
}
295+
cmd := exec.Command("go", args...)
296+
cmd.Dir = moduleDir
297+
cmd.Stdout = os.Stdout
298+
cmd.Stderr = os.Stderr
257299

258-
// Add/replace custom environment variables (e.g., GOOS, GOARCH, CGO_ENABLED)
259-
cmd.Env = mergeOrReplaceEnvVars(cmd.Env, envVars)
300+
// Start with all current environment variables.
301+
cmd.Env = os.Environ()
302+
if goPrivate != "" {
303+
cmd.Env = mergeOrReplaceEnvVars(cmd.Env, []string{"GOPRIVATE=" + goPrivate})
304+
}
305+
cmd.Env = mergeOrReplaceEnvVars(cmd.Env, envVars)
260306

261-
log.Printf("Running install command: go %s (in directory: %s)", strings.Join(args, " "), moduleDir)
307+
log.Printf("%s - running install command: go %s (in directory: %s)", pluginKey, strings.Join(args, " "), moduleDir)
262308

263-
if err := execCommand(cmd); err != nil {
264-
return fmt.Errorf("failed to install plugin %s[%d]: %w", pluginType, pluginIdx, err)
265-
}
309+
if err := execCommand(cmd); err != nil {
310+
return fmt.Errorf("%s - failed to install plugin: %w", pluginKey, err)
266311
}
267312

268313
return nil
269314
}
270315

271-
// writeBuildManifest writes installation artifacts to the specified file
316+
// writeBuildManifest writes installation artifacts to the specified file.
272317
func writeBuildManifest(tasks []PluginInstallTask, outputFile string) error {
273318
manifest := BuildManifest{
274319
BuildTime: time.Now().UTC().Format(time.RFC3339),
@@ -279,8 +324,7 @@ func writeBuildManifest(tasks []PluginInstallTask, outputFile string) error {
279324
for _, task := range tasks {
280325
configPath := task.ConfigFile
281326
if !filepath.IsAbs(configPath) {
282-
absPath, err := filepath.Abs(configPath)
283-
if err == nil {
327+
if absPath, err := filepath.Abs(configPath); err == nil {
284328
configPath = absPath
285329
}
286330
}
@@ -320,7 +364,7 @@ func writeBuildManifest(tasks []PluginInstallTask, outputFile string) error {
320364
return nil
321365
}
322366

323-
// installPlugins installs plugins concurrently using worker pool pattern
367+
// installPlugins installs plugins concurrently using a worker pool.
324368
func installPlugins(tasks []PluginInstallTask, concurrency int, verbose bool, outputFile string) error {
325369
if len(tasks) == 0 {
326370
log.Println("No enabled plugins found to install")
@@ -329,6 +373,7 @@ func installPlugins(tasks []PluginInstallTask, concurrency int, verbose bool, ou
329373

330374
log.Printf("Installing %d plugins with concurrency %d", len(tasks), concurrency)
331375

376+
// Optionally write the manifest first (so artifacts exist even if a build fails).
332377
if outputFile != "" {
333378
if err := writeBuildManifest(tasks, outputFile); err != nil {
334379
return fmt.Errorf("failed to write installation artifacts: %w", err)
@@ -349,6 +394,7 @@ func installPlugins(tasks []PluginInstallTask, concurrency int, verbose bool, ou
349394
}
350395

351396
start := time.Now()
397+
352398
err := downloadAndInstallPlugin(task.PluginType, 0, task.Plugin, task.Defaults)
353399
duration := time.Since(start)
354400

@@ -407,7 +453,7 @@ func installPlugins(tasks []PluginInstallTask, concurrency int, verbose bool, ou
407453
return nil
408454
}
409455

410-
// setupOutputFile ensures the output directory exists
456+
// setupOutputFile ensures the output path is absolute (and its directory exists is handled elsewhere).
411457
func setupOutputFile(outputFile string) (string, error) {
412458
if !filepath.IsAbs(outputFile) {
413459
wd, err := os.Getwd()

0 commit comments

Comments
 (0)