feat: add top-level ocas tag/untag commands, remove var tag

Adds `ocas tag <target> <tag>...` and `ocas untag <target> <tag>...`
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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 11:11:29 +00:00
parent 3fb179abde
commit 561f2a33b7
12 changed files with 387 additions and 347 deletions
+53 -61
View File
@@ -3,13 +3,12 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import type { Hash, ListOptions, Store } from "@ocas/core"; import type { Hash, ListOptions, Store, TagOp } from "@ocas/core";
import { import {
CasNodeNotFoundError, CasNodeNotFoundError,
computeHash, computeHash,
gc, gc,
getSchema, getSchema,
InvalidTagFormatError,
InvalidVariableNameError, InvalidVariableNameError,
putSchema, putSchema,
refs, refs,
@@ -685,64 +684,51 @@ async function cmdVarDelete(args: string[]): Promise<void> {
} }
} }
async function cmdVarTag(args: string[]): Promise<void> { async function cmdTag(args: string[]): Promise<void> {
const name = args[0]; const targetInput = args[0];
const schemaInput = flags.schema as string | undefined;
if (!name || !schemaInput) {
die("Usage: ocas var tag <name> --schema <hash-or-name> <operations...>");
}
const tagArgs = args.slice(1); const tagArgs = args.slice(1);
if (tagArgs.length === 0) { if (!targetInput || tagArgs.length === 0) {
die("Usage: ocas var tag <name> --schema <hash-or-name> <operations...>"); die("Usage: ocas tag <target> <tag>...");
} }
const store = await openStore(); const store = await openStore();
const target = resolveHash(targetInput, store);
try { const { tags, labels, deleteNames } = parseTagsLabels(tagArgs);
const schema = resolveHash(schemaInput, store); if (deleteNames.length > 0) {
const { tags, labels, deleteNames } = parseTagsLabels(tagArgs); die("Error: Cannot use deletion syntax (:name) in tag (use untag)");
// 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<string, string> = { ...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 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<void> {
const targetInput = args[0];
const tagArgs = args.slice(1);
if (!targetInput || tagArgs.length === 0) {
die("Usage: ocas untag <target> <tag>...");
}
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<void> { async function cmdVarHistory(args: string[]): Promise<void> {
@@ -1043,12 +1029,13 @@ Commands:
render --pipe/-p [options] Render { type, value } from stdin (raw output) render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-name> List hashes for a type (value=string[]) (@ocas/output/list) list --type <hash-or-name> List hashes for a type (value=string[]) (@ocas/output/list)
list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta) 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 <target> <tag>... Apply tags/labels to a target (@ocas/output/tag)
untag <target> <tag>... Remove tags/labels from a target (@ocas/output/untag)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set) var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get) var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete) var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list) var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@ocas/output/var-tag)
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history) var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set) template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get) template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
@@ -1125,6 +1112,14 @@ switch (cmd) {
await cmdListSchema(rest); await cmdListSchema(rest);
break; break;
case "tag":
await cmdTag(rest);
break;
case "untag":
await cmdUntag(rest);
break;
case "var": { case "var": {
const [sub, ...subRest] = rest; const [sub, ...subRest] = rest;
switch (sub) { switch (sub) {
@@ -1137,9 +1132,6 @@ switch (cmd) {
case "delete": case "delete":
await cmdVarDelete(subRest); await cmdVarDelete(subRest);
break; break;
case "tag":
await cmdVarTag(subRest);
break;
case "list": case "list":
await cmdVarList(subRest); await cmdVarList(subRest);
break; break;
+2 -2
View File
@@ -103,8 +103,8 @@ ocas var delete @myapp/config # remove
ocas var list [prefix] # list (prefix filter) ocas var list [prefix] # list (prefix filter)
ocas var list @myapp/ --tag env:prod # filter by scope + tag ocas var list @myapp/ --tag env:prod # filter by scope + tag
ocas var history @myapp/config # last 10 values (LRU) ocas var history @myapp/config # last 10 values (LRU)
ocas var tag @myapp/config --schema <h> status:active # add tag ocas tag @myapp/config status:active # add tag/label to a target
ocas var tag @myapp/config --schema <h> :status # remove tag ocas untag @myapp/config status # remove tag/label by key
``` ```
**Naming rules:** **Naming rules:**
@@ -25,12 +25,13 @@ Commands:
render --pipe/-p [options] Render { type, value } from stdin (raw output) render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-name> List hashes for a type (value=string[]) (@ocas/output/list) list --type <hash-or-name> List hashes for a type (value=string[]) (@ocas/output/list)
list-meta List meta-schema hashes (value=string[]) (@ocas/output/list-meta) 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 <target> <tag>... Apply tags/labels to a target (@ocas/output/tag)
untag <target> <tag>... Remove tags/labels from a target (@ocas/output/untag)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set) var set <name> <hash> [--tag <tag>...] Create/update a variable (@ocas/output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get) var get <name> --schema <hash> Get a variable by name + schema (@ocas/output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete) var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list) var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@ocas/output/var-tag)
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history) var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set) template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get) template get <schema-hash> 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": {}, "tags": {},
"value": "2TKP4RGBJ4V43", "value": "2TKP4RGBJ4V43",
}, },
{
"labels": [],
"name": "@ocas/output/tag",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "CPSWA9TB2JMWP",
},
{ {
"labels": [], "labels": [],
"name": "@ocas/output/template-delete", "name": "@ocas/output/template-delete",
@@ -230,6 +238,13 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {}, "tags": {},
"value": "BJDHPAE4Q8TXM", "value": "BJDHPAE4Q8TXM",
}, },
{
"labels": [],
"name": "@ocas/output/untag",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "BPEQMRQNJK80Z",
},
{ {
"labels": [], "labels": [],
"name": "@ocas/output/var-delete", "name": "@ocas/output/var-delete",
@@ -265,13 +280,6 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {}, "tags": {},
"value": "0Q5EMYK4SYSS9", "value": "0Q5EMYK4SYSS9",
}, },
{
"labels": [],
"name": "@ocas/output/var-tag",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "9103EYRMM949A",
},
{ {
"labels": [], "labels": [],
"name": "@ocas/output/verify", "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": { "value": {
"labels": [ "labels": [
"important", "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": { "value": {
"labels": [], "labels": [],
"name": "@myapp/config", "name": "@myapp/config",
+10 -9
View File
@@ -246,14 +246,15 @@ describe("Phase 3: Variable System", () => {
await runCli(["var", "set", "@myapp/config", nodeHash]); 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([ const { exitCode, stdout } = await runCli([
"var", "var",
"tag", "set",
"@myapp/config", "@myapp/config",
"--schema", nodeHash,
typeHash, "--tag",
"env:prod", "env:prod",
"--tag",
"important", "important",
]); ]);
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
@@ -284,14 +285,14 @@ describe("Phase 3: Variable System", () => {
expect(stripVolatile(stdout)).toMatchSnapshot(); 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([ const { exitCode, stdout } = await runCli([
"var", "var",
"tag", "set",
"@myapp/config", "@myapp/config",
"--schema", nodeHash,
typeHash, "--tag",
":important", "env:prod",
]); ]);
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot(); expect(stripVolatile(stdout)).toMatchSnapshot();
+235
View File
@@ -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<Hash> {
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 <hash> 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 <hash> 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 <target> <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 <target> <tag>...");
});
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);
});
});
+14 -240
View File
@@ -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 <name> --schema <hash-or-name> <operations...>",
);
});
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 <name> --schema <hash-or-name> <operations...>",
);
});
});
describe("global options", () => { describe("global options", () => {
test("--json flag for compact output", async () => { test("--json flag for compact output", async () => {
const store = await openFsStore(storePath); const store = await openFsStore(storePath);
@@ -1067,4 +827,18 @@ describe("old commands removed", () => {
expect(exitCode).toBe(1); expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown var subcommand: update"); 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");
});
}); });
+5 -4
View File
@@ -18,9 +18,10 @@ const OUTPUT_ALIASES = [
"@ocas/output/var-set", "@ocas/output/var-set",
"@ocas/output/var-get", "@ocas/output/var-get",
"@ocas/output/var-delete", "@ocas/output/var-delete",
"@ocas/output/var-tag",
"@ocas/output/var-list", "@ocas/output/var-list",
"@ocas/output/var-history", "@ocas/output/var-history",
"@ocas/output/tag",
"@ocas/output/untag",
"@ocas/output/template-set", "@ocas/output/template-set",
"@ocas/output/template-get", "@ocas/output/template-get",
"@ocas/output/template-list", "@ocas/output/template-list",
@@ -33,11 +34,11 @@ const OUTPUT_ALIASES = [
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => { 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 store = createMemoryStore();
const builtinSchemas = bootstrap(store); 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/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string"); expect(builtinSchemas).toHaveProperty("@ocas/string");
expect(builtinSchemas).toHaveProperty("@ocas/number"); expect(builtinSchemas).toHaveProperty("@ocas/number");
@@ -52,7 +53,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias); expect(builtinSchemas).toHaveProperty(alias);
} }
expect(Object.keys(builtinSchemas)).toHaveLength(30); expect(Object.keys(builtinSchemas)).toHaveLength(31);
// All values should be valid hashes // All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) { for (const [_alias, hash] of Object.entries(builtinSchemas)) {
+32 -8
View File
@@ -236,14 +236,6 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas var delete result", title: "ocas var delete result",
}, },
], ],
[
"@ocas/output/var-tag",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ocas var tag result",
},
],
[ [
"@ocas/output/var-list", "@ocas/output/var-list",
{ {
@@ -267,6 +259,38 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas var history result", 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", "@ocas/output/template-set",
{ {
+3 -3
View File
@@ -289,7 +289,7 @@ describe("bootstrap", () => {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); 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 () => { test("meta-schema node is stored and retrievable", async () => {
@@ -326,7 +326,7 @@ describe("bootstrap", () => {
const h2 = bootstrap(store); const h2 = bootstrap(store);
expect(h1).toEqual(h2); expect(h1).toEqual(h2);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs) // All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs)
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29); expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
}); });
}); });
+3 -2
View File
@@ -15,9 +15,10 @@ const OUTPUT_ALIASES = [
"@ocas/output/var-set", "@ocas/output/var-set",
"@ocas/output/var-get", "@ocas/output/var-get",
"@ocas/output/var-delete", "@ocas/output/var-delete",
"@ocas/output/var-tag",
"@ocas/output/var-list", "@ocas/output/var-list",
"@ocas/output/var-history", "@ocas/output/var-history",
"@ocas/output/tag",
"@ocas/output/untag",
"@ocas/output/template-set", "@ocas/output/template-set",
"@ocas/output/template-get", "@ocas/output/template-get",
"@ocas/output/template-list", "@ocas/output/template-list",
@@ -32,7 +33,7 @@ describe("registerOutputTemplates", () => {
const registered = await registerOutputTemplates(store); const registered = await registerOutputTemplates(store);
expect(Object.keys(registered)).toHaveLength(19); expect(Object.keys(registered)).toHaveLength(20);
for (const alias of OUTPUT_ALIASES) { for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias); expect(registered).toHaveProperty(alias);
+8 -4
View File
@@ -27,14 +27,18 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
"@ocas/output/var-delete", "@ocas/output/var-delete",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}", "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", "@ocas/output/var-list",
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}", "{% 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", "@ocas/output/var-history",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}", "name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}",
+1 -1
View File
@@ -67,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = bootstrap(store); const h2 = bootstrap(store);
expect(h1).toEqual(h2); expect(h1).toEqual(h2);
expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29); expect(store.cas.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
}); });
}); });