Files
sow-tools/internal/app/app.go
T
2026-05-07 15:36:40 +02:00

1460 lines
40 KiB
Go

package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/changelog"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
"gitea.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
}
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
}
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: "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 pull requests 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] {
spin.configure(ctx.stderr, isInteractiveTTY(ctx.stderr) && strings.TrimSpace(os.Getenv("CI")) == "" && ctx.logLevel == logLevelNormal)
spin.start("running " + cmd.name)
if err := cmd.run(ctx); err != nil {
spin.stop()
return 1, err
}
spin.stop()
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
}
result, err := pipeline.Build(p)
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources)
if result.TopPackageHAK != "" {
fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.TopPackageHAK)
fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.TopPackageTLK)
}
if len(result.HAKPaths) > 0 {
fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths))
fmt.Fprintf(ctx.stdout, "hak assets: %d\n", result.HAKAssets)
fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", result.Manifest)
}
return nil
}
func runBuildModule(ctx context) error {
p, err := loadProject(ctx)
if err != nil {
return err
}
result, err := pipeline.BuildModuleWithProgress(p, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
spin.linebreak()
fmt.Fprintf(ctx.stdout, "[build-module] %s\n", message)
})
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources)
return nil
}
type buildHAKOptions struct {
filteredHAKs []string
filteredArchives []string
sourceManifest string
planOnly bool
logLevel logLevel
}
type logLevel int
const (
logLevelNormal logLevel = iota
logLevelQuiet
logLevelVerbose
logLevelDebug
)
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: isInteractiveTTY(ctx.stderr) && strings.TrimSpace(os.Getenv("CI")) == "" && opts.logLevel == logLevelNormal,
}
}
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)
}
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.update("Build HAKs: starting")
var result pipeline.BuildResult
if opts.planOnly {
result, err = pipeline.PlanHAKsWithProgress(p, console.progress)
} else {
result, err = pipeline.BuildHAKsWithOptions(p, pipeline.BuildHAKOptions{
Progress: console.progress,
ArchiveNames: opts.filteredArchives,
SourceManifestPath: opts.sourceManifest,
})
}
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>] [--plan-only] [--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 "--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]
default:
if value, ok := parseInlineFlagValue(arg, "--hak"); ok {
opts.filteredHAKs = append(opts.filteredHAKs, value)
continue
}
if value, ok := parseInlineFlagValue(arg, "--archive"); ok {
opts.filteredArchives = append(opts.filteredArchives, value)
continue
}
if value, ok := parseInlineFlagValue(arg, "--source-manifest"); ok {
opts.sourceManifest = 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 runExtract(ctx context) error {
p, err := loadProject(ctx)
if err != nil {
return err
}
files := ctx.args[1:]
result, err := pipeline.Extract(p, files...)
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
if len(result.HAKPaths) > 0 {
fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths))
}
if len(result.DeletedArchivePaths) > 0 {
fmt.Fprintf(ctx.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths))
}
fmt.Fprintf(ctx.stdout, "written: %d\n", result.Written)
fmt.Fprintf(ctx.stdout, "overwritten: %d\n", result.Overwritten)
fmt.Fprintf(ctx.stdout, "removed: %d\n", result.Removed)
fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped)
return nil
}
func runValidate(ctx context) error {
p, err := loadProject(ctx)
if err != nil {
return err
}
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()
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "root: %s\n", p.Root)
fmt.Fprintf(ctx.stdout, "source files: %d\n", inventoryReport.SourceFiles)
fmt.Fprintf(ctx.stdout, "script files: %d\n", inventoryReport.ScriptFiles)
fmt.Fprintf(ctx.stdout, "asset files: %d\n", inventoryReport.AssetFiles)
fmt.Fprintf(ctx.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", "))
if warnings := validationReport.WarningCount(); warnings > 0 {
fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings)
}
fmt.Fprintf(ctx.stdout, "validation: ok\n")
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.hak_discovery":
return []string{"supported values: build_glob, configured_haks"}
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
}
result, err := pipeline.Compare(p)
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath)
if len(result.HAKPaths) > 0 {
fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths))
}
fmt.Fprintf(ctx.stdout, "checked resources: %d\n", result.Checked)
fmt.Fprintf(ctx.stdout, "compare: ok\n")
return nil
}
func runApplyHAKManifest(ctx context) error {
p, err := loadProject(ctx)
if err != nil {
return err
}
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
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "manifest: %s\n", result.ManifestPath)
fmt.Fprintf(ctx.stdout, "module source: %s\n", result.ModuleSource)
fmt.Fprintf(ctx.stdout, "hak entries: %d\n", result.HAKCount)
return nil
}
func runValidateTopData(ctx context) error {
p, err := loadProject(ctx)
if err != nil {
return err
}
report := topdata.ValidateProject(p)
emitTopdataValidationReport(ctx.stderr, report)
if report.HasErrors() {
return fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount())
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "topdata root: %s\n", p.TopDataSourceDir())
fmt.Fprintf(ctx.stdout, "topdata files: %d\n", report.Files)
fmt.Fprintf(ctx.stdout, "data files: %d\n", report.DataFiles)
fmt.Fprintf(ctx.stdout, "tlk files: %d\n", report.TLKFiles)
if warnings := report.WarningCount(); warnings > 0 {
fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings)
}
fmt.Fprintf(ctx.stdout, "topdata validation: ok\n")
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
}
result, err := topdata.BuildAndPackageWithOptions(p, opts, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[build-topdata] %s\n", message)
})
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode)
fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir)
fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir)
fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath)
fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath)
fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA)
fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK)
fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources)
fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles)
if result.WikiOutputDir != "" {
fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir)
fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages)
fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus)
}
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")
}
result, err := topdata.BuildPackage(p, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[build-top-package] %s\n", message)
})
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode)
fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir)
fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir)
fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath)
fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath)
fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA)
fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK)
fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources)
fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles)
if result.WikiOutputDir != "" {
fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir)
fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages)
fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus)
}
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
}
result, err := topdata.Compare(p, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[compare-topdata] %s\n", message)
})
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode)
fmt.Fprintf(ctx.stdout, "checked 2da files: %d\n", result.Compared2DA)
fmt.Fprintf(ctx.stdout, "checked tlk files: %d\n", result.ComparedTLK)
fmt.Fprintf(ctx.stdout, "native-pass: %d\n", result.NativePass)
fmt.Fprintf(ctx.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical)
fmt.Fprintf(ctx.stdout, "native-mismatch: %d\n", result.NativeMismatch)
fmt.Fprintf(ctx.stdout, "topdata compare: ok\n")
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
}
result, err := topdata.BuildWikiWithOptions(p, opts, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[build-wiki] %s\n", message)
})
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.OutputDir)
fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.PageCount)
fmt.Fprintf(ctx.stdout, "wiki status: %s\n", 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
}
result, err := topdata.DeployWikiWithOptions(p, opts, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[deploy-wiki] %s\n", message)
})
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "local pages: %d\n", result.LocalPages)
fmt.Fprintf(ctx.stdout, "created: %d\n", result.Created)
fmt.Fprintf(ctx.stdout, "updated: %d\n", result.Updated)
fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped)
fmt.Fprintf(ctx.stdout, "stale: %d\n", result.Stale)
fmt.Fprintf(ctx.stdout, "drifted: %d\n", result.Drifted)
fmt.Fprintf(ctx.stdout, "manifest: %s\n", 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 "--dry-run":
opts.DryRun = true
case "--force":
opts.Force = true
case "--create":
opts.AllowCreates = 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>] [--dry-run] [--create] [--force]", commandName)
default:
if strings.HasPrefix(arg, "--source-dir=") {
opts.SourceDir = strings.TrimPrefix(arg, "--source-dir=")
} else if strings.HasPrefix(arg, "--endpoint=") {
opts.Endpoint = strings.TrimPrefix(arg, "--endpoint=")
} else if strings.HasPrefix(arg, "--username=") {
opts.Username = strings.TrimPrefix(arg, "--username=")
} else if strings.HasPrefix(arg, "--password=") {
opts.Password = strings.TrimPrefix(arg, "--password=")
} else if strings.HasPrefix(arg, "--token=") {
opts.Token = strings.TrimPrefix(arg, "--token=")
} else if strings.HasPrefix(arg, "--version=") {
opts.Version = strings.TrimPrefix(arg, "--version=")
} else if strings.HasPrefix(arg, "--namespace=") {
opts.Namespaces = append(opts.Namespaces, strings.TrimPrefix(arg, "--namespace="))
} else if strings.HasPrefix(arg, "--category=") {
if opts.CategoryIDs == nil {
opts.CategoryIDs = map[string]int{}
}
if err := parseWikiCategoryArg(strings.TrimPrefix(arg, "--category="), opts.CategoryIDs); err != nil {
return opts, err
}
} else if strings.HasPrefix(arg, "--manifest=") {
opts.ManifestPath = strings.TrimPrefix(arg, "--manifest=")
} else if arg == "--dry-run" {
opts.DryRun = true
} else if arg == "--force" {
opts.Force = true
} else if arg == "--create" {
opts.AllowCreates = 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:
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)
}
}