@crafter/cli-tree 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/archaeology/cache.d.ts +11 -0
- package/dist/archaeology/delegate.d.ts +43 -0
- package/dist/archaeology/index.d.ts +12 -0
- package/dist/archaeology/index.js +61 -0
- package/dist/archaeology/index.js.map +9 -0
- package/dist/archaeology/llm.d.ts +1 -0
- package/dist/archaeology/merge.d.ts +3 -0
- package/dist/archaeology/orchestrator.d.ts +25 -0
- package/dist/archaeology/prompts.d.ts +13 -0
- package/dist/archaeology/types.d.ts +101 -0
- package/dist/archaeology/validate.d.ts +18 -0
- package/dist/chunk-57gtsvhb.js +434 -0
- package/dist/chunk-57gtsvhb.js.map +16 -0
- package/dist/chunk-5aahbfr2.js +293 -0
- package/dist/chunk-5aahbfr2.js.map +10 -0
- package/dist/chunk-pkfpaae1.js +678 -0
- package/dist/chunk-pkfpaae1.js.map +15 -0
- package/dist/chunk-q4se2rwe.js +181 -0
- package/dist/chunk-q4se2rwe.js.map +14 -0
- package/dist/chunk-v5w3w6bd.js +168 -0
- package/dist/chunk-v5w3w6bd.js.map +11 -0
- package/dist/chunk-ykze151b.js +770 -0
- package/dist/chunk-ykze151b.js.map +16 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +433 -0
- package/dist/cli.js.map +10 -0
- package/dist/encoders/ansi.d.ts +2 -0
- package/dist/encoders/html.d.ts +10 -0
- package/dist/encoders/string.d.ts +2 -0
- package/dist/flow/encode.d.ts +5 -0
- package/dist/flow/index.d.ts +8 -0
- package/dist/flow/index.js +25 -0
- package/dist/flow/index.js.map +9 -0
- package/dist/flow/layout.d.ts +30 -0
- package/dist/flow/parse.d.ts +2 -0
- package/dist/flow/render.d.ts +3 -0
- package/dist/flow/types.d.ts +42 -0
- package/dist/flow/validate.d.ts +3 -0
- package/dist/flow/yaml.d.ts +4 -0
- package/dist/grid.d.ts +14 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +9 -0
- package/dist/miner/history.d.ts +6 -0
- package/dist/miner/index.d.ts +18 -0
- package/dist/miner/index.js +38 -0
- package/dist/miner/index.js.map +9 -0
- package/dist/miner/sessions.d.ts +3 -0
- package/dist/miner/stats.d.ts +2 -0
- package/dist/miner/suggest.d.ts +11 -0
- package/dist/miner/transitions.d.ts +6 -0
- package/dist/miner/types.d.ts +46 -0
- package/dist/miner/workflows.d.ts +11 -0
- package/dist/parse.d.ts +3 -0
- package/dist/render.d.ts +3 -0
- package/dist/types.d.ts +39 -0
- package/package.json +85 -0
- package/skill/SKILL.md +263 -0
- package/skill/evals/evals.json +26 -0
- package/skill/install.sh +38 -0
- package/skill/references/archaeology-guide.md +157 -0
- package/skill/references/skill-template.md +120 -0
- package/src/archaeology/cache.ts +107 -0
- package/src/archaeology/delegate.ts +113 -0
- package/src/archaeology/index.ts +48 -0
- package/src/archaeology/llm.ts +10 -0
- package/src/archaeology/merge.ts +155 -0
- package/src/archaeology/orchestrator.ts +185 -0
- package/src/archaeology/prompts.ts +178 -0
- package/src/archaeology/types.ts +139 -0
- package/src/archaeology/validate.ts +157 -0
- package/src/cli.ts +451 -0
- package/src/encoders/ansi.ts +32 -0
- package/src/encoders/html.ts +78 -0
- package/src/encoders/string.ts +20 -0
- package/src/flow/encode.ts +21 -0
- package/src/flow/index.ts +15 -0
- package/src/flow/layout.ts +150 -0
- package/src/flow/parse.ts +100 -0
- package/src/flow/render.ts +186 -0
- package/src/flow/types.ts +45 -0
- package/src/flow/validate.ts +111 -0
- package/src/flow/yaml.ts +235 -0
- package/src/grid.ts +59 -0
- package/src/index.ts +24 -0
- package/src/miner/history.ts +156 -0
- package/src/miner/index.ts +76 -0
- package/src/miner/sessions.ts +39 -0
- package/src/miner/stats.ts +43 -0
- package/src/miner/suggest.ts +101 -0
- package/src/miner/transitions.ts +62 -0
- package/src/miner/types.ts +45 -0
- package/src/miner/workflows.ts +96 -0
- package/src/parse.ts +321 -0
- package/src/render.ts +182 -0
- package/src/types.ts +62 -0
- package/workflows/docker-deploy.yml +42 -0
- package/workflows/docker-parallel.yml +36 -0
- package/workflows/gh-issue-to-pr.yml +48 -0
- package/workflows/git-pr-flow.yml +36 -0
- package/workflows/kubectl-rollout.yml +37 -0
- package/workflows/npm-publish.yml +42 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Workflow, WorkflowNode, WorkflowEdge } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface LayoutNode {
|
|
4
|
+
id: string;
|
|
5
|
+
node: WorkflowNode;
|
|
6
|
+
col: number;
|
|
7
|
+
row: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LayoutEdge {
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
edge: WorkflowEdge;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Layout {
|
|
21
|
+
nodes: LayoutNode[];
|
|
22
|
+
edges: LayoutEdge[];
|
|
23
|
+
totalWidth: number;
|
|
24
|
+
totalHeight: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LayoutOptions {
|
|
28
|
+
boxPaddingX?: number;
|
|
29
|
+
colGap?: number;
|
|
30
|
+
rowGap?: number;
|
|
31
|
+
minBoxWidth?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function computeLayout(workflow: Workflow, opts: LayoutOptions = {}): Layout {
|
|
35
|
+
const boxPaddingX = opts.boxPaddingX ?? 2;
|
|
36
|
+
const colGap = opts.colGap ?? 4;
|
|
37
|
+
const rowGap = opts.rowGap ?? 2;
|
|
38
|
+
const minBoxWidth = opts.minBoxWidth ?? 8;
|
|
39
|
+
|
|
40
|
+
const ranks = computeRanks(workflow);
|
|
41
|
+
const byRank = groupByRank(workflow.nodes, ranks);
|
|
42
|
+
const maxRank = Math.max(0, ...Array.from(ranks.values()));
|
|
43
|
+
|
|
44
|
+
const allWidths = workflow.nodes.map(n => boxWidthFor(n, boxPaddingX, minBoxWidth));
|
|
45
|
+
const uniformWidth = Math.max(...allWidths);
|
|
46
|
+
|
|
47
|
+
const maxPerRank = Math.max(
|
|
48
|
+
1,
|
|
49
|
+
...Array.from(byRank.values()).map(v => v.length),
|
|
50
|
+
);
|
|
51
|
+
const totalRowWidth = maxPerRank * uniformWidth + (maxPerRank - 1) * colGap;
|
|
52
|
+
|
|
53
|
+
const layoutNodes: LayoutNode[] = [];
|
|
54
|
+
const boxHeight = 3;
|
|
55
|
+
|
|
56
|
+
let currentY = 0;
|
|
57
|
+
for (let r = 0; r <= maxRank; r++) {
|
|
58
|
+
const rankNodes = byRank.get(r) ?? [];
|
|
59
|
+
const count = rankNodes.length;
|
|
60
|
+
const rowWidth = count * uniformWidth + (count - 1) * colGap;
|
|
61
|
+
const startX = Math.floor((totalRowWidth - rowWidth) / 2);
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < rankNodes.length; i++) {
|
|
64
|
+
const node = rankNodes[i]!;
|
|
65
|
+
const x = startX + i * (uniformWidth + colGap);
|
|
66
|
+
layoutNodes.push({
|
|
67
|
+
id: node.id,
|
|
68
|
+
node,
|
|
69
|
+
col: i,
|
|
70
|
+
row: r,
|
|
71
|
+
width: uniformWidth,
|
|
72
|
+
height: boxHeight,
|
|
73
|
+
x,
|
|
74
|
+
y: currentY,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
currentY += boxHeight + rowGap;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const totalWidth = Math.max(
|
|
82
|
+
0,
|
|
83
|
+
...layoutNodes.map(n => n.x + n.width),
|
|
84
|
+
);
|
|
85
|
+
const totalHeight = Math.max(
|
|
86
|
+
0,
|
|
87
|
+
...layoutNodes.map(n => n.y + n.height),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const edges: LayoutEdge[] = workflow.edges.map(e => ({
|
|
91
|
+
from: e.from,
|
|
92
|
+
to: e.to,
|
|
93
|
+
edge: e,
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
return { nodes: layoutNodes, edges, totalWidth, totalHeight };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function computeRanks(workflow: Workflow): Map<string, number> {
|
|
100
|
+
const ranks = new Map<string, number>();
|
|
101
|
+
const inEdges = new Map<string, string[]>();
|
|
102
|
+
for (const node of workflow.nodes) {
|
|
103
|
+
ranks.set(node.id, 0);
|
|
104
|
+
inEdges.set(node.id, []);
|
|
105
|
+
}
|
|
106
|
+
for (const edge of workflow.edges) {
|
|
107
|
+
inEdges.get(edge.to)?.push(edge.from);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const visiting = new Set<string>();
|
|
111
|
+
function visit(id: string): number {
|
|
112
|
+
if (visiting.has(id)) return ranks.get(id) ?? 0;
|
|
113
|
+
visiting.add(id);
|
|
114
|
+
const parents = inEdges.get(id) ?? [];
|
|
115
|
+
if (parents.length === 0) {
|
|
116
|
+
ranks.set(id, 0);
|
|
117
|
+
} else {
|
|
118
|
+
const maxParent = Math.max(...parents.map(p => visit(p)));
|
|
119
|
+
ranks.set(id, maxParent + 1);
|
|
120
|
+
}
|
|
121
|
+
visiting.delete(id);
|
|
122
|
+
return ranks.get(id)!;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const node of workflow.nodes) {
|
|
126
|
+
visit(node.id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return ranks;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function groupByRank(nodes: WorkflowNode[], ranks: Map<string, number>): Map<number, WorkflowNode[]> {
|
|
133
|
+
const byRank = new Map<number, WorkflowNode[]>();
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
const r = ranks.get(node.id) ?? 0;
|
|
136
|
+
if (!byRank.has(r)) byRank.set(r, []);
|
|
137
|
+
byRank.get(r)!.push(node);
|
|
138
|
+
}
|
|
139
|
+
return byRank;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function boxWidthFor(node: WorkflowNode, padding: number, minWidth: number): number {
|
|
143
|
+
const label = node.label ?? node.id;
|
|
144
|
+
const contentWidth = label.length;
|
|
145
|
+
return Math.max(minWidth, contentWidth + padding * 2 + 2);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function findNode(layout: Layout, id: string): LayoutNode | undefined {
|
|
149
|
+
return layout.nodes.find(n => n.id === id);
|
|
150
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parseYaml, type YamlValue } from "./yaml";
|
|
2
|
+
import type { Workflow, WorkflowNode, WorkflowEdge } from "./types";
|
|
3
|
+
|
|
4
|
+
export function parseWorkflow(yamlText: string): Workflow {
|
|
5
|
+
const raw = parseYaml(yamlText);
|
|
6
|
+
if (!isObject(raw)) {
|
|
7
|
+
throw new Error("Workflow YAML must be a map at the top level");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const name = asString(raw.name, "name");
|
|
11
|
+
const cli = asString(raw.cli, "cli");
|
|
12
|
+
const description = raw.description ? asString(raw.description, "description") : undefined;
|
|
13
|
+
const version = raw.version ? asString(raw.version, "version") : undefined;
|
|
14
|
+
|
|
15
|
+
const nodes = parseNodes(raw.nodes);
|
|
16
|
+
const edges = parseEdges(raw.edges, nodes);
|
|
17
|
+
|
|
18
|
+
const workflow: Workflow = { name, cli, nodes, edges };
|
|
19
|
+
if (description) workflow.description = description;
|
|
20
|
+
if (version) workflow.version = version;
|
|
21
|
+
return workflow;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseNodes(raw: YamlValue): WorkflowNode[] {
|
|
25
|
+
if (!Array.isArray(raw)) {
|
|
26
|
+
throw new Error("'nodes' must be a list");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return raw.map((item, idx) => {
|
|
30
|
+
if (!isObject(item)) {
|
|
31
|
+
throw new Error(`nodes[${idx}] must be a map`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const id = asString(item.id, `nodes[${idx}].id`);
|
|
35
|
+
const cmdRaw = item.command;
|
|
36
|
+
|
|
37
|
+
let command: string[];
|
|
38
|
+
if (typeof cmdRaw === "string") {
|
|
39
|
+
command = cmdRaw.split(/\s+/).filter(Boolean);
|
|
40
|
+
} else if (Array.isArray(cmdRaw)) {
|
|
41
|
+
command = cmdRaw.map(c => String(c));
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error(`nodes[${idx}].command must be a string or list`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const node: WorkflowNode = { id, command };
|
|
47
|
+
if (typeof item.label === "string") node.label = item.label;
|
|
48
|
+
if (typeof item.description === "string") node.description = item.description;
|
|
49
|
+
if (typeof item.optional === "boolean") node.optional = item.optional;
|
|
50
|
+
return node;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseEdges(raw: YamlValue, nodes: WorkflowNode[]): WorkflowEdge[] {
|
|
55
|
+
if (!raw) return inferSequentialEdges(nodes);
|
|
56
|
+
if (!Array.isArray(raw)) {
|
|
57
|
+
throw new Error("'edges' must be a list");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return raw.map((item, idx) => {
|
|
61
|
+
if (typeof item === "string") {
|
|
62
|
+
const parts = item.split("->").map(s => s.trim());
|
|
63
|
+
if (parts.length !== 2) {
|
|
64
|
+
throw new Error(`edges[${idx}] string must be "from -> to"`);
|
|
65
|
+
}
|
|
66
|
+
return { from: parts[0]!, to: parts[1]! };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!isObject(item)) {
|
|
70
|
+
throw new Error(`edges[${idx}] must be a map or string`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const edge: WorkflowEdge = {
|
|
74
|
+
from: asString(item.from, `edges[${idx}].from`),
|
|
75
|
+
to: asString(item.to, `edges[${idx}].to`),
|
|
76
|
+
};
|
|
77
|
+
if (typeof item.condition === "string") edge.condition = item.condition;
|
|
78
|
+
if (typeof item.label === "string") edge.label = item.label;
|
|
79
|
+
return edge;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function inferSequentialEdges(nodes: WorkflowNode[]): WorkflowEdge[] {
|
|
84
|
+
const edges: WorkflowEdge[] = [];
|
|
85
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
86
|
+
edges.push({ from: nodes[i]!.id, to: nodes[i + 1]!.id });
|
|
87
|
+
}
|
|
88
|
+
return edges;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isObject(value: YamlValue): value is { [key: string]: YamlValue } {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function asString(value: YamlValue, field: string): string {
|
|
96
|
+
if (typeof value !== "string") {
|
|
97
|
+
throw new Error(`${field} must be a string`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createGrid, writeText, type Grid } from "../grid";
|
|
2
|
+
import { computeLayout, findNode, type Layout, type LayoutNode } from "./layout";
|
|
3
|
+
import type { Workflow, FlowRenderOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
const BOX = {
|
|
6
|
+
topLeft: "┌",
|
|
7
|
+
topRight: "┐",
|
|
8
|
+
botLeft: "└",
|
|
9
|
+
botRight: "┘",
|
|
10
|
+
horiz: "─",
|
|
11
|
+
vert: "│",
|
|
12
|
+
arrowDown: "▼",
|
|
13
|
+
arrowRight: "►",
|
|
14
|
+
junctionDown: "┬",
|
|
15
|
+
junctionUp: "┴",
|
|
16
|
+
junctionLeft: "┤",
|
|
17
|
+
junctionRight: "├",
|
|
18
|
+
cross: "┼",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const COLOR = {
|
|
22
|
+
boxFrame: "gray",
|
|
23
|
+
label: "cyan",
|
|
24
|
+
optional: "yellow",
|
|
25
|
+
arrow: "gray",
|
|
26
|
+
title: "magenta",
|
|
27
|
+
description: "gray",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function renderFlow(workflow: Workflow, opts: FlowRenderOptions = {}): Grid {
|
|
31
|
+
const useColor = opts.color ?? true;
|
|
32
|
+
const showDescriptions = opts.showDescriptions ?? true;
|
|
33
|
+
const compact = opts.compact ?? false;
|
|
34
|
+
|
|
35
|
+
const layout = computeLayout(workflow);
|
|
36
|
+
|
|
37
|
+
const titleLines = buildTitle(workflow, showDescriptions && !compact);
|
|
38
|
+
const titleOffset = titleLines.length;
|
|
39
|
+
const flowHeight = layout.totalHeight;
|
|
40
|
+
const legendLines = compact ? [] : buildLegend(workflow);
|
|
41
|
+
const legendOffset = legendLines.length > 0 ? legendLines.length + 1 : 0;
|
|
42
|
+
|
|
43
|
+
const totalHeight = titleOffset + flowHeight + legendOffset + 1;
|
|
44
|
+
const totalWidth = Math.max(layout.totalWidth, maxLineLength(titleLines), maxLineLength(legendLines)) + 2;
|
|
45
|
+
|
|
46
|
+
const grid = createGrid(totalWidth);
|
|
47
|
+
for (let i = 0; i < totalHeight; i++) grid.addRow();
|
|
48
|
+
|
|
49
|
+
let row = 0;
|
|
50
|
+
for (const line of titleLines) {
|
|
51
|
+
writeText(grid, 0, row, line.text, useColor ? line.color : null);
|
|
52
|
+
row++;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const node of layout.nodes) {
|
|
56
|
+
drawBox(grid, node, row + node.y, useColor);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const edge of layout.edges) {
|
|
60
|
+
const from = findNode(layout, edge.from);
|
|
61
|
+
const to = findNode(layout, edge.to);
|
|
62
|
+
if (!from || !to) continue;
|
|
63
|
+
drawEdge(grid, from, to, row, useColor);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (legendLines.length > 0) {
|
|
67
|
+
let legendRow = row + flowHeight + 1;
|
|
68
|
+
for (const line of legendLines) {
|
|
69
|
+
writeText(grid, 0, legendRow, line.text, useColor ? line.color : null);
|
|
70
|
+
legendRow++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return grid;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface TextLine {
|
|
78
|
+
text: string;
|
|
79
|
+
color: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildTitle(workflow: Workflow, showDescription: boolean): TextLine[] {
|
|
83
|
+
const lines: TextLine[] = [];
|
|
84
|
+
const title = `${workflow.cli} · ${workflow.name}`;
|
|
85
|
+
lines.push({ text: title, color: COLOR.title });
|
|
86
|
+
if (showDescription && workflow.description) {
|
|
87
|
+
lines.push({ text: workflow.description, color: COLOR.description });
|
|
88
|
+
}
|
|
89
|
+
lines.push({ text: "", color: null });
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildLegend(workflow: Workflow): TextLine[] {
|
|
94
|
+
const hasOptional = workflow.nodes.some(n => n.optional);
|
|
95
|
+
if (!hasOptional) return [];
|
|
96
|
+
return [
|
|
97
|
+
{ text: " ─── required ┈┈┈ optional", color: COLOR.description },
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function maxLineLength(lines: TextLine[]): number {
|
|
102
|
+
return lines.reduce((max, l) => Math.max(max, l.text.length), 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function drawBox(grid: Grid, node: LayoutNode, rowOffset: number, useColor: boolean): void {
|
|
106
|
+
const y = rowOffset;
|
|
107
|
+
const x = node.x;
|
|
108
|
+
const w = node.width;
|
|
109
|
+
const label = node.node.label ?? node.node.id;
|
|
110
|
+
const isOptional = node.node.optional;
|
|
111
|
+
const labelColor = useColor ? (isOptional ? COLOR.optional : COLOR.label) : null;
|
|
112
|
+
const frameColor = useColor ? COLOR.boxFrame : null;
|
|
113
|
+
|
|
114
|
+
const horizChar = isOptional ? "┈" : BOX.horiz;
|
|
115
|
+
const vertChar = isOptional ? "┊" : BOX.vert;
|
|
116
|
+
const tlChar = isOptional ? "┌" : BOX.topLeft;
|
|
117
|
+
const trChar = isOptional ? "┐" : BOX.topRight;
|
|
118
|
+
const blChar = isOptional ? "└" : BOX.botLeft;
|
|
119
|
+
const brChar = isOptional ? "┘" : BOX.botRight;
|
|
120
|
+
|
|
121
|
+
writeText(grid, x, y, tlChar, frameColor);
|
|
122
|
+
for (let i = 1; i < w - 1; i++) {
|
|
123
|
+
writeText(grid, x + i, y, horizChar, frameColor);
|
|
124
|
+
}
|
|
125
|
+
writeText(grid, x + w - 1, y, trChar, frameColor);
|
|
126
|
+
|
|
127
|
+
writeText(grid, x, y + 1, vertChar, frameColor);
|
|
128
|
+
const labelStart = x + Math.floor((w - label.length) / 2);
|
|
129
|
+
writeText(grid, labelStart, y + 1, label, labelColor);
|
|
130
|
+
writeText(grid, x + w - 1, y + 1, vertChar, frameColor);
|
|
131
|
+
|
|
132
|
+
writeText(grid, x, y + 2, blChar, frameColor);
|
|
133
|
+
for (let i = 1; i < w - 1; i++) {
|
|
134
|
+
writeText(grid, x + i, y + 2, horizChar, frameColor);
|
|
135
|
+
}
|
|
136
|
+
writeText(grid, x + w - 1, y + 2, brChar, frameColor);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function drawEdge(grid: Grid, from: LayoutNode, to: LayoutNode, rowOffset: number, useColor: boolean): void {
|
|
140
|
+
const color = useColor ? COLOR.arrow : null;
|
|
141
|
+
|
|
142
|
+
const fromCenterX = from.x + Math.floor(from.width / 2);
|
|
143
|
+
const toCenterX = to.x + Math.floor(to.width / 2);
|
|
144
|
+
const fromBottomY = rowOffset + from.y + from.height - 1;
|
|
145
|
+
const toTopY = rowOffset + to.y;
|
|
146
|
+
|
|
147
|
+
if (to.row > from.row) {
|
|
148
|
+
if (fromCenterX === toCenterX) {
|
|
149
|
+
for (let y = fromBottomY + 1; y < toTopY - 1; y++) {
|
|
150
|
+
writeText(grid, fromCenterX, y, BOX.vert, color);
|
|
151
|
+
}
|
|
152
|
+
writeText(grid, fromCenterX, toTopY - 1, BOX.arrowDown, color);
|
|
153
|
+
} else {
|
|
154
|
+
const midY = fromBottomY + 1;
|
|
155
|
+
writeText(grid, fromCenterX, midY, BOX.vert, color);
|
|
156
|
+
|
|
157
|
+
const bendY = midY + 1;
|
|
158
|
+
const startX = Math.min(fromCenterX, toCenterX);
|
|
159
|
+
const endX = Math.max(fromCenterX, toCenterX);
|
|
160
|
+
for (let x = startX + 1; x < endX; x++) {
|
|
161
|
+
writeText(grid, x, bendY, BOX.horiz, color);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (fromCenterX < toCenterX) {
|
|
165
|
+
writeText(grid, fromCenterX, bendY, "└", color);
|
|
166
|
+
writeText(grid, toCenterX, bendY, "┐", color);
|
|
167
|
+
} else {
|
|
168
|
+
writeText(grid, fromCenterX, bendY, "┘", color);
|
|
169
|
+
writeText(grid, toCenterX, bendY, "┌", color);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (let y = bendY + 1; y < toTopY - 1; y++) {
|
|
173
|
+
writeText(grid, toCenterX, y, BOX.vert, color);
|
|
174
|
+
}
|
|
175
|
+
writeText(grid, toCenterX, toTopY - 1, BOX.arrowDown, color);
|
|
176
|
+
}
|
|
177
|
+
} else if (to.row === from.row) {
|
|
178
|
+
const y = rowOffset + from.y + Math.floor(from.height / 2);
|
|
179
|
+
const startX = from.x + from.width;
|
|
180
|
+
const endX = to.x;
|
|
181
|
+
for (let x = startX; x < endX - 1; x++) {
|
|
182
|
+
writeText(grid, x, y, BOX.horiz, color);
|
|
183
|
+
}
|
|
184
|
+
writeText(grid, endX - 1, y, BOX.arrowRight, color);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CLINode } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface Workflow {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
cli: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
nodes: WorkflowNode[];
|
|
9
|
+
edges: WorkflowEdge[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WorkflowNode {
|
|
13
|
+
id: string;
|
|
14
|
+
command: string[];
|
|
15
|
+
label?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
optional?: boolean;
|
|
18
|
+
ref?: CLINode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WorkflowEdge {
|
|
22
|
+
from: string;
|
|
23
|
+
to: string;
|
|
24
|
+
condition?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FlowRenderOptions {
|
|
29
|
+
color?: boolean;
|
|
30
|
+
showDescriptions?: boolean;
|
|
31
|
+
showCommands?: boolean;
|
|
32
|
+
compact?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ValidationError {
|
|
36
|
+
node?: string;
|
|
37
|
+
edge?: { from: string; to: string };
|
|
38
|
+
message: string;
|
|
39
|
+
severity: "error" | "warning";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ValidationResult {
|
|
43
|
+
valid: boolean;
|
|
44
|
+
errors: ValidationError[];
|
|
45
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { CLINode } from "../types";
|
|
2
|
+
import type { Workflow, ValidationError, ValidationResult } from "./types";
|
|
3
|
+
|
|
4
|
+
export function validateWorkflow(workflow: Workflow, tree?: CLINode): ValidationResult {
|
|
5
|
+
const errors: ValidationError[] = [];
|
|
6
|
+
const nodeIds = new Set(workflow.nodes.map(n => n.id));
|
|
7
|
+
|
|
8
|
+
if (nodeIds.size !== workflow.nodes.length) {
|
|
9
|
+
errors.push({ severity: "error", message: "Duplicate node IDs found" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
for (const node of workflow.nodes) {
|
|
13
|
+
if (node.command.length === 0) {
|
|
14
|
+
errors.push({ node: node.id, severity: "error", message: `Node "${node.id}" has empty command` });
|
|
15
|
+
}
|
|
16
|
+
if (node.command[0] !== workflow.cli) {
|
|
17
|
+
errors.push({
|
|
18
|
+
node: node.id,
|
|
19
|
+
severity: "warning",
|
|
20
|
+
message: `Node "${node.id}" command "${node.command[0]}" does not match workflow cli "${workflow.cli}"`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const edge of workflow.edges) {
|
|
26
|
+
if (!nodeIds.has(edge.from)) {
|
|
27
|
+
errors.push({
|
|
28
|
+
edge: { from: edge.from, to: edge.to },
|
|
29
|
+
severity: "error",
|
|
30
|
+
message: `Edge references unknown node "${edge.from}"`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (!nodeIds.has(edge.to)) {
|
|
34
|
+
errors.push({
|
|
35
|
+
edge: { from: edge.from, to: edge.to },
|
|
36
|
+
severity: "error",
|
|
37
|
+
message: `Edge references unknown node "${edge.to}"`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (hasCycle(workflow)) {
|
|
43
|
+
errors.push({ severity: "error", message: "Workflow contains a cycle — DAGs only" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (tree) {
|
|
47
|
+
for (const node of workflow.nodes) {
|
|
48
|
+
const subcommand = node.command.slice(1).filter(arg => !arg.startsWith("-") && !arg.startsWith("$") && /^[a-z]/i.test(arg));
|
|
49
|
+
if (subcommand.length === 0) {
|
|
50
|
+
node.ref = tree;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const firstMatch = tree.subcommands?.find(s => s.name === subcommand[0] || s.aliases?.includes(subcommand[0]!));
|
|
55
|
+
if (!firstMatch) {
|
|
56
|
+
errors.push({
|
|
57
|
+
node: node.id,
|
|
58
|
+
severity: "warning",
|
|
59
|
+
message: `Command "${node.command.join(" ")}" not found in ${workflow.cli} tree`,
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
node.ref = findInTree(tree, subcommand);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
valid: errors.filter(e => e.severity === "error").length === 0,
|
|
69
|
+
errors,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasCycle(workflow: Workflow): boolean {
|
|
74
|
+
const adj = new Map<string, string[]>();
|
|
75
|
+
for (const node of workflow.nodes) adj.set(node.id, []);
|
|
76
|
+
for (const edge of workflow.edges) {
|
|
77
|
+
adj.get(edge.from)?.push(edge.to);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
81
|
+
const color = new Map<string, number>();
|
|
82
|
+
for (const node of workflow.nodes) color.set(node.id, WHITE);
|
|
83
|
+
|
|
84
|
+
function visit(u: string): boolean {
|
|
85
|
+
color.set(u, GRAY);
|
|
86
|
+
for (const v of adj.get(u) ?? []) {
|
|
87
|
+
const c = color.get(v);
|
|
88
|
+
if (c === GRAY) return true;
|
|
89
|
+
if (c === WHITE && visit(v)) return true;
|
|
90
|
+
}
|
|
91
|
+
color.set(u, BLACK);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const node of workflow.nodes) {
|
|
96
|
+
if (color.get(node.id) === WHITE && visit(node.id)) return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function findInTree(tree: CLINode, path: string[]): CLINode | undefined {
|
|
102
|
+
let current: CLINode | undefined = tree;
|
|
103
|
+
for (const segment of path) {
|
|
104
|
+
const child: CLINode | undefined = current?.subcommands?.find(
|
|
105
|
+
(s) => s.name === segment || s.aliases?.includes(segment),
|
|
106
|
+
);
|
|
107
|
+
if (!child) return current;
|
|
108
|
+
current = child;
|
|
109
|
+
}
|
|
110
|
+
return current;
|
|
111
|
+
}
|