diff --git a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap index dbb7672..6c08e65 100644 --- a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap +++ b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap @@ -254,13 +254,13 @@ command's @output/* schema (shown in parentheses); pipe any envelope into \`render -p\` to render its value (cas_ref hashes are expanded). Commands: - put Store node, print envelope (value=hash) (@output/put) + put Store node, print envelope (value=hash) (@output/put) get Print node as envelope (@output/get) has Print envelope (value=boolean) (@output/has) verify Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify) refs List direct cas_ref edges (@output/refs) walk [--format tree] Recursive traversal (@output/walk) - hash Compute hash without storing (@output/hash) + hash Compute hash without storing (@output/hash) render [options] Render node as text with resolution decay (raw output) render --pipe/-p [options] Render { type, value } from stdin (raw output) list --type List hashes for a type (value=string[]) (@output/list) @@ -285,5 +285,5 @@ Flags: --resolution Initial resolution for render (default: 1.0) --decay Decay factor for render (default: 0.5) --epsilon Cutoff threshold for render (default: 0.01) - --pipe, -p Read { type, value } JSON from stdin for render" + --pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)" `; diff --git a/packages/cli-json-cas/src/e2e.test.ts b/packages/cli-json-cas/src/e2e.test.ts index e0abe65..e4dbf81 100644 --- a/packages/cli-json-cas/src/e2e.test.ts +++ b/packages/cli-json-cas/src/e2e.test.ts @@ -628,3 +628,68 @@ describe("Phase 8: Pipe Composition", () => { expect(stdout).toBe("Person: Carol (25)"); }); }); + +// ---- Phase 9: Put/Hash Pipe Input ---- + +describe("Phase 9: Put/Hash Pipe Input", () => { + test("9.1 put -p reads JSON from stdin and stores node", async () => { + const payload = JSON.stringify({ name: "PipeAlice", age: 99 }); + const { stdout, exitCode } = await runCliWithStdin( + ["put", typeHash, "-p"], + payload, + ); + expect(exitCode).toBe(0); + const hash = envValue(stdout); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + + // Verify stored correctly + const { stdout: getOut } = await runCli(["get", hash as string]); + expect(getOut).toContain("PipeAlice"); + }); + + test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => { + const payload = JSON.stringify({ name: "PipeBob", age: 55 }); + const { stdout, exitCode } = await runCliWithStdin( + ["hash", typeHash, "-p"], + payload, + ); + expect(exitCode).toBe(0); + const hash = envValue(stdout); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + + // Should NOT be stored + const { exitCode: hasExit, stdout: hasOut } = await runCli([ + "has", + hash as string, + ]); + expect(hasExit).toBe(0); + expect(envValue(hasOut)).toBe(false); + }); + + test("9.3 put -p with file arg errors", async () => { + const { stderr, exitCode } = await runCliWithStdin( + ["put", typeHash, "some-file.json", "-p"], + "{}", + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Cannot use --pipe/-p with a file argument"); + }); + + test("9.4 put -p with empty stdin errors", async () => { + const { stderr, exitCode } = await runCliWithStdin( + ["put", typeHash, "-p"], + "", + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("No input on stdin"); + }); + + test("9.5 put -p with invalid JSON errors", async () => { + const { stderr, exitCode } = await runCliWithStdin( + ["put", typeHash, "-p"], + "not json", + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Invalid JSON on stdin"); + }); +}); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index f09abd6..3b8a5c8 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -75,6 +75,8 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } { } else { flags[key] = true; } + } else if (arg === "-p") { + flags.p = true; } else { positional.push(arg); } @@ -113,6 +115,22 @@ function readJsonFile(file: string): unknown { } } +async function readStdinJson(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + const input = Buffer.concat(chunks).toString("utf-8").trim(); + if (!input) { + die("No input on stdin. Pipe JSON content."); + } + try { + return JSON.parse(input); + } catch { + return die("Invalid JSON on stdin."); + } +} + /** * Open the filesystem-backed CAS store. * Automatically creates directory and bootstraps if needed. @@ -180,12 +198,17 @@ function parseTagsLabels(args: string[]): { // ---- Commands ---- async function cmdPut(args: string[]): Promise { + const isPipe = flags.pipe === true || flags.p === true; const typeHashOrAlias = args[0]; - const file = args[1]; - if (!typeHashOrAlias || !file) - die("Usage: json-cas put "); + const file = isPipe ? undefined : args[1]; + if (!typeHashOrAlias || (!isPipe && !file)) + die( + "Usage: json-cas put \n json-cas put --pipe/-p", + ); + if (isPipe && args[1]) + die("Cannot use --pipe/-p with a file argument. Use one or the other."); const typeHash = await resolveTypeHash(typeHashOrAlias); - const payload = readJsonFile(file); + const payload = isPipe ? await readStdinJson() : readJsonFile(file as string); const store = await openStore(); // Schema nodes: use putSchema() which validates via isValidSchema() (recursive) @@ -311,12 +334,17 @@ async function cmdWalk(args: string[]): Promise { } async function cmdHash(args: string[]): Promise { + const isPipe = flags.pipe === true || flags.p === true; const typeHashOrAlias = args[0]; - const file = args[1]; - if (!typeHashOrAlias || !file) - die("Usage: json-cas hash "); + const file = isPipe ? undefined : args[1]; + if (!typeHashOrAlias || (!isPipe && !file)) + die( + "Usage: json-cas hash \n json-cas hash --pipe/-p", + ); + if (isPipe && args[1]) + die("Cannot use --pipe/-p with a file argument. Use one or the other."); const typeHash = await resolveTypeHash(typeHashOrAlias); - const payload = readJsonFile(file); + const payload = isPipe ? await readStdinJson() : readJsonFile(file as string); const hash = await computeHash(typeHash, payload); const store = await openStore(); out(await wrapEnvelope(store, "@output/hash", hash)); @@ -804,13 +832,13 @@ command's @output/* schema (shown in parentheses); pipe any envelope into \`render -p\` to render its value (cas_ref hashes are expanded). Commands: - put Store node, print envelope (value=hash) (@output/put) + put Store node, print envelope (value=hash) (@output/put) get Print node as envelope (@output/get) has Print envelope (value=boolean) (@output/has) verify Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify) refs List direct cas_ref edges (@output/refs) walk [--format tree] Recursive traversal (@output/walk) - hash Compute hash without storing (@output/hash) + hash Compute hash without storing (@output/hash) render [options] Render node as text with resolution decay (raw output) render --pipe/-p [options] Render { type, value } from stdin (raw output) list --type List hashes for a type (value=string[]) (@output/list) @@ -835,7 +863,7 @@ Flags: --resolution Initial resolution for render (default: 1.0) --decay Decay factor for render (default: 0.5) --epsilon Cutoff threshold for render (default: 0.01) - --pipe, -p Read { type, value } JSON from stdin for render`); + --pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)`); } // ---- Dispatch ----