Merge pull request 'feat: implement template CLI subcommands (set/get/list/delete)' (#44) from fix/38-template-cli into main
This commit was merged in pull request #44.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdTemplateSet(args: string[]): Promise<void> {
|
||||
const schemaHash = args[0];
|
||||
const inlineFlag = flags.inline;
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template set <schema-hash> <file> | --inline <text>");
|
||||
}
|
||||
|
||||
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 <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
content = contentArg;
|
||||
} else {
|
||||
// File mode
|
||||
const file = args[1];
|
||||
if (!file) {
|
||||
die(
|
||||
"Usage: json-cas template set <schema-hash> <file> | --inline <text>",
|
||||
);
|
||||
}
|
||||
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/<schema-hash>
|
||||
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<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template get <schema-hash>");
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
const schemaHash = args[0];
|
||||
|
||||
if (!schemaHash) {
|
||||
die("Usage: json-cas template delete <schema-hash>");
|
||||
}
|
||||
|
||||
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<void> {
|
||||
const store = createFsStore(storePath);
|
||||
const varStore = createVariableStore(varDbPath, store);
|
||||
@@ -666,6 +835,10 @@ Commands:
|
||||
var delete <name> [--schema <hash>] Delete variable(s)
|
||||
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
|
||||
var tag <name> --schema <hash> <operations...> Modify tags/labels
|
||||
template set <schema-hash> <file> | --inline <text> Set template for schema
|
||||
template get <schema-hash> Get template content as raw text
|
||||
template list List all templates
|
||||
template delete <schema-hash> Delete template for schema
|
||||
gc Run garbage collection
|
||||
|
||||
Flags:
|
||||
@@ -674,6 +847,7 @@ Flags:
|
||||
--json Compact JSON output
|
||||
--schema <hash> Schema hash filter for var get/delete/tag/list
|
||||
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
|
||||
--inline <text> Inline text content for template set
|
||||
--resolution <n> Initial resolution for render (default: 1.0)
|
||||
--decay <n> Decay factor for render (default: 0.5)
|
||||
--epsilon <n> 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;
|
||||
|
||||
@@ -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<Hash> {
|
||||
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:");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user