Reviewed-on: #7 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2129 lines
59 KiB
Go
2129 lines
59 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/changelog"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/validator"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type command struct {
|
|
name string
|
|
description string
|
|
run func(context) error
|
|
}
|
|
|
|
type context struct {
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
cwd string
|
|
args []string
|
|
logLevel logLevel
|
|
}
|
|
|
|
type spinner struct {
|
|
mu sync.Mutex
|
|
on bool
|
|
text string
|
|
msg []string
|
|
painted bool
|
|
writer io.Writer
|
|
enabled bool
|
|
}
|
|
|
|
func newSpinner() *spinner {
|
|
return &spinner{
|
|
msg: []string{"-", "\\", "|", "/"},
|
|
}
|
|
}
|
|
|
|
var spin = newSpinner()
|
|
|
|
func (s *spinner) configure(writer io.Writer, enabled bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.writer = writer
|
|
s.enabled = enabled
|
|
}
|
|
|
|
func (s *spinner) start(text string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.enabled || s.writer == nil {
|
|
s.on = false
|
|
s.painted = false
|
|
s.text = text
|
|
return
|
|
}
|
|
if s.on {
|
|
s.text = text
|
|
return
|
|
}
|
|
s.on = true
|
|
s.text = text
|
|
go s.run()
|
|
}
|
|
|
|
func (s *spinner) stop() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.enabled || s.writer == nil {
|
|
s.on = false
|
|
s.painted = false
|
|
return
|
|
}
|
|
s.on = false
|
|
if s.painted {
|
|
fmt.Fprint(s.writer, "\r\033[K")
|
|
s.painted = false
|
|
}
|
|
}
|
|
|
|
func (s *spinner) linebreak() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.enabled && s.on && s.painted {
|
|
fmt.Fprint(s.writer, "\r\033[K")
|
|
s.painted = false
|
|
}
|
|
}
|
|
|
|
func (s *spinner) update(text string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.text = text
|
|
}
|
|
|
|
func (s *spinner) run() {
|
|
i := 0
|
|
for {
|
|
s.mu.Lock()
|
|
if !s.on {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
msg := s.msg[i%len(s.msg)]
|
|
fmt.Fprintf(s.writer, "\r[%s] %s", msg, s.text)
|
|
s.painted = true
|
|
s.mu.Unlock()
|
|
i++
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func isInteractiveTTY(w io.Writer) bool {
|
|
file, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return (info.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
func spinnerEnabledFor(w io.Writer, level logLevel) bool {
|
|
mode := strings.TrimSpace(strings.ToLower(os.Getenv("SOW_TOOLS_TTY_MODE")))
|
|
switch mode {
|
|
case "plain", "off", "0", "false", "no":
|
|
return false
|
|
case "spinner", "on", "1", "true", "yes":
|
|
return level == logLevelNormal && isInteractiveTTY(w)
|
|
case "", "auto":
|
|
return isInteractiveTTY(w) && strings.TrimSpace(os.Getenv("CI")) == "" && level == logLevelNormal
|
|
default:
|
|
return isInteractiveTTY(w) && strings.TrimSpace(os.Getenv("CI")) == "" && level == logLevelNormal
|
|
}
|
|
}
|
|
|
|
var commands = []command{
|
|
{
|
|
name: "build",
|
|
description: "Build module, HAK, and configured top package outputs.",
|
|
run: runBuild,
|
|
},
|
|
{
|
|
name: "build-module",
|
|
description: "Build only the configured module archive.",
|
|
run: runBuildModule,
|
|
},
|
|
{
|
|
name: "build-haks",
|
|
description: "Build only configured HAK archives and manifest.",
|
|
run: runBuildHAKs,
|
|
},
|
|
{
|
|
name: "extract",
|
|
description: "Extract built archives back into the configured source and asset trees.",
|
|
run: runExtract,
|
|
},
|
|
{
|
|
name: "validate",
|
|
description: "Validate project layout, config, and source inventory.",
|
|
run: runValidate,
|
|
},
|
|
{
|
|
name: "config",
|
|
description: "Inspect, explain, and validate the resolved project configuration.",
|
|
run: runConfig,
|
|
},
|
|
{
|
|
name: "music",
|
|
description: "List, scan, build, and validate configured music datasets.",
|
|
run: runMusic,
|
|
},
|
|
{
|
|
name: "compare",
|
|
description: "Compare current source content against the built archives.",
|
|
run: runCompare,
|
|
},
|
|
{
|
|
name: "apply-hak-manifest",
|
|
description: "Apply a generated HAK manifest to the configured module source.",
|
|
run: runApplyHAKManifest,
|
|
},
|
|
{
|
|
name: "validate-topdata",
|
|
description: "Validate topdata source layout and canonical JSON compatibility.",
|
|
run: runValidateTopData,
|
|
},
|
|
{
|
|
name: "build-topdata",
|
|
description: "Build configured topdata outputs, then refresh the configured top package.",
|
|
run: runBuildTopData,
|
|
},
|
|
{
|
|
name: "build-top-package",
|
|
description: "Build the configured top package from cached native topdata output and authored topdata assets.",
|
|
run: runBuildTopPackage,
|
|
},
|
|
{
|
|
name: "compare-topdata",
|
|
description: "Rebuild topdata natively and compare the current build output against that fresh native result.",
|
|
run: runCompareTopData,
|
|
},
|
|
{
|
|
name: "convert-topdata",
|
|
description: "Convert between 2DA and native topdata JSON/module formats.",
|
|
run: runConvertTopData,
|
|
},
|
|
{
|
|
name: "build-wiki",
|
|
description: "Build wiki pages from the current topdata state.",
|
|
run: runBuildWiki,
|
|
},
|
|
{
|
|
name: "deploy-wiki",
|
|
description: "Deploy generated wiki pages to NodeBB.",
|
|
run: runDeployWiki,
|
|
},
|
|
{
|
|
name: "build-changelog",
|
|
description: "Build a release changelog from tagged pushes and write it to stdout or a file.",
|
|
run: runBuildChangelog,
|
|
},
|
|
}
|
|
|
|
func Run(args []string) (int, error) {
|
|
ctx, err := newContext()
|
|
if err != nil {
|
|
return 1, err
|
|
}
|
|
|
|
if len(args) == 0 {
|
|
printUsage(ctx.stdout)
|
|
return 0, nil
|
|
}
|
|
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
printUsage(ctx.stdout)
|
|
return 0, nil
|
|
default:
|
|
cmdArgs, level := parseGlobalFlags(args[1:])
|
|
ctx.logLevel = level
|
|
ctx.args = append([]string{args[0]}, cmdArgs...)
|
|
for _, cmd := range commands {
|
|
if cmd.name == args[0] {
|
|
if err := cmd.run(ctx); err != nil {
|
|
return 1, err
|
|
}
|
|
return 0, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return 1, fmt.Errorf("unknown command %q", args[0])
|
|
}
|
|
|
|
func parseGlobalFlags(args []string) ([]string, logLevel) {
|
|
level := logLevelNormal
|
|
var filtered []string
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--quiet":
|
|
level = logLevelQuiet
|
|
case "--verbose":
|
|
level = logLevelVerbose
|
|
case "--debug":
|
|
level = logLevelDebug
|
|
default:
|
|
filtered = append(filtered, arg)
|
|
}
|
|
}
|
|
return filtered, level
|
|
}
|
|
|
|
func newContext() (context, error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return context{}, fmt.Errorf("resolve working directory: %w", err)
|
|
}
|
|
|
|
return context{
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
cwd: cwd,
|
|
args: os.Args[1:],
|
|
}, nil
|
|
}
|
|
|
|
func runBuild(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
preflight, err := runBuildModulePreflight(ctx, p, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "build")
|
|
|
|
result, err := pipeline.Build(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitBuildResult(result, preflight)
|
|
return nil
|
|
}
|
|
|
|
func runBuildModule(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "build-module")
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Build Module: starting")
|
|
defer spin.stop()
|
|
|
|
progress := func(message string) {
|
|
console.progress(message)
|
|
}
|
|
preflight, err := runBuildModulePreflight(ctx, p, progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := pipeline.BuildModuleWithProgress(p, func(message string) {
|
|
progress(message)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitBuildModuleResult(result, preflight)
|
|
return nil
|
|
}
|
|
|
|
type buildModulePreflightResult struct {
|
|
ManifestStatus string
|
|
ManifestPath string
|
|
}
|
|
|
|
func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) (buildModulePreflightResult, error) {
|
|
result := buildModulePreflightResult{}
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
manifestStatus, manifestPath, err := refreshBuildModuleManifest(ctx, p, progress)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.ManifestStatus = manifestStatus
|
|
result.ManifestPath = manifestPath
|
|
return result, nil
|
|
}
|
|
|
|
func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(string)) (string, string, error) {
|
|
manifestPath := p.HAKManifestPath()
|
|
if override := strings.TrimSpace(os.Getenv("SOW_HAK_MANIFEST_PATH")); override != "" {
|
|
manifestPath = override
|
|
if !filepath.IsAbs(manifestPath) {
|
|
manifestPath = filepath.Join(p.Root, manifestPath)
|
|
}
|
|
}
|
|
|
|
if envEnabled("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH") {
|
|
if _, err := os.Stat(manifestPath); err != nil {
|
|
return "", "", fmt.Errorf("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH is set, but %s does not exist", manifestPath)
|
|
}
|
|
progress(fmt.Sprintf("Using existing hak manifest at %s; skipping published sow-assets refresh.", relPathFromRoot(p.Root, manifestPath)))
|
|
return "reused", manifestPath, nil
|
|
}
|
|
|
|
progress("Refreshing hak list from the latest published sow-assets manifest...")
|
|
if err := runProjectScript(ctx, p, []string{"scripts", "fetch-hak-manifest"}, manifestPath); err != nil {
|
|
return "", "", err
|
|
}
|
|
if _, err := pipeline.ApplyHAKManifest(p, manifestPath); err != nil {
|
|
return "", "", err
|
|
}
|
|
return "refreshed", manifestPath, nil
|
|
}
|
|
|
|
func runProjectScript(ctx context, p *project.Project, scriptParts []string, args ...string) error {
|
|
if len(scriptParts) == 0 {
|
|
return errors.New("project script path is required")
|
|
}
|
|
scriptPath := projectScriptPath(p.Root, scriptParts...)
|
|
if !fileExists(scriptPath) {
|
|
return fmt.Errorf("required project script is missing: %s", scriptPath)
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
switch filepath.Ext(scriptPath) {
|
|
case ".ps1":
|
|
powershell := "powershell"
|
|
if runtime.GOOS != "windows" {
|
|
powershell = "pwsh"
|
|
}
|
|
commandArgs := []string{"-NoProfile", "-File", scriptPath}
|
|
commandArgs = append(commandArgs, args...)
|
|
cmd = exec.Command(powershell, commandArgs...)
|
|
default:
|
|
cmd = exec.Command(scriptPath, args...)
|
|
}
|
|
cmd.Dir = p.Root
|
|
cmd.Stdout = ctx.stderr
|
|
cmd.Stderr = ctx.stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func projectScriptPath(root string, pathParts ...string) string {
|
|
parts := append([]string{root}, pathParts...)
|
|
base := filepath.Join(parts...)
|
|
if runtime.GOOS == "windows" {
|
|
if strings.HasSuffix(base, ".ps1") || strings.HasSuffix(base, ".sh") {
|
|
return base
|
|
}
|
|
return base + ".ps1"
|
|
}
|
|
if strings.HasSuffix(base, ".ps1") || strings.HasSuffix(base, ".sh") {
|
|
return base
|
|
}
|
|
return base + ".sh"
|
|
}
|
|
|
|
func envEnabled(name string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func relPathFromRoot(root, path string) string {
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
type buildHAKOptions struct {
|
|
filteredHAKs []string
|
|
filteredArchives []string
|
|
sourceManifest string
|
|
contentAddressedRoot string
|
|
musicDatasets []string
|
|
skipMusic bool
|
|
planOnly bool
|
|
logLevel logLevel
|
|
}
|
|
|
|
type logLevel int
|
|
|
|
const (
|
|
logLevelNormal logLevel = iota
|
|
logLevelQuiet
|
|
logLevelVerbose
|
|
logLevelDebug
|
|
)
|
|
|
|
type projectConsole struct {
|
|
stdout io.Writer
|
|
projectRoot string
|
|
projectName string
|
|
commandName string
|
|
commandLabel string
|
|
level logLevel
|
|
spinnerEnabled bool
|
|
}
|
|
|
|
func newProjectConsole(ctx context, p *project.Project, commandName string) *projectConsole {
|
|
return &projectConsole{
|
|
stdout: ctx.stdout,
|
|
projectRoot: p.Root,
|
|
projectName: p.Config.Module.Name,
|
|
commandName: commandName,
|
|
commandLabel: projectCommandLabel(commandName),
|
|
level: ctx.logLevel,
|
|
spinnerEnabled: spinnerEnabledFor(ctx.stderr, ctx.logLevel),
|
|
}
|
|
}
|
|
|
|
func projectCommandLabel(commandName string) string {
|
|
switch commandName {
|
|
case "build":
|
|
return "Build"
|
|
case "build-module":
|
|
return "Build Module"
|
|
case "extract":
|
|
return "Extract"
|
|
case "validate":
|
|
return "Validate"
|
|
case "compare":
|
|
return "Compare"
|
|
case "apply-hak-manifest":
|
|
return "Apply HAK Manifest"
|
|
default:
|
|
return commandName
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) progress(message string) {
|
|
if phase := c.phaseLabel(message); phase != "" {
|
|
spin.update(c.commandLabel + ": " + phase)
|
|
}
|
|
if c.level != logLevelDebug {
|
|
return
|
|
}
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
|
|
}
|
|
|
|
func (c *projectConsole) phaseLabel(message string) string {
|
|
switch {
|
|
case strings.HasPrefix(message, "Refreshing hak list from the latest published sow-assets manifest"):
|
|
return "refreshing HAK manifest"
|
|
case strings.HasPrefix(message, "Using existing hak manifest at "):
|
|
return "reusing HAK manifest"
|
|
case strings.HasPrefix(message, "Validating project"):
|
|
return "validating project"
|
|
case strings.HasPrefix(message, "Resolving module HAK order"):
|
|
return "resolving HAK order"
|
|
case strings.HasPrefix(message, "Collecting module resources"):
|
|
return "collecting module resources"
|
|
case strings.HasPrefix(message, "Writing module archive"):
|
|
return "writing module archive"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) emitBuildResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
|
|
if preflight.ManifestStatus != "" {
|
|
fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
|
|
if preflight.ManifestPath != "" {
|
|
fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
|
|
}
|
|
fmt.Fprintln(c.stdout)
|
|
}
|
|
if result.TopPackageHAK != "" {
|
|
fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.TopPackageHAK))
|
|
fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.TopPackageTLK))
|
|
}
|
|
if len(result.HAKPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
|
|
fmt.Fprintf(c.stdout, "hak assets: %d\n", result.HAKAssets)
|
|
fmt.Fprintf(c.stdout, "hak manifest: %s\n", c.relPath(result.Manifest))
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) emitBuildModuleResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build Module ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
|
|
if preflight.ManifestStatus != "" {
|
|
fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
|
|
if preflight.ManifestPath != "" {
|
|
fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
|
|
}
|
|
fmt.Fprintln(c.stdout)
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) emitExtractResult(result pipeline.ExtractResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Extract ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
if len(result.HAKPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
|
|
}
|
|
if len(result.DeletedArchivePaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths))
|
|
}
|
|
fmt.Fprintf(c.stdout, "written: %d\n", result.Written)
|
|
fmt.Fprintf(c.stdout, "overwritten: %d\n", result.Overwritten)
|
|
fmt.Fprintf(c.stdout, "removed: %d\n", result.Removed)
|
|
fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped)
|
|
}
|
|
|
|
func (c *projectConsole) emitValidationResult(report validator.Report, root string, inventoryReport project.InventoryReport) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Validate ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "root: %s\n", c.relPath(root))
|
|
fmt.Fprintf(c.stdout, "source files: %d\n", inventoryReport.SourceFiles)
|
|
fmt.Fprintf(c.stdout, "script files: %d\n", inventoryReport.ScriptFiles)
|
|
fmt.Fprintf(c.stdout, "asset files: %d\n", inventoryReport.AssetFiles)
|
|
fmt.Fprintf(c.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", "))
|
|
if warnings := report.WarningCount(); warnings > 0 {
|
|
fmt.Fprintf(c.stdout, "warnings: %d\n", warnings)
|
|
}
|
|
fmt.Fprintln(c.stdout, "validation: ok")
|
|
}
|
|
|
|
func (c *projectConsole) emitCompareResult(result pipeline.CompareResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Compare ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
if len(result.HAKPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
|
|
}
|
|
fmt.Fprintf(c.stdout, "checked resources: %d\n", result.Checked)
|
|
fmt.Fprintln(c.stdout, "compare: ok")
|
|
}
|
|
|
|
func (c *projectConsole) emitApplyManifestResult(result pipeline.ApplyManifestResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Apply HAK Manifest ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.ManifestPath))
|
|
fmt.Fprintf(c.stdout, "module source: %s\n", c.relPath(result.ModuleSource))
|
|
fmt.Fprintf(c.stdout, "hak entries: %d\n", result.HAKCount)
|
|
}
|
|
|
|
func (c *projectConsole) relPath(path string) string {
|
|
if path == "" {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(c.projectRoot, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
type buildHAKConsole struct {
|
|
stdout io.Writer
|
|
projectRoot string
|
|
projectName string
|
|
planOnly bool
|
|
level logLevel
|
|
spinnerEnabled bool
|
|
}
|
|
|
|
func newBuildHAKConsole(ctx context, p *project.Project, opts buildHAKOptions) *buildHAKConsole {
|
|
return &buildHAKConsole{
|
|
stdout: ctx.stdout,
|
|
projectRoot: p.Root,
|
|
projectName: p.Config.Module.Name,
|
|
planOnly: opts.planOnly,
|
|
level: opts.logLevel,
|
|
spinnerEnabled: spinnerEnabledFor(ctx.stderr, opts.logLevel),
|
|
}
|
|
}
|
|
|
|
func (c *buildHAKConsole) progress(message string) {
|
|
if phase := c.phaseLabel(message); phase != "" {
|
|
spin.update("Build HAKs: " + phase)
|
|
}
|
|
if c.level != logLevelDebug {
|
|
return
|
|
}
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
|
|
}
|
|
|
|
func (c *buildHAKConsole) phaseLabel(message string) string {
|
|
switch {
|
|
case strings.HasPrefix(message, "Validating project"):
|
|
return "validating project"
|
|
case strings.HasPrefix(message, "Collecting asset resources"):
|
|
return "collecting asset resources"
|
|
case strings.HasPrefix(message, "Planning HAK chunks"):
|
|
return "planning HAK chunks"
|
|
case strings.HasPrefix(message, "Resolving module HAK order"):
|
|
return "resolving module HAK order"
|
|
case strings.HasPrefix(message, "Reusing unchanged generated HAKs"):
|
|
return "reusing unchanged HAKs"
|
|
case strings.HasPrefix(message, "Reusing HAK "), strings.HasPrefix(message, "Writing HAK "):
|
|
return "writing HAK archives"
|
|
case strings.HasPrefix(message, "Cleaning stale generated HAKs"):
|
|
return "cleaning stale generated HAKs"
|
|
case strings.HasPrefix(message, "Cleaning previous generated HAKs"):
|
|
return "cleaning previous generated HAKs"
|
|
case strings.HasPrefix(message, "Writing HAK manifest"):
|
|
return "writing HAK manifest"
|
|
case strings.HasPrefix(message, "Writing autogen manifest "):
|
|
return "writing autogen manifests"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *buildHAKConsole) emitResult(result pipeline.BuildResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build HAKs ----------")
|
|
if c.level != logLevelQuiet {
|
|
c.emitCreditsSummary(result)
|
|
c.emitCoreSteps(result)
|
|
}
|
|
c.emitFinalSummary(result)
|
|
}
|
|
|
|
func (c *buildHAKConsole) emitCreditsSummary(result pipeline.BuildResult) {
|
|
summary := result.CreditsSummary
|
|
if len(summary.ArtifactPaths) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(c.stdout, "Refreshing credits cache")
|
|
if len(summary.ScannedDirs) > 0 {
|
|
fmt.Fprintf(c.stdout, " scanned: %s\n", strings.Join(summary.ScannedDirs, ", "))
|
|
}
|
|
fmt.Fprintf(c.stdout, " tracks: %d\n", summary.TrackCount)
|
|
if len(summary.GeneratedPaths) > 0 {
|
|
for _, path := range summary.GeneratedPaths {
|
|
fmt.Fprintf(c.stdout, " output: %s\n", c.relPath(path))
|
|
}
|
|
}
|
|
switch {
|
|
case summary.ChangedFiles == 0:
|
|
fmt.Fprintln(c.stdout, " status: unchanged")
|
|
default:
|
|
fmt.Fprintf(c.stdout, " updated: %d artifact(s)\n", summary.ChangedFiles)
|
|
}
|
|
if summary.TrackCount > 0 {
|
|
if c.level >= logLevelVerbose {
|
|
fmt.Fprintln(c.stdout, " mappings:")
|
|
for _, mapping := range summary.Mappings {
|
|
fmt.Fprintf(c.stdout, " %s -> %s\n", mapping.Source, mapping.Output)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(c.stdout, " mapped: %d music file(s); use --verbose to list mappings\n", summary.TrackCount)
|
|
}
|
|
}
|
|
fmt.Fprintln(c.stdout)
|
|
}
|
|
|
|
func (c *buildHAKConsole) emitCoreSteps(result pipeline.BuildResult) {
|
|
fmt.Fprintln(c.stdout, "Validating project")
|
|
fmt.Fprintln(c.stdout, "Collecting asset resources")
|
|
if len(result.AutogenManifestPaths) > 0 {
|
|
fmt.Fprintln(c.stdout, "Writing autogen manifests")
|
|
for _, path := range result.AutogenManifestPaths {
|
|
fmt.Fprintf(c.stdout, " %s\n", filepath.Base(path))
|
|
}
|
|
fmt.Fprintln(c.stdout)
|
|
}
|
|
fmt.Fprintln(c.stdout, "Planning HAK chunks")
|
|
fmt.Fprintln(c.stdout, "Resolving module HAK order")
|
|
if !c.planOnly {
|
|
fmt.Fprintln(c.stdout, "Reusing unchanged HAKs")
|
|
fmt.Fprintf(c.stdout, " reused: %d / %d\n", result.HAKSummary.Reused, result.HAKSummary.Total)
|
|
fmt.Fprintf(c.stdout, " written: %d / %d\n", result.HAKSummary.Written, result.HAKSummary.Total)
|
|
fmt.Fprintf(c.stdout, " assets: %s\n", formatCount(result.HAKAssets))
|
|
if c.level >= logLevelVerbose {
|
|
for _, action := range result.HAKSummary.Actions {
|
|
verb := "wrote"
|
|
if action.Reused {
|
|
verb = "reused"
|
|
}
|
|
fmt.Fprintf(c.stdout, " %s: %s (%s assets)\n", verb, action.Name, formatCount(action.AssetCount))
|
|
}
|
|
}
|
|
fmt.Fprintln(c.stdout)
|
|
fmt.Fprintln(c.stdout, "Cleaning stale generated HAKs")
|
|
} else {
|
|
fmt.Fprintf(c.stdout, "Planned HAK archives: %d\n", result.HAKSummary.Total)
|
|
}
|
|
fmt.Fprintln(c.stdout, "Writing HAK manifest")
|
|
fmt.Fprintln(c.stdout)
|
|
}
|
|
|
|
func (c *buildHAKConsole) emitFinalSummary(result pipeline.BuildResult) {
|
|
fmt.Fprintln(c.stdout, "Summary ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
if c.planOnly {
|
|
fmt.Fprintf(c.stdout, "planned archives: %d\n", result.HAKSummary.Total)
|
|
} else {
|
|
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
|
|
fmt.Fprintf(c.stdout, "hak assets: %s\n", formatCount(result.HAKAssets))
|
|
}
|
|
if result.Manifest != "" {
|
|
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.Manifest))
|
|
}
|
|
if len(result.CreditsArtifactPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
|
|
}
|
|
if len(result.AutogenManifestPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
|
|
}
|
|
status := "complete"
|
|
if c.planOnly {
|
|
status = "planned"
|
|
}
|
|
fmt.Fprintf(c.stdout, "status: %s\n", status)
|
|
}
|
|
|
|
func (c *buildHAKConsole) relPath(path string) string {
|
|
if path == "" {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(c.projectRoot, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
type topdataConsole struct {
|
|
stdout io.Writer
|
|
projectRoot string
|
|
projectName string
|
|
commandName string
|
|
commandLabel string
|
|
level logLevel
|
|
spinnerEnabled bool
|
|
}
|
|
|
|
func newTopdataConsole(ctx context, p *project.Project, commandName string) *topdataConsole {
|
|
return &topdataConsole{
|
|
stdout: ctx.stdout,
|
|
projectRoot: p.Root,
|
|
projectName: p.Config.Module.Name,
|
|
commandName: commandName,
|
|
commandLabel: topdataCommandLabel(commandName),
|
|
level: ctx.logLevel,
|
|
spinnerEnabled: spinnerEnabledFor(ctx.stderr, ctx.logLevel),
|
|
}
|
|
}
|
|
|
|
func topdataCommandLabel(commandName string) string {
|
|
switch commandName {
|
|
case "validate-topdata":
|
|
return "Validate Topdata"
|
|
case "build-topdata":
|
|
return "Build Topdata"
|
|
case "build-top-package":
|
|
return "Build Top Package"
|
|
case "compare-topdata":
|
|
return "Compare Topdata"
|
|
case "build-wiki":
|
|
return "Build Wiki"
|
|
case "deploy-wiki":
|
|
return "Deploy Wiki"
|
|
default:
|
|
return commandName
|
|
}
|
|
}
|
|
|
|
func (c *topdataConsole) progress(message string) {
|
|
if phase := c.phaseLabel(message); phase != "" {
|
|
spin.update(c.commandLabel + ": " + phase)
|
|
}
|
|
if c.level != logLevelDebug {
|
|
return
|
|
}
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
|
|
}
|
|
|
|
func (c *topdataConsole) phaseLabel(message string) string {
|
|
switch {
|
|
case strings.HasPrefix(message, "Building native topdata outputs"):
|
|
return "building native outputs"
|
|
case strings.HasPrefix(message, "Topdata outputs are current"):
|
|
return "checking cached outputs"
|
|
case strings.HasPrefix(message, "Compiled topdata outputs are current"):
|
|
return "refreshing top package"
|
|
case strings.HasPrefix(message, "Packaging compiled topdata resources into "):
|
|
return "packaging HAK"
|
|
case strings.HasPrefix(message, "Preparing compiled TLK release output "):
|
|
return "packaging TLK"
|
|
case strings.HasPrefix(message, "Building native wiki pages"):
|
|
return "building wiki pages"
|
|
case strings.HasPrefix(message, "NodeBB wiki plan: "):
|
|
return "planning wiki deploy"
|
|
case strings.HasPrefix(message, "Planning NodeBB wiki deploy"):
|
|
return "planning wiki deploy"
|
|
case strings.HasPrefix(message, "Planning NodeBB wiki page "):
|
|
return "planning wiki pages"
|
|
case strings.HasPrefix(message, "Executing NodeBB wiki actions"):
|
|
return "executing wiki deploy"
|
|
case strings.HasPrefix(message, "Executing NodeBB wiki action "):
|
|
return "executing wiki actions"
|
|
case strings.HasPrefix(message, "Listing NodeBB wiki namespace category "):
|
|
return "listing remote wiki pages"
|
|
case strings.HasPrefix(message, "Collecting "):
|
|
return "collecting pages"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *topdataConsole) emitValidationResult(report topdata.ValidationReport, topdataRoot string) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Validate Topdata ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "topdata root: %s\n", c.relPath(topdataRoot))
|
|
fmt.Fprintf(c.stdout, "topdata files: %d\n", report.Files)
|
|
fmt.Fprintf(c.stdout, "data files: %d\n", report.DataFiles)
|
|
fmt.Fprintf(c.stdout, "tlk files: %d\n", report.TLKFiles)
|
|
if warnings := report.WarningCount(); warnings > 0 {
|
|
fmt.Fprintf(c.stdout, "warnings: %d\n", warnings)
|
|
}
|
|
fmt.Fprintln(c.stdout, "topdata validation: ok")
|
|
}
|
|
|
|
func (c *topdataConsole) emitPackageResult(result topdata.PackageResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "%s ----------\n", c.commandLabel)
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "mode: %s\n", result.Mode)
|
|
fmt.Fprintf(c.stdout, "topdata 2da output: %s\n", c.relPath(result.Output2DADir))
|
|
fmt.Fprintf(c.stdout, "topdata tlk output: %s\n", c.relPath(result.OutputTLKDir))
|
|
fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.OutputHAKPath))
|
|
fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.OutputTLKPath))
|
|
fmt.Fprintf(c.stdout, "2da files: %d\n", result.Files2DA)
|
|
fmt.Fprintf(c.stdout, "tlk files: %d\n", result.FilesTLK)
|
|
fmt.Fprintf(c.stdout, "top package resources: %d\n", result.HAKResources)
|
|
fmt.Fprintf(c.stdout, "top package assets: %d\n", result.AssetFiles)
|
|
if result.WikiOutputDir != "" {
|
|
fmt.Fprintf(c.stdout, "wiki output: %s\n", c.relPath(result.WikiOutputDir))
|
|
fmt.Fprintf(c.stdout, "wiki pages: %d\n", result.WikiPages)
|
|
fmt.Fprintf(c.stdout, "wiki status: %s\n", result.WikiStatus)
|
|
}
|
|
}
|
|
|
|
func (c *topdataConsole) emitCompareResult(result topdata.CompareResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Compare Topdata ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "mode: %s\n", result.Mode)
|
|
fmt.Fprintf(c.stdout, "checked 2da files: %d\n", result.Compared2DA)
|
|
fmt.Fprintf(c.stdout, "checked tlk files: %d\n", result.ComparedTLK)
|
|
fmt.Fprintf(c.stdout, "native-pass: %d\n", result.NativePass)
|
|
fmt.Fprintf(c.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical)
|
|
fmt.Fprintf(c.stdout, "native-mismatch: %d\n", result.NativeMismatch)
|
|
fmt.Fprintln(c.stdout, "topdata compare: ok")
|
|
}
|
|
|
|
func (c *topdataConsole) emitWikiBuildResult(outputDir string, pageCount int, status string) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build Wiki ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "wiki output: %s\n", c.relPath(outputDir))
|
|
fmt.Fprintf(c.stdout, "wiki pages: %d\n", pageCount)
|
|
fmt.Fprintf(c.stdout, "wiki status: %s\n", status)
|
|
}
|
|
|
|
func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Deploy Wiki ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "local pages: %d\n", localPages)
|
|
fmt.Fprintf(c.stdout, "created: %d\n", created)
|
|
fmt.Fprintf(c.stdout, "updated: %d\n", updated)
|
|
fmt.Fprintf(c.stdout, "skipped: %d\n", skipped)
|
|
fmt.Fprintf(c.stdout, "stale: %d\n", stale)
|
|
fmt.Fprintf(c.stdout, "archived: %d\n", archived)
|
|
fmt.Fprintf(c.stdout, "purged: %d\n", purged)
|
|
fmt.Fprintf(c.stdout, "drifted: %d\n", drifted)
|
|
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest))
|
|
}
|
|
|
|
func (c *topdataConsole) relPath(path string) string {
|
|
if path == "" {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(c.projectRoot, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
func formatCount(value int) string {
|
|
raw := strconv.Itoa(value)
|
|
if value < 1000 && value > -1000 {
|
|
return raw
|
|
}
|
|
sign := ""
|
|
if strings.HasPrefix(raw, "-") {
|
|
sign = "-"
|
|
raw = strings.TrimPrefix(raw, "-")
|
|
}
|
|
parts := make([]string, 0, (len(raw)+2)/3)
|
|
for len(raw) > 3 {
|
|
parts = append(parts, raw[len(raw)-3:])
|
|
raw = raw[:len(raw)-3]
|
|
}
|
|
parts = append(parts, raw)
|
|
slices := make([]string, 0, len(parts))
|
|
for index := len(parts) - 1; index >= 0; index-- {
|
|
slices = append(slices, parts[index])
|
|
}
|
|
return sign + strings.Join(slices, ",")
|
|
}
|
|
|
|
func runBuildHAKs(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts, err := parseBuildHAKArgs(ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ctx.logLevel > opts.logLevel {
|
|
opts.logLevel = ctx.logLevel
|
|
}
|
|
p, err = p.CloneWithHAKNames(opts.filteredHAKs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console := newBuildHAKConsole(ctx, p, opts)
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Build HAKs: starting")
|
|
defer spin.stop()
|
|
var result pipeline.BuildResult
|
|
pipelineOpts := pipeline.BuildHAKOptions{
|
|
Progress: console.progress,
|
|
ArchiveNames: opts.filteredArchives,
|
|
SourceManifestPath: opts.sourceManifest,
|
|
ContentAddressedRoot: opts.contentAddressedRoot,
|
|
SkipMusic: opts.skipMusic,
|
|
MusicDatasetIDs: opts.musicDatasets,
|
|
}
|
|
if opts.planOnly {
|
|
result, err = pipeline.PlanHAKsWithOptions(p, pipelineOpts)
|
|
} else {
|
|
result, err = pipeline.BuildHAKsWithOptions(p, pipelineOpts)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitResult(result)
|
|
return nil
|
|
}
|
|
|
|
func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
|
|
opts := buildHAKOptions{}
|
|
if len(args) == 0 {
|
|
return opts, nil
|
|
}
|
|
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch arg {
|
|
case "-h", "--help":
|
|
return opts, errors.New("usage: build-haks [--hak <hak-name> ...] [--archive <archive-name> ...] [--source-manifest <path>] [--content-addressed-root <path>] [--plan-only] [--skip-music] [--music-dataset <id> ...] [--quiet|--verbose|--debug]")
|
|
case "--hak":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--hak requires a value")
|
|
}
|
|
opts.filteredHAKs = append(opts.filteredHAKs, args[index])
|
|
case "--archive":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--archive requires a value")
|
|
}
|
|
opts.filteredArchives = append(opts.filteredArchives, args[index])
|
|
case "--plan-only":
|
|
opts.planOnly = true
|
|
case "--skip-music":
|
|
opts.skipMusic = true
|
|
case "--music-dataset":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--music-dataset requires a value")
|
|
}
|
|
opts.musicDatasets = append(opts.musicDatasets, args[index])
|
|
case "--quiet":
|
|
opts.logLevel = logLevelQuiet
|
|
case "--verbose":
|
|
opts.logLevel = logLevelVerbose
|
|
case "--debug":
|
|
opts.logLevel = logLevelDebug
|
|
case "--source-manifest":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--source-manifest requires a value")
|
|
}
|
|
opts.sourceManifest = args[index]
|
|
case "--content-addressed-root":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--content-addressed-root requires a value")
|
|
}
|
|
opts.contentAddressedRoot = args[index]
|
|
default:
|
|
if value, ok, err := requireInlineFlagValue(arg, "--hak"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.filteredHAKs = append(opts.filteredHAKs, value)
|
|
continue
|
|
}
|
|
if value, ok, err := requireInlineFlagValue(arg, "--archive"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.filteredArchives = append(opts.filteredArchives, value)
|
|
continue
|
|
}
|
|
if value, ok, err := requireInlineFlagValue(arg, "--source-manifest"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.sourceManifest = value
|
|
continue
|
|
}
|
|
if value, ok, err := requireInlineFlagValue(arg, "--content-addressed-root"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.contentAddressedRoot = value
|
|
continue
|
|
}
|
|
if value, ok, err := requireInlineFlagValue(arg, "--music-dataset"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.musicDatasets = append(opts.musicDatasets, value)
|
|
continue
|
|
}
|
|
if arg == "--quiet=true" {
|
|
opts.logLevel = logLevelQuiet
|
|
continue
|
|
}
|
|
return opts, fmt.Errorf("unknown build-haks argument %q", arg)
|
|
}
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func parseInlineFlagValue(arg, name string) (string, bool) {
|
|
prefix := name + "="
|
|
if !strings.HasPrefix(arg, prefix) {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(strings.TrimPrefix(arg, prefix)), true
|
|
}
|
|
|
|
func requireInlineFlagValue(arg, name string) (string, bool, error) {
|
|
value, ok := parseInlineFlagValue(arg, name)
|
|
if !ok {
|
|
return "", false, nil
|
|
}
|
|
if value == "" {
|
|
return "", true, fmt.Errorf("%s requires a value", name)
|
|
}
|
|
return value, true, nil
|
|
}
|
|
|
|
type musicCommandOptions struct {
|
|
datasets []string
|
|
json bool
|
|
dryRun bool
|
|
check bool
|
|
force bool
|
|
}
|
|
|
|
func runMusic(ctx context) error {
|
|
if len(ctx.args) < 2 {
|
|
return errors.New("usage: music <list-datasets|scan|build|credits|validate|manifest|normalize> [--dataset <id>] [--json] [--dry-run] [--check] [--force]")
|
|
}
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := p.Scan(); err != nil {
|
|
return err
|
|
}
|
|
subcommand := ctx.args[1]
|
|
opts, err := parseMusicCommandArgs(ctx.args[2:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch subcommand {
|
|
case "list-datasets":
|
|
return emitMusicDatasets(ctx, p, opts)
|
|
case "scan":
|
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return emitMusicResult(ctx, "scan", result, opts)
|
|
case "build", "credits":
|
|
write := true
|
|
if subcommand == "build" && opts.dryRun {
|
|
write = false
|
|
}
|
|
if subcommand == "credits" && opts.check {
|
|
write = false
|
|
}
|
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: write})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return emitMusicResult(ctx, subcommand, result, opts)
|
|
case "manifest", "normalize":
|
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return emitMusicResult(ctx, subcommand, result, opts)
|
|
case "validate":
|
|
if err := p.ValidateLayout(); err != nil {
|
|
return err
|
|
}
|
|
result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return emitMusicResult(ctx, "validate", result, opts)
|
|
default:
|
|
return fmt.Errorf("unknown music subcommand %q", subcommand)
|
|
}
|
|
}
|
|
|
|
func parseMusicCommandArgs(args []string) (musicCommandOptions, error) {
|
|
opts := musicCommandOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch arg {
|
|
case "--dataset":
|
|
index++
|
|
if index >= len(args) || strings.TrimSpace(args[index]) == "" {
|
|
return opts, errors.New("--dataset requires a value")
|
|
}
|
|
opts.datasets = append(opts.datasets, args[index])
|
|
case "--json":
|
|
opts.json = true
|
|
case "--dry-run":
|
|
opts.dryRun = true
|
|
case "--check":
|
|
opts.check = true
|
|
case "--force":
|
|
opts.force = true
|
|
default:
|
|
if value, ok, err := requireInlineFlagValue(arg, "--dataset"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.datasets = append(opts.datasets, value)
|
|
continue
|
|
}
|
|
return opts, fmt.Errorf("unknown music argument %q", arg)
|
|
}
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func emitMusicDatasets(ctx context, p *project.Project, opts musicCommandOptions) error {
|
|
effective := p.EffectiveConfig()
|
|
if opts.json {
|
|
return emitConfigValue(ctx.stdout, effective.Music.Datasets, "json")
|
|
}
|
|
if len(effective.Music.Datasets) == 0 {
|
|
fmt.Fprintln(ctx.stdout, "music datasets: none")
|
|
return nil
|
|
}
|
|
fmt.Fprintln(ctx.stdout, "music datasets:")
|
|
ids := make([]string, 0, len(effective.Music.Datasets))
|
|
for id := range effective.Music.Datasets {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
ds := effective.Music.Datasets[id]
|
|
fmt.Fprintf(ctx.stdout, " %s: source=%s output=%s prefix=%s\n", id, ds.Source, ds.Output, ds.Prefix)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func emitMusicResult(ctx context, action string, result pipeline.BuildResult, opts musicCommandOptions) error {
|
|
if opts.json {
|
|
return emitConfigValue(ctx.stdout, result.CreditsSummary, "json")
|
|
}
|
|
fmt.Fprintf(ctx.stdout, "music %s\n", action)
|
|
if len(result.CreditsSummary.ScannedDirs) > 0 {
|
|
fmt.Fprintf(ctx.stdout, "scanned: %s\n", strings.Join(result.CreditsSummary.ScannedDirs, ", "))
|
|
}
|
|
fmt.Fprintf(ctx.stdout, "tracks: %d\n", result.CreditsSummary.TrackCount)
|
|
if action != "scan" && len(result.CreditsArtifactPaths) > 0 {
|
|
fmt.Fprintf(ctx.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
|
|
}
|
|
if len(result.CreditsSummary.Mappings) > 0 && ctx.logLevel >= logLevelVerbose {
|
|
fmt.Fprintln(ctx.stdout, "mappings:")
|
|
for _, mapping := range result.CreditsSummary.Mappings {
|
|
fmt.Fprintf(ctx.stdout, " %s -> %s\n", mapping.Source, mapping.Output)
|
|
}
|
|
}
|
|
fmt.Fprintln(ctx.stdout, "status: ok")
|
|
return nil
|
|
}
|
|
|
|
func runExtract(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "extract")
|
|
|
|
files := ctx.args[1:]
|
|
result, err := pipeline.Extract(p, files...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitExtractResult(result)
|
|
return nil
|
|
}
|
|
|
|
func runValidate(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "validate")
|
|
|
|
validationReport := validator.ValidateProject(p)
|
|
emitValidatorReport(ctx.stderr, validationReport)
|
|
if validationReport.HasErrors() {
|
|
return fmt.Errorf("validation failed with %d error(s)", validationReport.ErrorCount())
|
|
}
|
|
|
|
inventoryReport := p.Inventory.Report()
|
|
console.emitValidationResult(validationReport, p.Root, inventoryReport)
|
|
return nil
|
|
}
|
|
|
|
func runConfig(ctx context) error {
|
|
if len(ctx.args) < 2 {
|
|
return errors.New("usage: config <validate|effective|inspect|explain|sources> ...")
|
|
}
|
|
root, err := project.FindRoot(ctx.cwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p, err := project.Load(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch ctx.args[1] {
|
|
case "validate":
|
|
if len(ctx.args) > 2 {
|
|
return fmt.Errorf("usage: config validate")
|
|
}
|
|
if err := p.ValidateLayout(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(ctx.stdout, "config: ok\n")
|
|
fmt.Fprintf(ctx.stdout, "source: %s\n", p.ConfigSource.Path)
|
|
return nil
|
|
case "effective":
|
|
format, err := parseConfigFormatArgs("config effective", ctx.args[2:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return emitConfigValue(ctx.stdout, p.EffectiveConfig(), format)
|
|
case "inspect":
|
|
key := ""
|
|
format := "yaml"
|
|
for _, arg := range ctx.args[2:] {
|
|
switch arg {
|
|
case "--json":
|
|
format = "json"
|
|
case "--yaml":
|
|
format = "yaml"
|
|
default:
|
|
if key != "" {
|
|
return fmt.Errorf("usage: config inspect [<key>] [--json|--yaml]")
|
|
}
|
|
key = arg
|
|
}
|
|
}
|
|
if key == "" {
|
|
return emitConfigValue(ctx.stdout, p.EffectiveConfig(), format)
|
|
}
|
|
value, _, ok := p.ExplainConfig(key)
|
|
if !ok {
|
|
return fmt.Errorf("unknown config key %q", key)
|
|
}
|
|
return emitConfigValue(ctx.stdout, value, format)
|
|
case "explain":
|
|
if len(ctx.args) != 3 {
|
|
return fmt.Errorf("usage: config explain <key>")
|
|
}
|
|
key := ctx.args[2]
|
|
value, provenance, ok := p.ExplainConfig(key)
|
|
if !ok {
|
|
return fmt.Errorf("unknown config key %q", key)
|
|
}
|
|
fmt.Fprintf(ctx.stdout, "key: %s\n", key)
|
|
fmt.Fprintf(ctx.stdout, "value: %s\n", formatConfigScalar(value))
|
|
fmt.Fprintf(ctx.stdout, "source: %s\n", provenance.Source)
|
|
if provenance.Detail != "" {
|
|
fmt.Fprintf(ctx.stdout, "detail: %s\n", provenance.Detail)
|
|
}
|
|
if provenance.Deprecated {
|
|
fmt.Fprintf(ctx.stdout, "deprecated: true\n")
|
|
}
|
|
if rules := configValidationRules(key); len(rules) > 0 {
|
|
fmt.Fprintf(ctx.stdout, "validation: %s\n", strings.Join(rules, "; "))
|
|
}
|
|
return nil
|
|
case "sources":
|
|
if len(ctx.args) > 2 {
|
|
return fmt.Errorf("usage: config sources")
|
|
}
|
|
fmt.Fprintf(ctx.stdout, "loaded: %s\n", p.ConfigSource.Path)
|
|
fmt.Fprintf(ctx.stdout, "name: %s\n", p.ConfigSource.Name)
|
|
fmt.Fprintf(ctx.stdout, "format: %s\n", p.ConfigSource.Format)
|
|
fmt.Fprintf(ctx.stdout, "legacy: %t\n", p.ConfigSource.Legacy)
|
|
fmt.Fprintf(ctx.stdout, "candidates: %s\n", strings.Join(project.ConfigFileCandidates, ", "))
|
|
overrides := p.ActiveOverrides()
|
|
if len(overrides) == 0 {
|
|
fmt.Fprintln(ctx.stdout, "active overrides: none")
|
|
return nil
|
|
}
|
|
fmt.Fprintln(ctx.stdout, "active overrides:")
|
|
for _, override := range overrides {
|
|
fmt.Fprintf(ctx.stdout, " %s=%s (%s)\n", override.Key, override.Value, override.Source)
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown config subcommand %q", ctx.args[1])
|
|
}
|
|
}
|
|
|
|
func configValidationRules(key string) []string {
|
|
switch {
|
|
case strings.HasPrefix(key, "paths."), strings.Contains(key, ".root"), strings.Contains(key, ".dir"), strings.Contains(key, ".cache"):
|
|
return []string{"relative paths must not escape the repository root unless explicitly documented"}
|
|
case strings.HasPrefix(key, "outputs."):
|
|
return []string{"generated output names must use the configured extension and must not escape the repository root"}
|
|
case key == "extract.layout":
|
|
return []string{"supported value: nwn_canonical_json"}
|
|
case key == "extract.archives":
|
|
return []string{"build-relative .mod/.hak glob patterns; .erf is intentionally rejected"}
|
|
case key == "extract.consume_archives":
|
|
return []string{"when true, selected build archives are deleted only after successful extraction"}
|
|
case key == "extract.hak_discovery":
|
|
return []string{"deprecated; use extract.archives instead"}
|
|
case key == "autogen.cache.max_age":
|
|
return []string{"Go duration string, for example 1h or 30m"}
|
|
case strings.HasPrefix(key, "topdata.package_hak"):
|
|
return []string{"must be a .hak file name"}
|
|
case strings.HasPrefix(key, "topdata.package_tlk"), strings.HasPrefix(key, "topdata.compiled_tlk"):
|
|
return []string{"must be a .tlk file name"}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func parseConfigFormatArgs(commandName string, args []string) (string, error) {
|
|
format := "yaml"
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--json":
|
|
format = "json"
|
|
case "--yaml":
|
|
format = "yaml"
|
|
default:
|
|
return "", fmt.Errorf("usage: %s [--json|--yaml]", commandName)
|
|
}
|
|
}
|
|
return format, nil
|
|
}
|
|
|
|
func emitConfigValue(w io.Writer, value any, format string) error {
|
|
var (
|
|
raw []byte
|
|
err error
|
|
)
|
|
switch format {
|
|
case "json":
|
|
raw, err = json.MarshalIndent(value, "", " ")
|
|
case "yaml":
|
|
raw, err = yaml.Marshal(value)
|
|
default:
|
|
return fmt.Errorf("unsupported config output format %q", format)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(raw) == 0 || raw[len(raw)-1] != '\n' {
|
|
raw = append(raw, '\n')
|
|
}
|
|
_, err = w.Write(raw)
|
|
return err
|
|
}
|
|
|
|
func formatConfigScalar(value any) string {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Sprint(value)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
func emitValidatorReport(w io.Writer, report validator.Report) {
|
|
lines := report.SummaryLines(5)
|
|
emitSummaryLines(w, lines, 20, "validation diagnostics")
|
|
if line := report.DecompiledSummaryLine(12); line != "" {
|
|
fmt.Fprintln(w, line)
|
|
}
|
|
}
|
|
|
|
func emitTopdataValidationReport(w io.Writer, report topdata.ValidationReport) {
|
|
lines := report.SummaryLines(5)
|
|
emitSummaryLines(w, lines, 20, "topdata validation diagnostics")
|
|
}
|
|
|
|
func emitSummaryLines(w io.Writer, lines []string, maxLines int, label string) {
|
|
if maxLines <= 0 || len(lines) <= maxLines {
|
|
for _, line := range lines {
|
|
fmt.Fprintln(w, line)
|
|
}
|
|
return
|
|
}
|
|
for _, line := range lines[:maxLines] {
|
|
fmt.Fprintln(w, line)
|
|
}
|
|
fmt.Fprintf(w, "info: %d additional %s omitted\n", len(lines)-maxLines, label)
|
|
}
|
|
|
|
func runCompare(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "compare")
|
|
|
|
result, err := pipeline.Compare(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitCompareResult(result)
|
|
return nil
|
|
}
|
|
|
|
func runApplyHAKManifest(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "apply-hak-manifest")
|
|
|
|
manifestPath := p.HAKManifestPath()
|
|
if len(ctx.args) > 1 {
|
|
manifestPath = ctx.args[1]
|
|
if !filepath.IsAbs(manifestPath) {
|
|
manifestPath = filepath.Join(ctx.cwd, manifestPath)
|
|
}
|
|
}
|
|
|
|
result, err := pipeline.ApplyHAKManifest(p, manifestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitApplyManifestResult(result)
|
|
return nil
|
|
}
|
|
|
|
func runValidateTopData(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console := newTopdataConsole(ctx, p, "validate-topdata")
|
|
report := topdata.ValidateProject(p)
|
|
emitTopdataValidationReport(ctx.stderr, report)
|
|
if report.HasErrors() {
|
|
return fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount())
|
|
}
|
|
|
|
console.emitValidationResult(report, p.TopDataSourceDir())
|
|
return nil
|
|
}
|
|
|
|
func runBuildTopData(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts, err := parseBuildTopDataArgs("build-topdata", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console := newTopdataConsole(ctx, p, "build-topdata")
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Build Topdata: starting")
|
|
defer spin.stop()
|
|
result, err := topdata.BuildAndPackageWithOptions(p, opts, console.progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitPackageResult(result)
|
|
return nil
|
|
}
|
|
|
|
func runBuildTopPackage(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts, err := parseBuildTopDataArgs("build-top-package", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.BuildWiki {
|
|
return fmt.Errorf("--wiki is not supported with build-top-package; use build-topdata when wiki generation is required")
|
|
}
|
|
|
|
console := newTopdataConsole(ctx, p, "build-top-package")
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Build Top Package: starting")
|
|
defer spin.stop()
|
|
result, err := topdata.BuildPackage(p, console.progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitPackageResult(result)
|
|
return nil
|
|
}
|
|
|
|
func parseBuildTopDataArgs(commandName string, args []string) (topdata.BuildAndPackageOptions, error) {
|
|
opts := topdata.BuildAndPackageOptions{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--force":
|
|
opts.Force = true
|
|
case "--wiki":
|
|
opts.BuildWiki = true
|
|
case "-h", "--help":
|
|
return opts, fmt.Errorf("usage: %s [--force] [--wiki]", commandName)
|
|
default:
|
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
|
}
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func runCompareTopData(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console := newTopdataConsole(ctx, p, "compare-topdata")
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Compare Topdata: starting")
|
|
defer spin.stop()
|
|
result, err := topdata.Compare(p, console.progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitCompareResult(result)
|
|
return nil
|
|
}
|
|
|
|
func runConvertTopData(ctx context) error {
|
|
if len(ctx.args) < 2 {
|
|
return errors.New("usage: convert-topdata <2da-to-json|2da-to-module|json-to-2da> ...")
|
|
}
|
|
return topdata.RunConvertCommand(ctx.args[1:], ctx.stdout)
|
|
}
|
|
|
|
func runBuildWiki(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts, err := parseBuildWikiArgs("build-wiki", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console := newTopdataConsole(ctx, p, "build-wiki")
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Build Wiki: starting")
|
|
defer spin.stop()
|
|
result, err := topdata.BuildWikiWithOptions(p, opts, console.progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitWikiBuildResult(result.OutputDir, result.PageCount, result.Status)
|
|
return nil
|
|
}
|
|
|
|
func parseBuildWikiArgs(commandName string, args []string) (topdata.BuildWikiOptions, error) {
|
|
opts := topdata.BuildWikiOptions{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--force":
|
|
opts.Force = true
|
|
case "-h", "--help":
|
|
return opts, fmt.Errorf("usage: %s [--force]", commandName)
|
|
default:
|
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
|
}
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func runDeployWiki(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts, err := parseDeployWikiArgs("deploy-wiki", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console := newTopdataConsole(ctx, p, "deploy-wiki")
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Deploy Wiki: starting")
|
|
defer spin.stop()
|
|
result, err := topdata.DeployWikiWithOptions(p, opts, console.progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitWikiDeployResult(result.LocalPages, result.Created, result.Updated, result.Skipped, result.Stale, result.Archived, result.Purged, result.Drifted, result.Manifest)
|
|
return nil
|
|
}
|
|
|
|
func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiOptions, error) {
|
|
opts := topdata.DeployWikiOptions{}
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch arg {
|
|
case "--source-dir":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--source-dir requires a value")
|
|
}
|
|
opts.SourceDir = args[i]
|
|
case "--endpoint":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--endpoint requires a value")
|
|
}
|
|
opts.Endpoint = args[i]
|
|
case "--username":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--username requires a value")
|
|
}
|
|
opts.Username = args[i]
|
|
case "--password":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--password requires a value")
|
|
}
|
|
opts.Password = args[i]
|
|
case "--token":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--token requires a value")
|
|
}
|
|
opts.Token = args[i]
|
|
case "--version":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--version requires a value")
|
|
}
|
|
opts.Version = args[i]
|
|
case "--namespace":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--namespace requires a value")
|
|
}
|
|
opts.Namespaces = append(opts.Namespaces, args[i])
|
|
case "--category":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--category requires a namespace=cid value")
|
|
}
|
|
if opts.CategoryIDs == nil {
|
|
opts.CategoryIDs = map[string]int{}
|
|
}
|
|
if err := parseWikiCategoryArg(args[i], opts.CategoryIDs); err != nil {
|
|
return opts, err
|
|
}
|
|
case "--manifest":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--manifest requires a value")
|
|
}
|
|
opts.ManifestPath = args[i]
|
|
case "--stale-policy":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--stale-policy requires a value")
|
|
}
|
|
opts.StalePolicy = args[i]
|
|
case "--dry-run":
|
|
opts.DryRun = true
|
|
case "--force":
|
|
opts.Force = true
|
|
case "--create":
|
|
opts.AllowCreates = true
|
|
case "--reset-managed-namespaces":
|
|
opts.ResetManagedNamespaces = true
|
|
case "-h", "--help":
|
|
return opts, fmt.Errorf("usage: %s [--source-dir <path>] [--endpoint <url>] [--token <token>] [--version <ver>] [--namespace <ns> ...] [--category <namespace=cid> ...] [--manifest <path>] [--stale-policy <report|archive|purge>] [--dry-run] [--create] [--force] [--reset-managed-namespaces]", commandName)
|
|
default:
|
|
if value, ok, err := requireInlineFlagValue(arg, "--source-dir"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.SourceDir = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--endpoint"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Endpoint = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--username"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Username = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--password"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Password = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--token"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Token = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--version"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Version = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--namespace"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Namespaces = append(opts.Namespaces, value)
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--category"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
if opts.CategoryIDs == nil {
|
|
opts.CategoryIDs = map[string]int{}
|
|
}
|
|
if err := parseWikiCategoryArg(value, opts.CategoryIDs); err != nil {
|
|
return opts, err
|
|
}
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--manifest"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.ManifestPath = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--stale-policy"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.StalePolicy = value
|
|
} else if arg == "--dry-run" {
|
|
opts.DryRun = true
|
|
} else if arg == "--force" {
|
|
opts.Force = true
|
|
} else if arg == "--create" {
|
|
opts.AllowCreates = true
|
|
} else if arg == "--reset-managed-namespaces" {
|
|
opts.ResetManagedNamespaces = true
|
|
} else {
|
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
|
}
|
|
}
|
|
}
|
|
|
|
if opts.Endpoint == "" {
|
|
opts.Endpoint = os.Getenv("NODEBB_API_ENDPOINT")
|
|
}
|
|
if opts.Token == "" {
|
|
opts.Token = os.Getenv("NODEBB_API_TOKEN")
|
|
}
|
|
if opts.Version == "" {
|
|
opts.Version = os.Getenv("GITHUB_REF_NAME")
|
|
}
|
|
if opts.CategoryIDs == nil {
|
|
opts.CategoryIDs = map[string]int{}
|
|
}
|
|
if raw := os.Getenv("NODEBB_WIKI_CATEGORIES"); raw != "" {
|
|
if err := parseWikiCategoryList(raw, opts.CategoryIDs); err != nil {
|
|
return opts, err
|
|
}
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
type buildChangelogOptions struct {
|
|
configPath string
|
|
outputPath string
|
|
currentTag string
|
|
previousTag string
|
|
apiBaseURL string
|
|
token string
|
|
}
|
|
|
|
func runBuildChangelog(ctx context) error {
|
|
opts, err := parseBuildChangelogArgs("build-changelog", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return changelog.Generate(changelog.Options{
|
|
RepoRoot: ctx.cwd,
|
|
ConfigPath: opts.configPath,
|
|
OutputPath: opts.outputPath,
|
|
CurrentTag: opts.currentTag,
|
|
PreviousTag: opts.previousTag,
|
|
APIBaseURL: opts.apiBaseURL,
|
|
Token: opts.token,
|
|
Stdout: ctx.stdout,
|
|
})
|
|
}
|
|
|
|
func parseBuildChangelogArgs(commandName string, args []string) (buildChangelogOptions, error) {
|
|
opts := buildChangelogOptions{}
|
|
usage := fmt.Errorf("usage: %s [--config <path>] [--output <path>] [--current-tag <tag>] [--previous-tag <tag>] [--api-base-url <url>] [--token <token>]", commandName)
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch arg {
|
|
case "--config":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.configPath = args[i]
|
|
case "--output":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.outputPath = args[i]
|
|
case "--current-tag":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.currentTag = args[i]
|
|
case "--previous-tag":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.previousTag = args[i]
|
|
case "--api-base-url":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.apiBaseURL = args[i]
|
|
case "--token":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.token = args[i]
|
|
case "-h", "--help":
|
|
return opts, usage
|
|
default:
|
|
if value, ok, err := requireInlineFlagValue(arg, "--config"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.configPath = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--output"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.outputPath = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--current-tag"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.currentTag = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--previous-tag"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.previousTag = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--api-base-url"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.apiBaseURL = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--token"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.token = value
|
|
} else {
|
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
|
}
|
|
}
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
func parseWikiCategoryList(raw string, target map[string]int) error {
|
|
for _, part := range strings.Split(raw, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if err := parseWikiCategoryArg(part, target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseWikiCategoryArg(raw string, target map[string]int) error {
|
|
namespace, rawCID, ok := strings.Cut(raw, "=")
|
|
if !ok {
|
|
return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw)
|
|
}
|
|
namespace = strings.TrimSpace(namespace)
|
|
rawCID = strings.TrimSpace(rawCID)
|
|
if namespace == "" || rawCID == "" {
|
|
return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw)
|
|
}
|
|
cid, err := strconv.Atoi(rawCID)
|
|
if err != nil || cid <= 0 {
|
|
return fmt.Errorf("wiki category mapping %q has invalid cid", raw)
|
|
}
|
|
target[namespace] = cid
|
|
return nil
|
|
}
|
|
|
|
func loadProject(ctx context) (*project.Project, error) {
|
|
root, err := project.FindRoot(ctx.cwd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p, err := project.Load(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ctx.stderr != nil && ctx.logLevel != logLevelQuiet {
|
|
fmt.Fprintf(ctx.stderr, "Loaded config: %s\n", p.ConfigSource.Name)
|
|
if p.ConfigSource.Legacy {
|
|
fmt.Fprintf(ctx.stderr, "Warning: %s is legacy repository configuration; migrate to %s.\n", project.LegacyConfigFile, project.ConfigFile)
|
|
}
|
|
}
|
|
|
|
var failures []error
|
|
if err := p.ValidateLayout(); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := p.Scan(); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
return nil, errors.Join(failures...)
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
func printUsage(w io.Writer) {
|
|
exe := filepath.Base(os.Args[0])
|
|
fmt.Fprintf(w, "%s <command>\n\n", exe)
|
|
fmt.Fprintln(w, "Commands:")
|
|
for _, cmd := range commands {
|
|
fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.description)
|
|
}
|
|
}
|