package clusterlog import ( "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "time" ) const Version = "0.1.0" const ( ExitOK = 0 ExitUsage = 2 ExitIdentity = 3 ExitValidation = 4 ExitConflict = 5 ExitGit = 6 ExitInternal = 10 ) type CLIError struct { Code int Kind string Message string Details any } func (e *CLIError) Error() string { return e.Message } func NewError(code int, kind, message string, details any) *CLIError { return &CLIError{Code: code, Kind: kind, Message: message, Details: details} } type Options struct { Root string JSON bool SSHKey string Commit bool DryRun bool Stdout io.Writer Stderr io.Writer Stdin io.Reader Now func() time.Time Executable string } type App struct { Options Options } func New(options Options) *App { if options.Stdout == nil { options.Stdout = os.Stdout } if options.Stderr == nil { options.Stderr = os.Stderr } if options.Stdin == nil { options.Stdin = os.Stdin } if options.Now == nil { options.Now = time.Now } if options.Executable == "" { options.Executable = "clusterlog" } return &App{Options: options} } type ReviewPolicy struct { Default string `json:"default"` IncludeAuthor bool `json:"include_author"` } type SSHKey struct { Fingerprint string `json:"fingerprint"` PublicKey string `json:"public_key"` AddedAt string `json:"added_at,omitempty"` } type Admin struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Active bool `json:"active"` Roles []string `json:"roles,omitempty"` SSHKeys []SSHKey `json:"ssh_keys"` } type AdminRegistry struct { Version int `json:"version"` ReviewPolicy ReviewPolicy `json:"review_policy"` Admins []Admin `json:"admins"` } type Identity struct { Admin Admin `json:"admin"` Fingerprint string `json:"fingerprint"` PublicKey string `json:"public_key"` PublicPath string `json:"public_key_path"` SigningKey string `json:"signing_key_path"` } type ReviewEvent struct { ID string `json:"id"` EntryID string `json:"entry_id"` AdminID string `json:"admin_id"` Fingerprint string `json:"fingerprint"` ReviewedAt string `json:"reviewed_at"` ContentSHA256 string `json:"content_sha256"` Note string `json:"note,omitempty"` } type ReviewLog struct { Version int `json:"version"` Events []ReviewEvent `json:"events"` } type TaskEvent struct { ID string `json:"id"` TaskID string `json:"task_id"` Type string `json:"type"` AdminID string `json:"admin_id"` Fingerprint string `json:"fingerprint"` At string `json:"at"` Status string `json:"status,omitempty"` Minutes int `json:"minutes,omitempty"` Note string `json:"note,omitempty"` } type TaskLog struct { Version int `json:"version"` Events []TaskEvent `json:"events"` } type PageMeta struct { Path string Body string Root map[string]any Taxonomies map[string][]string Extra map[string]any RawFront string ContentHash string } func (p PageMeta) String(key string) string { if v, ok := p.Root[key]; ok { return fmt.Sprint(v) } return "" } func (p PageMeta) ExtraString(key string) string { if v, ok := p.Extra[key]; ok { return fmt.Sprint(v) } return "" } func (p PageMeta) ExtraBool(key string) bool { v, ok := p.Extra[key] if !ok { return false } b, _ := v.(bool) return b } func (p PageMeta) ExtraInt(key string) int { v, ok := p.Extra[key] if !ok { return 0 } switch n := v.(type) { case int: return n case int64: return int(n) case float64: return int(n) default: return 0 } } func (p PageMeta) ExtraStrings(key string) []string { v, ok := p.Extra[key] if !ok { return nil } switch values := v.(type) { case []string: return values case []any: out := make([]string, 0, len(values)) for _, item := range values { out = append(out, fmt.Sprint(item)) } return out default: return nil } } type MemoryRecord struct { EntryID string `json:"entry_id"` Title string `json:"title"` Description string `json:"description"` Date string `json:"date"` AuthorID string `json:"author_id"` AuthorFingerprint string `json:"author_fingerprint"` Risk string `json:"risk"` Ticket string `json:"ticket,omitempty"` ReviewRequired bool `json:"review_required"` ReviewPolicy string `json:"review_policy"` ReviewerSnapshot bool `json:"reviewer_snapshot"` Reviewers []string `json:"reviewers"` Tags []string `json:"tags"` Systems []string `json:"systems"` Slug string `json:"slug"` Permalink string `json:"permalink"` SourcePath string `json:"source_path"` ContentSHA256 string `json:"content_sha256"` } type ReviewQueueItem struct { EntryID string `json:"entry_id"` Title string `json:"title"` Description string `json:"description"` Date string `json:"date"` AuthorID string `json:"author_id"` Risk string `json:"risk"` Tags []string `json:"tags"` Systems []string `json:"systems"` Permalink string `json:"permalink"` ContentSHA256 string `json:"content_sha256"` ReviewedBy []string `json:"reviewed_by"` MissingReviewers []string `json:"missing_reviewers"` RequiredCount int `json:"required_count"` ReviewedCount int `json:"reviewed_count"` ConfigurationError string `json:"configuration_error,omitempty"` } type TaskState struct { TaskID string `json:"task_id"` Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` Priority string `json:"priority"` ProjectID string `json:"project_id,omitempty"` Assignees []string `json:"assignees"` EstimateMinutes int `json:"estimate_minutes"` ActualMinutes int `json:"actual_minutes"` Due string `json:"due,omitempty"` RunningBy []string `json:"running_by"` Slug string `json:"slug"` Permalink string `json:"permalink"` SourcePath string `json:"source_path"` } type ProjectState struct { ProjectID string `json:"project_id"` Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` Owners []string `json:"owners"` TargetDate string `json:"target_date,omitempty"` Slug string `json:"slug"` Permalink string `json:"permalink"` SourcePath string `json:"source_path"` } type SiteStats struct { ActiveAdmins int `json:"active_admins"` PendingReviews int `json:"pending_reviews"` OpenTasks int `json:"open_tasks"` ActiveProjects int `json:"active_projects"` } type SiteState struct { Version int `json:"version"` GeneratedAt string `json:"generated_at"` SourceSHA256 string `json:"source_sha256"` Stats SiteStats `json:"stats"` ReviewQueue []ReviewQueueItem `json:"review_queue"` RecentMemories []MemoryRecord `json:"recent_memories"` Tasks []TaskState `json:"tasks"` Projects []ProjectState `json:"projects"` } type ResultEnvelope struct { OK bool `json:"ok"` Command string `json:"command,omitempty"` Data any `json:"data,omitempty"` Error any `json:"error,omitempty"` Warnings []string `json:"warnings,omitempty"` } func (a *App) printResult(command string, data any, warnings ...string) error { if a.Options.JSON { enc := json.NewEncoder(a.Options.Stdout) enc.SetIndent("", " ") return enc.Encode(ResultEnvelope{OK: true, Command: command, Data: data, Warnings: warnings}) } switch v := data.(type) { case string: fmt.Fprintln(a.Options.Stdout, v) default: bytes, err := json.MarshalIndent(v, "", " ") if err != nil { return err } fmt.Fprintln(a.Options.Stdout, string(bytes)) } for _, warning := range warnings { fmt.Fprintf(a.Options.Stderr, "ADVERTENCIA: %s\n", warning) } return nil } func PrintError(w io.Writer, jsonMode bool, err error) int { var cliErr *CLIError if !errors.As(err, &cliErr) { cliErr = NewError(ExitInternal, "internal", err.Error(), nil) } if jsonMode { payload := ResultEnvelope{OK: false, Error: map[string]any{ "kind": cliErr.Kind, "message": cliErr.Message, "details": cliErr.Details, "exit_code": cliErr.Code, }} enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(payload) } else { fmt.Fprintf(w, "ERROR [%s]: %s\n", cliErr.Kind, cliErr.Message) if cliErr.Details != nil { bytes, _ := json.MarshalIndent(cliErr.Details, "", " ") fmt.Fprintln(w, string(bytes)) } } return cliErr.Code } func (a *App) ResolveRoot() (string, error) { if a.Options.Root != "" { root, err := filepath.Abs(a.Options.Root) if err != nil { return "", err } if _, err := os.Stat(filepath.Join(root, "zola.toml")); err != nil { return "", NewError(ExitUsage, "root_not_found", "el directorio raíz no contiene zola.toml", root) } return root, nil } cwd, err := os.Getwd() if err != nil { return "", err } current := cwd for { if _, err := os.Stat(filepath.Join(current, "zola.toml")); err == nil { return current, nil } parent := filepath.Dir(current) if parent == current { break } current = parent } return "", NewError(ExitUsage, "root_not_found", "no se encontró zola.toml en el directorio actual ni en sus padres", cwd) }