From 561f2a33b7bd2a5f1b2e501e6849a7002983da8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 2 Jun 2026 11:11:29 +0000 Subject: [PATCH] feat: add top-level ocas tag/untag commands, remove var tag Adds `ocas tag ...` and `ocas untag ...` top-level CLI commands operating on store.tag.* (TagStore). Targets may be hashes or @scope/name variables (resolved via resolveHash). The redundant `ocas var tag` subcommand is removed; `var tag` now falls through to "Unknown var subcommand: tag". Registers `@ocas/output/tag` and `@ocas/output/untag` schemas and templates in bootstrap; removes `@ocas/output/var-tag`. Closes #52 Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/index.ts | 114 ++++---- packages/cli/src/prompts/usage.md | 4 +- .../__snapshots__/edge-cases.test.ts.snap | 34 ++- packages/cli/tests/edge-cases.test.ts | 19 +- packages/cli/tests/tag-untag.test.ts | 235 ++++++++++++++++ packages/cli/tests/variable.test.ts | 254 +----------------- packages/core/src/bootstrap.test.ts | 9 +- packages/core/src/bootstrap.ts | 40 ++- packages/core/src/index.test.ts | 6 +- packages/core/src/output-templates.test.ts | 5 +- packages/core/src/output-templates.ts | 12 +- packages/fs/src/store.test.ts | 2 +- 12 files changed, 387 insertions(+), 347 deletions(-) create mode 100644 packages/cli/tests/tag-untag.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9e1dbcd..cb54ced 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,13 +3,12 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import type { Hash, ListOptions, Store } from "@ocas/core"; +import type { Hash, ListOptions, Store, TagOp } from "@ocas/core"; import { CasNodeNotFoundError, computeHash, gc, getSchema, - InvalidTagFormatError, InvalidVariableNameError, putSchema, refs, @@ -685,64 +684,51 @@ async function cmdVarDelete(args: string[]): Promise { } } -async function cmdVarTag(args: string[]): Promise { - const name = args[0]; - const schemaInput = flags.schema as string | undefined; - - if (!name || !schemaInput) { - die("Usage: ocas var tag --schema "); - } - +async function cmdTag(args: string[]): Promise { + const targetInput = args[0]; const tagArgs = args.slice(1); - if (tagArgs.length === 0) { - die("Usage: ocas var tag --schema "); + if (!targetInput || tagArgs.length === 0) { + die("Usage: ocas tag ..."); } - const store = await openStore(); - - try { - const schema = resolveHash(schemaInput, store); - const { tags, labels, deleteNames } = parseTagsLabels(tagArgs); - - // VarStore.set with options replaces all tags/labels — to express - // "add some / delete some / preserve the rest", merge against the current. - const existing = store.var.get(name, schema); - if (existing === null) { - throw new VariableNotFoundError(name, schema); - } - const newTags: Record = { ...existing.tags }; - const newLabels: string[] = [...existing.labels]; - for (const k of deleteNames) { - delete newTags[k]; - const idx = newLabels.indexOf(k); - if (idx !== -1) newLabels.splice(idx, 1); - } - for (const [k, v] of Object.entries(tags)) { - newTags[k] = v; - } - for (const lb of labels) { - if (!newLabels.includes(lb)) newLabels.push(lb); - } - - const variable = store.var.set(name, existing.value, { - tags: newTags, - labels: newLabels, - }); - - await out( - await wrapEnvelope(store, "@ocas/output/var-tag", variable), - store, - ); - } catch (e) { - if ( - e instanceof VariableNotFoundError || - e instanceof TagLabelConflictError || - e instanceof InvalidTagFormatError - ) { - die(`Error: ${e.message}`); - } - throw e; + const target = resolveHash(targetInput, store); + const { tags, labels, deleteNames } = parseTagsLabels(tagArgs); + if (deleteNames.length > 0) { + die("Error: Cannot use deletion syntax (:name) in tag (use untag)"); } + const ops: TagOp[] = [ + ...Object.entries(tags).map( + ([key, value]) => ({ op: "set", key, value }) as TagOp, + ), + ...labels.map((key) => ({ op: "set", key }) as TagOp), + ]; + store.tag.tag(target, ops); + await out( + await wrapEnvelope(store, "@ocas/output/tag", store.tag.tags(target)), + store, + ); +} + +async function cmdUntag(args: string[]): Promise { + const targetInput = args[0]; + const tagArgs = args.slice(1); + if (!targetInput || tagArgs.length === 0) { + die("Usage: ocas untag ..."); + } + const store = await openStore(); + const target = resolveHash(targetInput, store); + const keys = tagArgs.map((a) => + a.startsWith(":") + ? a.slice(1) + : a.includes(":") + ? a.slice(0, a.indexOf(":")) + : a, + ); + store.tag.untag(target, keys); + await out( + await wrapEnvelope(store, "@ocas/output/untag", store.tag.tags(target)), + store, + ); } async function cmdVarHistory(args: string[]): Promise { @@ -1043,12 +1029,13 @@ Commands: render --pipe/-p [options] Render { type, value } from stdin (raw output) list --type List hashes for a type (value=string[]) (@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) + list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema) + tag ... Apply tags/labels to a target (@ocas/output/tag) + untag ... Remove tags/labels from a target (@ocas/output/untag) var set [--tag ...] Create/update a variable (@ocas/output/var-set) var get --schema Get a variable by name + schema (@ocas/output/var-get) var delete [--schema ] Delete variable(s) (@ocas/output/var-delete) var list [prefix] [--schema ] [--tag ...] List variables (@ocas/output/var-list) - var tag --schema Modify tags/labels (@ocas/output/var-tag) var history [--schema ] Show value history (LRU) (@ocas/output/var-history) template set | --inline Set template for schema (@ocas/output/template-set) template get Get template content (value=string) (@ocas/output/template-get) @@ -1125,6 +1112,14 @@ switch (cmd) { await cmdListSchema(rest); break; + case "tag": + await cmdTag(rest); + break; + + case "untag": + await cmdUntag(rest); + break; + case "var": { const [sub, ...subRest] = rest; switch (sub) { @@ -1137,9 +1132,6 @@ switch (cmd) { case "delete": await cmdVarDelete(subRest); break; - case "tag": - await cmdVarTag(subRest); - break; case "list": await cmdVarList(subRest); break; diff --git a/packages/cli/src/prompts/usage.md b/packages/cli/src/prompts/usage.md index 2d8573c..f78116c 100644 --- a/packages/cli/src/prompts/usage.md +++ b/packages/cli/src/prompts/usage.md @@ -103,8 +103,8 @@ ocas var delete @myapp/config # remove ocas var list [prefix] # list (prefix filter) ocas var list @myapp/ --tag env:prod # filter by scope + tag ocas var history @myapp/config # last 10 values (LRU) -ocas var tag @myapp/config --schema status:active # add tag -ocas var tag @myapp/config --schema :status # remove tag +ocas tag @myapp/config status:active # add tag/label to a target +ocas untag @myapp/config status # remove tag/label by key ``` **Naming rules:** diff --git a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap index 104eaee..7646251 100644 --- a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap @@ -25,12 +25,13 @@ Commands: render --pipe/-p [options] Render { type, value } from stdin (raw output) list --type List hashes for a type (value=string[]) (@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) + list-schema List all schema hashes (value=string[]) (@ocas/output/list-schema) + tag ... Apply tags/labels to a target (@ocas/output/tag) + untag ... Remove tags/labels from a target (@ocas/output/untag) var set [--tag ...] Create/update a variable (@ocas/output/var-set) var get --schema Get a variable by name + schema (@ocas/output/var-get) var delete [--schema ] Delete variable(s) (@ocas/output/var-delete) var list [prefix] [--schema ] [--tag ...] List variables (@ocas/output/var-list) - var tag --schema Modify tags/labels (@ocas/output/var-tag) var history [--schema ] Show value history (LRU) (@ocas/output/var-history) template set | --inline Set template for schema (@ocas/output/template-set) template get Get template content (value=string) (@ocas/output/template-get) @@ -202,6 +203,13 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` "tags": {}, "value": "2TKP4RGBJ4V43", }, + { + "labels": [], + "name": "@ocas/output/tag", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "CPSWA9TB2JMWP", + }, { "labels": [], "name": "@ocas/output/template-delete", @@ -230,6 +238,13 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` "tags": {}, "value": "BJDHPAE4Q8TXM", }, + { + "labels": [], + "name": "@ocas/output/untag", + "schema": "CTS5P6RD8HMCS", + "tags": {}, + "value": "BPEQMRQNJK80Z", + }, { "labels": [], "name": "@ocas/output/var-delete", @@ -265,13 +280,6 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` "tags": {}, "value": "0Q5EMYK4SYSS9", }, - { - "labels": [], - "name": "@ocas/output/var-tag", - "schema": "CTS5P6RD8HMCS", - "tags": {}, - "value": "9103EYRMM949A", - }, { "labels": [], "name": "@ocas/output/verify", @@ -332,9 +340,9 @@ exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1 } `; -exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = ` +exports[`Phase 3: Variable System 3.6 var set with tag and label adds them 1`] = ` { - "type": "9103EYRMM949A", + "type": "0Q5EMYK4SYSS9", "value": { "labels": [ "important", @@ -387,9 +395,9 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label } `; -exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = ` +exports[`Phase 3: Variable System 3.9 var set without label removes it 1`] = ` { - "type": "9103EYRMM949A", + "type": "0Q5EMYK4SYSS9", "value": { "labels": [], "name": "@myapp/config", diff --git a/packages/cli/tests/edge-cases.test.ts b/packages/cli/tests/edge-cases.test.ts index b2c8beb..3cb1956 100644 --- a/packages/cli/tests/edge-cases.test.ts +++ b/packages/cli/tests/edge-cases.test.ts @@ -246,14 +246,15 @@ describe("Phase 3: Variable System", () => { await runCli(["var", "set", "@myapp/config", nodeHash]); }); - test("3.6 var tag adds kv tag and label", async () => { + test("3.6 var set with tag and label adds them", async () => { const { exitCode, stdout } = await runCli([ "var", - "tag", + "set", "@myapp/config", - "--schema", - typeHash, + nodeHash, + "--tag", "env:prod", + "--tag", "important", ]); expect(exitCode).toBe(0); @@ -284,14 +285,14 @@ describe("Phase 3: Variable System", () => { expect(stripVolatile(stdout)).toMatchSnapshot(); }); - test("3.9 var tag remove deletes label", async () => { + test("3.9 var set without label removes it", async () => { const { exitCode, stdout } = await runCli([ "var", - "tag", + "set", "@myapp/config", - "--schema", - typeHash, - ":important", + nodeHash, + "--tag", + "env:prod", ]); expect(exitCode).toBe(0); expect(stripVolatile(stdout)).toMatchSnapshot(); diff --git a/packages/cli/tests/tag-untag.test.ts b/packages/cli/tests/tag-untag.test.ts new file mode 100644 index 0000000..92d93b7 --- /dev/null +++ b/packages/cli/tests/tag-untag.test.ts @@ -0,0 +1,235 @@ +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"; +import type { Hash } from "@ocas/core"; +import { bootstrap } from "@ocas/core"; +import { openStore as openFsStore } from "@ocas/fs"; + +let testDir: string; +let storePath: string; +let cliPath: string; + +beforeEach(() => { + testDir = join( + tmpdir(), + `ocas-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 createTestNode(): Promise { + const store = await openFsStore(storePath); + const aliases = bootstrap(store); + const typeHash = aliases["@ocas/string"]; + if (!typeHash) throw new Error("@ocas/string not found"); + const hash = store.cas.put(typeHash, `test-value-${Math.random()}`); + return hash; +} + +async function readTags(target: Hash) { + const store = await openFsStore(storePath); + return store.tag.tags(target); +} + +describe("ocas tag", () => { + test("Test 1: tag applies key:value tags and labels", async () => { + const hash = await createTestNode(); + + const { stdout, exitCode } = await runCli( + "tag", + hash, + "env:prod", + "stable", + ); + expect(exitCode).toBe(0); + + const envelope = JSON.parse(stdout); + expect(Array.isArray(envelope.value)).toBe(true); + const value = envelope.value as Array<{ + key: string; + value: string | null; + target: string; + }>; + const byKey = (k: string) => value.find((t) => t.key === k); + expect(byKey("env")).toMatchObject({ + key: "env", + value: "prod", + target: hash, + }); + expect(byKey("stable")).toMatchObject({ + key: "stable", + value: null, + target: hash, + }); + + const tags = await readTags(hash); + expect(tags.find((t) => t.key === "env")?.value).toBe("prod"); + expect(tags.find((t) => t.key === "stable")?.value).toBeNull(); + }); + + test("Test 2: tag @scope/name resolves variable to its value hash", async () => { + const hash = await createTestNode(); + + await runCli("var", "set", "@user/foo", hash); + const { exitCode } = await runCli("tag", "@user/foo", "reviewed"); + expect(exitCode).toBe(0); + + const tagsOnHash = await readTags(hash); + expect(tagsOnHash.find((t) => t.key === "reviewed")).toBeDefined(); + }); + + test("Test 3: untag env removes tag by key", async () => { + const hash = await createTestNode(); + + await runCli("tag", hash, "env:prod", "stable"); + const { stdout, exitCode } = await runCli("untag", hash, "env"); + expect(exitCode).toBe(0); + + const envelope = JSON.parse(stdout); + const keys = (envelope.value as Array<{ key: string }>).map((t) => t.key); + expect(keys).toContain("stable"); + expect(keys).not.toContain("env"); + + const remaining = await readTags(hash); + expect(remaining.map((t) => t.key)).toEqual(["stable"]); + }); + + test("Test 4: untag accepts key:value form (uses key only)", async () => { + const hash = await createTestNode(); + + await runCli("tag", hash, "env:prod"); + const { exitCode } = await runCli("untag", hash, "env:prod"); + expect(exitCode).toBe(0); + + expect(await readTags(hash)).toEqual([]); + }); + + test("Test 5: untag removes labels", async () => { + const hash = await createTestNode(); + + await runCli("tag", hash, "pinned"); + const { exitCode } = await runCli("untag", hash, "pinned"); + expect(exitCode).toBe(0); + + expect(await readTags(hash)).toEqual([]); + }); + + test("Test 6: tag without tag args errors", async () => { + const hash = await createTestNode(); + const { stderr, exitCode } = await runCli("tag", hash); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Usage: ocas tag ..."); + }); + + test("Test 7: tag with no args errors", async () => { + const { stderr, exitCode } = await runCli("tag"); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Usage:"); + }); + + test("Test 8: untag missing args errors", async () => { + const hash = await createTestNode(); + + const r1 = await runCli("untag"); + expect(r1.exitCode).not.toBe(0); + + const r2 = await runCli("untag", hash); + expect(r2.exitCode).not.toBe(0); + expect(r2.stderr).toContain("Usage: ocas untag ..."); + }); + + test("Test 9: tag with unknown variable name errors", async () => { + const { stderr, exitCode } = await runCli("tag", "@user/missing", "label"); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Schema not found: @user/missing"); + }); + + test("Test 10: var tag is removed", async () => { + const { stderr, exitCode } = await runCli( + "var", + "tag", + "@any/name", + "--schema", + "@ocas/string", + "foo", + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Unknown var subcommand: tag"); + }); + + test("Test 11: envelope schema name is @ocas/output/tag and @ocas/output/untag", async () => { + const hash = await createTestNode(); + const store = await openFsStore(storePath); + const aliases = bootstrap(store); + const tagSchemaHash = aliases["@ocas/output/tag"]; + const untagSchemaHash = aliases["@ocas/output/untag"]; + expect(tagSchemaHash).toBeDefined(); + expect(untagSchemaHash).toBeDefined(); + + const r1 = await runCli("tag", hash, "stable"); + const env1 = JSON.parse(r1.stdout); + expect(env1.type).toBe(tagSchemaHash); + + const r2 = await runCli("untag", hash, "stable"); + const env2 = JSON.parse(r2.stdout); + expect(env2.type).toBe(untagSchemaHash); + }); + + test("Test 12: idempotent re-tag updates existing key value", async () => { + const hash = await createTestNode(); + + await runCli("tag", hash, "env:dev"); + await runCli("tag", hash, "env:prod"); + + const tags = await readTags(hash); + const envTags = tags.filter((t) => t.key === "env"); + expect(envTags).toHaveLength(1); + expect(envTags[0]?.value).toBe("prod"); + }); + + test("Test 15: bootstrap registers @ocas/output/tag and @ocas/output/untag", async () => { + const store = await openFsStore(storePath); + const aliases = bootstrap(store); + expect(aliases["@ocas/output/tag"]).toBeDefined(); + expect(aliases["@ocas/output/untag"]).toBeDefined(); + + const tagVar = store.var.list({ exactName: "@ocas/output/tag" }); + expect(tagVar.length).toBeGreaterThan(0); + + const untagVar = store.var.list({ exactName: "@ocas/output/untag" }); + expect(untagVar.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/tests/variable.test.ts b/packages/cli/tests/variable.test.ts index 4692ed9..bfb8c02 100644 --- a/packages/cli/tests/variable.test.ts +++ b/packages/cli/tests/variable.test.ts @@ -752,246 +752,6 @@ describe("var list", () => { }); }); -describe("var tag", () => { - test("add new tag", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - const hash = await createTestNode(store, typeHash, { test: "data" }); - - // Create variable without tags - await runCli("var", "set", "@test/x", hash); - - // Add tag - const { stdout, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - "env:prod", - ); - - expect(exitCode).toBe(0); - - const envelope = JSON.parse(stdout); - expect(envelope.value.tags).toEqual({ env: "prod" }); - }); - - test("update existing tag value", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - const hash = await createTestNode(store, typeHash, { test: "data" }); - - // Create variable with tag - await runCli("var", "set", "@test/x", hash, "--tag", "env:dev"); - - // Update tag - const { stdout, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - "env:prod", - ); - - expect(exitCode).toBe(0); - - const envelope = JSON.parse(stdout); - expect(envelope.value.tags).toEqual({ env: "prod" }); - }); - - test("add label", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - const hash = await createTestNode(store, typeHash, { test: "data" }); - - // Create variable without labels - await runCli("var", "set", "@test/x", hash); - - // Add label - const { stdout, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - "stable", - ); - - expect(exitCode).toBe(0); - - const envelope = JSON.parse(stdout); - expect(envelope.value.labels).toEqual(["stable"]); - }); - - test("delete tag", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - const hash = await createTestNode(store, typeHash, { test: "data" }); - - // Create variable with tags - await runCli( - "var", - "set", - "@test/x", - hash, - "--tag", - "env:prod", - "--tag", - "version:1.0", - ); - - // Delete tag - const { stdout, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - ":env", - ); - - expect(exitCode).toBe(0); - - const envelope = JSON.parse(stdout); - expect(envelope.value.tags).toEqual({ version: "1.0" }); - }); - - test("delete label", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - const hash = await createTestNode(store, typeHash, { test: "data" }); - - // Create variable with labels - await runCli( - "var", - "set", - "@test/x", - hash, - "--tag", - "stable", - "--tag", - "beta", - ); - - // Delete label - const { stdout, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - ":stable", - ); - - expect(exitCode).toBe(0); - - const envelope = JSON.parse(stdout); - expect(envelope.value.labels).toEqual(["beta"]); - }); - - test("mixed add and delete operations", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - const hash = await createTestNode(store, typeHash, { test: "data" }); - - // Create variable with tags and labels - await runCli("var", "set", "@test/x", hash, "--tag", "a:1", "--tag", "b"); - - // Mixed operations - const { stdout, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - "c:3", - ":a", - "d", - ); - - expect(exitCode).toBe(0); - - const envelope = JSON.parse(stdout); - expect(envelope.value.tags).toEqual({ c: "3" }); - expect(envelope.value.labels.sort()).toEqual(["b", "d"]); - }); - - test("error on tag/label conflict", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - const hash = await createTestNode(store, typeHash, { test: "data" }); - - // Create variable with tag - await runCli("var", "set", "@test/x", hash, "--tag", "env:prod"); - - // Try to add same name as label - const { stderr, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - "env", - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain("Error: Conflict: 'env' already exists as a"); - }); - - test("error when variable not found", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - - const { stderr, exitCode } = await runCli( - "var", - "tag", - "@test/nonexistent", - "--schema", - typeHash, - "env:prod", - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain( - `Error: Variable not found: name=@test/nonexistent, schema=${typeHash}`, - ); - }); - - test("error when --schema missing", async () => { - const { stderr, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "env:prod", - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain( - "Usage: ocas var tag --schema ", - ); - }); - - test("error when no operations provided", async () => { - const store = await openFsStore(storePath); - const typeHash = await getBootstrapHash(store); - - const { stderr, exitCode } = await runCli( - "var", - "tag", - "@test/x", - "--schema", - typeHash, - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain( - "Usage: ocas var tag --schema ", - ); - }); -}); - describe("global options", () => { test("--json flag for compact output", async () => { const store = await openFsStore(storePath); @@ -1067,4 +827,18 @@ describe("old commands removed", () => { expect(exitCode).toBe(1); expect(stderr).toContain("Unknown var subcommand: update"); }); + + test("var tag subcommand removed", async () => { + const { stderr, exitCode } = await runCli( + "var", + "tag", + "@any/name", + "--schema", + "@ocas/string", + "foo", + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown var subcommand: tag"); + }); }); diff --git a/packages/core/src/bootstrap.test.ts b/packages/core/src/bootstrap.test.ts index 8ed7cf0..e487f49 100644 --- a/packages/core/src/bootstrap.test.ts +++ b/packages/core/src/bootstrap.test.ts @@ -18,9 +18,10 @@ const OUTPUT_ALIASES = [ "@ocas/output/var-set", "@ocas/output/var-get", "@ocas/output/var-delete", - "@ocas/output/var-tag", "@ocas/output/var-list", "@ocas/output/var-history", + "@ocas/output/tag", + "@ocas/output/untag", "@ocas/output/template-set", "@ocas/output/template-get", "@ocas/output/template-list", @@ -33,11 +34,11 @@ const OUTPUT_ALIASES = [ // ────────────────────────────────────────────────────────────────────────────── describe("bootstrap - Built-in Schemas", () => { - test("should return map of 30 built-in schema aliases to hashes", async () => { + test("should return map of 31 built-in schema aliases to hashes", async () => { const store = createMemoryStore(); const builtinSchemas = bootstrap(store); - // Should return object with 9 primitive + 21 output aliases = 30 + // Should return object with 9 primitive + 22 output aliases = 31 expect(builtinSchemas).toHaveProperty("@ocas/schema"); expect(builtinSchemas).toHaveProperty("@ocas/string"); expect(builtinSchemas).toHaveProperty("@ocas/number"); @@ -52,7 +53,7 @@ describe("bootstrap - Built-in Schemas", () => { expect(builtinSchemas).toHaveProperty(alias); } - expect(Object.keys(builtinSchemas)).toHaveLength(30); + expect(Object.keys(builtinSchemas)).toHaveLength(31); // All values should be valid hashes for (const [_alias, hash] of Object.entries(builtinSchemas)) { diff --git a/packages/core/src/bootstrap.ts b/packages/core/src/bootstrap.ts index e396e3e..5322604 100644 --- a/packages/core/src/bootstrap.ts +++ b/packages/core/src/bootstrap.ts @@ -236,14 +236,6 @@ const OUTPUT_SCHEMAS: ReadonlyArray< title: "ocas var delete result", }, ], - [ - "@ocas/output/var-tag", - { - type: "object", - properties: { ...VARIABLE_PROPERTIES }, - title: "ocas var tag result", - }, - ], [ "@ocas/output/var-list", { @@ -267,6 +259,38 @@ const OUTPUT_SCHEMAS: ReadonlyArray< title: "ocas var history result", }, ], + [ + "@ocas/output/tag", + { + type: "array", + items: { + type: "object", + properties: { + key: { type: "string" }, + value: { type: ["string", "null"] }, + target: { type: "string", format: "ocas_ref" }, + created: { type: "number" }, + }, + }, + title: "ocas tag result", + }, + ], + [ + "@ocas/output/untag", + { + type: "array", + items: { + type: "object", + properties: { + key: { type: "string" }, + value: { type: ["string", "null"] }, + target: { type: "string", format: "ocas_ref" }, + created: { type: "number" }, + }, + }, + title: "ocas untag result", + }, + ], [ "@ocas/output/template-set", { diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index b2b116c..45a89ff 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -289,7 +289,7 @@ describe("bootstrap", () => { expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); } - expect(Object.keys(builtinSchemas)).toHaveLength(30); + expect(Object.keys(builtinSchemas)).toHaveLength(31); }); test("meta-schema node is stored and retrievable", async () => { @@ -326,7 +326,7 @@ describe("bootstrap", () => { const h2 = bootstrap(store); expect(h1).toEqual(h2); - // All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs) - expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29); + // All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs) + expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30); }); }); diff --git a/packages/core/src/output-templates.test.ts b/packages/core/src/output-templates.test.ts index fdc5f36..adda02f 100644 --- a/packages/core/src/output-templates.test.ts +++ b/packages/core/src/output-templates.test.ts @@ -15,9 +15,10 @@ const OUTPUT_ALIASES = [ "@ocas/output/var-set", "@ocas/output/var-get", "@ocas/output/var-delete", - "@ocas/output/var-tag", "@ocas/output/var-list", "@ocas/output/var-history", + "@ocas/output/tag", + "@ocas/output/untag", "@ocas/output/template-set", "@ocas/output/template-get", "@ocas/output/template-list", @@ -32,7 +33,7 @@ describe("registerOutputTemplates", () => { const registered = await registerOutputTemplates(store); - expect(Object.keys(registered)).toHaveLength(19); + expect(Object.keys(registered)).toHaveLength(20); for (const alias of OUTPUT_ALIASES) { expect(registered).toHaveProperty(alias); diff --git a/packages/core/src/output-templates.ts b/packages/core/src/output-templates.ts index a9393bb..4b053b6 100644 --- a/packages/core/src/output-templates.ts +++ b/packages/core/src/output-templates.ts @@ -27,14 +27,18 @@ const DEFAULT_TEMPLATES: ReadonlyArray< "@ocas/output/var-delete", "name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}", ], - [ - "@ocas/output/var-tag", - "name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}", - ], [ "@ocas/output/var-list", "{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}", ], + [ + "@ocas/output/tag", + "{% for t in payload %}{{ t.key }}{% if t.value %}:{{ t.value }}{% endif %}\n{% endfor %}", + ], + [ + "@ocas/output/untag", + "{% for t in payload %}{{ t.key }}{% if t.value %}:{{ t.value }}{% endif %}\n{% endfor %}", + ], [ "@ocas/output/var-history", "name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}", diff --git a/packages/fs/src/store.test.ts b/packages/fs/src/store.test.ts index 2133313..39a3efb 100644 --- a/packages/fs/src/store.test.ts +++ b/packages/fs/src/store.test.ts @@ -67,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => { const h2 = bootstrap(store); expect(h1).toEqual(h2); - expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29); + expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30); }); });