From 9090456ed23e7af8b2a3abfa738a208b84778ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 16:00:21 +0000 Subject: [PATCH] feat: wrap template commands with envelope, update docs (Phase 4) Fixes #72 --- README.md | 62 +++++--- packages/cli-json-cas/README.md | 111 +++++++++++---- .../src/__snapshots__/e2e.test.ts.snap | 86 ++++++----- packages/cli-json-cas/src/e2e.test.ts | 118 +++++++++++++++- packages/cli-json-cas/src/index.ts | 85 ++++++----- packages/cli-json-cas/src/template.test.ts | 133 ++++++++---------- 6 files changed, 394 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index cbc66eb..f730d3c 100644 --- a/README.md +++ b/README.md @@ -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 +{ "type": "AYHQD2YA9G667", "value": true } +``` ``` Usage: json-cas [--store ] [--json] [args] -Commands: - init Create store dir and write bootstrap seed - bootstrap Write meta-schema seed, print hash - schema put Register schema, print type hash - schema get Print schema JSON - schema list List all schemas (name + hash) - schema validate Validate node against its schema - put Store node, print hash - get Print node as JSON - has Print true/false - verify Verify integrity, print ok/corrupted - refs List direct cas_ref edges - walk [--format tree] Recursive traversal - hash Compute hash without storing (dry run) - cat [--payload] Output node (--payload for payload only) +Commands (all emit a { type, value } envelope unless noted): + put Store node (value = hash) (@output/put) + get Node payload + metadata (@output/get) + has Existence boolean (@output/has) + verify ok / corrupted / invalid (@output/verify) + refs Direct cas_ref edges (@output/refs) + walk [--format tree] Recursive traversal (@output/walk) + hash Compute hash without storing (@output/hash) + render [options] Render node as text (raw output) + render --pipe/-p [options] Render a piped envelope (raw output) + list --type 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 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 diff --git a/packages/cli-json-cas/README.md b/packages/cli-json-cas/README.md index f8bd5fd..ba9e95f 100644 --- a/packages/cli-json-cas/README.md +++ b/packages/cli-json-cas/README.md @@ -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 ] [--json] [args] | Flag | Description | |------|-------------| | `--store ` | Store directory (default: `~/.uncaged/json-cas`) | -| `--json` | Compact JSON output for commands that print JSON | +| `--var-db ` | Variable database path (default: `/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 +{ "type": "AYHQD2YA9G667", "value": true } + +// json-cas template set --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 ` | Register schema from file; prints type hash | -| `schema get ` | Print schema JSON | -| `schema list` | List all schemas (`hash name`) | -| `schema validate ` | Validate node against its schema; prints `valid` / `invalid` | -| `put ` | Store node; prints content hash | -| `get ` | Print full node as JSON | -| `has ` | Print `true` or `false` | -| `verify ` | Verify integrity; prints `ok` or `corrupted` | -| `refs ` | Print direct `cas_ref` targets (one per line) | -| `walk ` | BFS traversal; one hash per line | -| `walk --format tree` | Tree-formatted traversal | -| `hash ` | Compute hash without storing | -| `cat ` | Print node JSON | -| `cat --payload` | Print payload only | +| Command | Envelope `value` | Result schema | +|---------|------------------|---------------| +| `put ` | stored node hash (string) | `@output/put` | +| `get ` | `{ type, payload, timestamp }` | `@output/get` | +| `has ` | boolean | `@output/has` | +| `verify ` | `ok` / `corrupted` / `invalid` | `@output/verify` | +| `refs ` | hashes (string[]) | `@output/refs` | +| `walk [--format tree]` | hashes (string[]) or tree string | `@output/walk` | +| `hash ` | computed hash (string) | `@output/hash` | +| `render [options]` | raw text (no envelope) | — | +| `render --pipe/-p [options]` | raw text from piped envelope | — | +| `list --type ` | hashes (string[]) | `@output/list` | +| `var set [--tag ...]` | variable object | `@output/var-set` | +| `var get --schema ` | variable object | `@output/var-get` | +| `var delete [--schema ]` | variable or variable[] | `@output/var-delete` | +| `var tag --schema ` | variable object | `@output/var-tag` | +| `var list [prefix] [--schema ] [--tag ...]` | variable[] | `@output/var-list` | +| `template set \| --inline ` | `{ schemaHash, contentHash }` | `@output/template-set` | +| `template get ` | template content (string) | `@output/template-get` | +| `template list` | `{ schemaHash, contentHash }[]` | `@output/template-list` | +| `template delete ` | `{ 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": "" } json-cas get --json json-cas verify json-cas walk --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/` variable). `render ` 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 +# → Item: Widget ``` ## Internal Structure diff --git a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap index 44bdbec..7245af2 100644 --- a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap +++ b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap @@ -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 ] [--json] [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 Store node, print { type, value } envelope (value=hash) - get Print node as { type, value } envelope - has Print { type, value } envelope (value=boolean) - verify Verify integrity + schema → { type, value } (value=ok/corrupted/invalid) - refs List direct cas_ref edges - walk [--format tree] Recursive traversal - hash Compute hash without storing → { type, value } envelope - render [options] Render node as YAML with resolution decay - render --pipe/-p [options] Render { type, value } from stdin - list --type List hashes for a type → { type, value } envelope (value=string[]) - var set [--tag ...] Create/update a variable - var get --schema Get a variable by name + schema - 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 + put Store node, print envelope (value=hash) (@output/put) + get Print node as envelope (@output/get) + has Print envelope (value=boolean) (@output/has) + verify Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify) + refs List direct cas_ref edges (@output/refs) + walk [--format tree] Recursive traversal (@output/walk) + hash Compute hash without storing (@output/hash) + render [options] Render node as text with resolution decay (raw output) + render --pipe/-p [options] Render { type, value } from stdin (raw output) + list --type List hashes for a type (value=string[]) (@output/list) + var set [--tag ...] Create/update a variable (@output/var-set) + var get --schema Get a variable by name + schema (@output/var-get) + var delete [--schema ] Delete variable(s) (@output/var-delete) + var list [prefix] [--schema ] [--tag ...] List variables (@output/var-list) + var tag --schema Modify tags/labels (@output/var-tag) + template set | --inline Set template for schema (@output/template-set) + template get Get template content (value=string) (@output/template-get) + template list List all templates (@output/template-list) + template delete Delete template for schema (@output/template-delete) + gc Run garbage collection (@output/gc) Flags: --store Store directory (default: ~/.uncaged/json-cas) diff --git a/packages/cli-json-cas/src/e2e.test.ts b/packages/cli-json-cas/src/e2e.test.ts index beaeaef..e0abe65 100644 --- a/packages/cli-json-cas/src/e2e.test.ts +++ b/packages/cli-json-cas/src/e2e.test.ts @@ -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 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)"); + }); +}); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 9e1a72b..1c55fd4 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -656,10 +656,12 @@ async function cmdTemplateSet(args: string[]): Promise { 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 { 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 { 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 { 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 ] [--json] [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 Store node, print { type, value } envelope (value=hash) - get Print node as { type, value } envelope - has Print { type, value } envelope (value=boolean) - verify Verify integrity + schema → { type, value } (value=ok/corrupted/invalid) - refs List direct cas_ref edges - walk [--format tree] Recursive traversal - hash Compute hash without storing → { type, value } envelope - render [options] Render node as YAML with resolution decay - render --pipe/-p [options] Render { type, value } from stdin - list --type List hashes for a type → { type, value } envelope (value=string[]) - var set [--tag ...] Create/update a variable - var get --schema Get a variable by name + schema - 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 + put Store node, print envelope (value=hash) (@output/put) + get Print node as envelope (@output/get) + has Print envelope (value=boolean) (@output/has) + verify Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify) + refs List direct cas_ref edges (@output/refs) + walk [--format tree] Recursive traversal (@output/walk) + hash Compute hash without storing (@output/hash) + render [options] Render node as text with resolution decay (raw output) + render --pipe/-p [options] Render { type, value } from stdin (raw output) + list --type List hashes for a type (value=string[]) (@output/list) + var set [--tag ...] Create/update a variable (@output/var-set) + var get --schema Get a variable by name + schema (@output/var-get) + var delete [--schema ] Delete variable(s) (@output/var-delete) + var list [prefix] [--schema ] [--tag ...] List variables (@output/var-list) + var tag --schema Modify tags/labels (@output/var-tag) + template set | --inline Set template for schema (@output/template-set) + template get Get template content (value=string) (@output/template-get) + template list List all templates (@output/template-list) + template delete Delete template for schema (@output/template-delete) + gc Run garbage collection (@output/gc) Flags: --store Store directory (default: ~/.uncaged/json-cas) diff --git a/packages/cli-json-cas/src/template.test.ts b/packages/cli-json-cas/src/template.test.ts index 6a00277..7f1db68 100644 --- a/packages/cli-json-cas/src/template.test.ts +++ b/packages/cli-json-cas/src/template.test.ts @@ -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); }); });