Skip to content

Commit 04818d4

Browse files
committed
feat(cli): add commands to manage external references
Add update add-ref and update remove-ref subcommands to add or remove external references on nodes. External references link nodes to source documents, ADRs, standards, code files, and other resources outside the SysProM graph. Resolves issue #22
1 parent f85853f commit 04818d4

4 files changed

Lines changed: 214 additions & 1 deletion

File tree

src/cli/commands/update.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import * as z from "zod";
22
import type { CommandDef } from "../define-command.js";
3-
import { RelationshipType, NodeStatus } from "../../schema.js";
3+
import {
4+
RelationshipType,
5+
NodeStatus,
6+
ExternalReferenceRole,
7+
} from "../../schema.js";
48
import {
59
updateNodeOp,
610
addRelationshipOp,
711
removeRelationshipOp,
812
updateMetadataOp,
13+
addExternalReferenceOp,
14+
removeExternalReferenceOp,
915
} from "../../operations/index.js";
1016
import { mutationOpts, loadDoc, persistDoc } from "../shared.js";
1117

@@ -96,6 +102,27 @@ const metaOpts = mutationOpts.extend({
96102
.describe("metadata field updates (key=value format)"),
97103
});
98104

105+
const addRefArgs = z.object({
106+
id: z.string().describe("node ID to add reference to"),
107+
});
108+
const addRefOpts = mutationOpts.extend({
109+
role: ExternalReferenceRole.describe("reference role"),
110+
identifier: z
111+
.string()
112+
.describe("reference identifier (URI, file path, etc.)"),
113+
description: z
114+
.string()
115+
.optional()
116+
.describe("optional description of the reference"),
117+
});
118+
119+
const removeRefArgs = z.object({
120+
id: z.string().describe("node ID to remove reference from"),
121+
});
122+
const removeRefOpts = mutationOpts.extend({
123+
identifier: z.string().describe("identifier of the reference to remove"),
124+
});
125+
99126
// ---------------------------------------------------------------------------
100127
// Subcommands
101128
// ---------------------------------------------------------------------------
@@ -266,6 +293,84 @@ const metaSubcommand: CommandDef = {
266293
},
267294
};
268295

