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:
+10
@@ -13,6 +13,16 @@
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["packages/cli-json-cas/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": { "noConsole": "off" }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.0.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"],
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user