mirror of https://gitee.com/answerdev/answer.git
477 lines
12 KiB
Go
477 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/Masterminds/semver/v3"
|
|
"github.com/answerdev/answer/pkg/dir"
|
|
"github.com/answerdev/answer/pkg/writer"
|
|
"github.com/answerdev/answer/ui"
|
|
"github.com/segmentfault/pacman/log"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
mainGoTpl = `package main
|
|
|
|
import (
|
|
answercmd "github.com/answerdev/answer/cmd"
|
|
|
|
// remote plugins
|
|
{{- range .remote_plugins}}
|
|
_ "{{.}}"
|
|
{{- end}}
|
|
|
|
// local plugins
|
|
{{- range .local_plugins}}
|
|
_ "answer/{{.}}"
|
|
{{- end}}
|
|
)
|
|
|
|
func main() {
|
|
answercmd.Main()
|
|
}
|
|
`
|
|
goModTpl = `module answer
|
|
|
|
go 1.19
|
|
`
|
|
)
|
|
|
|
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/answerdev/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(outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) *answerBuilder {
|
|
material := &buildingMaterial{originalAnswerInfo: originalAnswerInfo}
|
|
parentDir, _ := filepath.Abs(".")
|
|
material.tmpDir, _ = os.MkdirTemp(parentDir, "answer_build")
|
|
if len(outputPath) == 0 {
|
|
outputPath = filepath.Join(parentDir, "new_answer")
|
|
}
|
|
material.outputPath = 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(outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) (err error) {
|
|
builder := newAnswerBuilder(outputPath, plugins, originalAnswerInfo)
|
|
builder.DoTask(createMainGoFile)
|
|
builder.DoTask(downloadGoModFile)
|
|
builder.DoTask(copyUIFiles)
|
|
builder.DoTask(overwriteIndexTs)
|
|
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/answerdev/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] tmp 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 len(p.Path) == 0 {
|
|
continue
|
|
}
|
|
replacement := fmt.Sprintf("%s@v%s=%s", p.Name, p.Version, p.Path)
|
|
err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).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/answerdev/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
|
|
}
|
|
|
|
// 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/answerdev/answer")
|
|
buf := new(bytes.Buffer)
|
|
goListCmd.Stdout = buf
|
|
if err = goListCmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to run go list: %w", err)
|
|
}
|
|
|
|
goModUIDir := filepath.Join(strings.TrimSpace(buf.String()), "ui")
|
|
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/answerdev/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)
|
|
}
|
|
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/answerdev/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\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/answerdev/answer/ui")
|
|
|
|
pnpmInstallCmd := b.newExecCmd("pnpm", "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/answerdev/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/answerdev/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
|
|
}
|
|
|
|
// buildBinary build binary file
|
|
func buildBinary(b *buildingMaterial) (err error) {
|
|
versionInfo := b.originalAnswerInfo
|
|
cmdPkg := "github.com/answerdev/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)
|
|
}
|