Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9090456ed2 | |||
| 15ffff1fd4 | |||
| d2b9dee4b9 | |||
| 547c45829f |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,15 +20,27 @@ exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`;
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "4N5REDA48XYJP",
|
||||
"value": []
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`;
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "7124NEATTWYYY",
|
||||
"value": [
|
||||
"ERARPP19YJT05"
|
||||
]
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "AYHQD2YA9G667",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
@@ -41,7 +53,7 @@ exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "BVW2SAJ8606EZ",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
@@ -54,7 +66,7 @@ exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
@@ -69,7 +81,7 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
@@ -84,7 +96,7 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "AYHQD2YA9G667",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
@@ -97,7 +109,7 @@ exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "BKMJ3DJHTS6VB",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
@@ -114,7 +126,7 @@ exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
@@ -133,7 +145,7 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "3BY1S4RKNMR0P",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
@@ -152,7 +164,7 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "BKMJ3DJHTS6VB",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
@@ -167,7 +179,7 @@ exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "E1D32N3GT69Q8",
|
||||
"type": "ASWN8JEGAG7AP",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
@@ -185,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"`;
|
||||
@@ -223,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)
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -420,7 +439,7 @@ describe("Phase 6: GC", () => {
|
||||
const { exitCode, stdout } = await runCli(["gc"]);
|
||||
expect(exitCode).toBe(0);
|
||||
// Assert structural shape only — exact counts depend on phase history
|
||||
const result = JSON.parse(stdout) as Record<string, unknown>;
|
||||
const result = envValue(stdout) as Record<string, unknown>;
|
||||
expect(typeof result.total).toBe("number");
|
||||
expect(typeof result.reachable).toBe("number");
|
||||
expect(typeof result.collected).toBe("number");
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { existsSync, 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";
|
||||
import type { Hash, Store, VariableStore } from "@uncaged/json-cas";
|
||||
import {
|
||||
bootstrap,
|
||||
CasNodeNotFoundError,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getSchema,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
putSchema,
|
||||
refs,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
@@ -145,55 +144,6 @@ async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
|
||||
return typeHashOrAlias;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Variable schema's CAS hash
|
||||
* This is the type hash used in JSON envelopes
|
||||
*/
|
||||
async function getVariableSchemaHash(): Promise<Hash> {
|
||||
const store = await openStore();
|
||||
|
||||
// Define the Variable JSON Schema (updated for new model with composite key)
|
||||
const variableSchema: JSONSchema = {
|
||||
title: "Variable",
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
schema: { type: "string" },
|
||||
value: { type: "string" },
|
||||
created: { type: "number" },
|
||||
updated: { type: "number" },
|
||||
tags: { type: "object" },
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: [
|
||||
"name",
|
||||
"schema",
|
||||
"value",
|
||||
"created",
|
||||
"updated",
|
||||
"tags",
|
||||
"labels",
|
||||
],
|
||||
};
|
||||
|
||||
// Compute hash or retrieve from store
|
||||
const hash = await putSchema(store, variableSchema);
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap Variable output in JSON envelope
|
||||
*/
|
||||
async function wrapVariableEnvelope(
|
||||
variable: unknown,
|
||||
): Promise<{ type: Hash; value: unknown }> {
|
||||
const typeHash = await getVariableSchemaHash();
|
||||
return {
|
||||
type: typeHash,
|
||||
value: variable,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tag/label arguments
|
||||
* Returns: { tags: Record<string, string>, labels: string[], deleteNames: string[] }
|
||||
@@ -296,9 +246,7 @@ async function cmdRefs(args: string[]): Promise<void> {
|
||||
const node = store.get(hash);
|
||||
if (node === null) die(`Node not found: ${hash}`);
|
||||
const refHashes = refs(store, node);
|
||||
for (const r of refHashes) {
|
||||
console.log(r);
|
||||
}
|
||||
out(await wrapEnvelope(store, "@output/refs", refHashes));
|
||||
}
|
||||
|
||||
async function cmdWalk(args: string[]): Promise<void> {
|
||||
@@ -314,15 +262,16 @@ async function cmdWalk(args: string[]): Promise<void> {
|
||||
});
|
||||
|
||||
const printed = new Set<Hash>();
|
||||
const lines: string[] = [];
|
||||
|
||||
function printNode(h: Hash, prefix: string, isLast: boolean): void {
|
||||
const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
|
||||
if (printed.has(h)) {
|
||||
console.log(`${prefix}${connector}${h} (seen)`);
|
||||
lines.push(`${prefix}${connector}${h} (seen)`);
|
||||
return;
|
||||
}
|
||||
printed.add(h);
|
||||
console.log(`${prefix}${connector}${h}`);
|
||||
lines.push(`${prefix}${connector}${h}`);
|
||||
|
||||
const kids = childMap.get(h) ?? [];
|
||||
const childPrefix =
|
||||
@@ -333,10 +282,13 @@ async function cmdWalk(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
printNode(hash, "", true);
|
||||
out(await wrapEnvelope(store, "@output/walk", lines.join("\n")));
|
||||
} else {
|
||||
const hashes: Hash[] = [];
|
||||
walk(store, hash, (h) => {
|
||||
console.log(h);
|
||||
hashes.push(h);
|
||||
});
|
||||
out(await wrapEnvelope(store, "@output/walk", hashes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +423,8 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var set <name> <hash> [--tag <tag>...]");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
@@ -498,8 +451,7 @@ async function cmdVarSet(args: string[]): Promise<void> {
|
||||
: undefined;
|
||||
|
||||
const variable = varStore.set(name, value, options);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
out(await wrapEnvelope(store, "@output/var-set", variable));
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof InvalidVariableNameError ||
|
||||
@@ -522,15 +474,15 @@ async function cmdVarGet(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var get <name> --schema <hash>");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const variable = varStore.get(name, schema);
|
||||
if (variable === null) {
|
||||
die(`Error: Variable not found: name=${name}, schema=${schema}`);
|
||||
}
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
out(await wrapEnvelope(store, "@output/var-get", variable));
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
@@ -544,19 +496,18 @@ async function cmdVarDelete(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var delete <name> [--schema <hash>]");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
if (schema !== undefined) {
|
||||
// Precise deletion: remove specific (name, schema) variant
|
||||
const variable = varStore.remove(name, schema);
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
out(await wrapEnvelope(store, "@output/var-delete", variable));
|
||||
} else {
|
||||
// Batch deletion: remove all variants for this name
|
||||
const variables = varStore.remove(name);
|
||||
const envelope = await wrapVariableEnvelope(variables);
|
||||
out(envelope);
|
||||
out(await wrapEnvelope(store, "@output/var-delete", variables));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof VariableNotFoundError) {
|
||||
@@ -581,7 +532,8 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
die("Usage: json-cas var tag <name> --schema <hash> <operations...>");
|
||||
}
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
|
||||
@@ -592,8 +544,7 @@ async function cmdVarTag(args: string[]): Promise<void> {
|
||||
delete: deleteNames.length > 0 ? deleteNames : undefined,
|
||||
});
|
||||
|
||||
const envelope = await wrapVariableEnvelope(variable);
|
||||
out(envelope);
|
||||
out(await wrapEnvelope(store, "@output/var-tag", variable));
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof VariableNotFoundError ||
|
||||
@@ -613,7 +564,8 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
const schema = flags.schema as string | undefined;
|
||||
const tagFlags = flags.tag;
|
||||
|
||||
const varStore = await openVarStore();
|
||||
const store = await openStore();
|
||||
const varStore = createVariableStore(resolve(varDbPath), store);
|
||||
|
||||
try {
|
||||
// Parse tags/labels from --tag flags
|
||||
@@ -635,8 +587,7 @@ async function cmdVarList(args: string[]): Promise<void> {
|
||||
tags: Object.keys(tags).length > 0 ? tags : undefined,
|
||||
labels: labels.length > 0 ? labels : undefined,
|
||||
});
|
||||
const envelope = await wrapVariableEnvelope(variables);
|
||||
out(envelope);
|
||||
out(await wrapEnvelope(store, "@output/var-list", variables));
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidVariableNameError) {
|
||||
die(`Error: ${e.message}`);
|
||||
@@ -705,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}`);
|
||||
@@ -744,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();
|
||||
}
|
||||
@@ -762,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();
|
||||
}
|
||||
@@ -800,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}`);
|
||||
@@ -817,7 +761,7 @@ async function cmdGc(_args: string[]): Promise<void> {
|
||||
|
||||
try {
|
||||
const stats = gc(store, varStore);
|
||||
out(stats);
|
||||
out(await wrapEnvelope(store, "@output/gc", stats));
|
||||
} finally {
|
||||
varStore.close();
|
||||
}
|
||||
@@ -837,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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user