Merge pull request 'feat: top-level ocas tag/untag commands' (#55) from fix/52-tag-untag into main
This commit was merged in pull request #55.
This commit is contained in:
+53
-61
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdVarTag(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
const schemaInput = flags.schema as string | undefined;
|
||||
|
||||
if (!name || !schemaInput) {
|
||||
die("Usage: ocas var tag <name> --schema <hash-or-name> <operations...>");
|
||||
}
|
||||
|
||||
async function cmdTag(args: string[]): Promise<void> {
|
||||
const targetInput = args[0];
|
||||
const tagArgs = args.slice(1);
|
||||
if (tagArgs.length === 0) {
|
||||
die("Usage: ocas var tag <name> --schema <hash-or-name> <operations...>");
|
||||
if (!targetInput || tagArgs.length === 0) {
|
||||
die("Usage: ocas tag <target> <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<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 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<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> {
|
||||
@@ -1043,12 +1029,13 @@ Commands:
|
||||
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-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 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 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)
|
||||
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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <h> status:active # add tag
|
||||
ocas var tag @myapp/config --schema <h> :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:**
|
||||
|
||||
@@ -25,12 +25,13 @@ Commands:
|
||||
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-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 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 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)
|
||||
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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 %}",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user