435 lines
19 KiB
Go
435 lines
19 KiB
Go
package clusterlog
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type MemoryInput struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Tags []string `json:"tags"`
|
|
Systems []string `json:"systems"`
|
|
Risk string `json:"risk"`
|
|
Ticket string `json:"ticket,omitempty"`
|
|
ReviewRequired *bool `json:"review_required,omitempty"`
|
|
ReviewPolicy string `json:"review_policy,omitempty"`
|
|
BodyMarkdown string `json:"body_markdown,omitempty"`
|
|
Context string `json:"context,omitempty"`
|
|
Changes []string `json:"changes,omitempty"`
|
|
Commands []string `json:"commands,omitempty"`
|
|
Validation []string `json:"validation,omitempty"`
|
|
Rollback string `json:"rollback,omitempty"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
FinishedAt string `json:"finished_at,omitempty"`
|
|
DurationMinutes int `json:"duration_minutes,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
}
|
|
|
|
type TaskInput struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
Priority string `json:"priority,omitempty"`
|
|
Assignees []string `json:"assignees,omitempty"`
|
|
EstimateMinutes int `json:"estimate_minutes,omitempty"`
|
|
Due string `json:"due,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Systems []string `json:"systems,omitempty"`
|
|
BodyMarkdown string `json:"body_markdown,omitempty"`
|
|
Objective string `json:"objective,omitempty"`
|
|
Acceptance []string `json:"acceptance_criteria,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
type ProjectInput struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Objective string `json:"objective"`
|
|
Owners []string `json:"owners,omitempty"`
|
|
TargetDate string `json:"target_date,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Systems []string `json:"systems,omitempty"`
|
|
BodyMarkdown string `json:"body_markdown,omitempty"`
|
|
Scope []string `json:"scope,omitempty"`
|
|
OutOfScope []string `json:"out_of_scope,omitempty"`
|
|
Milestones []string `json:"milestones,omitempty"`
|
|
Success []string `json:"success_criteria,omitempty"`
|
|
}
|
|
|
|
type ContentInput struct {
|
|
Section string `json:"section"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Systems []string `json:"systems,omitempty"`
|
|
BodyMarkdown string `json:"body_markdown"`
|
|
Weight int `json:"weight,omitempty"`
|
|
}
|
|
|
|
func decodeJSONInput(path string, stdinReader io.Reader, dst any) error {
|
|
data, err := readAll(path, stdinReader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(dst); err != nil {
|
|
return NewError(ExitUsage, "invalid_json", "el documento de entrada no es JSON válido o contiene campos desconocidos", err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func renderMemoryBody(input MemoryInput) string {
|
|
if strings.TrimSpace(input.BodyMarkdown) != "" {
|
|
return normalizeMarkdownBody(input.BodyMarkdown)
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("## Contexto\n\n")
|
|
b.WriteString(firstNonEmpty(input.Context, "_No especificado._"))
|
|
b.WriteString("\n\n## Cambios realizados\n\n")
|
|
b.WriteString(bullets(input.Changes))
|
|
b.WriteString("\n## Comandos relevantes (secretos redactados)\n\n")
|
|
b.WriteString(codeBlock(input.Commands))
|
|
b.WriteString("\n## Validación\n\n")
|
|
b.WriteString(bullets(input.Validation))
|
|
b.WriteString("\n## Riesgo y reversión\n\n")
|
|
if input.Rollback == "" {
|
|
b.WriteString("_No se documentó un procedimiento de reversión._\n")
|
|
} else {
|
|
b.WriteString(input.Rollback + "\n")
|
|
}
|
|
if input.Ticket != "" {
|
|
b.WriteString("\n## Referencias\n\n- Ticket: `" + input.Ticket + "`\n")
|
|
}
|
|
return normalizeMarkdownBody(b.String())
|
|
}
|
|
|
|
func renderTaskBody(input TaskInput) string {
|
|
if strings.TrimSpace(input.BodyMarkdown) != "" {
|
|
return normalizeMarkdownBody(input.BodyMarkdown)
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("## Objetivo\n\n")
|
|
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
|
|
b.WriteString("\n\n## Criterios de aceptación\n\n")
|
|
b.WriteString(bullets(input.Acceptance))
|
|
if input.Notes != "" {
|
|
b.WriteString("\n## Notas\n\n" + input.Notes + "\n")
|
|
}
|
|
return normalizeMarkdownBody(b.String())
|
|
}
|
|
|
|
func renderProjectBody(input ProjectInput) string {
|
|
if strings.TrimSpace(input.BodyMarkdown) != "" {
|
|
return normalizeMarkdownBody(input.BodyMarkdown)
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("## Objetivo\n\n")
|
|
b.WriteString(firstNonEmpty(input.Objective, input.Description, "_No especificado._"))
|
|
b.WriteString("\n\n## Alcance\n\n")
|
|
b.WriteString(bullets(input.Scope))
|
|
b.WriteString("\n## Fuera de alcance\n\n")
|
|
b.WriteString(bullets(input.OutOfScope))
|
|
b.WriteString("\n## Hitos\n\n")
|
|
b.WriteString(bullets(input.Milestones))
|
|
b.WriteString("\n## Criterios de éxito\n\n")
|
|
b.WriteString(bullets(input.Success))
|
|
return normalizeMarkdownBody(b.String())
|
|
}
|
|
|
|
func (a *App) createMemory(root string, identity Identity, input MemoryInput) (map[string]any, []string, error) {
|
|
input.Title = strings.TrimSpace(input.Title)
|
|
input.Description = firstNonEmpty(input.Description, input.Title)
|
|
input.Risk = strings.ToLower(firstNonEmpty(input.Risk, "low"))
|
|
validRisk := map[string]bool{"low": true, "medium": true, "high": true, "critical": true}
|
|
if input.Title == "" {
|
|
return nil, nil, NewError(ExitUsage, "missing_title", "--title o title en JSON es obligatorio", nil)
|
|
}
|
|
if !validRisk[input.Risk] {
|
|
return nil, nil, NewError(ExitUsage, "invalid_risk", "risk debe ser low, medium, high o critical", input.Risk)
|
|
}
|
|
reviewRequired := true
|
|
if input.ReviewRequired != nil {
|
|
reviewRequired = *input.ReviewRequired
|
|
}
|
|
registry, err := loadAdmins(root)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
input.ReviewPolicy = firstNonEmpty(input.ReviewPolicy, registry.ReviewPolicy.Default, "all-active")
|
|
if input.ReviewPolicy != "all-active" && input.ReviewPolicy != "all-active-except-author" {
|
|
return nil, nil, NewError(ExitUsage, "invalid_review_policy", "review_policy debe ser all-active o all-active-except-author", input.ReviewPolicy)
|
|
}
|
|
reviewers := []string{}
|
|
if reviewRequired {
|
|
reviewers = requiredReviewers(registry, input.ReviewPolicy, identity.Admin.ID)
|
|
}
|
|
now := a.Options.Now().UTC()
|
|
id, err := generateID("MEM", now)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
slug := slugify(id + "-" + input.Title)
|
|
body := renderMemoryBody(input)
|
|
front := strings.Builder{}
|
|
front.WriteString("+++\n")
|
|
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
|
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
|
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
|
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
|
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
|
front.WriteString("template = \"memory.html\"\n\n")
|
|
front.WriteString("[taxonomies]\n")
|
|
front.WriteString("categories = [\"memorias\"]\n")
|
|
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
|
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
|
front.WriteString("[extra]\n")
|
|
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
|
front.WriteString("kind = \"memory\"\n")
|
|
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
|
|
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
|
fmt.Fprintf(&front, "risk = %s\n", tomlString(input.Risk))
|
|
fmt.Fprintf(&front, "ticket = %s\n", tomlString(input.Ticket))
|
|
fmt.Fprintf(&front, "review_required = %t\n", reviewRequired)
|
|
fmt.Fprintf(&front, "review_policy = %s\n", tomlString(input.ReviewPolicy))
|
|
front.WriteString("reviewer_snapshot = true\n")
|
|
fmt.Fprintf(&front, "reviewers = %s\n", tomlStringArray(reviewers))
|
|
fmt.Fprintf(&front, "started_at = %s\n", tomlString(input.StartedAt))
|
|
fmt.Fprintf(&front, "finished_at = %s\n", tomlString(input.FinishedAt))
|
|
fmt.Fprintf(&front, "duration_minutes = %d\n", input.DurationMinutes)
|
|
fmt.Fprintf(&front, "agent = %s\n", tomlString(input.Agent))
|
|
front.WriteString("+++\n\n")
|
|
content := front.String() + body
|
|
if findings := scanSecrets(content); len(findings) > 0 {
|
|
return nil, nil, NewError(ExitValidation, "possible_secret", "la memoria parece contener secretos; redacte antes de registrarla", findings)
|
|
}
|
|
filename := now.Format("2006-01-02") + "-" + slug + ".md"
|
|
path := filepath.Join(root, "content", "memorias", filename)
|
|
if _, err := os.Stat(path); err == nil {
|
|
return nil, nil, NewError(ExitConflict, "file_exists", "ya existe el archivo de memoria", path)
|
|
}
|
|
if !a.Options.DryRun {
|
|
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
warnings := []string{}
|
|
if !a.Options.Commit {
|
|
warnings = append(warnings, "la atribución local usa la huella SSH, pero la prueba criptográfica exige un commit firmado; repita con --commit o firme el commit manualmente")
|
|
}
|
|
return map[string]any{
|
|
"entry_id": id, "path": filepath.ToSlash(path), "slug": slug,
|
|
"review_required": reviewRequired, "reviewers": reviewers, "content_sha256": bytesSHA256([]byte(content)),
|
|
"dry_run": a.Options.DryRun,
|
|
}, warnings, nil
|
|
}
|
|
|
|
func (a *App) createTask(root string, identity Identity, input TaskInput) (map[string]any, []string, error) {
|
|
input.Title = strings.TrimSpace(input.Title)
|
|
input.Description = firstNonEmpty(input.Description, input.Title)
|
|
input.Priority = strings.ToLower(firstNonEmpty(input.Priority, "medium"))
|
|
if input.Title == "" {
|
|
return nil, nil, NewError(ExitUsage, "missing_title", "el título de la tarea es obligatorio", nil)
|
|
}
|
|
if !map[string]bool{"low": true, "medium": true, "high": true, "urgent": true}[input.Priority] {
|
|
return nil, nil, NewError(ExitUsage, "invalid_priority", "priority debe ser low, medium, high o urgent", input.Priority)
|
|
}
|
|
if err := validateDate(input.Due); err != nil {
|
|
return nil, nil, NewError(ExitUsage, "invalid_due", err.Error(), nil)
|
|
}
|
|
if err := ensureProjectExists(root, input.ProjectID); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
registry, err := loadAdmins(root)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, assignee := range input.Assignees {
|
|
admin, ok := adminByID(registry, assignee)
|
|
if !ok || !admin.Active {
|
|
return nil, nil, NewError(ExitValidation, "invalid_assignee", "la tarea contiene un responsable inexistente o inactivo", assignee)
|
|
}
|
|
}
|
|
now := a.Options.Now().UTC()
|
|
id, err := generateID("TSK", now)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
slug := slugify(id + "-" + input.Title)
|
|
body := renderTaskBody(input)
|
|
var front strings.Builder
|
|
front.WriteString("+++\n")
|
|
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
|
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
|
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
|
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
|
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
|
front.WriteString("template = \"task.html\"\n\n")
|
|
front.WriteString("[taxonomies]\n")
|
|
front.WriteString("categories = [\"tareas\"]\n")
|
|
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
|
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
|
front.WriteString("[extra]\n")
|
|
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
|
front.WriteString("kind = \"task\"\nstatus = \"todo\"\n")
|
|
fmt.Fprintf(&front, "priority = %s\n", tomlString(input.Priority))
|
|
fmt.Fprintf(&front, "project_id = %s\n", tomlString(normalizeID(input.ProjectID)))
|
|
fmt.Fprintf(&front, "assignees = %s\n", tomlStringArray(uniqueSorted(input.Assignees)))
|
|
fmt.Fprintf(&front, "estimate_minutes = %d\n", input.EstimateMinutes)
|
|
fmt.Fprintf(&front, "due = %s\n", tomlString(input.Due))
|
|
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
|
|
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
|
front.WriteString("+++\n\n")
|
|
content := front.String() + body
|
|
if findings := scanSecrets(content); len(findings) > 0 {
|
|
return nil, nil, NewError(ExitValidation, "possible_secret", "la tarea parece contener secretos; redacte antes de registrarla", findings)
|
|
}
|
|
path := filepath.Join(root, "content", "tareas", now.Format("2006-01-02")+"-"+slug+".md")
|
|
if !a.Options.DryRun {
|
|
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
warnings := []string{}
|
|
if !a.Options.Commit {
|
|
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
|
|
}
|
|
return map[string]any{"task_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
|
|
}
|
|
|
|
func (a *App) createProject(root string, identity Identity, input ProjectInput) (map[string]any, []string, error) {
|
|
input.Title = strings.TrimSpace(input.Title)
|
|
input.Description = firstNonEmpty(input.Description, input.Objective, input.Title)
|
|
input.Status = strings.ToLower(firstNonEmpty(input.Status, "planned"))
|
|
if input.Title == "" || strings.TrimSpace(input.Objective) == "" {
|
|
return nil, nil, NewError(ExitUsage, "missing_project_fields", "title y objective son obligatorios", nil)
|
|
}
|
|
if !map[string]bool{"planned": true, "active": true, "blocked": true, "done": true, "cancelled": true, "archived": true}[input.Status] {
|
|
return nil, nil, NewError(ExitUsage, "invalid_project_status", "status debe ser planned, active, blocked, done, cancelled o archived", input.Status)
|
|
}
|
|
if err := validateDate(input.TargetDate); err != nil {
|
|
return nil, nil, NewError(ExitUsage, "invalid_target_date", err.Error(), nil)
|
|
}
|
|
registry, err := loadAdmins(root)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, owner := range input.Owners {
|
|
admin, ok := adminByID(registry, owner)
|
|
if !ok || !admin.Active {
|
|
return nil, nil, NewError(ExitValidation, "invalid_owner", "el proyecto contiene un responsable inexistente o inactivo", owner)
|
|
}
|
|
}
|
|
if len(input.Owners) == 0 {
|
|
input.Owners = []string{identity.Admin.ID}
|
|
}
|
|
now := a.Options.Now().UTC()
|
|
id, err := generateID("PRJ", now)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
slug := slugify(id + "-" + input.Title)
|
|
body := renderProjectBody(input)
|
|
var front strings.Builder
|
|
front.WriteString("+++\n")
|
|
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
|
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
|
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
|
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
|
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
|
front.WriteString("template = \"project.html\"\n\n")
|
|
front.WriteString("[taxonomies]\n")
|
|
front.WriteString("categories = [\"proyectos\"]\n")
|
|
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
|
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
|
front.WriteString("[extra]\n")
|
|
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
|
front.WriteString("kind = \"project\"\n")
|
|
fmt.Fprintf(&front, "status = %s\n", tomlString(input.Status))
|
|
fmt.Fprintf(&front, "owners = %s\n", tomlStringArray(uniqueSorted(input.Owners)))
|
|
fmt.Fprintf(&front, "target_date = %s\n", tomlString(input.TargetDate))
|
|
fmt.Fprintf(&front, "created_by = %s\n", tomlString(identity.Admin.ID))
|
|
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
|
front.WriteString("+++\n\n")
|
|
content := front.String() + body
|
|
if findings := scanSecrets(content); len(findings) > 0 {
|
|
return nil, nil, NewError(ExitValidation, "possible_secret", "el proyecto parece contener secretos; redacte antes de registrarlo", findings)
|
|
}
|
|
path := filepath.Join(root, "content", "proyectos", now.Format("2006-01-02")+"-"+slug+".md")
|
|
if !a.Options.DryRun {
|
|
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
warnings := []string{}
|
|
if !a.Options.Commit {
|
|
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
|
|
}
|
|
return map[string]any{"project_id": id, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
|
|
}
|
|
|
|
func (a *App) createContent(root string, identity Identity, input ContentInput) (map[string]any, []string, error) {
|
|
input.Section = strings.ToLower(strings.TrimSpace(input.Section))
|
|
if input.Section != "documentacion" && input.Section != "manuales" {
|
|
return nil, nil, NewError(ExitUsage, "invalid_section", "section debe ser documentacion o manuales", input.Section)
|
|
}
|
|
input.Title = strings.TrimSpace(input.Title)
|
|
input.Description = firstNonEmpty(input.Description, input.Title)
|
|
if input.Title == "" || strings.TrimSpace(input.BodyMarkdown) == "" {
|
|
return nil, nil, NewError(ExitUsage, "missing_content_fields", "title y body_markdown son obligatorios", nil)
|
|
}
|
|
now := a.Options.Now().UTC()
|
|
idPrefix := "DOC"
|
|
if input.Section == "manuales" {
|
|
idPrefix = "RUN"
|
|
}
|
|
id, err := generateID(idPrefix, now)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
slug := slugify(id + "-" + input.Title)
|
|
var front strings.Builder
|
|
front.WriteString("+++\n")
|
|
fmt.Fprintf(&front, "title = %s\n", tomlString(input.Title))
|
|
fmt.Fprintf(&front, "description = %s\n", tomlString(input.Description))
|
|
fmt.Fprintf(&front, "date = %s\n", now.Format(time.RFC3339))
|
|
fmt.Fprintf(&front, "authors = %s\n", tomlStringArray([]string{identity.Admin.ID}))
|
|
fmt.Fprintf(&front, "slug = %s\n", tomlString(slug))
|
|
front.WriteString("template = \"page.html\"\n")
|
|
if input.Weight != 0 {
|
|
fmt.Fprintf(&front, "weight = %d\n", input.Weight)
|
|
}
|
|
front.WriteString("\n[taxonomies]\n")
|
|
fmt.Fprintf(&front, "categories = %s\n", tomlStringArray([]string{input.Section}))
|
|
fmt.Fprintf(&front, "tags = %s\n", tomlStringArray(uniqueSorted(input.Tags)))
|
|
fmt.Fprintf(&front, "systems = %s\n\n", tomlStringArray(uniqueSorted(input.Systems)))
|
|
front.WriteString("[extra]\n")
|
|
fmt.Fprintf(&front, "id = %s\n", tomlString(id))
|
|
fmt.Fprintf(&front, "kind = %s\n", tomlString(input.Section))
|
|
fmt.Fprintf(&front, "author_id = %s\n", tomlString(identity.Admin.ID))
|
|
fmt.Fprintf(&front, "author_fingerprint = %s\n", tomlString(identity.Fingerprint))
|
|
front.WriteString("+++\n\n")
|
|
content := front.String() + normalizeMarkdownBody(input.BodyMarkdown)
|
|
if findings := scanSecrets(content); len(findings) > 0 {
|
|
return nil, nil, NewError(ExitValidation, "possible_secret", "el contenido parece incluir secretos; redacte antes de registrarlo", findings)
|
|
}
|
|
path := filepath.Join(root, "content", input.Section, now.Format("2006-01-02")+"-"+slug+".md")
|
|
if !a.Options.DryRun {
|
|
if err := writeFileAtomic(path, []byte(content), 0o644); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
warnings := []string{}
|
|
if !a.Options.Commit {
|
|
warnings = append(warnings, "use --commit para dejar la operación en un commit SSH firmado")
|
|
}
|
|
return map[string]any{"content_id": id, "section": input.Section, "path": filepath.ToSlash(path), "slug": slug, "dry_run": a.Options.DryRun}, warnings, nil
|
|
}
|