feat: Phase 4 — CLI (@uncaged/cli-json-cas)

- Full CLI: init, bootstrap, schema (put/get/list/validate),
  put, get, has, verify, list, refs, walk, hash, cat
- --store flag, --json compact output, --format tree
- biome override: noConsole off for CLI package
- 64 tests passing

Closes #6
小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-17 09:37:27 +00:00
parent 49b1d5d665
commit 1ff719bf8b
8 changed files with 416 additions and 0 deletions
+10
View File
@@ -13,6 +13,16 @@
"indentStyle": "space",
"indentWidth": 2
},
"overrides": [
{
"includes": ["packages/cli-json-cas/**"],
"linter": {
"rules": {
"suspicious": { "noConsole": "off" }
}
}
}
],
"linter": {
"enabled": true,
"rules": {
+20
View File
@@ -6,9 +6,21 @@
"name": "@uncaged/json-cas-workspace",
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0",
},
},
"packages/cli-json-cas": {
"name": "@uncaged/cli-json-cas",
"version": "0.1.0",
"bin": {
"json-cas": "./src/index.ts",
},
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"@uncaged/json-cas-fs": "workspace:^",
},
},
"packages/json-cas": {
"name": "@uncaged/json-cas",
"version": "0.1.0",
@@ -46,12 +58,18 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
"@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@uncaged/cli-json-cas": ["@uncaged/cli-json-cas@workspace:packages/cli-json-cas"],
"@uncaged/json-cas": ["@uncaged/json-cas@workspace:packages/json-cas"],
"@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@workspace:packages/json-cas-fs"],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cborg": ["cborg@4.5.8", "", { "bin": { "cborg": "lib/bin.js" } }, "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -64,6 +82,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
}
}
+1
View File
@@ -6,6 +6,7 @@
],
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"bun-types": "^1.3.14",
"typescript": "^5.8.0"
},
"scripts": {
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.1.0",
"type": "module",
"bin": {
"json-cas": "./src/index.ts"
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "workspace:^",
"@uncaged/json-cas-fs": "workspace:^"
}
}
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env bun
import { mkdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { CasNode, Hash, JSONSchema, Store } from "@uncaged/json-cas";
import {
bootstrap,
computeHash,
getSchema,
putSchema,
refs,
validate,
verify,
walk,
} from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Argument parsing ----
type Flags = Record<string, string | boolean>;
/** Flags that consume the next token as their value. All others are boolean. */
const VALUE_FLAGS = new Set(["store", "format"]);
function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
const flags: Flags = {};
const positional: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i] as string;
if (arg.startsWith("--")) {
const key = arg.slice(2);
if (VALUE_FLAGS.has(key)) {
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
flags[key] = next;
i++;
} else {
flags[key] = true;
}
} else {
flags[key] = true;
}
} else {
positional.push(arg);
}
}
return { flags, positional };
}
const { flags, positional } = parseArgs(process.argv.slice(2));
const storePath = typeof flags["store"] === "string" ? flags["store"] : ".cas";
const compact = flags["json"] === true;
// ---- Helpers ----
function out(data: unknown): void {
console.log(compact ? JSON.stringify(data) : JSON.stringify(data, null, 2));
}
function die(msg: string): never {
console.error(msg);
process.exit(1);
}
function readJsonFile(file: string): unknown {
try {
return JSON.parse(readFileSync(file, "utf-8"));
} catch (e) {
return die(`Cannot read JSON from "${file}": ${e}`);
}
}
function openStore(): Store {
return createFsStore(resolve(storePath));
}
// ---- Commands ----
async function cmdInit(): Promise<void> {
const dir = resolve(storePath);
mkdirSync(dir, { recursive: true });
const store = createFsStore(dir);
const hash = await bootstrap(store);
console.log(hash);
}
async function cmdBootstrap(): Promise<void> {
const store = openStore();
const hash = await bootstrap(store);
console.log(hash);
}
async function cmdSchemaPut(args: string[]): Promise<void> {
const file = args[0];
if (!file) die("Usage: json-cas schema put <file.json>");
const schema = readJsonFile(file) as JSONSchema;
const store = openStore();
const hash = await putSchema(store, schema);
console.log(hash);
}
async function cmdSchemaGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema get <type-hash>");
const store = openStore();
const schema = getSchema(store, hash);
if (schema === null) die(`Schema not found: ${hash}`);
out(schema);
}
async function cmdSchemaList(): Promise<void> {
const store = openStore();
const metaHash = await bootstrap(store);
for (const hash of store.list()) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null && node.type === metaHash) {
const schema = node.payload as JSONSchema;
const name =
(schema["title"] as string | undefined) ??
(schema["description"] as string | undefined) ??
"(unnamed)";
console.log(`${hash} ${name}`);
}
}
}
async function cmdSchemaValidate(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas schema validate <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const valid = validate(store, node);
console.log(valid ? "valid" : "invalid");
}
async function cmdPut(args: string[]): Promise<void> {
const typeHash = args[0];
const file = args[1];
if (!typeHash || !file) die("Usage: json-cas put <type-hash> <file.json>");
const payload = readJsonFile(file);
const store = openStore();
const hash = await store.put(typeHash, payload);
console.log(hash);
}
async function cmdGet(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas get <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
out(node);
}
async function cmdHas(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas has <hash>");
const store = openStore();
console.log(String(store.has(hash)));
}
async function cmdVerify(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas verify <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
const ok = await verify(hash, node);
console.log(ok ? "ok" : "corrupted");
}
async function cmdList(): Promise<void> {
const store = openStore();
for (const hash of store.list()) {
console.log(hash);
}
}
async function cmdRefs(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas refs <hash>");
const store = openStore();
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);
}
}
async function cmdWalk(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas walk <hash> [--format tree]");
const store = openStore();
const format = flags["format"];
if (format === "tree") {
const childMap = new Map<Hash, Hash[]>();
walk(store, hash, (h, node) => {
childMap.set(h, refs(store, node));
});
const printed = new Set<Hash>();
function printNode(h: Hash, prefix: string, isLast: boolean): void {
const connector = prefix === "" ? "" : isLast ? "└── " : "├── ";
if (printed.has(h)) {
console.log(`${prefix}${connector}${h} (seen)`);
return;
}
printed.add(h);
console.log(`${prefix}${connector}${h}`);
const kids = childMap.get(h) ?? [];
const childPrefix =
prefix === "" ? "" : prefix + (isLast ? " " : "│ ");
for (let i = 0; i < kids.length; i++) {
printNode(kids[i] as Hash, childPrefix, i === kids.length - 1);
}
}
printNode(hash, "", true);
} else {
walk(store, hash, (h) => {
console.log(h);
});
}
}
async function cmdHash(args: string[]): Promise<void> {
const typeHash = args[0];
const file = args[1];
if (!typeHash || !file) die("Usage: json-cas hash <type-hash> <file.json>");
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
console.log(hash);
}
async function cmdCat(args: string[]): Promise<void> {
const hash = args[0];
if (!hash) die("Usage: json-cas cat <hash>");
const store = openStore();
const node = store.get(hash);
if (node === null) die(`Node not found: ${hash}`);
if (flags["payload"] === true) {
out(node.payload);
} else {
out(node);
}
}
function printUsage(): void {
console.log(`\
Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create .cas/ 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
list List all hashes
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)
Flags:
--store <path> Store directory (default: .cas)
--json Compact JSON output`);
}
// ---- Dispatch ----
const [cmd, ...rest] = positional;
if (!cmd) {
printUsage();
process.exit(0);
}
switch (cmd) {
case "init":
await cmdInit();
break;
case "bootstrap":
await cmdBootstrap();
break;
case "schema": {
const [sub, ...subRest] = rest;
switch (sub) {
case "put":
await cmdSchemaPut(subRest);
break;
case "get":
await cmdSchemaGet(subRest);
break;
case "list":
await cmdSchemaList();
break;
case "validate":
await cmdSchemaValidate(subRest);
break;
default:
die(`Unknown schema subcommand: ${sub ?? "(none)"}`);
}
break;
}
case "put":
await cmdPut(rest);
break;
case "get":
await cmdGet(rest);
break;
case "has":
await cmdHas(rest);
break;
case "verify":
await cmdVerify(rest);
break;
case "list":
await cmdList();
break;
case "refs":
await cmdRefs(rest);
break;
case "walk":
await cmdWalk(rest);
break;
case "hash":
await cmdHash(rest);
break;
case "cat":
await cmdCat(rest);
break;
default:
die(`Unknown command: ${cmd}`);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"types": ["bun-types"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",