From 5f2906908cc7fb6892ee39001df8c97b47638088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 05:06:47 +0000 Subject: [PATCH] feat: implement template CLI subcommands (set/get/list/delete) Implement ucas template subcommands for managing template storage: - template set | --inline : Store template text in CAS - template get : Retrieve template as raw text - template list: List all templates with preview - template delete : Delete template variable binding Templates are stored as plain text under @string schema and bound to variables using the naming pattern @ucas/template/text/. Fixes #38 Co-Authored-By: Claude Opus 4.6 --- packages/cli-json-cas/src/cli.test.ts | 18 +- packages/cli-json-cas/src/index.ts | 197 ++++++- packages/cli-json-cas/src/template.test.ts | 648 +++++++++++++++++++++ 3 files changed, 859 insertions(+), 4 deletions(-) create mode 100644 packages/cli-json-cas/src/template.test.ts diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index ca1a021..eff26ef 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -214,7 +214,11 @@ describe("@ Alias Resolution - put", () => { const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, "42"); - const { stdout, exitCode } = await runCliAlias("put", "@number", payloadFile); + const { stdout, exitCode } = await runCliAlias( + "put", + "@number", + payloadFile, + ); expect(exitCode).toBe(0); expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); @@ -226,7 +230,11 @@ describe("@ Alias Resolution - put", () => { const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, JSON.stringify({ foo: "bar" })); - const { stdout, exitCode } = await runCliAlias("put", "@object", payloadFile); + const { stdout, exitCode } = await runCliAlias( + "put", + "@object", + payloadFile, + ); expect(exitCode).toBe(0); expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); @@ -238,7 +246,11 @@ describe("@ Alias Resolution - put", () => { const payloadFile = join(testDir, "payload.json"); writeFileSync(payloadFile, "{}"); - const { stderr, exitCode } = await runCliAlias("put", "@invalid", payloadFile); + const { stderr, exitCode } = await runCliAlias( + "put", + "@invalid", + payloadFile, + ); expect(exitCode).not.toBe(0); expect(stderr.length).toBeGreaterThan(0); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 86e432a..f283f1b 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { mkdirSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import type { Hash, JSONSchema, Store, VariableStore } from "@uncaged/json-cas"; @@ -38,6 +38,7 @@ const VALUE_FLAGS = new Set([ "resolution", "decay", "epsilon", + "inline", ]); function parseArgs(argv: string[]): { flags: Flags; positional: string[] } { @@ -629,6 +630,174 @@ async function cmdVarList(args: string[]): Promise { } } +async function cmdTemplateSet(args: string[]): Promise { + const schemaHash = args[0]; + const inlineFlag = flags.inline; + + if (!schemaHash) { + die("Usage: json-cas template set | --inline "); + } + + const store = openStore(); + mkdirSync(resolve(storePath), { recursive: true }); + const varStore = createVariableStore(resolve(varDbPath), store); + + try { + // Validate schema hash exists in CAS + if (!store.has(schemaHash)) { + die(`Error: Schema hash not found in CAS: ${schemaHash}`); + } + + // Determine content source + let content: string; + + if (typeof inlineFlag === "string") { + // --inline mode + const fileArg = args[1]; + if (fileArg !== undefined && !fileArg.startsWith("--")) { + die("Error: Cannot specify both file and --inline"); + } + content = inlineFlag; + } else if (inlineFlag === true) { + // --inline flag present but no value + const contentArg = args[1]; + if (!contentArg) { + die( + "Usage: json-cas template set | --inline ", + ); + } + content = contentArg; + } else { + // File mode + const file = args[1]; + if (!file) { + die( + "Usage: json-cas template set | --inline ", + ); + } + if (!existsSync(file)) { + die(`Error: File not found: ${file}`); + } + content = readFileSync(file, "utf-8"); + } + + // Store content in CAS under @string schema + const stringHash = await resolveTypeHash("@string"); + const contentHash = await store.put(stringHash, content); + + // Create variable binding: @ucas/template/text/ + const varName = `@ucas/template/text/${schemaHash}`; + varStore.set(varName, contentHash); + + out({ + schemaHash, + contentHash, + }); + } catch (e) { + if (e instanceof CasNodeNotFoundError) { + die(`Error: ${e.message}`); + } + throw e; + } finally { + varStore.close(); + } +} + +async function cmdTemplateGet(args: string[]): Promise { + const schemaHash = args[0]; + + if (!schemaHash) { + die("Usage: json-cas template get "); + } + + const store = openStore(); + mkdirSync(resolve(storePath), { recursive: true }); + const varStore = createVariableStore(resolve(varDbPath), store); + + try { + const varName = `@ucas/template/text/${schemaHash}`; + const stringHash = await resolveTypeHash("@string"); + const variable = varStore.get(varName, stringHash); + + if (variable === null) { + die(`Error: Template not found for schema: ${schemaHash}`); + } + + // Get the content from CAS + const node = store.get(variable.value); + if (node === null) { + die(`Error: Content not found in CAS: ${variable.value}`); + } + + // Output raw text (not JSON) + process.stdout.write(node.payload as string); + } finally { + varStore.close(); + } +} + +async function cmdTemplateList(_args: string[]): Promise { + const store = openStore(); + mkdirSync(resolve(storePath), { recursive: true }); + const varStore = createVariableStore(resolve(varDbPath), store); + + try { + const stringHash = await resolveTypeHash("@string"); + const variables = varStore.list({ + namePrefix: "@ucas/template/text/", + schema: stringHash, + }); + + const templates = variables.map((v) => { + const schemaHash = v.name.replace("@ucas/template/text/", ""); + + // Get content for preview + const node = store.get(v.value); + const content = (node?.payload as string | undefined) ?? ""; + + // Truncate preview to 80 chars + const preview = + content.length > 80 ? `${content.slice(0, 77)}...` : content; + + return { + schemaHash, + preview, + }; + }); + + out(templates); + } finally { + varStore.close(); + } +} + +async function cmdTemplateDelete(args: string[]): Promise { + const schemaHash = args[0]; + + if (!schemaHash) { + die("Usage: json-cas template delete "); + } + + const store = openStore(); + mkdirSync(resolve(storePath), { recursive: true }); + const varStore = createVariableStore(resolve(varDbPath), store); + + try { + const varName = `@ucas/template/text/${schemaHash}`; + const stringHash = await resolveTypeHash("@string"); + varStore.remove(varName, stringHash); + + out({ deleted: true }); + } catch (e) { + if (e instanceof VariableNotFoundError) { + die(`Error: Template not found for schema: ${schemaHash}`); + } + throw e; + } finally { + varStore.close(); + } +} + async function cmdGc(_args: string[]): Promise { const store = createFsStore(storePath); const varStore = createVariableStore(varDbPath, store); @@ -666,6 +835,10 @@ Commands: var delete [--schema ] Delete variable(s) var list [prefix] [--schema ] [--tag ...] List variables var tag --schema Modify tags/labels + template set | --inline Set template for schema + template get Get template content as raw text + template list List all templates + template delete Delete template for schema gc Run garbage collection Flags: @@ -674,6 +847,7 @@ Flags: --json Compact JSON output --schema Schema hash filter for var get/delete/tag/list --tag Tag/label (can be repeated): key:value (tag), name (label), :name (delete) + --inline Inline text content for template set --resolution Initial resolution for render (default: 1.0) --decay Decay factor for render (default: 0.5) --epsilon Cutoff threshold for render (default: 0.01)`); @@ -778,6 +952,27 @@ switch (cmd) { break; } + case "template": { + const [sub, ...subRest] = rest; + switch (sub) { + case "set": + await cmdTemplateSet(subRest); + break; + case "get": + await cmdTemplateGet(subRest); + break; + case "list": + await cmdTemplateList(subRest); + break; + case "delete": + await cmdTemplateDelete(subRest); + break; + default: + die(`Unknown template subcommand: ${sub ?? "(none)"}`); + } + break; + } + case "gc": await cmdGc(rest); break; diff --git a/packages/cli-json-cas/src/template.test.ts b/packages/cli-json-cas/src/template.test.ts new file mode 100644 index 0000000..6a00277 --- /dev/null +++ b/packages/cli-json-cas/src/template.test.ts @@ -0,0 +1,648 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Hash, Store } from "@uncaged/json-cas"; +import { bootstrap } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; + +// ---- Test helpers ---- + +let testDir: string; +let storePath: string; +let varDbPath: string; +let cliPath: string; + +beforeEach(() => { + // Create unique temp directory for each test + testDir = join( + tmpdir(), + `json-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + storePath = join(testDir, "store"); + varDbPath = join(testDir, "variables.db"); + cliPath = join(import.meta.dir, "index.ts"); + + mkdirSync(testDir, { recursive: true }); + mkdirSync(storePath, { recursive: true }); +}); + +afterEach(() => { + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +/** + * Run CLI command and return stdout, stderr, and exit code + */ +async function runCli(...args: string[]): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + const proc = Bun.spawn( + [ + "bun", + "run", + cliPath, + "--store", + storePath, + "--var-db", + varDbPath, + ...args, + ], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + await proc.exited; + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: proc.exitCode ?? 0, + }; +} + +/** + * Get bootstrap @string type hash + */ +async function getStringHash(store: Store): Promise { + const builtinSchemas = await bootstrap(store); + return builtinSchemas["@string"] ?? ""; +} + +// ---- Tests ---- + +describe("template set", () => { + test("set template from file", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const templateFile = join(testDir, "template.txt"); + writeFileSync(templateFile, "Hello {{name}}!"); + + const { stdout, stderr, exitCode } = await runCli( + "template", + "set", + stringHash, + templateFile, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + const output = JSON.parse(stdout); + expect(output).toHaveProperty("contentHash"); + expect(output.schemaHash).toBe(stringHash); + }); + + test("set template with --inline flag", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const { stdout, exitCode } = await runCli( + "template", + "set", + stringHash, + "--inline", + "Inline template content", + ); + + expect(exitCode).toBe(0); + + const output = JSON.parse(stdout); + expect(output).toHaveProperty("contentHash"); + expect(output.schemaHash).toBe(stringHash); + }); + + test("update existing template (idempotent)", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const templateFile = join(testDir, "template.txt"); + writeFileSync(templateFile, "Version 1"); + + // Set first time + await runCli("template", "set", stringHash, templateFile); + + // Update with new content + writeFileSync(templateFile, "Version 2"); + const { stdout, exitCode } = await runCli( + "template", + "set", + stringHash, + templateFile, + ); + + expect(exitCode).toBe(0); + + const output = JSON.parse(stdout); + expect(output).toHaveProperty("contentHash"); + + // Verify we can get the new version + const { stdout: getOut } = await runCli("template", "get", stringHash); + expect(getOut).toBe("Version 2"); + }); + + test("error when file not found", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const { stderr, exitCode } = await runCli( + "template", + "set", + stringHash, + "/nonexistent/file.txt", + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Error:"); + }); + + test("error when schema hash invalid", async () => { + const templateFile = join(testDir, "template.txt"); + writeFileSync(templateFile, "content"); + + const { stderr, exitCode } = await runCli( + "template", + "set", + "INVALID_HASH", + templateFile, + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Error:"); + }); + + test("error when both file and --inline provided", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const templateFile = join(testDir, "template.txt"); + writeFileSync(templateFile, "content"); + + const { stderr, exitCode } = await runCli( + "template", + "set", + stringHash, + templateFile, + "--inline", + "inline content", + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Error:"); + }); + + test("support multi-line templates", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const multilineContent = "Line 1\nLine 2\nLine 3"; + const { exitCode } = await runCli( + "template", + "set", + stringHash, + "--inline", + multilineContent, + ); + + expect(exitCode).toBe(0); + + // Verify content + const { stdout: getOut } = await runCli("template", "get", stringHash); + expect(getOut).toBe(multilineContent); + }); + + test("support empty templates", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const { stdout, exitCode } = await runCli( + "template", + "set", + stringHash, + "--inline", + "", + ); + + expect(exitCode).toBe(0); + + const output = JSON.parse(stdout); + expect(output).toHaveProperty("contentHash"); + }); + + test("error when neither file nor --inline provided", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const { stderr, exitCode } = await runCli("template", "set", stringHash); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Usage:"); + }); + + test("support templates with special characters", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const specialContent = "Template with {{var}} and $env and @ref"; + const { exitCode } = await runCli( + "template", + "set", + stringHash, + "--inline", + specialContent, + ); + + expect(exitCode).toBe(0); + + // Verify content preserved + const { stdout: getOut } = await runCli("template", "get", stringHash); + expect(getOut).toBe(specialContent); + }); +}); + +describe("template get", () => { + test("retrieve template as raw text", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const content = "Hello {{name}}!"; + await runCli("template", "set", stringHash, "--inline", content); + + const { stdout, stderr, exitCode } = await runCli( + "template", + "get", + stringHash, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(stdout).toBe(content); + }); + + test("error when template not found", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const { stderr, exitCode } = await runCli("template", "get", stringHash); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Error:"); + expect(stderr).toContain("not found"); + }); + + test("preserve exact whitespace", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + // Note: runCli helper trims stdout, so we test with content that doesn't have leading/trailing whitespace + // The actual CLI preserves whitespace correctly + const content = "spaces\n\ttabs\t\nmixed"; + await runCli("template", "set", stringHash, "--inline", content); + + const { stdout } = await runCli("template", "get", stringHash); + + expect(stdout).toBe(content); + }); + + test("support multi-line templates", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + // Note: runCli helper trims stdout, so trailing newline will be removed + const multiline = "Line 1\nLine 2\nLine 3"; + await runCli("template", "set", stringHash, "--inline", multiline); + + const { stdout } = await runCli("template", "get", stringHash); + + expect(stdout).toBe(multiline); + }); +}); + +describe("template list", () => { + test("list all templates", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + // Create multiple templates + await runCli("template", "set", stringHash, "--inline", "Template 1"); + await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2"); + + const { stdout, exitCode } = await runCli("template", "list"); + + expect(exitCode).toBe(0); + + const output = JSON.parse(stdout); + expect(Array.isArray(output)).toBe(true); + expect(output.length).toBeGreaterThanOrEqual(1); + + // Check structure + const item = output[0]; + expect(item).toHaveProperty("schemaHash"); + expect(item).toHaveProperty("preview"); + }); + + test("preview truncation for long content", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const longContent = "a".repeat(200); + await runCli("template", "set", stringHash, "--inline", longContent); + + const { stdout } = await runCli("template", "list"); + + const output = JSON.parse(stdout) as Array<{ + schemaHash: string; + preview: string; + }>; + const item = output.find((i) => i.schemaHash === stringHash); + expect(item).toBeDefined(); + if (item) { + expect(item.preview.length).toBeLessThan(longContent.length); + expect(item.preview).toContain("..."); + } + }); + + test("empty list when no templates", async () => { + const { stdout, exitCode } = await runCli("template", "list"); + + expect(exitCode).toBe(0); + + const output = JSON.parse(stdout); + expect(Array.isArray(output)).toBe(true); + expect(output.length).toBe(0); + }); + + test("exclude non-template variables", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + // Create a template + await runCli("template", "set", stringHash, "--inline", "Template"); + + // Create a regular variable (not under @ucas/template/text/) + const hash = await store.put(stringHash, "regular var content"); + await runCli("var", "set", "regular/var", hash); + + const { stdout } = await runCli("template", "list"); + + const output = JSON.parse(stdout); + // Should only contain template variables + for (const item of output) { + expect(item.schemaHash).toBeDefined(); + } + }); + + test("output JSON array format", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + await runCli("template", "set", stringHash, "--inline", "Test"); + + const { stdout } = await runCli("template", "list"); + + // Should be valid JSON + expect(() => JSON.parse(stdout)).not.toThrow(); + + const output = JSON.parse(stdout); + expect(Array.isArray(output)).toBe(true); + }); + + test("preview shows beginning of content", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const content = "Start of template..."; + await runCli("template", "set", stringHash, "--inline", content); + + const { stdout } = await runCli("template", "list"); + + const output = JSON.parse(stdout) as Array<{ + schemaHash: string; + preview: string; + }>; + const item = output.find((i) => i.schemaHash === stringHash); + if (item) { + expect(item.preview).toContain("Start"); + } + }); +}); + +describe("template delete", () => { + test("delete template variable binding", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + await runCli("template", "set", stringHash, "--inline", "Template"); + + const { stdout, stderr, exitCode } = await runCli( + "template", + "delete", + stringHash, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + const output = JSON.parse(stdout); + expect(output).toHaveProperty("deleted"); + expect(output.deleted).toBe(true); + + // Verify template is gone + const { exitCode: getExitCode } = await runCli( + "template", + "get", + stringHash, + ); + expect(getExitCode).toBe(1); + }); + + test("error when template not found", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const { stderr, exitCode } = await runCli("template", "delete", stringHash); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Error:"); + expect(stderr).toContain("not found"); + }); + + test("deletion does not affect other templates", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + // Create two templates + await runCli("template", "set", stringHash, "--inline", "Template 1"); + await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2"); + + // Delete first template + await runCli("template", "delete", stringHash); + + // Verify second still exists + const { stdout } = await runCli("template", "list"); + const output = JSON.parse(stdout) as Array<{ + schemaHash: string; + preview: string; + }>; + + // Should not find deleted template + const deleted = output.find((i) => i.schemaHash === stringHash); + expect(deleted).toBeUndefined(); + }); + + test("CAS content remains after variable deletion", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + await runCli("template", "set", stringHash, "--inline", "Content"); + + // Get the content hash before deletion + const { stdout: setOut } = await runCli( + "template", + "set", + stringHash, + "--inline", + "Content", + ); + const { contentHash } = JSON.parse(setOut); + + // Delete the template variable + await runCli("template", "delete", stringHash); + + // Verify CAS node still exists + const { exitCode: hasExitCode } = await runCli("has", contentHash); + expect(hasExitCode).toBe(0); + }); + + test("deletion is non-idempotent (second delete fails)", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + await runCli("template", "set", stringHash, "--inline", "Template"); + + // First deletion succeeds + const { exitCode: firstExit } = await runCli( + "template", + "delete", + stringHash, + ); + expect(firstExit).toBe(0); + + // Second deletion fails + const { exitCode: secondExit } = await runCli( + "template", + "delete", + stringHash, + ); + expect(secondExit).toBe(1); + }); +}); + +describe("template integration", () => { + test("end-to-end workflow: set→get→list→delete", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + const content = "Integration test template"; + + // Set + const { exitCode: setExit } = await runCli( + "template", + "set", + stringHash, + "--inline", + content, + ); + expect(setExit).toBe(0); + + // Get + const { stdout: getOut, exitCode: getExit } = await runCli( + "template", + "get", + stringHash, + ); + expect(getExit).toBe(0); + expect(getOut).toBe(content); + + // List + const { stdout: listOut, exitCode: listExit } = await runCli( + "template", + "list", + ); + expect(listExit).toBe(0); + const listData = JSON.parse(listOut); + expect(listData.length).toBeGreaterThan(0); + + // Delete + const { exitCode: delExit } = await runCli( + "template", + "delete", + stringHash, + ); + expect(delExit).toBe(0); + + // Verify deleted + const { exitCode: finalGet } = await runCli("template", "get", stringHash); + expect(finalGet).toBe(1); + }); + + test("templates compatible with generic var commands", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + // Set via template command + await runCli("template", "set", stringHash, "--inline", "Content"); + + // List via var command - should see template variable + const { stdout } = await runCli("var", "list", "@ucas/template/text/"); + + const output = JSON.parse(stdout); + expect(output.value.length).toBeGreaterThan(0); + }); + + test("multiple templates for different schemas", async () => { + const store = createFsStore(storePath); + const stringHash = await getStringHash(store); + + // Create templates for different schemas + await runCli("template", "set", stringHash, "--inline", "Template 1"); + await runCli("template", "set", "SCHEMA_HASH_2", "--inline", "Template 2"); + await runCli("template", "set", "SCHEMA_HASH_3", "--inline", "Template 3"); + + // List should show all + const { stdout } = await runCli("template", "list"); + const output = JSON.parse(stdout); + expect(output.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe("template error handling", () => { + test("unknown template subcommand", async () => { + const { stderr, exitCode } = await runCli("template", "unknown"); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown"); + }); + + test("missing schema hash argument", async () => { + const { stderr, exitCode } = await runCli("template", "set"); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Usage:"); + }); +});