Files
sow-tools/internal/app/app.go
T

2227 lines
62 KiB
Go

package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"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
}
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
ScriptCompilationStatus 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
scriptStatus, err := prepareBuildModuleCompiler(ctx, p, progress)
if err != nil {
return result, err
}
result.ScriptCompilationStatus = scriptStatus
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 prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(string)) (string, error) {
if envEnabled("SOW_MODULE_SKIP_NSS_COMPILATION") {
progress("SOW_MODULE_SKIP_NSS_COMPILATION is set; skipping NWScript compiler resolution and NSS compilation for this run.")
return "skipped", nil
}
if !projectHasScriptSources(p) {
return "not-needed", nil
}
compilerPathEnv := p.ScriptCompilerPathEnv()
explicitCompiler := strings.TrimSpace(os.Getenv(compilerPathEnv))
if explicitCompiler != "" {
if scriptCompilerRunnable(explicitCompiler) {
return "enabled", nil
}
if fileExists(explicitCompiler) {
return disableBuildModuleCompiler(compilerPathEnv, progress, fmt.Sprintf("Configured NWScript compiler is present but cannot start: %s", explicitCompiler))
}
}
if _, ok := findUsableBuildModuleCompiler(p); ok {
return "enabled", nil
}
installScript := projectScriptPath(p.Root, "scripts", "install-script-compiler")
if !fileExists(installScript) {
return disableBuildModuleCompiler(compilerPathEnv, progress, "NWScript compiler not found locally and automatic installer script is unavailable.")
}
progress("NWScript compiler not found locally. Installing it now...")
if err := runProjectScript(ctx, p, []string{"scripts", "install-script-compiler"}); err != nil {
return disableBuildModuleCompiler(compilerPathEnv, progress, "Automatic NWScript compiler install failed.")
}
if _, ok := findUsableBuildModuleCompiler(p); ok {
return "installed", nil
}
return disableBuildModuleCompiler(compilerPathEnv, progress, "Installed NWScript compiler but it still cannot start.")
}
func disableBuildModuleCompiler(compilerPathEnv string, progress func(string), reason string) (string, error) {
if err := os.Setenv("SOW_MODULE_SKIP_NSS_COMPILATION", "1"); err != nil {
return "", fmt.Errorf("set SOW_MODULE_SKIP_NSS_COMPILATION: %w", err)
}
progress("Skipping NWScript compilation for this build. " + reason)
if compilerPathEnv != "" {
_ = os.Unsetenv(compilerPathEnv)
}
return "skipped", nil
}
func projectHasScriptSources(p *project.Project) bool {
for _, rel := range p.Inventory.ScriptFiles {
if strings.EqualFold(filepath.Ext(rel), ".nss") {
return true
}
}
return false
}
func findUsableBuildModuleCompiler(p *project.Project) (string, bool) {
candidates := make([]string, 0, 2+len(p.ScriptCompilerSearchPaths()))
if configured := p.ScriptCompilerPath(); configured != "" {
candidates = append(candidates, configured)
}
if configured := strings.TrimSpace(os.Getenv(p.ScriptCompilerPathEnv())); configured != "" {
candidates = append(candidates, configured)
}
candidates = append(candidates, p.ScriptCompilerSearchPaths()...)
for _, candidate := range candidates {
if compiler, ok := resolveRunnableCompilerCandidate(candidate); ok {
return compiler, true
}
}
return "", false
}
func resolveRunnableCompilerCandidate(candidate string) (string, bool) {
if candidate == "" {
return "", false
}
if binary, ok := strings.CutPrefix(candidate, "PATH:"); ok {
compiler, err := exec.LookPath(binary)
if err != nil {
return "", false
}
if scriptCompilerRunnable(compiler) {
return compiler, true
}
return "", false
}
if !fileExists(candidate) {
return "", false
}
if scriptCompilerRunnable(candidate) {
return candidate, true
}
return "", false
}
func scriptCompilerRunnable(path string) bool {
if !fileExists(path) {
return false
}
cmd := exec.Command(path, "--help")
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
return cmd.Run() == 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
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, "NWScript compiler not found locally. Installing it now"):
return "installing script compiler"
case strings.HasPrefix(message, "Skipping NWScript compilation for this build."):
return "skipping script compilation"
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 preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" {
fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus)
}
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)
}
if preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" {
fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus)
}
}
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, "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,
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>] [--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]
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, "--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 "-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]", 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 {
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)
}
}