296+
const addRefSubcommand: CommandDef = {
297+
name: "add-ref",
298+
description: addExternalReferenceOp.def.description,
299+
apiLink: addExternalReferenceOp.def.name,
300+
args: addRefArgs,
301+
opts: addRefOpts,
302+
action(rawArgs: unknown, rawOpts: unknown) {
303+
const args = addRefArgs.parse(rawArgs);
304+
const opts = addRefOpts.parse(rawOpts);
305+
const loaded = loadDoc(opts.path);
306+
const { doc } = loaded;
307+
308+
const newDoc = addExternalReferenceOp({
309+
doc,
310+
nodeId: args.id,
311+
role: opts.role,
312+
identifier: opts.identifier,
313+
description: opts.description,
314+
});
315+
316+
persistDoc(newDoc, loaded, opts);
317+
318+
if (opts.json) {
319+
const node = newDoc.nodes.find((n) => n.id === args.id);
320+
const ref = node?.external_references?.find(
321+
(r) => r.identifier === opts.identifier,
322+
);
323+
console.log(JSON.stringify(ref, null, 2));
324+
} else {
325+
console.log(
326+
`${opts.dryRun ? "[dry-run] Would add" : "Added"} external reference to ${args.id}: ${opts.role}${opts.identifier}`,
327+
);
328+
}
329+
},
330+
};
331+
332+
const removeRefSubcommand: CommandDef = {
333+
name: "remove-ref",
334+
description: removeExternalReferenceOp.def.description,
335+
apiLink: removeExternalReferenceOp.def.name,
336+
args: removeRefArgs,
337+
opts: removeRefOpts,
338+
action(rawArgs: unknown, rawOpts: unknown) {
339+
const args = removeRefArgs.parse(rawArgs);
340+
const opts = removeRefOpts.parse(rawOpts);
341+
const loaded = loadDoc(opts.path);
342+
const { doc } = loaded;
343+
344+
removeExternalReferenceOp({
345+
doc,
346+
nodeId: args.id,
347+
identifier: opts.identifier,
348+
});
349+
350+
const newDoc = removeExternalReferenceOp({
351+
doc,
352+
nodeId: args.id,
353+
identifier: opts.identifier,
354+
});
355+
356+
persistDoc(newDoc, loaded, opts);
357+
358+
if (opts.json) {
359+
console.log(
360+
JSON.stringify(
361+
{ nodeId: args.id, identifier: opts.identifier },
362+
null,
363+
2,
364+
),
365+
);
366+
} else {
367+
console.log(
368+
`${opts.dryRun ? "[dry-run] Would remove" : "Removed"} external reference from ${args.id}: ${opts.identifier}`,
369+
);
370+
}
371+
},
372+
};
373+
269374
// ---------------------------------------------------------------------------
270375
// Main command
271376
// ---------------------------------------------------------------------------
@@ -277,6 +382,8 @@ export const updateCommand: CommandDef = {
277382
nodeSubcommand,
278383
addRelSubcommand,
279384
removeRelSubcommand,
385+
addRefSubcommand,
386+
removeRefSubcommand,
280387
metaSubcommand,
281388
],
282389
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as z from "zod";
2+
import { defineOperation } from "./define-operation.js";
3+
import {
4+
SysProMDocument,
5+
ExternalReference,
6+
ExternalReferenceRole,
7+
} from "../schema.js";
8+
9+
/**
10+
* Add an external reference to a node.
11+
*
12+
* External references link nodes to source documents, ADRs, standards, code files,
13+
* and other resources outside the SysProM graph.
14+
*/
15+
export const addExternalReferenceOp = defineOperation({
16+
name: "add-external-reference",
17+
description: "Add an external reference to a node",
18+
input: z.object({
19+
doc: SysProMDocument,
20+
nodeId: z.string().describe("The node to add the reference to"),
21+
role: ExternalReferenceRole.describe(
22+
"Reference role (e.g. source, output, evidence, standard)",
23+
),
24+
identifier: z
25+
.string()
26+
.describe("Reference identifier (URI, file path, etc.)"),
27+
description: z
28+
.string()
29+
.optional()
30+
.describe("Optional description of the reference"),
31+
}),
32+
output: SysProMDocument,
33+
fn: (input) => {
34+
const node = input.doc.nodes.find((n) => n.id === input.nodeId);
35+
if (!node) {
36+
throw new Error(`Node not found: ${input.nodeId}`);
37+
}
38+
39+
const newRef: ExternalReference = {
40+
role: input.role,
41+
identifier: input.identifier,
42+
...(input.description && { description: input.description }),
43+
};
44+
45+
const updatedNode = {
46+
...node,
47+
external_references: [...(node.external_references ?? []), newRef],
48+
};
49+
50+
return {
51+
...input.doc,
52+
nodes: input.doc.nodes.map((n) =>
53+
n.id === input.nodeId ? updatedNode : n,
54+
),
55+
};
56+
},
57+
});

src/operations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export { updateNodeOp } from "./update-node.js";
1111
export { addRelationshipOp } from "./add-relationship.js";
1212
export { removeRelationshipOp } from "./remove-relationship.js";
1313
export { updateMetadataOp } from "./update-metadata.js";
14+
export { addExternalReferenceOp } from "./add-external-reference.js";
15+
export { removeExternalReferenceOp } from "./remove-external-reference.js";
1416
export { nextIdOp } from "./next-id.js";
1517
export { initDocumentOp } from "./init-document.js";
1618
export { planInitOp } from "./plan-init.js";
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as z from "zod";
2+
import { defineOperation } from "./define-operation.js";
3+
import { SysProMDocument } from "../schema.js";
4+
5+
/**
6+
* Remove an external reference from a node by its identifier.
7+
*/
8+
export const removeExternalReferenceOp = defineOperation({
9+
name: "remove-external-reference",
10+
description: "Remove an external reference from a node",
11+
input: z.object({
12+
doc: SysProMDocument,
13+
nodeId: z.string().describe("The node to remove the reference from"),
14+
identifier: z
15+
.string()
16+
.describe("The identifier of the reference to remove"),
17+
}),
18+
output: SysProMDocument,
19+
fn: (input) => {
20+
const node = input.doc.nodes.find((n) => n.id === input.nodeId);
21+
if (!node) {
22+
throw new Error(`Node not found: ${input.nodeId}`);
23+
}
24+
25+
const refs = node.external_references ?? [];
26+
const found = refs.some((r) => r.identifier === input.identifier);
27+
if (!found) {
28+
throw new Error(
29+
`Reference not found: ${input.identifier} on node ${input.nodeId}`,
30+
);
31+
}
32+
33+
const updatedNode = {
34+
...node,
35+
external_references: refs.filter(
36+
(r) => r.identifier !== input.identifier,
37+
),
38+
};
39+
40+
return {
41+
...input.doc,
42+
nodes: input.doc.nodes.map((n) =>
43+
n.id === input.nodeId ? updatedNode : n,
44+
),
45+
};
46+
},
47+
});

0 commit comments

Comments
 (0)