ci / ci (pull_request) Successful in 3m52s
deploy-wiki purged pages with DELETE /api/v3/topics/{tid}. A NodeBB
running nodebb-plugin-westgate-wiki refuses that for topics in wiki
categories, because wiki revision history is plugin-owned, so every
purge failed with HTTP 400 and the deploy exited 1. Purge now goes
through the plugin's own page actions: tombstone the page, then
hard-purge it, since a page must be tombstoned before it can be purged.
Archiving is unaffected: it rewrites the page through the ordinary post
edit the plugin allows, rather than deleting anything.
Every fake NodeBB in the deploy tests now goes through one constructor
that refuses native topic mutation the way the plugin does. The old
fakes answered the core API, which is why this shipped green.
A namespace reset can reach pages NodeBB will not delete at all - the
wiki home topic above all - so those are skipped rather than aborting
the reset. NodeBB answers 403 for that and for a token without purge
privileges alike, and the response cannot tell them apart; what can is
scope, so a category where nothing at all could be deleted still fails
the run.
Under --reset-managed-namespaces the preview reported stale: 0 on a run
that would delete every topic in the managed categories, because the
reset purge never went through stale computation. The reset deletions
are now counted as stale, which is the number callers word their
destructive-policy warning around, and the summary names how many of
them the manifest has no record of writing - the deletions a re-seed
cannot undo.
Closes #99
Closes #100
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1949 lines
54 KiB
Go
1949 lines
54 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/changelog"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/validator"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type command struct {
|
|
name string
|
|
description string
|
|
run func(context) error
|
|
}
|
|
|
|
type context struct {
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
cwd string
|
|
args []string
|
|
logLevel logLevel
|
|
}
|
|
|
|
type spinner struct {
|
|
mu sync.Mutex
|
|
on bool
|
|
text string
|
|
msg []string
|
|
painted bool
|
|
writer io.Writer
|
|
enabled bool
|
|
}
|
|
|
|
func newSpinner() *spinner {
|
|
return &spinner{
|
|
msg: []string{"-", "\\", "|", "/"},
|
|
}
|
|
}
|
|
|
|
var spin = newSpinner()
|
|
|
|
func (s *spinner) configure(writer io.Writer, enabled bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.writer = writer
|
|
s.enabled = enabled
|
|
}
|
|
|
|
func (s *spinner) start(text string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.enabled || s.writer == nil {
|
|
s.on = false
|
|
s.painted = false
|
|
s.text = text
|
|
return
|
|
}
|
|
if s.on {
|
|
s.text = text
|
|
return
|
|
}
|
|
s.on = true
|
|
s.text = text
|
|
go s.run()
|
|
}
|
|
|
|
func (s *spinner) stop() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.enabled || s.writer == nil {
|
|
s.on = false
|
|
s.painted = false
|
|
return
|
|
}
|
|
s.on = false
|
|
if s.painted {
|
|
fmt.Fprint(s.writer, "\r\033[K")
|
|
s.painted = false
|
|
}
|
|
}
|
|
|
|
func (s *spinner) linebreak() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.enabled && s.on && s.painted {
|
|
fmt.Fprint(s.writer, "\r\033[K")
|
|
s.painted = false
|
|
}
|
|
}
|
|
|
|
func (s *spinner) update(text string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.text = text
|
|
}
|
|
|
|
func (s *spinner) run() {
|
|
i := 0
|
|
for {
|
|
s.mu.Lock()
|
|
if !s.on {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
msg := s.msg[i%len(s.msg)]
|
|
fmt.Fprintf(s.writer, "\r[%s] %s", msg, s.text)
|
|
s.painted = true
|
|
s.mu.Unlock()
|
|
i++
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func isInteractiveTTY(w io.Writer) bool {
|
|
file, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return (info.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
func spinnerEnabledFor(w io.Writer, level logLevel) bool {
|
|
mode := strings.TrimSpace(strings.ToLower(os.Getenv("SOW_TOOLS_TTY_MODE")))
|
|
switch mode {
|
|
case "plain", "off", "0", "false", "no":
|
|
return false
|
|
case "spinner", "on", "1", "true", "yes":
|
|
return level == logLevelNormal && isInteractiveTTY(w)
|
|
case "", "auto":
|
|
return isInteractiveTTY(w) && strings.TrimSpace(os.Getenv("CI")) == "" && level == logLevelNormal
|
|
default:
|
|
return isInteractiveTTY(w) && strings.TrimSpace(os.Getenv("CI")) == "" && level == logLevelNormal
|
|
}
|
|
}
|
|
|
|
var commands = []command{
|
|
{
|
|
name: "build",
|
|
description: "Build module, HAK, and configured top package outputs.",
|
|
run: runBuild,
|
|
},
|
|
{
|
|
name: "build-module",
|
|
description: "Build only the configured module archive.",
|
|
run: runBuildModule,
|
|
},
|
|
{
|
|
name: "build-haks",
|
|
description: "Build only configured HAK archives and manifest.",
|
|
run: runBuildHAKs,
|
|
},
|
|
{
|
|
name: "extract",
|
|
description: "Extract built archives back into the configured source and asset trees.",
|
|
run: runExtract,
|
|
},
|
|
{
|
|
name: "validate",
|
|
description: "Validate project layout, config, and source inventory.",
|
|
run: runValidate,
|
|
},
|
|
{
|
|
name: "config",
|
|
description: "Inspect, explain, and validate the resolved project configuration.",
|
|
run: runConfig,
|
|
},
|
|
{
|
|
name: "compare",
|
|
description: "Compare current source content against the built archives.",
|
|
run: runCompare,
|
|
},
|
|
{
|
|
name: "apply-hak-manifest",
|
|
description: "Apply a generated HAK manifest to the configured module source.",
|
|
run: runApplyHAKManifest,
|
|
},
|
|
{
|
|
name: "validate-topdata",
|
|
description: "Validate topdata source layout and canonical JSON compatibility.",
|
|
run: runValidateTopData,
|
|
},
|
|
{
|
|
name: "build-topdata",
|
|
description: "Build configured topdata outputs, then refresh the configured top package.",
|
|
run: runBuildTopData,
|
|
},
|
|
{
|
|
name: "build-top-package",
|
|
description: "Build the configured top package from cached native topdata output and authored topdata assets.",
|
|
run: runBuildTopPackage,
|
|
},
|
|
{
|
|
name: "compare-topdata",
|
|
description: "Rebuild topdata natively and compare the current build output against that fresh native result.",
|
|
run: runCompareTopData,
|
|
},
|
|
{
|
|
name: "convert-topdata",
|
|
description: "Convert between 2DA and native topdata JSON/module formats.",
|
|
run: runConvertTopData,
|
|
},
|
|
{
|
|
name: "build-wiki",
|
|
description: "Build wiki pages from the current topdata state.",
|
|
run: runBuildWiki,
|
|
},
|
|
{
|
|
name: "deploy-wiki",
|
|
description: "Deploy generated wiki pages to NodeBB.",
|
|
run: runDeployWiki,
|
|
},
|
|
{
|
|
name: "build-changelog",
|
|
description: "Build a release changelog from tagged pushes and write it to stdout or a file.",
|
|
run: runBuildChangelog,
|
|
},
|
|
}
|
|
|
|
func Run(args []string) (int, error) {
|
|
ctx, err := newContext()
|
|
if err != nil {
|
|
return 1, err
|
|
}
|
|
|
|
if len(args) == 0 {
|
|
printUsage(ctx.stdout)
|
|
return 0, nil
|
|
}
|
|
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
printUsage(ctx.stdout)
|
|
return 0, nil
|
|
default:
|
|
cmdArgs, level := parseGlobalFlags(args[1:])
|
|
ctx.logLevel = level
|
|
ctx.args = append([]string{args[0]}, cmdArgs...)
|
|
for _, cmd := range commands {
|
|
if cmd.name == args[0] {
|
|
if err := cmd.run(ctx); err != nil {
|
|
return 1, err
|
|
}
|
|
return 0, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return 1, fmt.Errorf("unknown command %q", args[0])
|
|
}
|
|
|
|
func parseGlobalFlags(args []string) ([]string, logLevel) {
|
|
level := logLevelNormal
|
|
var filtered []string
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--quiet":
|
|
level = logLevelQuiet
|
|
case "--verbose":
|
|
level = logLevelVerbose
|
|
case "--debug":
|
|
level = logLevelDebug
|
|
default:
|
|
filtered = append(filtered, arg)
|
|
}
|
|
}
|
|
return filtered, level
|
|
}
|
|
|
|
func newContext() (context, error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return context{}, fmt.Errorf("resolve working directory: %w", err)
|
|
}
|
|
|
|
return context{
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
cwd: cwd,
|
|
args: os.Args[1:],
|
|
}, nil
|
|
}
|
|
|
|
func runBuild(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
preflight, err := runBuildModulePreflight(ctx, p, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "build")
|
|
|
|
result, err := pipeline.Build(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitBuildResult(result, preflight)
|
|
return nil
|
|
}
|
|
|
|
func runBuildModule(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
console := newProjectConsole(ctx, p, "build-module")
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Build Module: starting")
|
|
defer spin.stop()
|
|
|
|
progress := func(message string) {
|
|
console.progress(message)
|
|
}
|
|
preflight, err := runBuildModulePreflight(ctx, p, progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := pipeline.BuildModuleWithProgress(p, func(message string) {
|
|
progress(message)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitBuildModuleResult(result, preflight)
|
|
return nil
|
|
}
|
|
|
|
type buildModulePreflightResult struct {
|
|
ManifestStatus string
|
|
ManifestPath string
|
|
}
|
|
|
|
func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) (buildModulePreflightResult, error) {
|
|
result := buildModulePreflightResult{}
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
manifestStatus, manifestPath, err := refreshBuildModuleManifest(ctx, p, progress)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.ManifestStatus = manifestStatus
|
|
result.ManifestPath = manifestPath
|
|
return result, nil
|
|
}
|
|
|
|
func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(string)) (string, string, error) {
|
|
manifestPath := p.HAKManifestPath()
|
|
if override := strings.TrimSpace(os.Getenv("SOW_HAK_MANIFEST_PATH")); override != "" {
|
|
manifestPath = override
|
|
if !filepath.IsAbs(manifestPath) {
|
|
manifestPath = filepath.Join(p.Root, manifestPath)
|
|
}
|
|
}
|
|
|
|
if envEnabled("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH") {
|
|
if _, err := os.Stat(manifestPath); err != nil {
|
|
return "", "", fmt.Errorf("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH is set, but %s does not exist", manifestPath)
|
|
}
|
|
progress(fmt.Sprintf("Using existing hak manifest at %s; skipping published sow-assets refresh.", relPathFromRoot(p.Root, manifestPath)))
|
|
return "reused", manifestPath, nil
|
|
}
|
|
|
|
progress("Refreshing hak list from the latest published sow-assets manifest...")
|
|
if err := runProjectScript(ctx, p, []string{"scripts", "fetch-upstream-manifests"}, manifestPath); err != nil {
|
|
return "", "", err
|
|
}
|
|
if _, err := pipeline.ApplyHAKManifest(p, manifestPath); err != nil {
|
|
return "", "", err
|
|
}
|
|
return "refreshed", manifestPath, nil
|
|
}
|
|
|
|
func runProjectScript(ctx context, p *project.Project, scriptParts []string, args ...string) error {
|
|
if len(scriptParts) == 0 {
|
|
return errors.New("project script path is required")
|
|
}
|
|
scriptPath := projectScriptPath(p.Root, scriptParts...)
|
|
if !fileExists(scriptPath) {
|
|
return fmt.Errorf("required project script is missing: %s", scriptPath)
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
switch filepath.Ext(scriptPath) {
|
|
case ".ps1":
|
|
powershell := "powershell"
|
|
if runtime.GOOS != "windows" {
|
|
powershell = "pwsh"
|
|
}
|
|
commandArgs := []string{"-NoProfile", "-File", scriptPath}
|
|
commandArgs = append(commandArgs, args...)
|
|
cmd = exec.Command(powershell, commandArgs...)
|
|
default:
|
|
cmd = exec.Command(scriptPath, args...)
|
|
}
|
|
cmd.Dir = p.Root
|
|
cmd.Stdout = ctx.stderr
|
|
cmd.Stderr = ctx.stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func projectScriptPath(root string, pathParts ...string) string {
|
|
parts := append([]string{root}, pathParts...)
|
|
base := filepath.Join(parts...)
|
|
if runtime.GOOS == "windows" {
|
|
if strings.HasSuffix(base, ".ps1") || strings.HasSuffix(base, ".sh") {
|
|
return base
|
|
}
|
|
return base + ".ps1"
|
|
}
|
|
if strings.HasSuffix(base, ".ps1") || strings.HasSuffix(base, ".sh") {
|
|
return base
|
|
}
|
|
return base + ".sh"
|
|
}
|
|
|
|
func envEnabled(name string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func relPathFromRoot(root, path string) string {
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
type buildHAKOptions struct {
|
|
filteredHAKs []string
|
|
filteredArchives []string
|
|
sourceManifest string
|
|
contentAddressedRoot string
|
|
planOnly bool
|
|
logLevel logLevel
|
|
}
|
|
|
|
type logLevel int
|
|
|
|
const (
|
|
logLevelNormal logLevel = iota
|
|
logLevelQuiet
|
|
logLevelVerbose
|
|
logLevelDebug
|
|
)
|
|
|
|
type projectConsole struct {
|
|
stdout io.Writer
|
|
projectRoot string
|
|
projectName string
|
|
commandName string
|
|
commandLabel string
|
|
level logLevel
|
|
spinnerEnabled bool
|
|
}
|
|
|
|
func newProjectConsole(ctx context, p *project.Project, commandName string) *projectConsole {
|
|
return &projectConsole{
|
|
stdout: ctx.stdout,
|
|
projectRoot: p.Root,
|
|
projectName: p.Config.Module.Name,
|
|
commandName: commandName,
|
|
commandLabel: projectCommandLabel(commandName),
|
|
level: ctx.logLevel,
|
|
spinnerEnabled: spinnerEnabledFor(ctx.stderr, ctx.logLevel),
|
|
}
|
|
}
|
|
|
|
func projectCommandLabel(commandName string) string {
|
|
switch commandName {
|
|
case "build":
|
|
return "Build"
|
|
case "build-module":
|
|
return "Build Module"
|
|
case "extract":
|
|
return "Extract"
|
|
case "validate":
|
|
return "Validate"
|
|
case "compare":
|
|
return "Compare"
|
|
case "apply-hak-manifest":
|
|
return "Apply HAK Manifest"
|
|
default:
|
|
return commandName
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) progress(message string) {
|
|
if phase := c.phaseLabel(message); phase != "" {
|
|
spin.update(c.commandLabel + ": " + phase)
|
|
}
|
|
if c.level != logLevelDebug {
|
|
return
|
|
}
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
|
|
}
|
|
|
|
func (c *projectConsole) phaseLabel(message string) string {
|
|
switch {
|
|
case strings.HasPrefix(message, "Refreshing hak list from the latest published sow-assets manifest"):
|
|
return "refreshing HAK manifest"
|
|
case strings.HasPrefix(message, "Using existing hak manifest at "):
|
|
return "reusing HAK manifest"
|
|
case strings.HasPrefix(message, "Validating project"):
|
|
return "validating project"
|
|
case strings.HasPrefix(message, "Resolving module HAK order"):
|
|
return "resolving HAK order"
|
|
case strings.HasPrefix(message, "Collecting module resources"):
|
|
return "collecting module resources"
|
|
case strings.HasPrefix(message, "Writing module archive"):
|
|
return "writing module archive"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) emitBuildResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
|
|
if preflight.ManifestStatus != "" {
|
|
fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
|
|
if preflight.ManifestPath != "" {
|
|
fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
|
|
}
|
|
fmt.Fprintln(c.stdout)
|
|
}
|
|
if result.TopPackageHAK != "" {
|
|
fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.TopPackageHAK))
|
|
fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.TopPackageTLK))
|
|
}
|
|
if len(result.HAKPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
|
|
fmt.Fprintf(c.stdout, "hak assets: %d\n", result.HAKAssets)
|
|
fmt.Fprintf(c.stdout, "hak manifest: %s\n", c.relPath(result.Manifest))
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) emitBuildModuleResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build Module ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
|
|
if preflight.ManifestStatus != "" {
|
|
fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
|
|
if preflight.ManifestPath != "" {
|
|
fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
|
|
}
|
|
fmt.Fprintln(c.stdout)
|
|
}
|
|
}
|
|
|
|
func (c *projectConsole) emitExtractResult(result pipeline.ExtractResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Extract ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
if len(result.HAKPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
|
|
}
|
|
if len(result.DeletedArchivePaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths))
|
|
}
|
|
fmt.Fprintf(c.stdout, "written: %d\n", result.Written)
|
|
fmt.Fprintf(c.stdout, "overwritten: %d\n", result.Overwritten)
|
|
fmt.Fprintf(c.stdout, "removed: %d\n", result.Removed)
|
|
fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped)
|
|
}
|
|
|
|
func (c *projectConsole) emitValidationResult(report validator.Report, root string, inventoryReport project.InventoryReport) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Validate ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "root: %s\n", c.relPath(root))
|
|
fmt.Fprintf(c.stdout, "source files: %d\n", inventoryReport.SourceFiles)
|
|
fmt.Fprintf(c.stdout, "script files: %d\n", inventoryReport.ScriptFiles)
|
|
fmt.Fprintf(c.stdout, "asset files: %d\n", inventoryReport.AssetFiles)
|
|
fmt.Fprintf(c.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", "))
|
|
if warnings := report.WarningCount(); warnings > 0 {
|
|
fmt.Fprintf(c.stdout, "warnings: %d\n", warnings)
|
|
}
|
|
fmt.Fprintln(c.stdout, "validation: ok")
|
|
}
|
|
|
|
func (c *projectConsole) emitCompareResult(result pipeline.CompareResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Compare ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
|
|
if len(result.HAKPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
|
|
}
|
|
fmt.Fprintf(c.stdout, "checked resources: %d\n", result.Checked)
|
|
fmt.Fprintln(c.stdout, "compare: ok")
|
|
}
|
|
|
|
func (c *projectConsole) emitApplyManifestResult(result pipeline.ApplyManifestResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Apply HAK Manifest ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.ManifestPath))
|
|
fmt.Fprintf(c.stdout, "module source: %s\n", c.relPath(result.ModuleSource))
|
|
fmt.Fprintf(c.stdout, "hak entries: %d\n", result.HAKCount)
|
|
}
|
|
|
|
func (c *projectConsole) relPath(path string) string {
|
|
if path == "" {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(c.projectRoot, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
type buildHAKConsole struct {
|
|
stdout io.Writer
|
|
projectRoot string
|
|
projectName string
|
|
planOnly bool
|
|
level logLevel
|
|
spinnerEnabled bool
|
|
}
|
|
|
|
func newBuildHAKConsole(ctx context, p *project.Project, opts buildHAKOptions) *buildHAKConsole {
|
|
return &buildHAKConsole{
|
|
stdout: ctx.stdout,
|
|
projectRoot: p.Root,
|
|
projectName: p.Config.Module.Name,
|
|
planOnly: opts.planOnly,
|
|
level: opts.logLevel,
|
|
spinnerEnabled: spinnerEnabledFor(ctx.stderr, opts.logLevel),
|
|
}
|
|
}
|
|
|
|
func (c *buildHAKConsole) progress(message string) {
|
|
if phase := c.phaseLabel(message); phase != "" {
|
|
spin.update("Build HAKs: " + phase)
|
|
}
|
|
if c.level != logLevelDebug {
|
|
return
|
|
}
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
|
|
}
|
|
|
|
func (c *buildHAKConsole) phaseLabel(message string) string {
|
|
switch {
|
|
case strings.HasPrefix(message, "Validating project"):
|
|
return "validating project"
|
|
case strings.HasPrefix(message, "Collecting asset resources"):
|
|
return "collecting asset resources"
|
|
case strings.HasPrefix(message, "Planning HAK chunks"):
|
|
return "planning HAK chunks"
|
|
case strings.HasPrefix(message, "Resolving module HAK order"):
|
|
return "resolving module HAK order"
|
|
case strings.HasPrefix(message, "Reusing unchanged generated HAKs"):
|
|
return "reusing unchanged HAKs"
|
|
case strings.HasPrefix(message, "Reusing HAK "), strings.HasPrefix(message, "Writing HAK "):
|
|
return "writing HAK archives"
|
|
case strings.HasPrefix(message, "Cleaning stale generated HAKs"):
|
|
return "cleaning stale generated HAKs"
|
|
case strings.HasPrefix(message, "Cleaning previous generated HAKs"):
|
|
return "cleaning previous generated HAKs"
|
|
case strings.HasPrefix(message, "Writing HAK manifest"):
|
|
return "writing HAK manifest"
|
|
case strings.HasPrefix(message, "Writing autogen manifest "):
|
|
return "writing autogen manifests"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *buildHAKConsole) emitResult(result pipeline.BuildResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build HAKs ----------")
|
|
if c.level != logLevelQuiet {
|
|
c.emitCoreSteps(result)
|
|
}
|
|
c.emitFinalSummary(result)
|
|
}
|
|
|
|
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.AutogenManifestPaths) > 0 {
|
|
fmt.Fprintf(c.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
|
|
}
|
|
status := "complete"
|
|
if c.planOnly {
|
|
status = "planned"
|
|
}
|
|
fmt.Fprintf(c.stdout, "status: %s\n", status)
|
|
}
|
|
|
|
func (c *buildHAKConsole) relPath(path string) string {
|
|
if path == "" {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(c.projectRoot, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
type topdataConsole struct {
|
|
stdout io.Writer
|
|
projectRoot string
|
|
projectName string
|
|
commandName string
|
|
commandLabel string
|
|
level logLevel
|
|
spinnerEnabled bool
|
|
}
|
|
|
|
func newTopdataConsole(ctx context, p *project.Project, commandName string) *topdataConsole {
|
|
return &topdataConsole{
|
|
stdout: ctx.stdout,
|
|
projectRoot: p.Root,
|
|
projectName: p.Config.Module.Name,
|
|
commandName: commandName,
|
|
commandLabel: topdataCommandLabel(commandName),
|
|
level: ctx.logLevel,
|
|
spinnerEnabled: spinnerEnabledFor(ctx.stderr, ctx.logLevel),
|
|
}
|
|
}
|
|
|
|
func topdataCommandLabel(commandName string) string {
|
|
switch commandName {
|
|
case "validate-topdata":
|
|
return "Validate Topdata"
|
|
case "build-topdata":
|
|
return "Build Topdata"
|
|
case "build-top-package":
|
|
return "Build Top Package"
|
|
case "compare-topdata":
|
|
return "Compare Topdata"
|
|
case "build-wiki":
|
|
return "Build Wiki"
|
|
case "deploy-wiki":
|
|
return "Deploy Wiki"
|
|
default:
|
|
return commandName
|
|
}
|
|
}
|
|
|
|
func (c *topdataConsole) progress(message string) {
|
|
if phase := c.phaseLabel(message); phase != "" {
|
|
spin.update(c.commandLabel + ": " + phase)
|
|
}
|
|
if c.level != logLevelDebug {
|
|
return
|
|
}
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
|
|
}
|
|
|
|
func (c *topdataConsole) phaseLabel(message string) string {
|
|
switch {
|
|
case strings.HasPrefix(message, "Building native topdata outputs"):
|
|
return "building native outputs"
|
|
case strings.HasPrefix(message, "Topdata outputs are current"):
|
|
return "checking cached outputs"
|
|
case strings.HasPrefix(message, "Compiled topdata outputs are current"):
|
|
return "refreshing top package"
|
|
case strings.HasPrefix(message, "Packaging compiled topdata resources into "):
|
|
return "packaging HAK"
|
|
case strings.HasPrefix(message, "Preparing compiled TLK release output "):
|
|
return "packaging TLK"
|
|
case strings.HasPrefix(message, "Building native wiki pages"):
|
|
return "building wiki pages"
|
|
case strings.HasPrefix(message, "NodeBB wiki plan: "):
|
|
return "planning wiki deploy"
|
|
case strings.HasPrefix(message, "Planning NodeBB wiki deploy"):
|
|
return "planning wiki deploy"
|
|
case strings.HasPrefix(message, "Planning NodeBB wiki page "):
|
|
return "planning wiki pages"
|
|
case strings.HasPrefix(message, "Executing NodeBB wiki actions"):
|
|
return "executing wiki deploy"
|
|
case strings.HasPrefix(message, "Executing NodeBB wiki action "):
|
|
return "executing wiki actions"
|
|
case strings.HasPrefix(message, "Listing NodeBB wiki namespace category "):
|
|
return "listing remote wiki pages"
|
|
case strings.HasPrefix(message, "Collecting "):
|
|
return "collecting pages"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *topdataConsole) emitValidationResult(report topdata.ValidationReport, topdataRoot string) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Validate Topdata ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "topdata root: %s\n", c.relPath(topdataRoot))
|
|
fmt.Fprintf(c.stdout, "topdata files: %d\n", report.Files)
|
|
fmt.Fprintf(c.stdout, "data files: %d\n", report.DataFiles)
|
|
fmt.Fprintf(c.stdout, "tlk files: %d\n", report.TLKFiles)
|
|
if warnings := report.WarningCount(); warnings > 0 {
|
|
fmt.Fprintf(c.stdout, "warnings: %d\n", warnings)
|
|
}
|
|
fmt.Fprintln(c.stdout, "topdata validation: ok")
|
|
}
|
|
|
|
func (c *topdataConsole) emitPackageResult(result topdata.PackageResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintf(c.stdout, "%s ----------\n", c.commandLabel)
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "mode: %s\n", result.Mode)
|
|
fmt.Fprintf(c.stdout, "topdata 2da output: %s\n", c.relPath(result.Output2DADir))
|
|
fmt.Fprintf(c.stdout, "topdata tlk output: %s\n", c.relPath(result.OutputTLKDir))
|
|
fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.OutputHAKPath))
|
|
fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.OutputTLKPath))
|
|
fmt.Fprintf(c.stdout, "2da files: %d\n", result.Files2DA)
|
|
fmt.Fprintf(c.stdout, "tlk files: %d\n", result.FilesTLK)
|
|
fmt.Fprintf(c.stdout, "top package resources: %d\n", result.HAKResources)
|
|
fmt.Fprintf(c.stdout, "top package assets: %d\n", result.AssetFiles)
|
|
if result.WikiOutputDir != "" {
|
|
fmt.Fprintf(c.stdout, "wiki output: %s\n", c.relPath(result.WikiOutputDir))
|
|
fmt.Fprintf(c.stdout, "wiki pages: %d\n", result.WikiPages)
|
|
fmt.Fprintf(c.stdout, "wiki status: %s\n", result.WikiStatus)
|
|
}
|
|
}
|
|
|
|
func (c *topdataConsole) emitCompareResult(result topdata.CompareResult) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Compare Topdata ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "mode: %s\n", result.Mode)
|
|
fmt.Fprintf(c.stdout, "checked 2da files: %d\n", result.Compared2DA)
|
|
fmt.Fprintf(c.stdout, "checked tlk files: %d\n", result.ComparedTLK)
|
|
fmt.Fprintf(c.stdout, "native-pass: %d\n", result.NativePass)
|
|
fmt.Fprintf(c.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical)
|
|
fmt.Fprintf(c.stdout, "native-mismatch: %d\n", result.NativeMismatch)
|
|
fmt.Fprintln(c.stdout, "topdata compare: ok")
|
|
}
|
|
|
|
func (c *topdataConsole) emitWikiBuildResult(outputDir string, pageCount int, status string) {
|
|
spin.linebreak()
|
|
fmt.Fprintln(c.stdout, "Build Wiki ----------")
|
|
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
|
|
fmt.Fprintf(c.stdout, "wiki output: %s\n", c.relPath(outputDir))
|
|
fmt.Fprintf(c.stdout, "wiki pages: %d\n", pageCount)
|
|
fmt.Fprintf(c.stdout, "wiki status: %s\n", status)
|
|
}
|
|
|
|
func (c *topdataConsole) emitWikiDeployResult(result topdata.DeployResult) {
|
|
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", result.LocalPages)
|
|
fmt.Fprintf(c.stdout, "created: %d\n", result.Created)
|
|
fmt.Fprintf(c.stdout, "updated: %d\n", result.Updated)
|
|
fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped)
|
|
fmt.Fprintf(c.stdout, "stale: %d\n", result.Stale)
|
|
fmt.Fprintf(c.stdout, "archived: %d\n", result.Archived)
|
|
fmt.Fprintf(c.stdout, "purged: %d\n", result.Purged)
|
|
fmt.Fprintf(c.stdout, "drifted: %d\n", result.Drifted)
|
|
if result.ResetPurged > 0 || result.ResetSkipped > 0 {
|
|
fmt.Fprintf(c.stdout, "namespace reset: %d (unrecognized: %d)\n", result.ResetPurged, result.ResetUnrecognized)
|
|
}
|
|
if result.ResetUnrecognized > 0 {
|
|
fmt.Fprintln(c.stdout, " unrecognized pages were not written by this deployer; recreating them is not possible")
|
|
}
|
|
if result.ResetSkipped > 0 {
|
|
fmt.Fprintf(c.stdout, " reset skipped: %d (NodeBB refused deletion, e.g. the wiki home page)\n", result.ResetSkipped)
|
|
}
|
|
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.Manifest))
|
|
}
|
|
|
|
func (c *topdataConsole) relPath(path string) string {
|
|
if path == "" {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(c.projectRoot, path)
|
|
if err != nil {
|
|
return filepath.ToSlash(path)
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
func formatCount(value int) string {
|
|
raw := strconv.Itoa(value)
|
|
if value < 1000 && value > -1000 {
|
|
return raw
|
|
}
|
|
sign := ""
|
|
if strings.HasPrefix(raw, "-") {
|
|
sign = "-"
|
|
raw = strings.TrimPrefix(raw, "-")
|
|
}
|
|
parts := make([]string, 0, (len(raw)+2)/3)
|
|
for len(raw) > 3 {
|
|
parts = append(parts, raw[len(raw)-3:])
|
|
raw = raw[:len(raw)-3]
|
|
}
|
|
parts = append(parts, raw)
|
|
slices := make([]string, 0, len(parts))
|
|
for index := len(parts) - 1; index >= 0; index-- {
|
|
slices = append(slices, parts[index])
|
|
}
|
|
return sign + strings.Join(slices, ",")
|
|
}
|
|
|
|
func runBuildHAKs(ctx context) error {
|
|
p, err := loadProject(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
opts, err := parseBuildHAKArgs(ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ctx.logLevel > opts.logLevel {
|
|
opts.logLevel = ctx.logLevel
|
|
}
|
|
p, err = p.CloneWithHAKNames(opts.filteredHAKs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console := newBuildHAKConsole(ctx, p, opts)
|
|
spin.configure(ctx.stderr, console.spinnerEnabled)
|
|
spin.start("Build HAKs: starting")
|
|
defer spin.stop()
|
|
var result pipeline.BuildResult
|
|
pipelineOpts := pipeline.BuildHAKOptions{
|
|
Progress: console.progress,
|
|
ArchiveNames: opts.filteredArchives,
|
|
SourceManifestPath: opts.sourceManifest,
|
|
ContentAddressedRoot: opts.contentAddressedRoot,
|
|
}
|
|
if opts.planOnly {
|
|
result, err = pipeline.PlanHAKsWithOptions(p, pipelineOpts)
|
|
} else {
|
|
result, err = pipeline.BuildHAKsWithOptions(p, pipelineOpts)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
console.emitResult(result)
|
|
return nil
|
|
}
|
|
|
|
func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
|
|
opts := buildHAKOptions{}
|
|
if len(args) == 0 {
|
|
return opts, nil
|
|
}
|
|
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch arg {
|
|
case "-h", "--help":
|
|
return opts, errors.New("usage: build-haks [--hak <hak-name> ...] [--archive <archive-name> ...] [--source-manifest <path>] [--content-addressed-root <path>] [--plan-only] [--quiet|--verbose|--debug]")
|
|
case "--hak":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--hak requires a value")
|
|
}
|
|
opts.filteredHAKs = append(opts.filteredHAKs, args[index])
|
|
case "--archive":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--archive requires a value")
|
|
}
|
|
opts.filteredArchives = append(opts.filteredArchives, args[index])
|
|
case "--plan-only":
|
|
opts.planOnly = true
|
|
case "--quiet":
|
|
opts.logLevel = logLevelQuiet
|
|
case "--verbose":
|
|
opts.logLevel = logLevelVerbose
|
|
case "--debug":
|
|
opts.logLevel = logLevelDebug
|
|
case "--source-manifest":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--source-manifest requires a value")
|
|
}
|
|
opts.sourceManifest = args[index]
|
|
case "--content-addressed-root":
|
|
index++
|
|
if index >= len(args) {
|
|
return opts, errors.New("--content-addressed-root requires a value")
|
|
}
|
|
opts.contentAddressedRoot = args[index]
|
|
default:
|
|
if value, ok, err := requireInlineFlagValue(arg, "--hak"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.filteredHAKs = append(opts.filteredHAKs, value)
|
|
continue
|
|
}
|
|
if value, ok, err := requireInlineFlagValue(arg, "--archive"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.filteredArchives = append(opts.filteredArchives, value)
|
|
continue
|
|
}
|
|
if value, ok, err := requireInlineFlagValue(arg, "--source-manifest"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.sourceManifest = value
|
|
continue
|
|
}
|
|
if value, ok, err := requireInlineFlagValue(arg, "--content-addressed-root"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.contentAddressedRoot = value
|
|
continue
|
|
}
|
|
if 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
parsed, err := parseBuildTopDataArgs("build-topdata", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if parsed.skipLFS {
|
|
os.Setenv("CRUCIBLE_SKIP_LFS", "1") //nolint:errcheck
|
|
}
|
|
|
|
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, parsed.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
|
|
}
|
|
|
|
parsed, err := parseBuildTopDataArgs("build-top-package", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if parsed.opts.BuildWiki {
|
|
return fmt.Errorf("--wiki is not supported with build-top-package; use build-topdata when wiki generation is required")
|
|
}
|
|
|
|
if parsed.skipLFS {
|
|
os.Setenv("CRUCIBLE_SKIP_LFS", "1") //nolint:errcheck
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type buildTopDataArgs struct {
|
|
opts topdata.BuildAndPackageOptions
|
|
skipLFS bool
|
|
}
|
|
|
|
func parseBuildTopDataArgs(commandName string, args []string) (buildTopDataArgs, error) {
|
|
var parsed buildTopDataArgs
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "--force":
|
|
parsed.opts.Force = true
|
|
case "--wiki":
|
|
parsed.opts.BuildWiki = true
|
|
case "--skip-lfs":
|
|
// ponytail: CRUCIBLE_SKIP_LFS env is the single skip mechanism; set it at
|
|
// the CLI entry point rather than carrying a dead field through BuildAndPackageOptions.
|
|
parsed.skipLFS = true
|
|
case "-h", "--help":
|
|
return parsed, fmt.Errorf("usage: %s [--force] [--wiki] [--skip-lfs]", commandName)
|
|
default:
|
|
return parsed, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
|
}
|
|
}
|
|
return parsed, 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)
|
|
return nil
|
|
}
|
|
|
|
func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiOptions, error) {
|
|
opts := topdata.DeployWikiOptions{}
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch arg {
|
|
case "--source-dir":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--source-dir requires a value")
|
|
}
|
|
opts.SourceDir = args[i]
|
|
case "--endpoint":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--endpoint requires a value")
|
|
}
|
|
opts.Endpoint = args[i]
|
|
case "--username":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--username requires a value")
|
|
}
|
|
opts.Username = args[i]
|
|
case "--password":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--password requires a value")
|
|
}
|
|
opts.Password = args[i]
|
|
case "--token":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--token requires a value")
|
|
}
|
|
opts.Token = args[i]
|
|
case "--version":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--version requires a value")
|
|
}
|
|
opts.Version = args[i]
|
|
case "--namespace":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--namespace requires a value")
|
|
}
|
|
opts.Namespaces = append(opts.Namespaces, args[i])
|
|
case "--category":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--category requires a namespace=cid value")
|
|
}
|
|
if opts.CategoryIDs == nil {
|
|
opts.CategoryIDs = map[string]int{}
|
|
}
|
|
if err := parseWikiCategoryArg(args[i], opts.CategoryIDs); err != nil {
|
|
return opts, err
|
|
}
|
|
case "--manifest":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--manifest requires a value")
|
|
}
|
|
opts.ManifestPath = args[i]
|
|
case "--stale-policy":
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, fmt.Errorf("--stale-policy requires a value")
|
|
}
|
|
opts.StalePolicy = args[i]
|
|
case "--dry-run":
|
|
opts.DryRun = true
|
|
case "--force":
|
|
opts.Force = true
|
|
case "--create":
|
|
opts.AllowCreates = true
|
|
case "--reset-managed-namespaces":
|
|
opts.ResetManagedNamespaces = true
|
|
case "-h", "--help":
|
|
return opts, fmt.Errorf("usage: %s [--source-dir <path>] [--endpoint <url>] [--token <token>] [--version <ver>] [--namespace <ns> ...] [--category <namespace=cid> ...] [--manifest <path>] [--stale-policy <report|archive|purge>] [--dry-run] [--create] [--force] [--reset-managed-namespaces]", commandName)
|
|
default:
|
|
if value, ok, err := requireInlineFlagValue(arg, "--source-dir"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.SourceDir = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--endpoint"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Endpoint = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--username"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Username = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--password"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Password = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--token"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Token = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--version"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Version = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--namespace"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.Namespaces = append(opts.Namespaces, value)
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--category"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
if opts.CategoryIDs == nil {
|
|
opts.CategoryIDs = map[string]int{}
|
|
}
|
|
if err := parseWikiCategoryArg(value, opts.CategoryIDs); err != nil {
|
|
return opts, err
|
|
}
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--manifest"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.ManifestPath = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--stale-policy"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.StalePolicy = value
|
|
} else if arg == "--dry-run" {
|
|
opts.DryRun = true
|
|
} else if arg == "--force" {
|
|
opts.Force = true
|
|
} else if arg == "--create" {
|
|
opts.AllowCreates = true
|
|
} else if arg == "--reset-managed-namespaces" {
|
|
opts.ResetManagedNamespaces = true
|
|
} else {
|
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
|
}
|
|
}
|
|
}
|
|
|
|
if opts.Endpoint == "" {
|
|
opts.Endpoint = os.Getenv("NODEBB_API_ENDPOINT")
|
|
}
|
|
if opts.Token == "" {
|
|
opts.Token = os.Getenv("NODEBB_API_TOKEN")
|
|
}
|
|
if opts.Version == "" {
|
|
opts.Version = os.Getenv("GITHUB_REF_NAME")
|
|
}
|
|
if opts.CategoryIDs == nil {
|
|
opts.CategoryIDs = map[string]int{}
|
|
}
|
|
if raw := os.Getenv("NODEBB_WIKI_CATEGORIES"); raw != "" {
|
|
if err := parseWikiCategoryList(raw, opts.CategoryIDs); err != nil {
|
|
return opts, err
|
|
}
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
type buildChangelogOptions struct {
|
|
configPath string
|
|
outputPath string
|
|
currentTag string
|
|
previousTag string
|
|
apiBaseURL string
|
|
token string
|
|
}
|
|
|
|
func runBuildChangelog(ctx context) error {
|
|
opts, err := parseBuildChangelogArgs("build-changelog", ctx.args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return changelog.Generate(changelog.Options{
|
|
RepoRoot: ctx.cwd,
|
|
ConfigPath: opts.configPath,
|
|
OutputPath: opts.outputPath,
|
|
CurrentTag: opts.currentTag,
|
|
PreviousTag: opts.previousTag,
|
|
APIBaseURL: opts.apiBaseURL,
|
|
Token: opts.token,
|
|
Stdout: ctx.stdout,
|
|
})
|
|
}
|
|
|
|
func parseBuildChangelogArgs(commandName string, args []string) (buildChangelogOptions, error) {
|
|
opts := buildChangelogOptions{}
|
|
usage := fmt.Errorf("usage: %s [--config <path>] [--output <path>] [--current-tag <tag>] [--previous-tag <tag>] [--api-base-url <url>] [--token <token>]", commandName)
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch arg {
|
|
case "--config":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.configPath = args[i]
|
|
case "--output":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.outputPath = args[i]
|
|
case "--current-tag":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.currentTag = args[i]
|
|
case "--previous-tag":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.previousTag = args[i]
|
|
case "--api-base-url":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.apiBaseURL = args[i]
|
|
case "--token":
|
|
i++
|
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
|
return opts, usage
|
|
}
|
|
opts.token = args[i]
|
|
case "-h", "--help":
|
|
return opts, usage
|
|
default:
|
|
if value, ok, err := requireInlineFlagValue(arg, "--config"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.configPath = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--output"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.outputPath = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--current-tag"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.currentTag = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--previous-tag"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.previousTag = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--api-base-url"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.apiBaseURL = value
|
|
} else if value, ok, err := requireInlineFlagValue(arg, "--token"); ok || err != nil {
|
|
if err != nil {
|
|
return opts, err
|
|
}
|
|
opts.token = value
|
|
} else {
|
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
|
}
|
|
}
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
func parseWikiCategoryList(raw string, target map[string]int) error {
|
|
for _, part := range strings.Split(raw, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if err := parseWikiCategoryArg(part, target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseWikiCategoryArg(raw string, target map[string]int) error {
|
|
namespace, rawCID, ok := strings.Cut(raw, "=")
|
|
if !ok {
|
|
return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw)
|
|
}
|
|
namespace = strings.TrimSpace(namespace)
|
|
rawCID = strings.TrimSpace(rawCID)
|
|
if namespace == "" || rawCID == "" {
|
|
return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw)
|
|
}
|
|
cid, err := strconv.Atoi(rawCID)
|
|
if err != nil || cid <= 0 {
|
|
return fmt.Errorf("wiki category mapping %q has invalid cid", raw)
|
|
}
|
|
target[namespace] = cid
|
|
return nil
|
|
}
|
|
|
|
func loadProject(ctx context) (*project.Project, error) {
|
|
root, err := project.FindRoot(ctx.cwd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p, err := project.Load(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ctx.stderr != nil && ctx.logLevel != logLevelQuiet {
|
|
fmt.Fprintf(ctx.stderr, "Loaded config: %s\n", p.ConfigSource.Name)
|
|
if p.ConfigSource.Legacy {
|
|
fmt.Fprintf(ctx.stderr, "Warning: %s is legacy repository configuration; migrate to %s.\n", project.LegacyConfigFile, project.ConfigFile)
|
|
}
|
|
}
|
|
|
|
var failures []error
|
|
if err := p.ValidateLayout(); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := p.Scan(); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
return nil, errors.Join(failures...)
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
func printUsage(w io.Writer) {
|
|
exe := filepath.Base(os.Args[0])
|
|
fmt.Fprintf(w, "%s <command>\n\n", exe)
|
|
fmt.Fprintln(w, "Commands:")
|
|
for _, cmd := range commands {
|
|
fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.description)
|
|
}
|
|
}
|