feat: wrap simple commands with envelope (Phase 2) #79

Merged
xiaoju merged 1 commits from fix/70-simple-envelope into main 2026-05-31 15:29:55 +00:00
4 changed files with 106 additions and 94 deletions
@@ -2,15 +2,23 @@
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
"type": "ASE7K6A0HG8W9",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
},
"type": "7XX5H51CVD9H0",
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `"ok"`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"type": "8E2M8H30BHXS8",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`;
@@ -216,16 +224,16 @@ exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity + schema, print ok/corrupted/invalid
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 (dry run)
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 all hashes for a given type
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)
+47 -47
View File
@@ -15,6 +15,11 @@ import { createFsStore, openStore as openFsStore } from "@uncaged/json-cas-fs";
const pkgPath = resolve(import.meta.dir, "../package.json");
const entrypoint = resolve(import.meta.dir, "index.ts");
/** Extract the `value` field from a { type, value } envelope JSON string. */
function envValue(json: string): unknown {
return (JSON.parse(json.trim()) as { value: unknown }).value;
}
/**
* Register a schema directly via the library (CLI schema put was removed).
* Returns the type hash.
@@ -168,8 +173,8 @@ describe("@ Alias Resolution - put", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
// Should output a valid hash (13 chars)
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Should output an envelope whose value is a valid hash (13 chars)
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @number <file> should resolve alias", async () => {
@@ -185,7 +190,7 @@ describe("@ Alias Resolution - put", () => {
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @object <file> should resolve alias", async () => {
@@ -201,7 +206,7 @@ describe("@ Alias Resolution - put", () => {
);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("ucas put @invalid <file> should fail", async () => {
@@ -236,7 +241,7 @@ describe("@ Alias Resolution - hash", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
@@ -313,10 +318,10 @@ describe("Issue #50: Schema Validation in put", () => {
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify node was stored
const hash = stdout.trim();
const hash = envValue(stdout) as string;
const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore);
expect(hasExitCode).toBe(0);
} finally {
@@ -355,7 +360,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -403,7 +408,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -423,7 +428,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -469,7 +474,7 @@ describe("Issue #50: Schema Validation in put", () => {
["has", "0000000000000"],
tmpStore,
);
expect(hasOutput.trim()).toBe("false");
expect(envValue(hasOutput)).toBe(false);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -692,7 +697,7 @@ describe("Issue #50: Schema Validation in put", () => {
);
expect(exitCode).toBe(0);
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
} finally {
rmSync(tmpStore, { recursive: true, force: true });
}
@@ -850,33 +855,30 @@ describe("Suite 6: CLI Integration with Templates", () => {
// Create node
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice" }));
const { stdout: nodeHash } = await runCli(
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Create template file (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!"));
const { stdout: tmplHash } = await runCli(
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
// Register template
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with template
const { stdout: output, exitCode } = await runCli(
["render", nodeHash.trim()],
["render", nodeHash],
tmpStore,
);
@@ -911,21 +913,23 @@ describe("Suite 6: CLI Integration with Templates", () => {
// Create child node
const childFile = join(tmpStore, "child.json");
writeFileSync(childFile, JSON.stringify({ value: "child", child: null }));
const { stdout: childHash } = await runCli(
const { stdout: childOut } = await runCli(
["put", schemaHash.trim(), childFile],
tmpStore,
);
const childHash = envValue(childOut) as string;
// Create parent node
const parentFile = join(tmpStore, "parent.json");
writeFileSync(
parentFile,
JSON.stringify({ value: "parent", child: childHash.trim() }),
JSON.stringify({ value: "parent", child: childHash }),
);
const { stdout: parentHash } = await runCli(
const { stdout: parentOut } = await runCli(
["put", schemaHash.trim(), parentFile],
tmpStore,
);
const parentHash = envValue(parentOut) as string;
// Create template showing resolution (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
@@ -933,25 +937,21 @@ describe("Suite 6: CLI Integration with Templates", () => {
templateFile,
JSON.stringify("{{ payload.value }}(res={{ resolution }})"),
);
const { stdout: tmplHash } = await runCli(
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
// Register template
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
// Render with custom decay
const { stdout: output, exitCode } = await runCli(
["render", parentHash.trim(), "--decay", "0.7"],
["render", parentHash, "--decay", "0.7"],
tmpStore,
);
@@ -979,10 +979,11 @@ describe("Suite 6: CLI Integration with Templates", () => {
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Bob" }));
const { stdout: nodeHash } = await runCli(
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Create template (JSON-encoded string)
const templateFile = join(tmpStore, "template.json");
@@ -990,25 +991,21 @@ describe("Suite 6: CLI Integration with Templates", () => {
templateFile,
JSON.stringify("Greetings {{ payload.name }}!"),
);
const { stdout: tmplHash } = await runCli(
const { stdout: tmplOut } = await runCli(
["put", "@string", templateFile],
tmpStore,
);
const tmplHash = envValue(tmplOut) as string;
await runCli(
[
"var",
"set",
`@ucas/template/text/${schemaHash.trim()}`,
tmplHash.trim(),
],
["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash],
tmpStore,
);
const { stdout: output, exitCode } = await runCli(
[
"render",
nodeHash.trim(),
nodeHash,
"--resolution",
"0.8",
"--decay",
@@ -1043,14 +1040,15 @@ describe("Suite 6: CLI Integration with Templates", () => {
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" }));
const { stdout: nodeHash } = await runCli(
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// No template registered - should fall back to YAML
const { stdout: output, exitCode } = await runCli(
["render", nodeHash.trim()],
["render", nodeHash],
tmpStore,
);
@@ -1079,13 +1077,14 @@ describe("Suite 6: CLI Integration with Templates", () => {
const nodeFile = join(tmpStore, "node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Test" }));
const { stdout: nodeHash } = await runCli(
const { stdout: nodeOut } = await runCli(
["put", schemaHash.trim(), nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
const { exitCode, stderr } = await runCli(
["render", nodeHash.trim(), "--decay", "1.5"],
["render", nodeHash, "--decay", "1.5"],
tmpStore,
);
@@ -1126,14 +1125,15 @@ describe("Suite 6: CLI Integration with Templates", () => {
// Create and store a simple string node
const nodeFile = join(tmpStore, "test.json");
writeFileSync(nodeFile, JSON.stringify("hello world"));
const { stdout: nodeHash } = await runCli(
const { stdout: nodeOut } = await runCli(
["put", stringType, nodeFile],
tmpStore,
);
const nodeHash = envValue(nodeOut) as string;
// Render the valid hash
const { exitCode, stdout, stderr } = await runCli(
["render", nodeHash.trim()],
["render", nodeHash],
tmpStore,
);
+22 -20
View File
@@ -54,6 +54,11 @@ function stripVolatile(json: string): unknown {
return strip(JSON.parse(json));
}
/** Extract the `value` field from a { type, value } envelope JSON string. */
function envValue(json: string): unknown {
return (JSON.parse(json) as { value: unknown }).value;
}
// ---- Phase 1: CAS Core ----
describe("Phase 1: CAS Core", () => {
@@ -88,8 +93,8 @@ describe("Phase 1: CAS Core", () => {
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout, exitCode } = await runCli(["put", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = stdout;
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = envValue(stdout) as string;
});
test("1.6 get returns node JSON (snapshot)", async () => {
@@ -101,19 +106,19 @@ describe("Phase 1: CAS Core", () => {
test("1.7 has returns true for existing node", async () => {
const { stdout, exitCode } = await runCli(["has", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("true");
expect(envValue(stdout)).toBe(true);
});
test("1.8 has returns false for non-existing hash", async () => {
const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]);
expect(exitCode).toBe(0);
expect(stdout).toBe("false");
expect(envValue(stdout)).toBe(false);
});
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.10 refs lists direct references (snapshot)", async () => {
@@ -132,13 +137,13 @@ describe("Phase 1: CAS Core", () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toBe(nodeHash);
expect(envValue(stdout)).toBe(nodeHash);
});
test("1.13 list --type returns nodes of that type", async () => {
const { stdout, exitCode } = await runCli(["list", "--type", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toContain(nodeHash);
expect(envValue(stdout)).toContain(nodeHash);
});
});
@@ -163,7 +168,7 @@ describe("Phase 2: Schema Validation", () => {
test("2.2 verify on valid node returns ok (hash + schema)", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("ok");
expect(envValue(stdout)).toBe("ok");
});
test("2.3 put against non-existent schema hash fails", async () => {
@@ -222,12 +227,13 @@ describe("Phase 3: Variable System", () => {
test("3.5 var set upsert updates existing variable", async () => {
const node2File = join(tmpStore, "node2.json");
writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 }));
const { stdout: node2Hash } = await runCli(["put", typeHash, node2File]);
const { stdout: node2Out } = await runCli(["put", typeHash, node2File]);
const node2Hash = envValue(node2Out) as string;
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash.trim(),
node2Hash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
@@ -405,7 +411,7 @@ describe("Phase 6: GC", () => {
const gcNodeFile = join(tmpStore, "gc-node.json");
writeFileSync(gcNodeFile, JSON.stringify({ name: "GcAlice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, gcNodeFile]);
gcNodeHash = stdout.trim();
gcNodeHash = envValue(stdout) as string;
// Set a var referencing this node so it survives GC during Phase 6
await runCli(["var", "set", "gc-test/ref", gcNodeHash]);
});
@@ -428,25 +434,21 @@ describe("Phase 6: GC", () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", gcNodeHash]);
expect(stdout).toBe("true");
expect(envValue(stdout)).toBe(true);
});
test("6.3 gc reclaims orphan node", async () => {
const orphanFile = join(tmpStore, "orphan.json");
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
const { stdout: orphanHashRaw } = await runCli([
"put",
typeHash,
orphanFile,
]);
const orphanHash = orphanHashRaw.trim();
const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]);
const orphanHash = envValue(orphanOut) as string;
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(beforeGc).toBe("true");
expect(envValue(beforeGc)).toBe(true);
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(afterGc).toBe("false");
expect(envValue(afterGc)).toBe(false);
});
});
+18 -16
View File
@@ -22,6 +22,7 @@ import {
validate,
verify,
walk,
wrapEnvelope,
} from "@uncaged/json-cas";
import { openStore as openFsStore } from "@uncaged/json-cas-fs";
@@ -253,7 +254,7 @@ async function cmdPut(args: string[]): Promise<void> {
}
const hash = await store.put(typeHash, payload);
console.log(hash);
out(await wrapEnvelope(store, "@output/put", hash));
}
async function cmdGet(args: string[]): Promise<void> {
@@ -262,14 +263,14 @@ async function cmdGet(args: string[]): Promise<void> {
const store = await openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
out(node);
out(await wrapEnvelope(store, "@output/get", node));
}
async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas has <hash>");
const store = await openStore();
console.log(String(store.has(hash)));
out(await wrapEnvelope(store, "@output/has", store.has(hash)));
}
async function cmdVerify(args: string[]): Promise<void> {
@@ -279,12 +280,13 @@ async function cmdVerify(args: string[]): Promise<void> {
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const ok = await verify(hash, node);
let status: string;
if (!ok) {
console.log("corrupted");
status = "corrupted";
} else {
const valid = validate(store, node);
console.log(valid ? "ok" : "invalid");
status = validate(store, node) ? "ok" : "invalid";
}
out(await wrapEnvelope(store, "@output/verify", status));
}
async function cmdRefs(args: string[]): Promise<void> {
@@ -346,7 +348,8 @@ async function cmdHash(args: string[]): Promise<void> {
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
console.log(hash);
const store = await openStore();
out(await wrapEnvelope(store, "@output/hash", hash));
}
async function cmdRender(args: string[]): Promise<void> {
@@ -826,9 +829,8 @@ async function cmdList(_args: string[]): Promise<void> {
die("Usage: json-cas list --type <hash-or-alias>");
const typeHash = await resolveTypeHash(typeFlag);
const store = await openStore();
for (const hash of store.listByType(typeHash)) {
console.log(hash);
}
const hashes = Array.from(store.listByType(typeHash));
out(await wrapEnvelope(store, "@output/list", hashes));
}
function printUsage(): void {
@@ -836,16 +838,16 @@ function printUsage(): void {
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity + schema, print ok/corrupted/invalid
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 (dry run)
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 all hashes for a given type
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)