Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>", "write it here"],
],
flags: [
["--into <dir>", "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"],
},
Expand Down
7 changes: 7 additions & 0 deletions src/completion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
12 changes: 11 additions & 1 deletion src/templates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -275,14 +275,15 @@ 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 <name> copy a bundled one into this directory
moshcode template install <url> copy one from a git repo or a .tar.gz
moshcode template install <owner/repo> the GitHub shorthand

--into <dir> 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.`;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions test/completion-powershell.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
2 changes: 2 additions & 0 deletions test/completion.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""]), []);
Expand Down
2 changes: 1 addition & 1 deletion test/help.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
23 changes: 23 additions & 0 deletions test/templates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
Loading