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 } 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 } if err := runBuildModulePreflight(ctx, p, nil); 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 } progress := func(message string) { if ctx.logLevel == logLevelQuiet { return } spin.linebreak() fmt.Fprintf(ctx.stdout, "[build-module] %s\n", message) } if err := runBuildModulePreflight(ctx, p, progress); err != nil { return err } result, err := pipeline.BuildModuleWithProgress(p, func(message string) { progress(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 } func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) error { if progress == nil { progress = func(string) {} } if err := refreshBuildModuleManifest(ctx, p, progress); err != nil { return err } if err := prepareBuildModuleCompiler(ctx, p, progress); err != nil { return err } return nil } func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(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 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 nil } func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(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 nil } if !projectHasScriptSources(p) { return nil } compilerPathEnv := p.ScriptCompilerPathEnv() explicitCompiler := strings.TrimSpace(os.Getenv(compilerPathEnv)) if explicitCompiler != "" { if scriptCompilerRunnable(explicitCompiler) { return 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 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 nil } return disableBuildModuleCompiler(compilerPathEnv, progress, "Installed NWScript compiler but it still cannot start.") } func disableBuildModuleCompiler(compilerPathEnv string, progress func(string), reason 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 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 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) } 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: isInteractiveTTY(ctx.stderr) && strings.TrimSpace(os.Getenv("CI")) == "" && ctx.logLevel == logLevelNormal, } } 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, 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, "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 ...] [--archive ...] [--source-manifest ] [--plan-only] [--skip-music] [--music-dataset ...] [--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 [--dataset ] [--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 } 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 ...") } 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 [] [--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 := 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 } 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 } 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.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 "--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 ] [--endpoint ] [--token ] [--version ] [--namespace ...] [--category ...] [--manifest ] [--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 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 ] [--output ] [--current-tag ] [--previous-tag ] [--api-base-url ] [--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 \n\n", exe) fmt.Fprintln(w, "Commands:") for _, cmd := range commands { fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.description) } }