diff --git a/README.md b/README.md index aaba3cb..e1cc1a6 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ something to put behind it: ```sh moshcode template list # what there is +moshcode template list --json # machine-readable template metadata moshcode template install bun-caddy-sqlite # into the current directory moshcode template install caddy-static --into /srv/site moshcode template install owner/repo # or a git URL, or a .tar.gz diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index a46782e..f97255a 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -229,9 +229,15 @@ export const CORE_CLI_COMMANDS = [ group: "hosting", description: "scaffold a stack for a Moshpit-hosted service", synopsis: [ - ["moshcode template list", "what there is"], + ["moshcode template list [--json]", "what there is"], ["moshcode template install ", "write it here"], ], + flags: [ + ["--into ", "write somewhere other than the current directory", ""], + ["--force", "overwrite files that are already there", ""], + ["--dry-run", "show changes without writing anything", ""], + ["--json", "machine-readable template list", ""], + ], examples: [["moshcode template install bun-caddy-sqlite", ""]], seeAlso: ["site"], }, diff --git a/src/completion.mjs b/src/completion.mjs index b9d8ca0..953b7a7 100644 --- a/src/completion.mjs +++ b/src/completion.mjs @@ -205,6 +205,8 @@ Register-ArgumentCompleter -Native -CommandName moshcode -ScriptBlock { { $_ -in @('template', 'templates') } { if ($argumentIndex -eq 2) { $choices = $script:MoshcodeCompletionTemplate + } elseif ($nested -eq 'list' -and $wordToComplete.StartsWith('-')) { + $choices = $script:MoshcodeCompletionJson } elseif ($nested -eq 'install' -and $wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionTemplateInstall } @@ -306,6 +308,8 @@ _moshcode_completion() { template|templates) if (( COMP_CWORD == 2 )); then choices="list install" + elif [[ "$nested" == "list" && "$cur" == -* ]]; then + choices="--json" elif [[ "$nested" == "install" && "$cur" == -* ]]; then choices="--into --force --dry-run" fi @@ -432,6 +436,8 @@ _moshcode() { template|templates) if (( CURRENT == 3 )); then _values "template command" list install + elif [[ "\${words[3]}" == "list" && "$PREFIX" == -* ]]; then + _values "template list option" --json elif [[ "\${words[3]}" == "install" && "$PREFIX" == -* ]]; then _values "template install option" --into --force --dry-run else @@ -504,6 +510,7 @@ complete -c moshcode -n '__moshcode_nested_is dns resolve' -l json -d 'print JSO complete -c moshcode -n '__moshcode_nested_is dns resolve' -l open -d 'open a parked name in the Pit' complete -c moshcode -n '__moshcode_nested_is dns resolve' -l registry -r -d 'registry base URL' complete -c moshcode -n '${atSecondToken("template templates")}' -a 'list install' -d 'template command' +complete -c moshcode -n '__moshcode_nested_is template list; or __moshcode_nested_is templates list' -l json -d 'print JSON' complete -c moshcode -n '__moshcode_nested_is template install; or __moshcode_nested_is templates install' -l into -r -d 'target directory' complete -c moshcode -n '__moshcode_nested_is template install; or __moshcode_nested_is templates install' -l force -d 'overwrite existing files' complete -c moshcode -n '__moshcode_nested_is template install; or __moshcode_nested_is templates install' -l dry-run -d 'preview without writing' diff --git a/src/templates.mjs b/src/templates.mjs index 1ca8ed7..9962bbe 100644 --- a/src/templates.mjs +++ b/src/templates.mjs @@ -275,7 +275,7 @@ export async function fetchRemote( const USAGE = `moshcode template — starting stacks for Moshpit-hosted services - moshcode template list the templates that ship with moshcode + moshcode template list [--json] the templates that ship with moshcode moshcode template install copy a bundled one into this directory moshcode template install copy one from a git repo or a .tar.gz moshcode template install the GitHub shorthand @@ -283,6 +283,7 @@ const USAGE = `moshcode template — starting stacks for Moshpit-hosted services --into write somewhere other than the current directory --force overwrite files that are already there --dry-run show every create/overwrite without writing anything + --json print the template list as JSON Nothing in a template is executed on install — the files are copied and what to run is yours to decide. Read them before you do.`; @@ -339,7 +340,16 @@ export async function templateCommand( } if (sub === "list") { + const unknown = rest.filter((arg) => arg !== "--json"); + if (unknown.length) { + out(`moshcode template list: unknown option ${JSON.stringify(unknown[0])}`); + return 1; + } const templates = await listTemplates(); + if (rest.includes("--json")) { + out(JSON.stringify(templates, null, 2)); + return 0; + } if (!templates.length) { out("no templates bundled with this install"); return 0; diff --git a/test/completion-powershell.test.mjs b/test/completion-powershell.test.mjs index 8362c84..78bf3ad 100644 --- a/test/completion-powershell.test.mjs +++ b/test/completion-powershell.test.mjs @@ -59,12 +59,14 @@ test("PowerShell completes commands, install targets, and nested options", (t) = const installs = expand(executable, "moshcode install cl"); const options = expand(executable, "moshcode mcp list --j"); const templateCommands = expand(executable, "moshcode template ins"); + const templateListOptions = expand(executable, "moshcode template list --j"); const templateOptions = expand(executable, "moshcode template install --d"); const templateAliasOptions = expand(executable, "moshcode templates install --i"); assert.ok(commands.includes("engines"), JSON.stringify(commands)); assert.ok(installs.includes("claude"), JSON.stringify(installs)); assert.ok(options.includes("--json"), JSON.stringify(options)); assert.ok(templateCommands.includes("install"), JSON.stringify(templateCommands)); + assert.ok(templateListOptions.includes("--json"), JSON.stringify(templateListOptions)); assert.ok(templateOptions.includes("--dry-run"), JSON.stringify(templateOptions)); assert.ok(templateAliasOptions.includes("--into"), JSON.stringify(templateAliasOptions)); }); diff --git a/test/completion.test.mjs b/test/completion.test.mjs index 07ed033..a073a4c 100644 --- a/test/completion.test.mjs +++ b/test/completion.test.mjs @@ -132,6 +132,8 @@ test("bash completion respects argument depth and preserves file fallbacks", () assert.ok(bashCompletions(["moshcode", "dns", ""]).includes("resolve")); assert.ok(bashCompletions(["moshcode", "dns", "resolve", "--j"]).includes("--json")); assert.ok(bashCompletions(["moshcode", "template", ""]).includes("install")); + assert.ok(bashCompletions(["moshcode", "template", "list", "--j"]).includes("--json")); + assert.ok(bashCompletions(["moshcode", "templates", "list", "--j"]).includes("--json")); assert.ok(bashCompletions(["moshcode", "template", "install", "--"]).includes("--dry-run")); assert.deepEqual(bashCompletions(["moshcode", "run", ""]), []); diff --git a/test/help.test.mjs b/test/help.test.mjs index 8d62f02..7530313 100644 --- a/test/help.test.mjs +++ b/test/help.test.mjs @@ -246,7 +246,7 @@ test("flags that the dispatcher parses are documented", () => { for (const flag of verb.flags) for (const f of flag.flags.split(/[ ,]+/)) documented.add(f.replace(/[<>].*$/, "").trim()); } } - for (const flag of ["--json", "--max", "-n", "--dry-run", "--yes", "-y", "--device", "-d", + for (const flag of ["--json", "--max", "-n", "--dry-run", "--into", "--force", "--yes", "-y", "--device", "-d", "--browser", "-b", "--port", "--bind", "--ttyd", "--url", "--name", "-t", "-e", "-H", "--registry", "--check", "--nginx", "--all"]) { assert.ok(documented.has(flag), `${flag} is parsed somewhere but documented nowhere`); diff --git a/test/templates.test.mjs b/test/templates.test.mjs index b474d3c..169ec42 100644 --- a/test/templates.test.mjs +++ b/test/templates.test.mjs @@ -39,6 +39,29 @@ test("the bundled templates are listed with what they are for", async () => { } }); +test("template list --json prints the stable template metadata", async () => { + const lines = []; + const code = await templateCommand(["list", "--json"], (line) => lines.push(line)); + const templates = JSON.parse(lines.join("\n")); + + assert.equal(code, 0); + assert.ok(Array.isArray(templates)); + assert.deepEqual(templates.map((template) => template.name), ["bun-caddy-sqlite", "caddy-static"]); + for (const template of templates) { + assert.deepEqual(Object.keys(template), ["name", "description"]); + assert.equal(typeof template.description, "string"); + assert.ok(template.description.length > 0, `${template.name} has no description`); + } +}); + +test("template list rejects options it does not understand", async () => { + const lines = []; + const code = await templateCommand(["list", "--jsn"], (line) => lines.push(line)); + + assert.equal(code, 1); + assert.deepEqual(lines, ['moshcode template list: unknown option "--jsn"']); +}); + test("an archive member that escapes the target is refused", () => { // Each of these writes outside the directory the user chose. for (const evil of [