Wrapper tool hardening and cleanup

This commit is contained in:
2026-05-13 19:06:24 +02:00
parent 53cb1e6996
commit 7d286551b3
19 changed files with 1376 additions and 149 deletions
+416 -90
View File
@@ -6,7 +6,9 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
@@ -294,6 +296,9 @@ func runBuild(ctx context) error {
if err != nil {
return err
}
if err := runBuildModulePreflight(ctx, p, nil); err != nil {
return err
}
result, err := pipeline.Build(p)
if err != nil {
@@ -321,12 +326,19 @@ func runBuildModule(ctx context) error {
return err
}
result, err := pipeline.BuildModuleWithProgress(p, func(message string) {
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
@@ -338,6 +350,222 @@ func runBuildModule(ctx context) error {
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
@@ -530,6 +758,161 @@ func (c *buildHAKConsole) relPath(path string) string {
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 {
@@ -1145,21 +1528,14 @@ func runValidateTopData(ctx context) error {
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())
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "topdata root: %s\n", p.TopDataSourceDir())
fmt.Fprintf(ctx.stdout, "topdata files: %d\n", report.Files)
fmt.Fprintf(ctx.stdout, "data files: %d\n", report.DataFiles)
fmt.Fprintf(ctx.stdout, "tlk files: %d\n", report.TLKFiles)
if warnings := report.WarningCount(); warnings > 0 {
fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings)
}
fmt.Fprintf(ctx.stdout, "topdata validation: ok\n")
console.emitValidationResult(report, p.TopDataSourceDir())
return nil
}
@@ -1174,31 +1550,16 @@ func runBuildTopData(ctx context) error {
return err
}
result, err := topdata.BuildAndPackageWithOptions(p, opts, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[build-topdata] %s\n", message)
})
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
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode)
fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir)
fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir)
fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath)
fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath)
fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA)
fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK)
fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources)
fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles)
if result.WikiOutputDir != "" {
fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir)
fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages)
fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus)
}
console.emitPackageResult(result)
return nil
}
@@ -1217,31 +1578,16 @@ func runBuildTopPackage(ctx context) error {
return fmt.Errorf("--wiki is not supported with build-top-package; use build-topdata when wiki generation is required")
}
result, err := topdata.BuildPackage(p, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[build-top-package] %s\n", message)
})
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
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode)
fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir)
fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir)
fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath)
fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath)
fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA)
fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK)
fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources)
fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles)
if result.WikiOutputDir != "" {
fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir)
fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages)
fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus)
}
console.emitPackageResult(result)
return nil
}
@@ -1268,24 +1614,16 @@ func runCompareTopData(ctx context) error {
return err
}
result, err := topdata.Compare(p, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[compare-topdata] %s\n", message)
})
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
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode)
fmt.Fprintf(ctx.stdout, "checked 2da files: %d\n", result.Compared2DA)
fmt.Fprintf(ctx.stdout, "checked tlk files: %d\n", result.ComparedTLK)
fmt.Fprintf(ctx.stdout, "native-pass: %d\n", result.NativePass)
fmt.Fprintf(ctx.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical)
fmt.Fprintf(ctx.stdout, "native-mismatch: %d\n", result.NativeMismatch)
fmt.Fprintf(ctx.stdout, "topdata compare: ok\n")
console.emitCompareResult(result)
return nil
}
@@ -1307,20 +1645,16 @@ func runBuildWiki(ctx context) error {
return err
}
result, err := topdata.BuildWikiWithOptions(p, opts, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[build-wiki] %s\n", message)
})
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
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.OutputDir)
fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.PageCount)
fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.Status)
console.emitWikiBuildResult(result.OutputDir, result.PageCount, result.Status)
return nil
}
@@ -1350,24 +1684,16 @@ func runDeployWiki(ctx context) error {
return err
}
result, err := topdata.DeployWikiWithOptions(p, opts, func(message string) {
if ctx.logLevel == logLevelQuiet {
return
}
fmt.Fprintf(ctx.stdout, "[deploy-wiki] %s\n", message)
})
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
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "local pages: %d\n", result.LocalPages)
fmt.Fprintf(ctx.stdout, "created: %d\n", result.Created)
fmt.Fprintf(ctx.stdout, "updated: %d\n", result.Updated)
fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped)
fmt.Fprintf(ctx.stdout, "stale: %d\n", result.Stale)
fmt.Fprintf(ctx.stdout, "drifted: %d\n", result.Drifted)
fmt.Fprintf(ctx.stdout, "manifest: %s\n", result.Manifest)
console.emitWikiDeployResult(result.LocalPages, result.Created, result.Updated, result.Skipped, result.Stale, result.Drifted, result.Manifest)
return nil
}
+255 -54
View File
@@ -16,18 +16,16 @@ func TestRunBuildTopPackageUsesCachedCompiledOutputs(t *testing.T) {
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "test_module"
},
"topdata": {
"source": "topdata",
"build": ".cache",
"package_hak": "sow_top.hak",
"package_tlk": "sow_tlk.tlk"
}
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test Module
resref: test_module
topdata:
source: topdata
build: .cache
package_hak: sow_top.hak
package_tlk: sow_tlk.tlk
`)
writeFile(t, filepath.Join(root, ".cache", "2da", "repadjust.2da"), "2DA V2.0\n\n Label\n0 TEST_LABEL\n")
writeFile(t, filepath.Join(root, "build", "sow_tlk.tlk"), "compiled tlk")
writeFile(t, filepath.Join(root, "topdata", "assets", "gui", "testicon.png"), "icon-data")
@@ -51,8 +49,12 @@ func TestRunBuildTopPackageUsesCachedCompiledOutputs(t *testing.T) {
if err := runBuildTopPackage(ctx); err != nil {
t.Fatalf("runBuildTopPackage failed: %v", err)
}
if !strings.Contains(stdout.String(), "top package hak:") {
t.Fatalf("expected build-top-package output, got %q", stdout.String())
output := stdout.String()
if !strings.Contains(output, "top package hak: build/sow_top.hak") {
t.Fatalf("expected build-top-package output, got %q", output)
}
if strings.Contains(output, "[build-top-package]") {
t.Fatalf("did not expect raw progress lines in normal output, got %q", output)
}
if _, err := os.Stat(filepath.Join(root, "build", "sow_top.hak")); err != nil {
t.Fatalf("expected packaged hak output: %v", err)
@@ -65,26 +67,22 @@ func TestRunBuildHAKsEmitsCompactSummary(t *testing.T) {
mkdirAll(t, filepath.Join(root, "build"))
mkdirAll(t, filepath.Join(root, "src"))
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
},
"haks": [
{
"name": "envi",
"priority": 1,
"max_bytes": 1048576,
"split": false,
"include": ["envi/**"]
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test Module
resref: testmod
paths:
source: src
assets: assets
build: build
haks:
- name: envi
priority: 1
max_bytes: 1048576
split: false
include:
- envi/**
`)
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "AleandAnecdotes.mp3"), "source-mp3")
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "CREDITS.md"), "# NWN Music Pack Credits\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n| Darren Curtis | Ale and Anecdotes | `mus_wg_andnc.bmu` | `AleandAnecdotes.mp3` | Darren's Commercially Free Music | | Darren Curtis Music | |\n")
@@ -136,26 +134,22 @@ func TestRunBuildHAKsVerboseListsMappings(t *testing.T) {
mkdirAll(t, filepath.Join(root, "build"))
mkdirAll(t, filepath.Join(root, "src"))
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
},
"haks": [
{
"name": "envi",
"priority": 1,
"max_bytes": 1048576,
"split": false,
"include": ["envi/**"]
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test Module
resref: testmod
paths:
source: src
assets: assets
build: build
haks:
- name: envi
priority: 1
max_bytes: 1048576
split: false
include:
- envi/**
`)
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "AleandAnecdotes.mp3"), "source-mp3")
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "CREDITS.md"), "# NWN Music Pack Credits\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n| Darren Curtis | Ale and Anecdotes | `mus_wg_andnc.bmu` | `AleandAnecdotes.mp3` | Darren's Commercially Free Music | | Darren Curtis Music | |\n")
@@ -198,6 +192,204 @@ func TestRunBuildHAKsVerboseListsMappings(t *testing.T) {
}
}
func TestTopdataConsoleSuppressesProgressInNormalMode(t *testing.T) {
var stdout bytes.Buffer
console := &topdataConsole{
stdout: &stdout,
projectRoot: "/workspace/project",
projectName: "Test Module",
commandName: "build-topdata",
commandLabel: "Build Topdata",
level: logLevelNormal,
}
console.progress("Packaging compiled topdata resources into sow_top.hak...")
if stdout.String() != "" {
t.Fatalf("expected no normal-mode progress output, got %q", stdout.String())
}
}
func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) {
var stdout bytes.Buffer
console := &topdataConsole{
stdout: &stdout,
projectRoot: "/workspace/project",
projectName: "Test Module",
commandName: "deploy-wiki",
commandLabel: "Deploy Wiki",
level: logLevelDebug,
}
console.progress("NodeBB wiki plan: create 1, update 2, skip 3, drift 0")
console.emitWikiDeployResult(10, 1, 2, 3, 4, 0, "/workspace/project/build/wiki/deploy-manifest.json")
output := stdout.String()
if !strings.Contains(output, "[debug] NodeBB wiki plan: create 1, update 2, skip 3, drift 0") {
t.Fatalf("expected debug progress line, got %q", output)
}
if !strings.Contains(output, "manifest: build/wiki/deploy-manifest.json") {
t.Fatalf("expected relative deploy manifest path, got %q", output)
}
}
func TestRunBuildModuleToolkitPreflightRefreshesManifestAndSkipsBrokenCompiler(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src", "module"))
mkdirAll(t, filepath.Join(root, "src", "placeables"))
mkdirAll(t, filepath.Join(root, "src", "scripts"))
mkdirAll(t, filepath.Join(root, "build"))
mkdirAll(t, filepath.Join(root, "scripts"))
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test Module
resref: testmod
paths:
source: src
build: build
scripts:
compiler:
search:
- tools/script-compiler/nwn_script_comp
`)
writeFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
"file_type": "IFO ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}
]
}
}
`)
writeFile(t, filepath.Join(root, "src", "placeables", "thing.utp.json"), `{
"file_type": "UTP ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{"label": "OnUsed", "type": "ResRef", "value": "use_thing"}
]
}
}
`)
writeFile(t, filepath.Join(root, "src", "scripts", "use_thing.nss"), "void main() {}\n")
writeFile(t, filepath.Join(root, "scripts", "fetch-hak-manifest.sh"), "#!/usr/bin/env sh\nset -eu\nmkdir -p \"$(dirname \"$1\")\"\nprintf '{\"module_haks\":[\"sow_core\"],\"haks\":[]}\n' >\"$1\"\n")
if err := os.Chmod(filepath.Join(root, "scripts", "fetch-hak-manifest.sh"), 0o755); err != nil {
t.Fatalf("chmod fetch-hak-manifest.sh: %v", err)
}
badCompiler := filepath.Join(root, "bad-compiler.sh")
writeFile(t, badCompiler, "#!/usr/bin/env sh\nexit 1\n")
if err := os.Chmod(badCompiler, 0o755); err != nil {
t.Fatalf("chmod bad compiler: %v", err)
}
t.Setenv("SOW_NWN_SCRIPT_COMPILER", badCompiler)
t.Setenv("SOW_MODULE_SKIP_NSS_COMPILATION", "")
var stdout bytes.Buffer
var stderr bytes.Buffer
ctx := context{
stdout: &stdout,
stderr: &stderr,
cwd: root,
args: []string{"build-module"},
}
if err := runBuildModule(ctx); err != nil {
t.Fatalf("runBuildModule failed: %v", err)
}
moduleSource := mustReadFile(t, filepath.Join(root, "src", "module", "module.ifo.json"))
if !strings.Contains(moduleSource, "\"Mod_HakList\"") || !strings.Contains(moduleSource, "\"sow_core\"") {
t.Fatalf("expected manifest-applied module source, got %q", moduleSource)
}
if _, err := os.Stat(filepath.Join(root, ".cache", "ncs", "use_thing.ncs")); !os.IsNotExist(err) {
t.Fatalf("expected broken explicit compiler to skip compilation, got err=%v", err)
}
if !strings.Contains(stdout.String(), "Skipping NWScript compilation for this build.") {
t.Fatalf("expected skip-compilation progress in output, got %q", stdout.String())
}
}
func TestRunBuildModuleToolkitPreflightInstallsCompilerWhenNeeded(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src", "module"))
mkdirAll(t, filepath.Join(root, "src", "placeables"))
mkdirAll(t, filepath.Join(root, "src", "scripts"))
mkdirAll(t, filepath.Join(root, "build"))
mkdirAll(t, filepath.Join(root, "scripts"))
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test Module
resref: testmod
paths:
source: src
build: build
scripts:
compiler:
search:
- tools/script-compiler/nwn_script_comp
`)
writeFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
"file_type": "IFO ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{"label": "Mod_Name", "type": "CExoString", "value": "Test Module"}
]
}
}
`)
writeFile(t, filepath.Join(root, "src", "placeables", "thing.utp.json"), `{
"file_type": "UTP ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{"label": "OnUsed", "type": "ResRef", "value": "use_thing"}
]
}
}
`)
writeFile(t, filepath.Join(root, "src", "scripts", "use_thing.nss"), "void main() {}\n")
writeFile(t, filepath.Join(root, "scripts", "fetch-hak-manifest.sh"), "#!/usr/bin/env sh\nset -eu\nmkdir -p \"$(dirname \"$1\")\"\nprintf '{\"module_haks\":[\"sow_core\"],\"haks\":[]}\n' >\"$1\"\n")
writeFile(t, filepath.Join(root, "scripts", "install-script-compiler.sh"), "#!/usr/bin/env sh\nset -eu\ninstall_dir=\"${SOW_SCRIPT_COMPILER_INSTALL_DIR:-./tools/script-compiler}\"\nmkdir -p \"$install_dir\"\ncat >\"$install_dir/nwn_script_comp\" <<'SH'\n#!/usr/bin/env sh\nset -eu\nif [ \"${1:-}\" = \"--help\" ]; then\n exit 0\nfi\nout=\"\"\nsrc=\"\"\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n -o)\n out=\"$2\"\n shift 2\n ;;\n --dirs)\n shift 2\n ;;\n *)\n src=\"$1\"\n shift\n ;;\n esac\ndone\nprintf 'compiled:%s\\n' \"$(basename \"$src\")\" >\"$out\"\nSH\nchmod +x \"$install_dir/nwn_script_comp\"\nprintf 'installed: %s\\n' \"$install_dir\"\n")
if err := os.Chmod(filepath.Join(root, "scripts", "fetch-hak-manifest.sh"), 0o755); err != nil {
t.Fatalf("chmod fetch-hak-manifest.sh: %v", err)
}
if err := os.Chmod(filepath.Join(root, "scripts", "install-script-compiler.sh"), 0o755); err != nil {
t.Fatalf("chmod install-script-compiler.sh: %v", err)
}
t.Setenv("SOW_NWN_SCRIPT_COMPILER", "")
t.Setenv("SOW_MODULE_SKIP_NSS_COMPILATION", "")
var stdout bytes.Buffer
var stderr bytes.Buffer
ctx := context{
stdout: &stdout,
stderr: &stderr,
cwd: root,
args: []string{"build-module"},
}
if err := runBuildModule(ctx); err != nil {
t.Fatalf("runBuildModule failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
compiledScript := filepath.Join(root, ".cache", "ncs", "use_thing.ncs")
if _, err := os.Stat(compiledScript); err != nil {
t.Fatalf("expected installed compiler to compile script: %v", err)
}
if !strings.Contains(stdout.String(), "NWScript compiler not found locally. Installing it now...") {
t.Fatalf("expected install progress in output, got %q", stdout.String())
}
}
func TestRunMusicScanUsesConfiguredDatasetsWithoutWriting(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "assets", "audio", "westgate"))
@@ -448,3 +640,12 @@ func setFileTime(t *testing.T, path string, modTime time.Time) {
t.Fatalf("set file time %s: %v", path, err)
}
}
func mustReadFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(raw)
}