package main import ( "bytes" "encoding/json" "errors" "flag" "fmt" "io" "net/http" "os" "os/exec" "path" "path/filepath" "regexp" "sort" "strings" "time" "github.com/santhosh-tekuri/jsonschema/v6" ) // draft writes a first template for a new game with an AI model: it gives the model AUTHORING.md as its // rules, the facts read from the image (inspect-image) and, when available, a first-boot report, plus two // working templates as examples. The answer is written to games// and checked with the same validator // and image checks as any template; problems go back to the model to fix, a few rounds at most. type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } type model interface { chat(messages []chatMessage, schema map[string]any) (string, error) name() string } func runDraft(args []string) error { fs := flag.NewFlagSet("draft", flag.ExitOnError) gamesDir := fs.String("games", "games", "directory holding one folder per game") schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas") gameName := fs.String("name", "", "the game's name, e.g. \"Valheim\"") id := fs.String("id", "", "folder/id for the new game (default: from -name)") image := fs.String("image", "", "Docker image the server runs in") steamApp := fs.String("steam-app", "", "SteamCMD app id, if the server is installed with SteamCMD") notes := fs.String("notes", "", "anything else the model should know (variant, crossplay plugin...)") reportPath := fs.String("first-boot", "", "a first-boot report.json for this image") provider := fs.String("provider", "ollama", "ollama or gemini") modelName := fs.String("model", "", "model name (default qwen3.8:27b for ollama, gemini-3.5-flash for gemini)") attempts := fs.Int("attempts", 3, "fix rounds after the first draft") noPull := fs.Bool("no-pull", false, "use the local image instead of pulling it") fs.Parse(args) if *gameName == "" || *image == "" { return errors.New("draft needs -name and -image") } var report *bootReport if *reportPath != "" { var err error if report, err = readBootReport(*reportPath); err != nil { return err } } d, err := newDrafter(draftJob{GamesDir: *gamesDir, SchemaDir: *schemaDir, Name: *gameName, ID: *id, Image: *image, SteamApp: *steamApp, Notes: *notes, Report: report, Provider: *provider, Model: *modelName, NoPull: *noPull}) if err != nil { return err } if problems, err := d.refine(*attempts, nil); err != nil { return err } else if len(problems) > 0 { return fmt.Errorf("draft still has problems after %d fix rounds; it is in %s for a person to finish", *attempts, d.dir) } fmt.Println("[draft] next: review it, then boot-test it (build / CI) before opening a PR") return nil } type draftJob struct { GamesDir, SchemaDir string Name, ID, Image string SteamApp, Notes string Report *bootReport Provider, Model string NoPull bool // Text of the pages research based the variant on; when set, every launch option in the startup // command must appear in it (or in the image's scripts) Docs string } // drafter holds one conversation with the model about one game, so later problems (a failed boot) can // be sent back to it with everything it was told before. type drafter struct { job draftJob dir, id string m model facts *imageFacts gameSchema, testSchema *jsonschema.Schema answerSchema map[string]any messages []chatMessage rounds int } func newDrafter(j draftJob) (*drafter, error) { id := j.ID if id == "" { id = gameID(j.Name) } d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)} if _, err := os.Stat(d.dir); err == nil { return nil, fmt.Errorf("%s already exists; drafts only create new games", d.dir) } var err error if d.m, err = newModel(j.Provider, j.Model); err != nil { return nil, err } rules, err := os.ReadFile("AUTHORING.md") if err != nil { return nil, fmt.Errorf("AUTHORING.md (the model's rules): %w", err) } if d.gameSchema, err = compileSchema(filepath.Join(j.SchemaDir, "game.schema.json")); err != nil { return nil, err } if d.testSchema, err = compileSchema(filepath.Join(j.SchemaDir, "test.schema.json")); err != nil { return nil, err } if d.answerSchema, err = draftAnswerSchema(j.SchemaDir); err != nil { return nil, err } if !j.NoPull { fmt.Printf("[draft] pulling %s\n", j.Image) if out, err := runCmd("docker", "pull", "-q", j.Image); err != nil { return nil, fmt.Errorf("docker pull: %v: %s", err, out) } } if d.facts, err = inspectImage(j.Image, nil); err != nil { return nil, err } if why := imageUnusable(j.Report); why != "" { return nil, errors.New(why) } d.messages = []chatMessage{ {Role: "system", Content: draftSystemPrompt(string(rules))}, {Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report, exampleTemplates(j.GamesDir, 2))}, } return d, nil } func gameID(name string) string { return strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(name), "-"), "-") } // refine asks for a (corrected) template until it passes validation and the image checks, at most // attempts+1 answers. feedback, when given, is what went wrong since the last answer (e.g. its boot). // It returns the problems still left, nil when the draft passes. func (d *drafter) refine(attempts int, feedback []string) ([]string, error) { if len(feedback) > 0 { d.ask(feedback) } var problems []string for round := 0; round <= attempts; round++ { d.rounds++ fmt.Printf("[draft] round %d: asking %s\n", d.rounds, d.m.name()) start := time.Now() answer, err := d.m.chat(d.messages, d.answerSchema) if err != nil { return nil, err } fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds()) d.messages = append(d.messages, chatMessage{Role: "assistant", Content: answer}) if problems = d.check(answer); len(problems) == 0 { fmt.Printf("[draft] %s passes validation and the image checks\n", d.dir) return nil, nil } fmt.Printf("[draft] %d problem(s):\n", len(problems)) for _, p := range problems { fmt.Println(" - " + p) } if round < attempts { d.ask(problems) } } return problems, nil } func (d *drafter) ask(problems []string) { d.messages = append(d.messages, chatMessage{Role: "user", Content: "Your template has these problems. Fix all of them and " + "answer with the complete corrected JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- ")}) } func (d *drafter) check(answer string) []string { problems := writeDraft(d.dir, d.id, answer, d.job.Report) if len(problems) == 0 { problems = validateGame(d.dir, d.id, d.gameSchema, d.testSchema) } if len(problems) == 0 { if g, err := loadMap(filepath.Join(d.dir, "game.yaml")); err == nil { imageProblems, _ := checkTemplate(g, d.facts) problems = append(problems, imageProblems...) t, _ := loadMap(filepath.Join(d.dir, "test.yaml")) problems = append(problems, draftCompleteness(g, t)...) problems = append(problems, unreadSettings(g, d.facts)...) problems = append(problems, savesOutsideVolumes(g, d.job.Report)...) problems = append(problems, splitSettings(g)...) problems = append(problems, portSettings(g)...) problems = append(problems, undocumentedOptions(g, d.job.Docs, d.facts)...) } } return problems } func readBootReport(p string) (*bootReport, error) { raw, err := os.ReadFile(p) if err != nil { return nil, err } r := &bootReport{} if err := json.Unmarshal(raw, r); err != nil { return nil, fmt.Errorf("%s: %w", p, err) } return r, nil } func draftSystemPrompt(rules string) string { return "You write game server templates for GBR Hosting. Follow these house rules exactly; they describe " + "how servers really run, and breaking them has broken real servers:\n\n" + rules + "\n\n" + "Answer with one JSON object and nothing else: {\"game\": , " + "\"test\": }.\n\n" + "The template must be COMPLETE enough for a player to run and configure the server:\n" + "- defaultPorts: every port players or the server browser connect to (from the image's exposed ports " + "and the game's documentation), each with its protocol. Never RCON/REST/admin or web/status ports.\n" + "- defaultConfig and configSchema: every setting the image reads (the facts' envRead and env lists, the " + "first-boot settings) that an owner would want to change: server name, world, password, player limit, " + "difficulty and gameplay options. Each with title, description, type, default and category.\n" + "- requiredMemoryMb: what the game really needs, with headroom.\n" + "- readyPattern when a first-boot report shows the ready line; otherwise set test.settleSec.\n" + "Base every value on the facts given (image facts, first-boot report) and on what you know for certain " + "about this game and image. Do not invent settings an image does not read; say what you are unsure of " + "in the setting's description instead of leaving the template empty." } // draftCompleteness is the bar a draft must clear on top of validation: an almost empty template is // valid but useless, and a cautious model will happily produce one. func draftCompleteness(game, test map[string]any) []string { var problems []string if ports, _ := game["defaultPorts"].([]any); len(ports) == 0 { problems = append(problems, "defaultPorts is empty: list the ports players connect to") } props := schemaProperties(game) defaults, _ := game["defaultConfig"].(map[string]any) if len(defaults) < 3 { problems = append(problems, fmt.Sprintf("defaultConfig has only %d settings: add every setting the image reads that an owner would change (name, world, password, players, gameplay)", len(defaults))) } var undescribed []string for _, key := range sortedKeys(defaults) { if _, ok := props[key]; !ok { undescribed = append(undescribed, key) } } if len(undescribed) > 0 { problems = append(problems, "these defaultConfig settings have no configSchema.properties entry (each needs title, description, "+ "type, default and category, under configSchema: {type: object, properties: {...}}): "+strings.Join(undescribed, ", ")) } for _, key := range sortedKeys(props) { p, _ := props[key].(map[string]any) for _, field := range []string{"title", "description", "type", "category"} { if v, ok := p[field]; !ok || v == "" { problems = append(problems, fmt.Sprintf("setting %s has no %s", key, field)) } } } if n, ok := game["requiredMemoryMb"].(json.Number); !ok { problems = append(problems, "requiredMemoryMb is missing") } else if v, _ := n.Int64(); v < 1024 { problems = append(problems, "requiredMemoryMb is below 1024") } // Secrets only exist as CI settings a person adds; a draft with one can't be boot-tested anywhere if secrets, _ := test["secrets"].(map[string]any); len(secrets) > 0 { problems = append(problems, "test.yaml has 'secrets' ("+strings.Join(sortedKeys(secrets), ", ")+"), which no boot test of a "+ "new game has: remove them. Password settings get a throwaway value in boot tests automatically") } if _, ok := game["readyPattern"]; !ok { if _, ok := test["settleSec"]; !ok { problems = append(problems, "no readyPattern and no test.settleSec: boot tests need one of them") } } return problems } // unreadSettings finds settings that reach the container under a variable name the image never reads, // e.g. ServerPassword -> SERVER_PASSWORD when the image reads SERVER_PASS. Only for images configured // through environment variables (no config file) whose startup scripts the inspector could read. func unreadSettings(game map[string]any, facts *imageFacts) []string { if path, _ := game["configFilePath"].(string); path != "" || len(facts.EnvRead) == 0 { return nil } read := map[string]bool{} for _, v := range facts.EnvRead { read[v] = true } for v := range facts.Env { read[v] = true } startup, _ := game["defaultStartupCommand"].(string) defaults, _ := game["defaultConfig"].(map[string]any) var problems []string for _, key := range sortedKeys(defaults) { env := envName(key) if read[env] || strings.Contains(startup, env) { continue } msg := fmt.Sprintf("setting %s reaches the container as %s, which the image never reads", key, env) if near := closestVar(env, facts.EnvRead); near != "" { msg += fmt.Sprintf(" (did you mean %s? name the key so it becomes that)", near) } problems = append(problems, msg) } return problems } // closestVar picks the variable that shares the longest common prefix with name (SERVER_PASSWORD -> // SERVER_PASS), if it's a real resemblance. func closestVar(name string, vars []string) string { best, bestLen := "", 0 for _, v := range vars { n := 0 for n < len(v) && n < len(name) && v[n] == name[n] { n++ } if n > bestLen { best, bestLen = v, n } } if bestLen < 5 { return "" } return best } // Files the Steam client, Proton and the OS write for themselves: not the game's data var outsideNoise = []string{"/Steam/logs/", "/.steam/", "/steamcmd/", "/.cache/", "/tmp/", "/.local/share/umu"} // savesOutsideVolumes turns first boot's "written outside the volumes" into a hard check: anything the // game itself wrote there (Valheim's worlds and admin lists in ~/.config/unity3d/...) is lost every time the // node recreates the container. A template passes once its startup command or settings point the game at // /server or /data; a boot test then shows whether that really worked. func savesOutsideVolumes(game map[string]any, report *bootReport) []string { dirs := outsideDirs(report) if len(dirs) == 0 { return nil } startup, _ := game["defaultStartupCommand"].(string) redirected := strings.Contains(startup, "/server") || strings.Contains(startup, "/data") defaults, _ := game["defaultConfig"].(map[string]any) for _, v := range defaults { if s, ok := v.(string); ok && (strings.HasPrefix(s, "/server") || strings.HasPrefix(s, "/data")) { redirected = true } } if redirected { return nil } return []string{fmt.Sprintf("first boot: the server wrote its own files outside the server volume, in %s. Those are lost "+ "whenever the node recreates the container (every start). Make the game keep them under /server, e.g. with "+ "its save-directory launch option or by pointing that folder at /server, and include it in backupPaths", strings.Join(dirs, ", "))} } // outsideDirs lists the folders outside the volumes where the game itself wrote files during a boot. func outsideDirs(report *bootReport) []string { if report == nil { return nil } dirs := map[string]bool{} for _, c := range report.ContainerChanges { _, p, _ := strings.Cut(c, " ") noise := false for _, n := range outsideNoise { if strings.Contains(p, n) { noise = true } } if !noise && p != "" { dirs[path.Dir(p)] = true } } return sortedKeysBool(dirs) } // imageUnusable recognises a first boot where the image died within seconds because it needs root (or // another privilege nodes never grant): drafting a template for it can't produce a working server. func imageUnusable(r *bootReport) string { if r == nil || r.ExitCode == nil || *r.ExitCode == 0 || r.RanSeconds > 60 { return "" } var denied []string for _, l := range r.ErrorLines { if strings.Contains(strings.ToLower(l.Text), "permission denied") { denied = append(denied, l.Text) } } if len(denied) == 0 { return "" } return fmt.Sprintf("first boot: the image exited (code %d) after %.0fs as %s with permission errors, e.g. %q. It needs root, "+ "which nodes never allow; use another image for this game, or cm2network/steamcmd with -steam-app", *r.ExitCode, r.RanSeconds, r.User, denied[0]) } func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts, report *bootReport, examples string) string { var b strings.Builder fmt.Fprintf(&b, "Write the template for **%s**.\n- id: %s\n- defaultDockerImage: %s\n", gameName, id, image) if steamApp != "" { fmt.Fprintf(&b, "- installMethod STEAMCMD with appId %s\n", steamApp) } if notes != "" { fmt.Fprintf(&b, "- notes: %s\n", notes) } f := *facts f.Scripts = map[string]string{} for p, s := range facts.Scripts { if len(s) > 8000 { s = s[:8000] + "\n...(truncated)" } f.Scripts[p] = s } fj, _ := json.MarshalIndent(f, "", " ") fmt.Fprintf(&b, "\n## Facts read from the image (inspect-image)\n```json\n%s\n```\n", fj) if report != nil { fmt.Fprintf(&b, "\n## First boot with no settings\nOutcome: %s\n", report.Outcome) if report.SuggestedReady != "" { fmt.Fprintf(&b, "Suggested readyPattern: `%s` (readyExample: `%s`)\n", report.SuggestedReady, report.SuggestedExample) } for _, l := range report.ReadyCandidates { fmt.Fprintf(&b, "ready-looking line: %s\n", l.Text) } for _, l := range report.ErrorLines { fmt.Fprintf(&b, "error-looking line: %s\n", l.Text) } fmt.Fprintf(&b, "Config files the server wrote: %s\n", strings.Join(report.ConfigFiles, ", ")) settings := report.Settings if len(settings) > 300 { settings = settings[:300] } b.WriteString("Settings it wrote, with defaults (file | section | key = value):\n") for _, s := range settings { fmt.Fprintf(&b, "%s | %s | %s = %s\n", s.File, s.Section, s.Key, s.Value) } } else { b.WriteString("\n(No first-boot report: keep readyPattern out unless the facts show the ready line.)\n") } b.WriteString("\n## Working templates from this repo, for format and style (not content)\n") b.WriteString(examples) return b.String() } // exampleTemplates picks the shortest templates in the repo as examples: real, validated, and small // enough to leave the model room for the facts. func exampleTemplates(gamesDir string, n int) string { entries, _ := os.ReadDir(gamesDir) type ex struct { id string body string } var all []ex for _, e := range entries { raw, err := os.ReadFile(filepath.Join(gamesDir, e.Name(), "game.yaml")) if err == nil { all = append(all, ex{e.Name(), string(raw)}) } } sort.Slice(all, func(i, j int) bool { return len(all[i].body) < len(all[j].body) }) var b strings.Builder for i := 0; i < n && i < len(all); i++ { fmt.Fprintf(&b, "\n### games/%s/game.yaml\n```yaml\n%s```\n", all[i].id, all[i].body) } return b.String() } // writeDraft turns the model's JSON answer into games//game.yaml and test.yaml, using the importer's // writer so drafts look like every other template. func writeDraft(dir, id, answer string, report *bootReport) []string { answer = trimToJSON(answer) var parsed struct { Game map[string]any `json:"game"` Test map[string]any `json:"test"` } if err := json.Unmarshal([]byte(answer), &parsed); err != nil { return []string{"the answer is not valid JSON: " + err.Error()} } if parsed.Game == nil { return []string{`the answer has no "game" object`} } parsed.Game["id"] = id if parsed.Test == nil { parsed.Test = map[string]any{} } // The ready line is a fact from the first boot, already checked against the real log line; a model // that leaves it out (or answers null) gets the measured one rather than another guessing round if p, _ := parsed.Game["readyPattern"].(string); p == "" && report != nil && report.SuggestedReady != "" { parsed.Game["readyPattern"] = report.SuggestedReady parsed.Test["readyExample"] = report.SuggestedExample } if err := os.MkdirAll(dir, 0o755); err != nil { return []string{err.Error()} } var problems []string known := map[string]bool{} for _, f := range gameFields { known[f] = true } for k := range parsed.Game { if !known[k] { problems = append(problems, fmt.Sprintf("%q is not a template field; remove it or use one from the schema", k)) } } if err := writeGame(filepath.Join(dir, "game.yaml"), parsed.Game); err != nil { return []string{"could not write game.yaml: " + err.Error()} } node, err := toNode(normalize(parsed.Test)) if err != nil { return []string{err.Error()} } if err := writeYAML(filepath.Join(dir, "test.yaml"), testHeader, node); err != nil { return []string{"could not write test.yaml: " + err.Error()} } return problems } // draftAnswerSchema is the shape Ollama is asked to produce: {"game": ..., "test": ...}, using the repo's // schemas minus the parts structured-output engines don't support. func draftAnswerSchema(schemaDir string) (map[string]any, error) { load := func(name string) (map[string]any, error) { raw, err := os.ReadFile(filepath.Join(schemaDir, name)) if err != nil { return nil, err } var s map[string]any if err := json.Unmarshal(raw, &s); err != nil { return nil, err } for _, k := range []string{"$schema", "$id", "allOf", "title", "description"} { delete(s, k) } return s, nil } game, err := load("game.schema.json") if err != nil { return nil, err } test, err := load("test.schema.json") if err != nil { return nil, err } tightenForDraft(game) return map[string]any{ "type": "object", "required": []string{"game", "test"}, "properties": map[string]any{"game": game, "test": test}, "additionalProperties": false, }, nil } // tightenForDraft makes the fields a working template needs required and non-null in the format Ollama // enforces. The repo schema leaves them optional (hand-written templates may omit some), and a constrained // model takes that literally: it answered null for installConfig, defaultConfig and configSchema, the // writer dropped them, and no amount of "add installConfig" could change that. func tightenForDraft(game map[string]any) { props, _ := game["properties"].(map[string]any) nonNull := func(name, typ string) { if p, ok := props[name].(map[string]any); ok { p["type"] = typ } } nonNull("defaultStartupCommand", "string") nonNull("requiredMemoryMb", "integer") nonNull("defaultPorts", "array") if p, ok := props["defaultPorts"].(map[string]any); ok { p["minItems"] = 1 } props["installConfig"] = map[string]any{ "type": "object", "properties": map[string]any{ "appId": map[string]any{"type": "string", "description": "Steam app id, for installMethod STEAMCMD"}, "script": map[string]any{"type": "string", "description": "install script, for installMethod BASH_SCRIPT"}, }, } props["defaultConfig"] = map[string]any{"type": "object", "minProperties": 3} props["configSchema"] = map[string]any{ "type": "object", "required": []string{"type", "properties"}, "properties": map[string]any{ "type": map[string]any{"enum": []string{"object"}}, "properties": map[string]any{ "type": "object", "minProperties": 3, "additionalProperties": map[string]any{ "type": "object", "required": []string{"title", "description", "type", "default", "category"}, "properties": map[string]any{ "title": map[string]any{"type": "string"}, "description": map[string]any{"type": "string"}, "type": map[string]any{"enum": []string{"string", "integer", "number", "boolean"}}, "category": map[string]any{"enum": []string{"Security & Access", "Gameplay & Rules", "World & Environment", "System & General"}}, }, }, }, }, } game["required"] = []string{"id", "name", "defaultDockerImage", "defaultStartupCommand", "requiredMemoryMb", "defaultPorts", "installMethod", "installConfig", "defaultConfig", "configSchema"} } func newModel(provider, name string) (model, error) { switch provider { case "ollama": if name == "" { name = "qwen3.8:27b" } url := os.Getenv("OLLAMA_URL") if url == "" { url = "http://localhost:11434" } return &ollamaModel{url: strings.TrimRight(url, "/"), model: name}, nil case "gemini": if name == "" { name = "gemini-3.5-flash" } key := os.Getenv("GEMINI_API_KEY") if key == "" { return nil, errors.New("set GEMINI_API_KEY to use -provider gemini") } return &geminiModel{key: key, model: name}, nil } return nil, fmt.Errorf("unknown provider %q (ollama or gemini)", provider) } type ollamaModel struct{ url, model string } func (o *ollamaModel) name() string { return "ollama " + o.model + " at " + o.url } func (o *ollamaModel) chat(messages []chatMessage, schema map[string]any) (string, error) { body := map[string]any{ "model": o.model, "messages": messages, "stream": false, "format": schema, // Ollama silently truncates input beyond its default context "options": map[string]any{"num_ctx": 32768, "temperature": 0.2}, } var resp struct { Message struct { Content string `json:"content"` } `json:"message"` Error string `json:"error"` } if err := postJSON(o.url+"/api/chat", body, &resp, 30*time.Minute); err != nil { return "", err } if resp.Error != "" { return "", errors.New("ollama: " + resp.Error) } return resp.Message.Content, nil } type geminiModel struct{ key, model string } func (g *geminiModel) name() string { return "gemini " + g.model } func (g *geminiModel) chat(messages []chatMessage, _ map[string]any) (string, error) { var system string var contents []map[string]any for _, m := range messages { if m.Role == "system" { system = m.Content continue } role := "user" if m.Role == "assistant" { role = "model" } contents = append(contents, map[string]any{"role": role, "parts": []map[string]string{{"text": m.Content}}}) } body := map[string]any{ "systemInstruction": map[string]any{"parts": []map[string]string{{"text": system}}}, "contents": contents, "generationConfig": map[string]any{"responseMimeType": "application/json", "temperature": 0.2}, } var resp struct { Candidates []struct { Content struct { Parts []struct { Text string `json:"text"` } `json:"parts"` } `json:"content"` } `json:"candidates"` Error *struct { Message string `json:"message"` } `json:"error"` } url := "https://generativelanguage.googleapis.com/v1beta/models/" + g.model + ":generateContent?key=" + g.key if err := postJSON(url, body, &resp, 10*time.Minute); err != nil { return "", err } if resp.Error != nil { return "", errors.New("gemini: " + resp.Error.Message) } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { return "", errors.New("gemini returned no answer") } return resp.Candidates[0].Content.Parts[0].Text, nil } func postJSON(url string, body, out any, timeout time.Duration) error { payload, err := json.Marshal(body) if err != nil { return err } client := &http.Client{Timeout: timeout} resp, err := client.Post(url, "application/json", bytes.NewReader(payload)) if err != nil { return err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return err } if err := json.Unmarshal(raw, out); err != nil { return fmt.Errorf("HTTP %d, unreadable answer: %.300s", resp.StatusCode, raw) } return nil } func runCmd(name string, args ...string) (string, error) { out, err := exec.Command(name, args...).CombinedOutput() return strings.TrimSpace(string(out)), err }