feat: wrap template commands with envelope, update docs (Phase 4) #81

Merged
xiaoju merged 1 commits from fix/72-template-envelope into main 2026-05-31 16:00:33 +00:00
6 changed files with 394 additions and 201 deletions
+46 -16
View File
@@ -88,30 +88,60 @@ Or use the CLI (see [CLI Reference](#cli-reference) and [`packages/cli-json-cas/
## CLI Reference
Binary: `json-cas` (from `@uncaged/cli-json-cas`). Default store: `~/.uncaged/json-cas`.
Binary: `json-cas` (also aliased `ucas`, from `@uncaged/cli-json-cas`). Default store:
`~/.uncaged/json-cas`. The store is auto-created and bootstrapped on first use — there is
no `init`/`bootstrap` command, and schemas are ordinary `@schema`-typed nodes (`ucas put
@schema file.json`), so there is no `schema` subcommand.
### Envelope format
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
of the command's `@output/*` result schema and `value` is the command payload. This makes
output self-describing and pipeable: feed any envelope into `render -p` to render its
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
raw (non-envelope) text.
```jsonc
// ucas has <hash>
{ "type": "AYHQD2YA9G667", "value": true }
```
```
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
cat <hash> [--payload] Output node (--payload for payload only)
Commands (all emit a { type, value } envelope unless noted):
put <type-hash> <file.json> Store node (value = hash) (@output/put)
get <hash> Node payload + metadata (@output/get)
has <hash> Existence boolean (@output/has)
verify <hash> ok / corrupted / invalid (@output/verify)
refs <hash> Direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text (raw output)
render --pipe/-p [options] Render a piped envelope (raw output)
list --type <hash-or-alias> Hashes for a type (value = list) (@output/list)
var set|get|delete|tag|list ... Variable CRUD (@output/var-*)
template set|get|list|delete ... Output-template CRUD (@output/template-*)
gc Garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--json Compact JSON output
--pipe, -p Read a { type, value } envelope from stdin for render
```
### Pipe examples
```bash
# Store a node, then render the stored content (the put envelope's hash is
# a cas_ref, so render -p dereferences and renders it):
ucas put @schema ./schemas/item.json | ucas render -p
# Render garbage-collection stats:
ucas gc | ucas render -p
# List every schema, then consume the envelope's value array with jq:
ucas list --type @schema | jq -r '.value[]'
```
## Development
+80 -31
View File
@@ -4,7 +4,9 @@ CLI tool for json-cas stores.
## Overview
`@uncaged/cli-json-cas` provides the `json-cas` command for managing a filesystem-backed store: bootstrap, schema registration, node CRUD, integrity checks, reference listing, and graph walks. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
`@uncaged/cli-json-cas` provides the `json-cas` command (also aliased `ucas`) for managing a filesystem-backed store: node CRUD, integrity checks, reference listing, graph walks, variables, and output templates. It uses `@uncaged/json-cas-fs` for persistence and `@uncaged/json-cas` for core operations.
The store is **auto-created and bootstrapped** on first use, so there is no `init`/`bootstrap` command. Schemas are ordinary `@schema`-typed nodes — register one with `ucas put @schema file.json` and list them with `ucas list --type @schema`; there is no dedicated `schema` subcommand.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
@@ -37,48 +39,95 @@ Usage: json-cas [--store <path>] [--json] <command> [args]
| Flag | Description |
|------|-------------|
| `--store <path>` | Store directory (default: `~/.uncaged/json-cas`) |
| `--json` | Compact JSON output for commands that print JSON |
| `--var-db <path>` | Variable database path (default: `<store>/variables.db`) |
| `--json` | Compact (single-line) JSON output |
### Envelope format
Every JSON-emitting command prints a uniform `{ type, value }` envelope. `type` is the hash
of the command's `@output/*` result schema and `value` is the command payload. The output
is therefore self-describing and pipeable: feed any envelope into `render -p` to render its
`value` (embedded `cas_ref` hashes are expanded). `render` is the only command that emits
raw, non-envelope text.
```jsonc
// json-cas has <hash>
{ "type": "AYHQD2YA9G667", "value": true }
// json-cas template set <schema-hash> --inline "Hi {{ payload.name }}"
{ "type": "9YJZ09DDAYAWR", "value": { "schemaHash": "7XX5H51CVD9H0", "contentHash": "FC8WACA792B6F" } }
```
### Commands
| Command | Description |
|---------|-------------|
| `init` | Create store directory and write bootstrap seed; prints meta hash |
| `bootstrap` | Write meta-schema seed into existing store; prints hash |
| `schema put <file.json>` | Register schema from file; prints type hash |
| `schema get <type-hash>` | Print schema JSON |
| `schema list` | List all schemas (`hash name`) |
| `schema validate <hash>` | Validate node against its schema; prints `valid` / `invalid` |
| `put <type-hash> <file.json>` | Store node; prints content hash |
| `get <hash>` | Print full node as JSON |
| `has <hash>` | Print `true` or `false` |
| `verify <hash>` | Verify integrity; prints `ok` or `corrupted` |
| `refs <hash>` | Print direct `cas_ref` targets (one per line) |
| `walk <hash>` | BFS traversal; one hash per line |
| `walk <hash> --format tree` | Tree-formatted traversal |
| `hash <type-hash> <file.json>` | Compute hash without storing |
| `cat <hash>` | Print node JSON |
| `cat <hash> --payload` | Print payload only |
| Command | Envelope `value` | Result schema |
|---------|------------------|---------------|
| `put <type-hash> <file.json>` | stored node hash (string) | `@output/put` |
| `get <hash>` | `{ type, payload, timestamp }` | `@output/get` |
| `has <hash>` | boolean | `@output/has` |
| `verify <hash>` | `ok` / `corrupted` / `invalid` | `@output/verify` |
| `refs <hash>` | hashes (string[]) | `@output/refs` |
| `walk <hash> [--format tree]` | hashes (string[]) or tree string | `@output/walk` |
| `hash <type-hash> <file.json>` | computed hash (string) | `@output/hash` |
| `render <hash> [options]` | raw text (no envelope) | — |
| `render --pipe/-p [options]` | raw text from piped envelope | — |
| `list --type <hash-or-alias>` | hashes (string[]) | `@output/list` |
| `var set <name> <hash> [--tag ...]` | variable object | `@output/var-set` |
| `var get <name> --schema <hash>` | variable object | `@output/var-get` |
| `var delete <name> [--schema <hash>]` | variable or variable[] | `@output/var-delete` |
| `var tag <name> --schema <hash> <ops...>` | variable object | `@output/var-tag` |
| `var list [prefix] [--schema <hash>] [--tag ...]` | variable[] | `@output/var-list` |
| `template set <schema-hash> <file> \| --inline <text>` | `{ schemaHash, contentHash }` | `@output/template-set` |
| `template get <schema-hash>` | template content (string) | `@output/template-get` |
| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` |
| `template delete <schema-hash>` | `{ deleted: boolean }` | `@output/template-delete` |
| `gc` | `{ total, reachable, collected, scanned }` | `@output/gc` |
### Examples
```bash
# Initialize default store at ~/.uncaged/json-cas
json-cas init
# Register a schema (schemas are plain @schema nodes) and store a payload
json-cas put @schema ./schemas/item.json
# → { "type": "...", "value": "0123456789ABC" } (the schema's type hash)
# Use a custom store path
json-cas --store ./data/cas bootstrap
# Register a schema and store a payload
json-cas schema put ./schemas/item.json
# → prints type hash, e.g. 0123456789ABCD
json-cas put 0123456789ABCD ./payloads/item.json
# → prints content hash
json-cas put 0123456789ABC ./payloads/item.json
# → { "type": "...", "value": "<content-hash>" }
json-cas get <content-hash> --json
json-cas verify <content-hash>
json-cas walk <content-hash> --format tree
# List every registered schema, then extract the hashes with jq
json-cas list --type @schema | jq -r '.value[]'
```
### Pipe composition
Because every command shares the `{ type, value }` envelope, output composes directly into
`render -p`:
```bash
# put emits a cas_ref hash envelope; render -p dereferences and renders the node
json-cas put @schema ./schemas/item.json | json-cas render -p
# render gc statistics
json-cas gc | json-cas render -p
# render every schema referenced by a list result
json-cas list --type @schema | json-cas render -p
```
### Templates
`template` commands manage the LiquidJS template bound to a schema (stored as a
`@ucas/template/text/<schema-hash>` variable). `render <hash>` uses the template registered
for the node's type, falling back to YAML when none exists.
```bash
# Bind a template to a schema, then render a node of that type
json-cas template set 0123456789ABC --inline "Item: {{ payload.name }}"
json-cas render <content-hash>
# → Item: Widget
```
## Internal Structure
@@ -197,27 +197,41 @@ exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
"{
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "FC8WACA792B6F"
}"
{
"type": "9YJZ09DDAYAWR",
"value": {
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
},
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `"Name: {{ payload.name }}, Age: {{ payload.age }}"`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "FJG23DR9456WA",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
"[
{
"schemaHash": "7XX5H51CVD9H0",
"preview": "Name: {{ payload.name }}, Age: {{ payload.age }}"
}
]"
{
"type": "3JB2JHXHZG2Z1",
"value": [
{
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
},
],
}
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
"{
"deleted": true
}"
{
"type": "0PYGQE16XPM70",
"value": {
"deleted": true,
},
}
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`;
@@ -235,27 +249,31 @@ exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"
exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json> Store node, print { type, value } envelope (value=hash)
get <hash> Print node as { type, value } envelope
has <hash> Print { type, value } envelope (value=boolean)
verify <hash> Verify integrity + schema → { type, value } (value=ok/corrupted/invalid)
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing → { type, value } envelope
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
list --type <hash-or-alias> List hashes for a type → { type, value } envelope (value=string[])
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
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
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
+113 -5
View File
@@ -59,6 +59,23 @@ function envValue(json: string): unknown {
return (JSON.parse(json) as { value: unknown }).value;
}
/** Run a CLI command feeding `stdin` to its standard input. */
async function runCliWithStdin(
args: string[],
stdin: string,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
proc.stdin.write(stdin);
proc.stdin.end();
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
// ---- Phase 1: CAS Core ----
describe("Phase 1: CAS Core", () => {
@@ -336,27 +353,29 @@ describe("Phase 4: Template System", () => {
tmplFile,
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.2 template get returns template text", async () => {
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Name: {{ payload.name }}, Age: {{ payload.age }}");
expect(stdout).toMatchSnapshot();
expect(envValue(stdout)).toBe(
"Name: {{ payload.name }}, Age: {{ payload.age }}",
);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.3 template list shows registered templates", async () => {
const { stdout, exitCode } = await runCli(["template", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(typeHash);
expect(stdout).toMatchSnapshot();
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.4 template delete removes template", async () => {
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("4.5 template get deleted template returns not found", async () => {
@@ -520,3 +539,92 @@ describe("Phase 7: Edge Cases", () => {
expect(stderr).toContain("not a directory");
});
});
// ---- Phase 8: Pipe Composition ----
//
// Every JSON command emits a { type, value } envelope, so its stdout can be
// fed straight into `render --pipe` (which renders the envelope value) or into
// any downstream JSON consumer. These tests verify the envelopes compose
// end-to-end.
describe("Phase 8: Pipe Composition", () => {
test("8.1 put | render -p expands the stored hash to its content", async () => {
const nodeFile = join(tmpStore, "pipe-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 }));
const { stdout: putOut, exitCode: putExit } = await runCli([
"put",
typeHash,
nodeFile,
]);
expect(putExit).toBe(0);
// The put envelope value is a cas_ref hash; render -p dereferences it and
// renders the stored node's payload.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
putOut,
);
expect(exitCode).toBe(0);
expect(stdout).toContain("Bob");
});
test("8.2 gc | render -p renders the gc stats", async () => {
const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]);
expect(gcExit).toBe(0);
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
gcOut,
);
expect(exitCode).toBe(0);
// gc value is an object { total, reachable, collected, scanned }
expect(stdout).toContain("total:");
});
test("8.3 list --type @schema emits a parseable envelope of hashes", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]);
expect(exitCode).toBe(0);
// Downstream consumers (jq, etc.) read the `value` array of hashes.
const value = envValue(stdout) as string[];
expect(Array.isArray(value)).toBe(true);
for (const hash of value) {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
});
test("8.4 list --type @schema | render -p expands the schema list", async () => {
const { stdout: listOut } = await runCli(["list", "--type", "@schema"]);
// list result items are cas_ref hashes; render -p dereferences each one
// and renders the schema contents.
const { stdout, exitCode } = await runCliWithStdin(
["render", "--pipe"],
listOut,
);
expect(exitCode).toBe(0);
expect(stdout.length).toBeGreaterThan(0);
});
test("8.5 render <hash> uses a registered template", async () => {
// Register a template for the schema, then render a fresh node by hash.
const tmplFile = join(tmpStore, "pipe-render.liquid");
writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})");
const { exitCode: setExit } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(setExit).toBe(0);
const nodeFile = join(tmpStore, "pipe-render-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 }));
const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]);
const freshHash = envValue(putOut) as string;
const { stdout, exitCode } = await runCli(["render", freshHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Person: Carol (25)");
});
});
+41 -44
View File
@@ -656,10 +656,12 @@ async function cmdTemplateSet(args: string[]): Promise<void> {
const varName = `@ucas/template/text/${schemaHash}`;
varStore.set(varName, contentHash);
out({
schemaHash,
contentHash,
});
out(
await wrapEnvelope(store, "@output/template-set", {
schemaHash,
contentHash,
}),
);
} catch (e) {
if (e instanceof CasNodeNotFoundError) {
die(`Error: ${e.message}`);
@@ -695,8 +697,9 @@ async function cmdTemplateGet(args: string[]): Promise<void> {
die(`Error: Content not found in CAS: ${variable.value}`);
}
// Output raw text (not JSON)
process.stdout.write(node.payload as string);
out(
await wrapEnvelope(store, "@output/template-get", node.payload as string),
);
} finally {
varStore.close();
}
@@ -713,24 +716,12 @@ async function cmdTemplateList(_args: string[]): Promise<void> {
schema: stringHash,
});
const templates = variables.map((v) => {
const schemaHash = v.name.replace("@ucas/template/text/", "");
const templates = variables.map((v) => ({
schemaHash: v.name.replace("@ucas/template/text/", ""),
contentHash: v.value,
}));
// 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);
out(await wrapEnvelope(store, "@output/template-list", templates));
} finally {
varStore.close();
}
@@ -751,7 +742,9 @@ async function cmdTemplateDelete(args: string[]): Promise<void> {
const stringHash = await resolveTypeHash("@string");
varStore.remove(varName, stringHash);
out({ deleted: true });
out(
await wrapEnvelope(store, "@output/template-delete", { deleted: true }),
);
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: Template not found for schema: ${schemaHash}`);
@@ -788,27 +781,31 @@ function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
All JSON commands emit a { type, value } envelope. The type is the hash of the
command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json> Store node, print { type, value } envelope (value=hash)
get <hash> Print node as { type, value } envelope
has <hash> Print { type, value } envelope (value=boolean)
verify <hash> Verify integrity + schema → { type, value } (value=ok/corrupted/invalid)
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing → { type, value } envelope
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
list --type <hash-or-alias> List hashes for a type → { type, value } envelope (value=string[])
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
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
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
+62 -71
View File
@@ -103,9 +103,10 @@ describe("template set", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
});
test("set template with --inline flag", async () => {
@@ -122,9 +123,10 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
expect(output.schemaHash).toBe(stringHash);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("contentHash");
expect(envelope.value.schemaHash).toBe(stringHash);
});
test("update existing template (idempotent)", async () => {
@@ -148,12 +150,12 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
// Verify we can get the new version
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe("Version 2");
expect(JSON.parse(getOut).value).toBe("Version 2");
});
test("error when file not found", async () => {
@@ -223,7 +225,7 @@ describe("template set", () => {
// Verify content
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(multilineContent);
expect(JSON.parse(getOut).value).toBe(multilineContent);
});
test("support empty templates", async () => {
@@ -240,8 +242,8 @@ describe("template set", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(output).toHaveProperty("contentHash");
const envelope = JSON.parse(stdout);
expect(envelope.value).toHaveProperty("contentHash");
});
test("error when neither file nor --inline provided", async () => {
@@ -271,12 +273,12 @@ describe("template set", () => {
// Verify content preserved
const { stdout: getOut } = await runCli("template", "get", stringHash);
expect(getOut).toBe(specialContent);
expect(JSON.parse(getOut).value).toBe(specialContent);
});
});
describe("template get", () => {
test("retrieve template as raw text", async () => {
test("retrieve template as envelope value", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -291,7 +293,9 @@ describe("template get", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toBe(content);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toBe(content);
});
test("error when template not found", async () => {
@@ -309,27 +313,26 @@ describe("template get", () => {
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
// The envelope's value preserves exact whitespace (JSON-escaped),
// so trimming the surrounding JSON output is harmless.
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);
expect(JSON.parse(stdout).value).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);
expect(JSON.parse(stdout).value).toBe(multiline);
});
});
@@ -346,34 +349,40 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBeGreaterThanOrEqual(1);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBeGreaterThanOrEqual(1);
// Check structure
const item = output[0];
const item = envelope.value[0];
expect(item).toHaveProperty("schemaHash");
expect(item).toHaveProperty("preview");
expect(item).toHaveProperty("contentHash");
});
test("preview truncation for long content", async () => {
test("entry contentHash matches set result", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
const longContent = "a".repeat(200);
await runCli("template", "set", stringHash, "--inline", longContent);
const { stdout: setOut } = await runCli(
"template",
"set",
stringHash,
"--inline",
"Some template content",
);
const { contentHash } = JSON.parse(setOut).value;
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
const value = JSON.parse(stdout).value as Array<{
schemaHash: string;
preview: string;
contentHash: string;
}>;
const item = output.find((i) => i.schemaHash === stringHash);
const item = value.find((i) => i.schemaHash === stringHash);
expect(item).toBeDefined();
if (item) {
expect(item.preview.length).toBeLessThan(longContent.length);
expect(item.preview).toContain("...");
expect(item.contentHash).toBe(contentHash);
}
});
@@ -382,9 +391,9 @@ describe("template list", () => {
expect(exitCode).toBe(0);
const output = JSON.parse(stdout);
expect(Array.isArray(output)).toBe(true);
expect(output.length).toBe(0);
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
expect(envelope.value.length).toBe(0);
});
test("exclude non-template variables", async () => {
@@ -400,14 +409,14 @@ describe("template list", () => {
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
const envelope = JSON.parse(stdout);
// Should only contain template variables
for (const item of output) {
for (const item of envelope.value) {
expect(item.schemaHash).toBeDefined();
}
});
test("output JSON array format", async () => {
test("output JSON envelope with array value", async () => {
const store = createFsStore(storePath);
const stringHash = await getStringHash(store);
@@ -418,27 +427,8 @@ describe("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");
}
const envelope = JSON.parse(stdout);
expect(Array.isArray(envelope.value)).toBe(true);
});
});
@@ -458,9 +448,10 @@ describe("template delete", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const output = JSON.parse(stdout);
expect(output).toHaveProperty("deleted");
expect(output.deleted).toBe(true);
const envelope = JSON.parse(stdout);
expect(envelope).toHaveProperty("type");
expect(envelope.value).toHaveProperty("deleted");
expect(envelope.value.deleted).toBe(true);
// Verify template is gone
const { exitCode: getExitCode } = await runCli(
@@ -495,13 +486,13 @@ describe("template delete", () => {
// Verify second still exists
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout) as Array<{
const value = JSON.parse(stdout).value as Array<{
schemaHash: string;
preview: string;
contentHash: string;
}>;
// Should not find deleted template
const deleted = output.find((i) => i.schemaHash === stringHash);
const deleted = value.find((i) => i.schemaHash === stringHash);
expect(deleted).toBeUndefined();
});
@@ -519,7 +510,7 @@ describe("template delete", () => {
"--inline",
"Content",
);
const { contentHash } = JSON.parse(setOut);
const { contentHash } = JSON.parse(setOut).value;
// Delete the template variable
await runCli("template", "delete", stringHash);
@@ -577,7 +568,7 @@ describe("template integration", () => {
stringHash,
);
expect(getExit).toBe(0);
expect(getOut).toBe(content);
expect(JSON.parse(getOut).value).toBe(content);
// List
const { stdout: listOut, exitCode: listExit } = await runCli(
@@ -585,7 +576,7 @@ describe("template integration", () => {
"list",
);
expect(listExit).toBe(0);
const listData = JSON.parse(listOut);
const listData = JSON.parse(listOut).value;
expect(listData.length).toBeGreaterThan(0);
// Delete
@@ -626,8 +617,8 @@ describe("template integration", () => {
// List should show all
const { stdout } = await runCli("template", "list");
const output = JSON.parse(stdout);
expect(output.length).toBeGreaterThanOrEqual(1);
const value = JSON.parse(stdout).value;
expect(value.length).toBeGreaterThanOrEqual(1);
});
});