internal/cli/build.go (460 lines of code) (raw):

/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package cli import ( "bytes" "fmt" "io" "io/fs" "os" "os/exec" "path" "path/filepath" "strings" "text/template" "github.com/Masterminds/semver/v3" "github.com/apache/answer/pkg/dir" "github.com/apache/answer/pkg/writer" "github.com/apache/answer/ui" "github.com/segmentfault/pacman/log" "gopkg.in/yaml.v3" ) const ( mainGoTpl = `package main import ( answercmd "github.com/apache/answer/cmd" // remote plugins {{- range .remote_plugins}} _ "{{.}}" {{- end}} // local plugins {{- range .local_plugins}} _ "answer/{{.}}" {{- end}} ) func main() { answercmd.Main() } ` goModTpl = `module answer go 1.22 ` ) type answerBuilder struct { buildingMaterial *buildingMaterial BuildError error } type buildingMaterial struct { answerModuleReplacement string plugins []*pluginInfo outputPath string tmpDir string originalAnswerInfo OriginalAnswerInfo } type OriginalAnswerInfo struct { Version string Revision string Time string } type pluginInfo struct { // Name of the plugin e.g. github.com/apache/answer-plugins/github-connector Name string // Path to the plugin. If path exist, read plugin from local filesystem Path string // Version of the plugin Version string } func newAnswerBuilder(buildDir, outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) *answerBuilder { material := &buildingMaterial{originalAnswerInfo: originalAnswerInfo} parentDir, _ := filepath.Abs(".") if buildDir != "" { material.tmpDir = filepath.Join(parentDir, buildDir) } else { material.tmpDir, _ = os.MkdirTemp(parentDir, "answer_build") } if len(outputPath) == 0 { outputPath = filepath.Join(parentDir, "new_answer") } material.outputPath, _ = filepath.Abs(outputPath) material.plugins = formatPlugins(plugins) material.answerModuleReplacement = os.Getenv("ANSWER_MODULE") return &answerBuilder{ buildingMaterial: material, } } func (a *answerBuilder) DoTask(task func(b *buildingMaterial) error) { if a.BuildError != nil { return } a.BuildError = task(a.buildingMaterial) } // BuildNewAnswer builds a new answer with specified plugins func BuildNewAnswer(buildDir, outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) (err error) { builder := newAnswerBuilder(buildDir, outputPath, plugins, originalAnswerInfo) builder.DoTask(createMainGoFile) builder.DoTask(downloadGoModFile) builder.DoTask(movePluginToVendor) builder.DoTask(copyUIFiles) builder.DoTask(buildUI) builder.DoTask(mergeI18nFiles) builder.DoTask(buildBinary) builder.DoTask(cleanByproduct) return builder.BuildError } func formatPlugins(plugins []string) (formatted []*pluginInfo) { for _, plugin := range plugins { plugin = strings.TrimSpace(plugin) // plugin description like this 'github.com/apache/answer-plugins/github-connector@latest=/local/path' info := &pluginInfo{} plugin, info.Path, _ = strings.Cut(plugin, "=") info.Name, info.Version, _ = strings.Cut(plugin, "@") formatted = append(formatted, info) } return formatted } // createMainGoFile creates main.go file in tmp dir that content is mainGoTpl func createMainGoFile(b *buildingMaterial) (err error) { fmt.Printf("[build] build dir: %s\n", b.tmpDir) err = dir.CreateDirIfNotExist(b.tmpDir) if err != nil { return err } var ( remotePlugins []string ) for _, p := range b.plugins { remotePlugins = append(remotePlugins, versionedModulePath(p.Name, p.Version)) } mainGoFile := &bytes.Buffer{} tmpl, err := template.New("main").Parse(mainGoTpl) if err != nil { return err } err = tmpl.Execute(mainGoFile, map[string]any{ "remote_plugins": remotePlugins, }) if err != nil { return err } err = writer.WriteFile(filepath.Join(b.tmpDir, "main.go"), mainGoFile.String()) if err != nil { return err } err = writer.WriteFile(filepath.Join(b.tmpDir, "go.mod"), goModTpl) if err != nil { return err } for _, p := range b.plugins { // If user set a path, use it to replace the module with local path if len(p.Path) > 0 { replacement := fmt.Sprintf("%s@%s=%s", p.Name, p.Version, p.Path) err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run() } else if len(p.Version) > 0 { // If user specify a version, use it to get specific version of the module err = b.newExecCmd("go", "get", fmt.Sprintf("%s@%s", p.Name, p.Version)).Run() } if err != nil { return err } } return } // downloadGoModFile run go mod commands to download dependencies func downloadGoModFile(b *buildingMaterial) (err error) { // If user specify a module replacement, use it. Otherwise, use the latest version. if len(b.answerModuleReplacement) > 0 { replacement := fmt.Sprintf("%s=%s", "github.com/apache/answer", b.answerModuleReplacement) err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run() if err != nil { return err } } err = b.newExecCmd("go", "mod", "tidy").Run() if err != nil { return err } err = b.newExecCmd("go", "mod", "vendor").Run() if err != nil { return err } return } // movePluginToVendor move plugin to vendor dir // Traverse the plugins, and if the plugin path is not github.com/apache/answer-plugins, move the contents of the current plugin to the vendor/github.com/apache/answer-plugins/ directory. func movePluginToVendor(b *buildingMaterial) (err error) { pluginsDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer-plugins/") for _, p := range b.plugins { pluginDir := filepath.Join(b.tmpDir, "vendor/", p.Name) pluginName := filepath.Base(p.Name) if !strings.HasPrefix(p.Name, "github.com/apache/answer-plugins/") { fmt.Printf("try to copy dir from %s to %s\n", pluginDir, filepath.Join(pluginsDir, pluginName)) err = copyDirEntries(os.DirFS(pluginDir), ".", filepath.Join(pluginsDir, pluginName), "node_modules") if err != nil { return err } } } return nil } // copyUIFiles copy ui files from answer module to tmp dir func copyUIFiles(b *buildingMaterial) (err error) { goListCmd := b.newExecCmd("go", "list", "-mod=mod", "-m", "-f", "{{.Dir}}", "github.com/apache/answer") buf := new(bytes.Buffer) goListCmd.Stdout = buf if err = goListCmd.Run(); err != nil { return fmt.Errorf("failed to run go list: %w", err) } answerDir := strings.TrimSpace(buf.String()) goModUIDir := filepath.Join(answerDir, "ui") localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui/") // The node_modules folder generated during development will interfere packaging, so it needs to be ignored. if err = copyDirEntries(os.DirFS(goModUIDir), ".", localUIBuildDir, "node_modules"); err != nil { return fmt.Errorf("failed to copy ui files: %w", err) } pluginsDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer-plugins/") localUIPluginDir := filepath.Join(localUIBuildDir, "src/plugins/") // copy plugins dir fmt.Printf("try to copy dir from %s to %s\n", pluginsDir, localUIPluginDir) // if plugins dir not exist means no plugins if !dir.CheckDirExist(pluginsDir) { return nil } pluginsDirEntries, err := os.ReadDir(pluginsDir) if err != nil { return fmt.Errorf("failed to read plugins dir: %w", err) } for _, entry := range pluginsDirEntries { if !entry.IsDir() { continue } sourcePluginDir := filepath.Join(pluginsDir, entry.Name()) // check if plugin is a ui plugin packageJsonPath := filepath.Join(sourcePluginDir, "package.json") fmt.Printf("check if %s is a ui plugin\n", packageJsonPath) if !dir.CheckFileExist(packageJsonPath) { continue } pnpmInstallCmd := b.newExecCmd("pnpm", "install") pnpmInstallCmd.Dir = sourcePluginDir if err = pnpmInstallCmd.Run(); err != nil { return fmt.Errorf("failed to install plugin dependencies: %w", err) } localPluginDir := filepath.Join(localUIPluginDir, entry.Name()) fmt.Printf("try to copy dir from %s to %s\n", sourcePluginDir, localPluginDir) if err = copyDirEntries(os.DirFS(sourcePluginDir), ".", localPluginDir, "node_modules"); err != nil { return fmt.Errorf("failed to copy ui files: %w", err) } } formatUIPluginsDirName(localUIPluginDir) return nil } // overwriteIndexTs overwrites index.ts file in ui/src/plugins/ dir func overwriteIndexTs(b *buildingMaterial) (err error) { localUIPluginDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui/src/plugins/") folders, err := getFolders(localUIPluginDir) if err != nil { return fmt.Errorf("failed to get folders: %w", err) } content := generateIndexTsContent(folders) err = os.WriteFile(filepath.Join(localUIPluginDir, "index.ts"), []byte(content), 0644) if err != nil { return fmt.Errorf("failed to write index.ts: %w", err) } return nil } func getFolders(dir string) ([]string, error) { var folders []string files, err := os.ReadDir(dir) if err != nil { return nil, err } for _, file := range files { if file.IsDir() && file.Name() != "builtin" { folders = append(folders, file.Name()) } } return folders, nil } func generateIndexTsContent(folders []string) string { builder := &strings.Builder{} builder.WriteString("export default null;\n") // Line 2:1: Delete `⏎` prettier/prettier if len(folders) > 0 { builder.WriteString("\n") } for _, folder := range folders { builder.WriteString(fmt.Sprintf("export { default as %s } from '%s';\n", folder, folder)) } return builder.String() } // buildUI run pnpm install and pnpm build commands to build ui func buildUI(b *buildingMaterial) (err error) { localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui") pnpmInstallCmd := b.newExecCmd("pnpm", "pre-install") pnpmInstallCmd.Dir = localUIBuildDir if err = pnpmInstallCmd.Run(); err != nil { return err } pnpmBuildCmd := b.newExecCmd("pnpm", "build") pnpmBuildCmd.Dir = localUIBuildDir if err = pnpmBuildCmd.Run(); err != nil { return err } return nil } func replaceNecessaryFile(b *buildingMaterial) (err error) { fmt.Printf("try to replace ui build directory\n") uiBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui") err = copyDirEntries(ui.Build, ".", uiBuildDir) return err } // mergeI18nFiles merge i18n files func mergeI18nFiles(b *buildingMaterial) (err error) { fmt.Printf("try to merge i18n files\n") type YamlPluginContent struct { Plugin map[string]any `yaml:"plugin"` } pluginAllTranslations := make(map[string]*YamlPluginContent) for _, plugin := range b.plugins { i18nDir := filepath.Join(b.tmpDir, fmt.Sprintf("vendor/%s/i18n", plugin.Name)) fmt.Println("i18n dir: ", i18nDir) if !dir.CheckDirExist(i18nDir) { continue } entries, err := os.ReadDir(i18nDir) if err != nil { return err } for _, file := range entries { // ignore directory if file.IsDir() { continue } // ignore non-YAML file if filepath.Ext(file.Name()) != ".yaml" { continue } buf, err := os.ReadFile(filepath.Join(i18nDir, file.Name())) if err != nil { log.Debugf("read translation file failed: %s %s", file.Name(), err) continue } translation := &YamlPluginContent{} if err = yaml.Unmarshal(buf, translation); err != nil { log.Debugf("unmarshal translation file failed: %s %s", file.Name(), err) continue } if pluginAllTranslations[file.Name()] == nil { pluginAllTranslations[file.Name()] = &YamlPluginContent{Plugin: make(map[string]any)} } for k, v := range translation.Plugin { pluginAllTranslations[file.Name()].Plugin[k] = v } } } originalI18nDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/i18n") entries, err := os.ReadDir(originalI18nDir) if err != nil { return err } for _, file := range entries { // ignore directory if file.IsDir() { continue } // ignore non-YAML file filename := file.Name() if filepath.Ext(filename) != ".yaml" && filename != "i18n.yaml" { continue } // if plugin don't have this translation file, ignore it if pluginAllTranslations[filename] == nil { continue } out, _ := yaml.Marshal(pluginAllTranslations[filename]) buf, err := os.OpenFile(filepath.Join(originalI18nDir, filename), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Debugf("read translation file failed: %s %s", filename, err) continue } _, _ = buf.WriteString("\n") _, _ = buf.Write(out) _ = buf.Close() } return err } func copyDirEntries(sourceFs fs.FS, sourceDir, targetDir string, ignoreDir ...string) (err error) { err = dir.CreateDirIfNotExist(targetDir) if err != nil { return err } ignoreThisDir := func(path string) bool { for _, s := range ignoreDir { if strings.HasPrefix(path, s) { return true } } return false } err = fs.WalkDir(sourceFs, sourceDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if ignoreThisDir(path) { return nil } // Convert the path to use forward slashes, important because we use embedded FS which always uses forward slashes path = filepath.ToSlash(path) // Construct the absolute path for the source file/directory srcPath := filepath.Join(sourceDir, path) // Construct the absolute path for the destination file/directory dstPath := filepath.Join(targetDir, path) if d.IsDir() { // Create the directory in the destination err := os.MkdirAll(dstPath, os.ModePerm) if err != nil { return fmt.Errorf("failed to create directory %s: %w", dstPath, err) } } else { // Open the source file srcFile, err := sourceFs.Open(srcPath) if err != nil { return fmt.Errorf("failed to open source file %s: %w", srcPath, err) } defer srcFile.Close() // Create the destination file dstFile, err := os.Create(dstPath) if err != nil { return fmt.Errorf("failed to create destination file %s: %w", dstPath, err) } defer dstFile.Close() // Copy the file contents _, err = io.Copy(dstFile, srcFile) if err != nil { return fmt.Errorf("failed to copy file contents from %s to %s: %w", srcPath, dstPath, err) } } return nil }) return err } // format plugins dir name from dash to underline func formatUIPluginsDirName(dirPath string) { entries, err := os.ReadDir(dirPath) if err != nil { fmt.Printf("read ui plugins dir failed: [%s] %s\n", dirPath, err) return } for _, entry := range entries { if !entry.IsDir() || !strings.Contains(entry.Name(), "-") { continue } newName := strings.ReplaceAll(entry.Name(), "-", "_") if err := os.Rename(filepath.Join(dirPath, entry.Name()), filepath.Join(dirPath, newName)); err != nil { fmt.Printf("rename ui plugins dir failed: [%s] %s\n", dirPath, err) } else { fmt.Printf("rename ui plugins dir success: [%s] -> [%s]\n", entry.Name(), newName) } } } // buildBinary build binary file func buildBinary(b *buildingMaterial) (err error) { versionInfo := b.originalAnswerInfo cmdPkg := "github.com/apache/answer/cmd" ldflags := fmt.Sprintf("-X %s.Version=%s -X %s.Revision=%s -X %s.Time=%s", cmdPkg, versionInfo.Version, cmdPkg, versionInfo.Revision, cmdPkg, versionInfo.Time) err = b.newExecCmd("go", "build", "-ldflags", ldflags, "-o", b.outputPath, ".").Run() if err != nil { return err } return } // cleanByproduct delete tmp dir func cleanByproduct(b *buildingMaterial) (err error) { return os.RemoveAll(b.tmpDir) } func (b *buildingMaterial) newExecCmd(command string, args ...string) *exec.Cmd { cmd := exec.Command(command, args...) fmt.Println(cmd.Args) cmd.Dir = b.tmpDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd } func versionedModulePath(modulePath, moduleVersion string) string { if moduleVersion == "" { return modulePath } ver, err := semver.StrictNewVersion(strings.TrimPrefix(moduleVersion, "v")) if err != nil { return modulePath } major := ver.Major() if major > 1 { modulePath += fmt.Sprintf("/v%d", major) } return path.Clean(modulePath) }