From ca334692b71b391212907899b8a172ebbe205f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 2 Jun 2026 11:36:55 +0000 Subject: [PATCH] feat: add --tag filter to ocas list and ocas var list Multiple --tag flags AND together. Tag format: key:value for tags, bare name for labels. Without --tag, existing behavior is preserved. Fixes #54 --- .changeset/issue-54-list-tag-filter.md | 5 + packages/cli/src/index.ts | 58 ++- .../__snapshots__/edge-cases.test.ts.snap | 2 +- packages/cli/tests/list-tag-filter.test.ts | 369 ++++++++++++++++++ 4 files changed, 428 insertions(+), 6 deletions(-) create mode 100644 .changeset/issue-54-list-tag-filter.md create mode 100644 packages/cli/tests/list-tag-filter.test.ts diff --git a/.changeset/issue-54-list-tag-filter.md b/.changeset/issue-54-list-tag-filter.md new file mode 100644 index 0000000..d16132e --- /dev/null +++ b/.changeset/issue-54-list-tag-filter.md @@ -0,0 +1,5 @@ +--- +"@ocas/cli": minor +--- + +Add `--tag` filter to `ocas list` and `ocas var list`. Multiple `--tag` flags AND together. Tag format: `key:value` for tags, bare name for labels. Without `--tag`, behavior is unchanged. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c423ce6..f1e0768 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,8 +3,9 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import type { Hash, ListOptions, Store, TagOp } from "@ocas/core"; +import type { Hash, ListEntry, ListOptions, Store, TagOp } from "@ocas/core"; import { + applyListOptions, CasNodeNotFoundError, computeHash, gc, @@ -987,12 +988,59 @@ async function cmdGc(_args: string[]): Promise { async function cmdList(_args: string[]): Promise { const typeFlag = flags.type; if (typeof typeFlag !== "string") - die("Usage: ocas list --type "); + die("Usage: ocas list --type [--tag ...]"); const opts = parseListOptions(); + const tagFlags = flags.tag; + const tagArgs = Array.isArray(tagFlags) + ? tagFlags + : typeof tagFlags === "string" + ? [tagFlags] + : []; const store = await openStore(); const typeHash = resolveHash(typeFlag, store); - const entries = store.cas.listByType(typeHash, opts); - await out(await wrapEnvelope(store, "@ocas/output/list", entries), store); + + if (tagArgs.length === 0) { + const entries = store.cas.listByType(typeHash, opts); + await out(await wrapEnvelope(store, "@ocas/output/list", entries), store); + return; + } + + const { tags, labels, deleteNames } = parseTagsLabels(tagArgs); + if (deleteNames.length > 0) { + die("Error: Cannot use deletion syntax (:name) in list filters"); + } + + // Build per-tag-spec hash sets, then intersect. + const tagSpecs: string[] = [ + ...Object.entries(tags).map(([k, v]) => `${k}=${v}`), + ...labels, + ]; + let intersection: Set | null = null; + for (const spec of tagSpecs) { + const hashes = store.tag.listByTag(spec); + const set = new Set(hashes); + if (intersection === null) { + intersection = set; + } else { + const next = new Set(); + for (const h of intersection) { + if (set.has(h)) next.add(h); + } + intersection = next; + } + if (intersection.size === 0) break; + } + + // Get all entries of the requested type (no limit/offset yet) and filter. + const allOfType = store.cas.listByType(typeHash, { + sort: opts.sort, + desc: opts.desc, + }); + const filtered: ListEntry[] = allOfType.filter((e) => + intersection!.has(e.hash), + ); + const paged = applyListOptions(filtered, opts); + await out(await wrapEnvelope(store, "@ocas/output/list", paged), store); } async function cmdListMeta(_args: string[]): Promise { @@ -1035,7 +1083,7 @@ Commands: hash Compute hash without storing (@ocas/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[]) (@ocas/output/list) + list --type [--tag ...] List hashes for a type, optionally filtered by tags (@ocas/output/list) list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta) list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema) tag ... Apply tags/labels to a target (@ocas/output/tag) diff --git a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap index 361a10f..0339d9f 100644 --- a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap @@ -23,7 +23,7 @@ Commands: hash Compute hash without storing (@ocas/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[]) (@ocas/output/list) + list --type [--tag ...] List hashes for a type, optionally filtered by tags (@ocas/output/list) list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta) list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema) tag ... Apply tags/labels to a target (@ocas/output/tag) diff --git a/packages/cli/tests/list-tag-filter.test.ts b/packages/cli/tests/list-tag-filter.test.ts new file mode 100644 index 0000000..ff587bc --- /dev/null +++ b/packages/cli/tests/list-tag-filter.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let testDir: string; +let storePath: string; +let cliPath: string; + +beforeEach(() => { + testDir = join( + tmpdir(), + `ocas-list-tag-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + storePath = join(testDir, "store"); + cliPath = join(import.meta.dir, "../src/index.ts"); + mkdirSync(testDir, { recursive: true }); + mkdirSync(storePath, { recursive: true }); +}); + +afterEach(() => { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +async function runCli(...args: string[]): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + const proc = Bun.spawn( + ["bun", "run", cliPath, "--home", storePath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: proc.exitCode ?? 0, + }; +} + +async function putString(value: string): Promise { + const proc = Bun.spawn( + [ + "bun", + "run", + cliPath, + "--home", + storePath, + "put", + "@ocas/string", + "--pipe", + ], + { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + ); + proc.stdin.write(JSON.stringify(value)); + await proc.stdin.end(); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + await proc.exited; + if ((proc.exitCode ?? 0) !== 0) { + throw new Error(`put failed: ${err}`); + } + return JSON.parse(out.trim()).value as string; +} + +async function tag(target: string, ...tagSpecs: string[]): Promise { + const { exitCode, stderr } = await runCli("tag", target, ...tagSpecs); + if (exitCode !== 0) { + throw new Error(`tag failed: ${stderr}`); + } +} + +async function varSet( + name: string, + hash: string, + ...tagSpecs: string[] +): Promise { + const args = ["var", "set", name, hash]; + for (const t of tagSpecs) { + args.push("--tag", t); + } + const { exitCode, stderr } = await runCli(...args); + if (exitCode !== 0) { + throw new Error(`var set failed: ${stderr}`); + } +} + +function parseListHashes(stdout: string): string[] { + const env = JSON.parse(stdout); + const value = env.value as Array<{ hash: string }>; + return value.map((e) => e.hash); +} + +function parseVarNames(stdout: string): string[] { + const env = JSON.parse(stdout); + const value = env.value as Array<{ name: string }>; + return value.map((v) => v.name); +} + +describe("ocas list --tag", () => { + test("A1. filter by single key-value tag", async () => { + const n1 = await putString("a1-1"); + const n2 = await putString("a1-2"); + const n3 = await putString("a1-3"); + await tag(n1, "env:prod"); + await tag(n2, "env:prod"); + await tag(n3, "env:staging"); + + const { stdout, exitCode } = await runCli( + "list", + "--type", + "@ocas/string", + "--tag", + "env:prod", + ); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes.sort()).toEqual([n1, n2].sort()); + }); + + test("A2. filter by single bare label", async () => { + const n1 = await putString("a2-1"); + const n2 = await putString("a2-2"); + await tag(n1, "featured"); + + const { stdout, exitCode } = await runCli( + "list", + "--type", + "@ocas/string", + "--tag", + "featured", + ); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes).toEqual([n1]); + expect(hashes).not.toContain(n2); + }); + + test("A3. multiple --tag flags = AND", async () => { + const n1 = await putString("a3-1"); + const n2 = await putString("a3-2"); + const n3 = await putString("a3-3"); + await tag(n1, "env:prod", "tier:gold"); + await tag(n2, "env:prod", "tier:silver"); + await tag(n3, "env:staging", "tier:gold"); + + const { stdout, exitCode } = await runCli( + "list", + "--type", + "@ocas/string", + "--tag", + "env:prod", + "--tag", + "tier:gold", + ); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes).toEqual([n1]); + }); + + test("A4. mix tag and label (AND)", async () => { + const n1 = await putString("a4-1"); + const n2 = await putString("a4-2"); + await tag(n1, "env:prod", "featured"); + await tag(n2, "env:prod"); + + const { stdout, exitCode } = await runCli( + "list", + "--type", + "@ocas/string", + "--tag", + "env:prod", + "--tag", + "featured", + ); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes).toEqual([n1]); + }); + + test("A5. no matching nodes returns empty list", async () => { + const n1 = await putString("a5-1"); + await tag(n1, "env:prod"); + + const { stdout, exitCode } = await runCli( + "list", + "--type", + "@ocas/string", + "--tag", + "env:nope", + ); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes).toEqual([]); + }); + + test("A6. no --tag flag → existing behavior unchanged", async () => { + const n1 = await putString("a6-1"); + const n2 = await putString("a6-2"); + + const { stdout, exitCode } = await runCli("list", "--type", "@ocas/string"); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes).toContain(n1); + expect(hashes).toContain(n2); + }); + + test("A7. --tag with --limit", async () => { + const n1 = await putString("a7-1"); + const n2 = await putString("a7-2"); + const n3 = await putString("a7-3"); + await tag(n1, "env:prod"); + await tag(n2, "env:prod"); + await tag(n3, "env:prod"); + + const { stdout, exitCode } = await runCli( + "list", + "--type", + "@ocas/string", + "--tag", + "env:prod", + "--limit", + "2", + ); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes).toHaveLength(2); + for (const h of hashes) { + expect([n1, n2, n3]).toContain(h); + } + }); + + test("A8. filter restricts to requested type", async () => { + const n1 = await putString("a8-1"); + await tag(n1, "env:prod"); + // Tag a non-string node (a schema) with the same tag + // Use @ocas/schema as a known schema-typed node + const { stdout: schemaListStdout } = await runCli( + "list-schema", + "--limit", + "1000", + ); + const schemaEntries = JSON.parse(schemaListStdout).value as Array<{ + hash: string; + }>; + expect(schemaEntries.length).toBeGreaterThan(0); + const otherTypeHash = schemaEntries[0]!.hash; + await tag(otherTypeHash, "env:prod"); + + const { stdout, exitCode } = await runCli( + "list", + "--type", + "@ocas/string", + "--tag", + "env:prod", + ); + expect(exitCode).toBe(0); + const hashes = parseListHashes(stdout); + expect(hashes).toContain(n1); + expect(hashes).not.toContain(otherTypeHash); + }); +}); + +describe("ocas var list --tag", () => { + test("B1. filter by key-value tag", async () => { + const h1 = await putString("b1-1"); + const h2 = await putString("b1-2"); + await varSet("@app/db1", h1, "env:prod"); + await varSet("@app/db2", h2, "env:staging"); + + const { stdout, exitCode } = await runCli( + "var", + "list", + "--tag", + "env:prod", + ); + expect(exitCode).toBe(0); + const names = parseVarNames(stdout); + expect(names).toContain("@app/db1"); + expect(names).not.toContain("@app/db2"); + }); + + test("B2. filter by bare label", async () => { + const h1 = await putString("b2-1"); + const h2 = await putString("b2-2"); + await varSet("@app/v1", h1, "stable"); + await varSet("@app/v2", h2); + + const { stdout, exitCode } = await runCli("var", "list", "--tag", "stable"); + expect(exitCode).toBe(0); + const names = parseVarNames(stdout); + expect(names).toContain("@app/v1"); + expect(names).not.toContain("@app/v2"); + }); + + test("B3. multiple --tag flags = AND", async () => { + const h1 = await putString("b3-1"); + const h2 = await putString("b3-2"); + const h3 = await putString("b3-3"); + await varSet("@app/v1", h1, "env:prod", "stable"); + await varSet("@app/v2", h2, "env:prod"); + await varSet("@app/v3", h3, "stable"); + + const { stdout, exitCode } = await runCli( + "var", + "list", + "--tag", + "env:prod", + "--tag", + "stable", + ); + expect(exitCode).toBe(0); + const names = parseVarNames(stdout); + expect(names.filter((n) => n.startsWith("@app/"))).toEqual(["@app/v1"]); + }); + + test("B4. no --tag → existing behavior unchanged", async () => { + const h1 = await putString("b4-1"); + await varSet("@app/v1", h1); + + const { stdout, exitCode } = await runCli("var", "list", "@app/"); + expect(exitCode).toBe(0); + const names = parseVarNames(stdout); + expect(names).toContain("@app/v1"); + }); + + test("B5. --tag combined with namePrefix", async () => { + const h1 = await putString("b5-1"); + const h2 = await putString("b5-2"); + await varSet("@app/db1", h1, "env:prod"); + await varSet("@other/db1", h2, "env:prod"); + + const { stdout, exitCode } = await runCli( + "var", + "list", + "@app/", + "--tag", + "env:prod", + ); + expect(exitCode).toBe(0); + const names = parseVarNames(stdout); + expect(names).toContain("@app/db1"); + expect(names).not.toContain("@other/db1"); + }); + + test("B6. empty result when no matching variables", async () => { + const h1 = await putString("b6-1"); + await varSet("@app/v1", h1); + + const { stdout, exitCode } = await runCli( + "var", + "list", + "--tag", + "missing", + ); + expect(exitCode).toBe(0); + const names = parseVarNames(stdout); + expect(names.filter((n) => n.startsWith("@app/"))).toEqual([]); + }); +});