|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "os" |
| 6 | + "path/filepath" |
| 7 | + |
| 8 | + "github.com/jeduden/mdsmith/internal/archetypes" |
| 9 | + "github.com/jeduden/mdsmith/internal/config" |
| 10 | +) |
| 11 | + |
| 12 | +const archetypesUsage = `Usage: mdsmith archetypes <subcommand> [args] |
| 13 | +
|
| 14 | +Subcommands: |
| 15 | + init [dir] Scaffold an archetype directory with an example. |
| 16 | + Defaults to ./archetypes. Safe to re-run: existing |
| 17 | + files are preserved. |
| 18 | +
|
| 19 | + list Print every archetype discovered under the |
| 20 | + configured roots, one per line as |
| 21 | + "<name>\t<path>". |
| 22 | +
|
| 23 | + show <name> Print the raw schema source of the named archetype |
| 24 | + to stdout. |
| 25 | +
|
| 26 | + path <name> Print the resolved filesystem path of the named |
| 27 | + archetype. |
| 28 | +
|
| 29 | +Archetype roots are configured under 'archetypes.roots' in |
| 30 | +.mdsmith.yml. When unset, the single root './archetypes' is used. |
| 31 | +` |
| 32 | + |
| 33 | +// runArchetypes dispatches the archetypes subcommand. |
| 34 | +func runArchetypes(args []string) int { |
| 35 | + if len(args) == 0 { |
| 36 | + fmt.Fprint(os.Stderr, archetypesUsage) |
| 37 | + return 0 |
| 38 | + } |
| 39 | + |
| 40 | + switch args[0] { |
| 41 | + case "--help", "-h": |
| 42 | + fmt.Fprint(os.Stderr, archetypesUsage) |
| 43 | + return 0 |
| 44 | + case "init": |
| 45 | + return runArchetypesInit(args[1:]) |
| 46 | + case "list": |
| 47 | + return runArchetypesList(args[1:]) |
| 48 | + case "show": |
| 49 | + return runArchetypesShow(args[1:]) |
| 50 | + case "path": |
| 51 | + return runArchetypesPath(args[1:]) |
| 52 | + default: |
| 53 | + fmt.Fprintf(os.Stderr, |
| 54 | + "mdsmith: archetypes: unknown subcommand %q\n\n%s", |
| 55 | + args[0], archetypesUsage) |
| 56 | + return 2 |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +// archetypesResolver builds a resolver constrained to the project |
| 61 | +// root. When cfg carries archetypes.roots it validates that each |
| 62 | +// entry is relative and does not escape the project root; when a |
| 63 | +// rootDir is known it sets FS = os.DirFS(rootDir) so reads cannot |
| 64 | +// reach outside it, matching required-structure's RootFS-backed |
| 65 | +// schema resolution. |
| 66 | +func archetypesResolver(configPath string) (*archetypes.Resolver, *config.Config, string, error) { |
| 67 | + cfg, cfgPath, err := loadConfig(configPath) |
| 68 | + if err != nil { |
| 69 | + return nil, nil, "", err |
| 70 | + } |
| 71 | + rootDir := rootDirFromConfig(cfgPath) |
| 72 | + roots := cfg.Archetypes.Roots |
| 73 | + if err := archetypes.ValidateRoots(roots); err != nil { |
| 74 | + return nil, nil, "", err |
| 75 | + } |
| 76 | + resolver := &archetypes.Resolver{ |
| 77 | + Roots: roots, |
| 78 | + RootDir: rootDir, |
| 79 | + } |
| 80 | + if rootDir != "" { |
| 81 | + resolver.FS = os.DirFS(rootDir) |
| 82 | + } |
| 83 | + return resolver, cfg, rootDir, nil |
| 84 | +} |
| 85 | + |
| 86 | +const archetypesInitExample = `--- |
| 87 | +title: 'string & != ""' |
| 88 | +"status?": '"draft" | "review" | "approved"' |
| 89 | +--- |
| 90 | +# ? |
| 91 | +
|
| 92 | +## Overview |
| 93 | +
|
| 94 | +## Details |
| 95 | +
|
| 96 | +## ... |
| 97 | +` |
| 98 | + |
| 99 | +const archetypesInitReadme = `# Archetypes |
| 100 | +
|
| 101 | +This directory holds required-structure schemas for common |
| 102 | +document types used in this repository. Each ` + "`<name>.md`" + ` |
| 103 | +file is an archetype referenced by name via: |
| 104 | +
|
| 105 | +` + "```yaml" + ` |
| 106 | +rules: |
| 107 | + required-structure: |
| 108 | + archetype: <name> |
| 109 | +` + "```" + ` |
| 110 | +
|
| 111 | +Use ` + "`mdsmith archetypes list`" + ` to see what is available and |
| 112 | +` + "`mdsmith archetypes show <name>`" + ` to print one. |
| 113 | +
|
| 114 | +## Reserved filenames |
| 115 | +
|
| 116 | +These files are **not** treated as archetypes by discovery, so |
| 117 | +they are safe to keep in this directory as metadata: |
| 118 | +
|
| 119 | +- ` + "`README.md`" + `, ` + "`LICENSE.md`" + `, |
| 120 | + ` + "`CONTRIBUTING.md`" + `, ` + "`CODEOWNERS.md`" + ` |
| 121 | + (case-insensitive) |
| 122 | +- Any filename beginning with ` + "`_`" + ` or ` + "`.`" + ` |
| 123 | +` |
| 124 | + |
| 125 | +// runArchetypesInit scaffolds an archetype directory with an example |
| 126 | +// schema and a README. Does not mutate the user's .mdsmith.yml. |
| 127 | +// The dir must be a valid archetype root (relative, non-traversing) |
| 128 | +// so the printed config snippet is immediately registrable. |
| 129 | +func runArchetypesInit(args []string) int { |
| 130 | + dir := "archetypes" |
| 131 | + switch len(args) { |
| 132 | + case 0: |
| 133 | + case 1: |
| 134 | + if args[0] == "--help" || args[0] == "-h" { |
| 135 | + fmt.Fprintln(os.Stderr, "Usage: mdsmith archetypes init [dir]") |
| 136 | + return 0 |
| 137 | + } |
| 138 | + dir = args[0] |
| 139 | + default: |
| 140 | + fmt.Fprintln(os.Stderr, "mdsmith: archetypes init takes at most one argument") |
| 141 | + return 2 |
| 142 | + } |
| 143 | + |
| 144 | + if err := archetypes.ValidateRoot(dir); err != nil { |
| 145 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 146 | + return 2 |
| 147 | + } |
| 148 | + |
| 149 | + if err := os.MkdirAll(dir, 0o755); err != nil { |
| 150 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 151 | + return 2 |
| 152 | + } |
| 153 | + |
| 154 | + examplePath := filepath.Join(dir, "example.md") |
| 155 | + readmePath := filepath.Join(dir, "README.md") |
| 156 | + |
| 157 | + wroteExample, err := writeIfAbsent(examplePath, archetypesInitExample) |
| 158 | + if err != nil { |
| 159 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 160 | + return 2 |
| 161 | + } |
| 162 | + wroteReadme, err := writeIfAbsent(readmePath, archetypesInitReadme) |
| 163 | + if err != nil { |
| 164 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 165 | + return 2 |
| 166 | + } |
| 167 | + |
| 168 | + fmt.Fprintf(os.Stderr, "mdsmith: archetypes directory ready at %s\n", dir) |
| 169 | + if wroteExample { |
| 170 | + fmt.Fprintf(os.Stderr, " created %s\n", examplePath) |
| 171 | + } else { |
| 172 | + fmt.Fprintf(os.Stderr, " kept %s\n", examplePath) |
| 173 | + } |
| 174 | + if wroteReadme { |
| 175 | + fmt.Fprintf(os.Stderr, " created %s\n", readmePath) |
| 176 | + } else { |
| 177 | + fmt.Fprintf(os.Stderr, " kept %s\n", readmePath) |
| 178 | + } |
| 179 | + fmt.Fprintln(os.Stderr, "\nAdd this to .mdsmith.yml to register the directory:") |
| 180 | + fmt.Fprintf(os.Stderr, "\narchetypes:\n roots:\n - %s\n", dir) |
| 181 | + return 0 |
| 182 | +} |
| 183 | + |
| 184 | +// writeIfAbsent writes content to path only when path does not exist. |
| 185 | +// Returns true when the file was created. |
| 186 | +func writeIfAbsent(path, content string) (bool, error) { |
| 187 | + if _, err := os.Stat(path); err == nil { |
| 188 | + return false, nil |
| 189 | + } else if !os.IsNotExist(err) { |
| 190 | + return false, err |
| 191 | + } |
| 192 | + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| 193 | + return false, err |
| 194 | + } |
| 195 | + return true, nil |
| 196 | +} |
| 197 | + |
| 198 | +// runArchetypesList prints each archetype discovered under the |
| 199 | +// configured roots. |
| 200 | +func runArchetypesList(args []string) int { |
| 201 | + if len(args) > 0 && (args[0] == "--help" || args[0] == "-h") { |
| 202 | + fmt.Fprintln(os.Stderr, "Usage: mdsmith archetypes list") |
| 203 | + return 0 |
| 204 | + } |
| 205 | + if len(args) > 0 { |
| 206 | + fmt.Fprintln(os.Stderr, "mdsmith: archetypes list takes no arguments") |
| 207 | + return 2 |
| 208 | + } |
| 209 | + resolver, _, _, err := archetypesResolver("") |
| 210 | + if err != nil { |
| 211 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 212 | + return 2 |
| 213 | + } |
| 214 | + entries, listErrs := resolver.ListWithErrors() |
| 215 | + for _, err := range listErrs { |
| 216 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 217 | + } |
| 218 | + if len(entries) == 0 { |
| 219 | + if len(listErrs) > 0 { |
| 220 | + return 2 |
| 221 | + } |
| 222 | + fmt.Fprintf(os.Stderr, |
| 223 | + "mdsmith: no archetypes found under roots %v\n", |
| 224 | + resolver.EffectiveRoots()) |
| 225 | + return 1 |
| 226 | + } |
| 227 | + for _, e := range entries { |
| 228 | + path := e.Path |
| 229 | + if resolver.RootDir != "" { |
| 230 | + path = filepath.Join(resolver.RootDir, e.Path) |
| 231 | + } |
| 232 | + if _, err := fmt.Fprintf(os.Stdout, "%s\t%s\n", e.Name, path); err != nil { |
| 233 | + return 2 |
| 234 | + } |
| 235 | + } |
| 236 | + return 0 |
| 237 | +} |
| 238 | + |
| 239 | +// runArchetypesShow prints the raw archetype schema source. |
| 240 | +func runArchetypesShow(args []string) int { |
| 241 | + name, code := singleNameArg("show", args) |
| 242 | + if code >= 0 { |
| 243 | + return code |
| 244 | + } |
| 245 | + resolver, _, _, err := archetypesResolver("") |
| 246 | + if err != nil { |
| 247 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 248 | + return 2 |
| 249 | + } |
| 250 | + body, err := resolver.Content(name) |
| 251 | + if err != nil { |
| 252 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 253 | + return 2 |
| 254 | + } |
| 255 | + if _, err := os.Stdout.Write(body); err != nil { |
| 256 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 257 | + return 2 |
| 258 | + } |
| 259 | + return 0 |
| 260 | +} |
| 261 | + |
| 262 | +// runArchetypesPath prints the resolved filesystem path. |
| 263 | +func runArchetypesPath(args []string) int { |
| 264 | + name, code := singleNameArg("path", args) |
| 265 | + if code >= 0 { |
| 266 | + return code |
| 267 | + } |
| 268 | + resolver, _, _, err := archetypesResolver("") |
| 269 | + if err != nil { |
| 270 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 271 | + return 2 |
| 272 | + } |
| 273 | + p, err := resolver.AbsPath(name) |
| 274 | + if err != nil { |
| 275 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 276 | + return 2 |
| 277 | + } |
| 278 | + if _, err := fmt.Fprintln(os.Stdout, p); err != nil { |
| 279 | + fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err) |
| 280 | + return 2 |
| 281 | + } |
| 282 | + return 0 |
| 283 | +} |
| 284 | + |
| 285 | +// singleNameArg extracts a single positional name from args. Returns |
| 286 | +// a negative exit code on success, otherwise the exit code to return. |
| 287 | +func singleNameArg(verb string, args []string) (string, int) { |
| 288 | + if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { |
| 289 | + fmt.Fprintf(os.Stderr, "Usage: mdsmith archetypes %s <name>\n", verb) |
| 290 | + return "", 0 |
| 291 | + } |
| 292 | + if len(args) != 1 { |
| 293 | + fmt.Fprintf(os.Stderr, |
| 294 | + "mdsmith: archetypes %s requires exactly one archetype name\n", verb) |
| 295 | + return "", 2 |
| 296 | + } |
| 297 | + return args[0], -1 |
| 298 | +} |
0 commit comments