@sweny-ai/core 0.1.64 → 0.1.66
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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { runWorkflowDiagram } from "../diagram.js";
|
|
7
|
+
const sampleWorkflow = {
|
|
8
|
+
id: "sample",
|
|
9
|
+
name: "Sample",
|
|
10
|
+
description: "sample",
|
|
11
|
+
entry: "a",
|
|
12
|
+
nodes: {
|
|
13
|
+
a: { name: "A", instruction: "do a", skills: [] },
|
|
14
|
+
b: { name: "B", instruction: "do b", skills: [] },
|
|
15
|
+
},
|
|
16
|
+
edges: [{ from: "a", to: "b" }],
|
|
17
|
+
};
|
|
18
|
+
// Minimal in-memory writable stream that accumulates text. Avoids the
|
|
19
|
+
// overhead of real Node streams for these pure-output assertions.
|
|
20
|
+
function captureStream() {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
const stream = {
|
|
23
|
+
write: (chunk) => {
|
|
24
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
stream,
|
|
30
|
+
get text() {
|
|
31
|
+
return chunks.join("");
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
describe("runWorkflowDiagram", () => {
|
|
36
|
+
let tmpdir;
|
|
37
|
+
const loadWorkflowFile = vi.fn((_f) => sampleWorkflow);
|
|
38
|
+
const exit = vi.fn();
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-diagram-test-"));
|
|
41
|
+
loadWorkflowFile.mockClear();
|
|
42
|
+
exit.mockClear();
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
fs.rmSync(tmpdir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
describe("stdout", () => {
|
|
48
|
+
it("writes a fenced mermaid block to stdout by default", () => {
|
|
49
|
+
const stdout = captureStream();
|
|
50
|
+
const stderr = captureStream();
|
|
51
|
+
runWorkflowDiagram("any.yml", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: stderr.stream, exit });
|
|
52
|
+
expect(stdout.text).toMatch(/^```mermaid\n/);
|
|
53
|
+
expect(stdout.text).toMatch(/\n```\n$/);
|
|
54
|
+
expect(exit).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it("writes raw mermaid when --no-block is explicit", () => {
|
|
57
|
+
const stdout = captureStream();
|
|
58
|
+
const stderr = captureStream();
|
|
59
|
+
runWorkflowDiagram("any.yml", { block: false }, { loadWorkflowFile, stdout: stdout.stream, stderr: stderr.stream, exit });
|
|
60
|
+
expect(stdout.text).not.toMatch(/```mermaid/);
|
|
61
|
+
expect(stdout.text).toMatch(/graph TB/);
|
|
62
|
+
});
|
|
63
|
+
it("honors --direction LR", () => {
|
|
64
|
+
const stdout = captureStream();
|
|
65
|
+
runWorkflowDiagram("any.yml", { direction: "LR" }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
|
|
66
|
+
expect(stdout.text).toMatch(/graph LR/);
|
|
67
|
+
});
|
|
68
|
+
it("honors --title override", () => {
|
|
69
|
+
const stdout = captureStream();
|
|
70
|
+
runWorkflowDiagram("any.yml", { title: "Custom Title" }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
|
|
71
|
+
expect(stdout.text).toContain("Custom Title");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe("-o / --output file", () => {
|
|
75
|
+
it("writes raw mermaid to .mmd by default (no fence)", () => {
|
|
76
|
+
const out = path.join(tmpdir, "diagram.mmd");
|
|
77
|
+
const stderr = captureStream();
|
|
78
|
+
runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: stderr.stream, exit });
|
|
79
|
+
const contents = fs.readFileSync(out, "utf8");
|
|
80
|
+
expect(contents).not.toMatch(/```mermaid/);
|
|
81
|
+
expect(contents).toMatch(/graph TB/);
|
|
82
|
+
expect(stderr.text).toMatch(/✓ Wrote diagram to/);
|
|
83
|
+
});
|
|
84
|
+
it("writes fenced markdown to .md by default", () => {
|
|
85
|
+
const out = path.join(tmpdir, "diagram.md");
|
|
86
|
+
runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
|
|
87
|
+
const contents = fs.readFileSync(out, "utf8");
|
|
88
|
+
expect(contents).toMatch(/^```mermaid\n/);
|
|
89
|
+
expect(contents).toMatch(/\n```\n$/);
|
|
90
|
+
});
|
|
91
|
+
it("falls back to fenced default for unknown extensions", () => {
|
|
92
|
+
const out = path.join(tmpdir, "diagram.txt");
|
|
93
|
+
runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
|
|
94
|
+
const contents = fs.readFileSync(out, "utf8");
|
|
95
|
+
expect(contents).toMatch(/```mermaid/);
|
|
96
|
+
});
|
|
97
|
+
it("creates parent directories as needed (-o nested/subdir/file.mmd)", () => {
|
|
98
|
+
const out = path.join(tmpdir, "a", "b", "c", "diagram.mmd");
|
|
99
|
+
runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
|
|
100
|
+
expect(fs.existsSync(out)).toBe(true);
|
|
101
|
+
expect(fs.readFileSync(out, "utf8")).toMatch(/graph TB/);
|
|
102
|
+
});
|
|
103
|
+
it("does not write anything to stdout when -o is set", () => {
|
|
104
|
+
const stdout = captureStream();
|
|
105
|
+
const out = path.join(tmpdir, "diagram.mmd");
|
|
106
|
+
runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
|
|
107
|
+
expect(stdout.text).toBe("");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe("explicit --block / --no-block precedence over extension inference", () => {
|
|
111
|
+
function cmdWithBlock(block, explicit) {
|
|
112
|
+
const cmd = new Command();
|
|
113
|
+
cmd.option("--block", "fenced", true);
|
|
114
|
+
cmd.option("--no-block", "raw");
|
|
115
|
+
if (explicit) {
|
|
116
|
+
// Simulate user typing `--block` or `--no-block` on the CLI — commander
|
|
117
|
+
// marks the source as "cli" when the flag is seen in argv.
|
|
118
|
+
cmd.setOptionValueWithSource("block", block, "cli");
|
|
119
|
+
}
|
|
120
|
+
return cmd;
|
|
121
|
+
}
|
|
122
|
+
it("explicit --block wins over .mmd extension (writes fenced to .mmd)", () => {
|
|
123
|
+
const out = path.join(tmpdir, "diagram.mmd");
|
|
124
|
+
runWorkflowDiagram("any.yml", { output: out, block: true }, {
|
|
125
|
+
loadWorkflowFile,
|
|
126
|
+
command: cmdWithBlock(true, true),
|
|
127
|
+
stdout: captureStream().stream,
|
|
128
|
+
stderr: captureStream().stream,
|
|
129
|
+
exit,
|
|
130
|
+
});
|
|
131
|
+
expect(fs.readFileSync(out, "utf8")).toMatch(/```mermaid/);
|
|
132
|
+
});
|
|
133
|
+
it("explicit --no-block wins over .md extension (writes raw to .md)", () => {
|
|
134
|
+
const out = path.join(tmpdir, "diagram.md");
|
|
135
|
+
runWorkflowDiagram("any.yml", { output: out, block: false }, {
|
|
136
|
+
loadWorkflowFile,
|
|
137
|
+
command: cmdWithBlock(false, true),
|
|
138
|
+
stdout: captureStream().stream,
|
|
139
|
+
stderr: captureStream().stream,
|
|
140
|
+
exit,
|
|
141
|
+
});
|
|
142
|
+
expect(fs.readFileSync(out, "utf8")).not.toMatch(/```mermaid/);
|
|
143
|
+
});
|
|
144
|
+
it("default --block source does not suppress .mmd extension inference", () => {
|
|
145
|
+
// Commander always sets `block: true` via the declared default, but its
|
|
146
|
+
// source is "default" — our inference should still kick in for .mmd.
|
|
147
|
+
const out = path.join(tmpdir, "diagram.mmd");
|
|
148
|
+
runWorkflowDiagram("any.yml", { output: out, block: true }, {
|
|
149
|
+
loadWorkflowFile,
|
|
150
|
+
command: cmdWithBlock(true, false), // not explicit
|
|
151
|
+
stdout: captureStream().stream,
|
|
152
|
+
stderr: captureStream().stream,
|
|
153
|
+
exit,
|
|
154
|
+
});
|
|
155
|
+
expect(fs.readFileSync(out, "utf8")).not.toMatch(/```mermaid/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe("builtin workflow names", () => {
|
|
159
|
+
it("resolves 'triage' without calling loadWorkflowFile", () => {
|
|
160
|
+
const stdout = captureStream();
|
|
161
|
+
runWorkflowDiagram("triage", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
|
|
162
|
+
expect(loadWorkflowFile).not.toHaveBeenCalled();
|
|
163
|
+
expect(stdout.text).toMatch(/```mermaid/);
|
|
164
|
+
});
|
|
165
|
+
it("resolves 'implement' without calling loadWorkflowFile", () => {
|
|
166
|
+
const stdout = captureStream();
|
|
167
|
+
runWorkflowDiagram("implement", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
|
|
168
|
+
expect(loadWorkflowFile).not.toHaveBeenCalled();
|
|
169
|
+
expect(stdout.text.length).toBeGreaterThan(0);
|
|
170
|
+
});
|
|
171
|
+
it("resolves 'seed-content' without calling loadWorkflowFile", () => {
|
|
172
|
+
const stdout = captureStream();
|
|
173
|
+
runWorkflowDiagram("seed-content", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
|
|
174
|
+
expect(loadWorkflowFile).not.toHaveBeenCalled();
|
|
175
|
+
expect(stdout.text.length).toBeGreaterThan(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
describe("error handling", () => {
|
|
179
|
+
it("exits with code 1 and prints an error when loadWorkflowFile throws", () => {
|
|
180
|
+
const stderr = captureStream();
|
|
181
|
+
const throwingLoader = vi.fn(() => {
|
|
182
|
+
throw new Error("file not found: missing.yml");
|
|
183
|
+
});
|
|
184
|
+
runWorkflowDiagram("missing.yml", {}, { loadWorkflowFile: throwingLoader, stdout: captureStream().stream, stderr: stderr.stream, exit });
|
|
185
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
186
|
+
expect(stderr.text).toMatch(/file not found/);
|
|
187
|
+
});
|
|
188
|
+
it("exits with code 1 when the output path is unwritable", () => {
|
|
189
|
+
// Point -o at a path where the parent is a file (not a dir). mkdirSync
|
|
190
|
+
// with recursive:true will throw EEXIST, writeFileSync then fails. We
|
|
191
|
+
// specifically want to assert the error path doesn't crash the process.
|
|
192
|
+
const blocker = path.join(tmpdir, "blocker");
|
|
193
|
+
fs.writeFileSync(blocker, "im-a-file");
|
|
194
|
+
const out = path.join(blocker, "diagram.mmd");
|
|
195
|
+
const stderr = captureStream();
|
|
196
|
+
runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: stderr.stream, exit });
|
|
197
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
198
|
+
expect(stderr.text).toMatch(/Error:/);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { Workflow } from "../types.js";
|
|
3
|
+
export interface DiagramOptions {
|
|
4
|
+
direction?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
block?: boolean;
|
|
7
|
+
output?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DiagramDeps {
|
|
10
|
+
/** Load a workflow file (YAML or JSON). Injected so tests can stub. */
|
|
11
|
+
loadWorkflowFile: (file: string) => Workflow;
|
|
12
|
+
/** Commander instance used to detect whether --block was set explicitly. */
|
|
13
|
+
command?: Command;
|
|
14
|
+
/** Stream for success/error messages. Defaults to process.stderr. */
|
|
15
|
+
stderr?: NodeJS.WritableStream;
|
|
16
|
+
/** Stream for diagram output when no --output is set. Defaults to process.stdout. */
|
|
17
|
+
stdout?: NodeJS.WritableStream;
|
|
18
|
+
/** Exit handler. Defaults to process.exit. Tests can throw instead. */
|
|
19
|
+
exit?: (code: number) => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Render a workflow as a Mermaid diagram and write to stdout or a file.
|
|
23
|
+
*
|
|
24
|
+
* Block-default rules (in order of precedence):
|
|
25
|
+
* 1. Explicit `--block` / `--no-block` — always wins (detected via the
|
|
26
|
+
* Command's option-value source when provided)
|
|
27
|
+
* 2. When `-o` is set, infer from extension: `.mmd` → raw, `.md` → fenced
|
|
28
|
+
* 3. Otherwise default to fenced (matches prior stdout behavior)
|
|
29
|
+
*/
|
|
30
|
+
export declare function runWorkflowDiagram(file: string, options: DiagramOptions, deps: DiagramDeps): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { toMermaid, toMermaidBlock } from "../mermaid.js";
|
|
5
|
+
import { triageWorkflow, implementWorkflow, seedContentWorkflow } from "../workflows/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Render a workflow as a Mermaid diagram and write to stdout or a file.
|
|
8
|
+
*
|
|
9
|
+
* Block-default rules (in order of precedence):
|
|
10
|
+
* 1. Explicit `--block` / `--no-block` — always wins (detected via the
|
|
11
|
+
* Command's option-value source when provided)
|
|
12
|
+
* 2. When `-o` is set, infer from extension: `.mmd` → raw, `.md` → fenced
|
|
13
|
+
* 3. Otherwise default to fenced (matches prior stdout behavior)
|
|
14
|
+
*/
|
|
15
|
+
export function runWorkflowDiagram(file, options, deps) {
|
|
16
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
17
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
18
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
19
|
+
let workflow;
|
|
20
|
+
// Builtin workflow names
|
|
21
|
+
if (file === "triage") {
|
|
22
|
+
workflow = triageWorkflow;
|
|
23
|
+
}
|
|
24
|
+
else if (file === "implement") {
|
|
25
|
+
workflow = implementWorkflow;
|
|
26
|
+
}
|
|
27
|
+
else if (file === "seed-content") {
|
|
28
|
+
workflow = seedContentWorkflow;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
try {
|
|
32
|
+
workflow = deps.loadWorkflowFile(file);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
stderr.write(chalk.red(` Error: ${err instanceof Error ? err.message : String(err)}`) + "\n");
|
|
36
|
+
exit(1);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const direction = (options.direction === "LR" ? "LR" : "TB");
|
|
41
|
+
const title = options.title ?? workflow.name;
|
|
42
|
+
// Explicit --block / --no-block always wins. When commander's source is
|
|
43
|
+
// "cli" or "env" the user (or env var) set it; otherwise we may infer.
|
|
44
|
+
const blockSource = deps.command?.getOptionValueSource("block");
|
|
45
|
+
const blockExplicit = blockSource === "cli" || blockSource === "env";
|
|
46
|
+
let useBlock = options.block !== false;
|
|
47
|
+
if (!blockExplicit && options.output) {
|
|
48
|
+
const ext = path.extname(options.output).toLowerCase();
|
|
49
|
+
if (ext === ".mmd")
|
|
50
|
+
useBlock = false;
|
|
51
|
+
else if (ext === ".md")
|
|
52
|
+
useBlock = true;
|
|
53
|
+
}
|
|
54
|
+
const render = useBlock ? toMermaidBlock : toMermaid;
|
|
55
|
+
const output = render(workflow, { direction, title }) + "\n";
|
|
56
|
+
if (options.output) {
|
|
57
|
+
try {
|
|
58
|
+
// Create parent dirs so `-o some/nested/diagram.mmd` works without a
|
|
59
|
+
// separate mkdir — matches `pandoc -o`, `tsc --outFile`, etc.
|
|
60
|
+
const dir = path.dirname(options.output);
|
|
61
|
+
if (dir && dir !== "." && !fs.existsSync(dir)) {
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
fs.writeFileSync(options.output, output, "utf8");
|
|
65
|
+
stderr.write(chalk.green(` ✓ Wrote diagram to ${options.output}`) + "\n");
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
stderr.write(chalk.red(` Error: ${err instanceof Error ? err.message : String(err)}`) + "\n");
|
|
69
|
+
exit(1);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
stdout.write(output);
|
|
74
|
+
}
|
package/dist/cli/main.js
CHANGED
|
@@ -17,7 +17,8 @@ import { loadAdditionalContext } from "../templates.js";
|
|
|
17
17
|
import { validateWorkflow as validateWorkflowSchema } from "../schema.js";
|
|
18
18
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
19
19
|
import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
|
|
20
|
-
import {
|
|
20
|
+
import { toMermaidBlock } from "../mermaid.js";
|
|
21
|
+
import { runWorkflowDiagram } from "./diagram.js";
|
|
21
22
|
import { DagRenderer } from "./renderer.js";
|
|
22
23
|
import * as readline from "node:readline";
|
|
23
24
|
import { loadDotenv, loadConfigFile } from "./config-file.js";
|
|
@@ -800,32 +801,9 @@ workflowCmd
|
|
|
800
801
|
.option("--title <title>", "Diagram title (defaults to workflow name)")
|
|
801
802
|
.option("--block", "Wrap in ```mermaid fenced code block (default)", true)
|
|
802
803
|
.option("--no-block", "Output raw Mermaid without code fence")
|
|
803
|
-
.
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
if (file === "triage") {
|
|
807
|
-
workflow = triageWorkflow;
|
|
808
|
-
}
|
|
809
|
-
else if (file === "implement") {
|
|
810
|
-
workflow = implementWorkflow;
|
|
811
|
-
}
|
|
812
|
-
else if (file === "seed-content") {
|
|
813
|
-
workflow = seedContentWorkflow;
|
|
814
|
-
}
|
|
815
|
-
else {
|
|
816
|
-
try {
|
|
817
|
-
workflow = loadWorkflowFile(file);
|
|
818
|
-
}
|
|
819
|
-
catch (err) {
|
|
820
|
-
console.error(chalk.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
821
|
-
process.exit(1);
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
const direction = (options.direction === "LR" ? "LR" : "TB");
|
|
826
|
-
const title = options.title ?? workflow.name;
|
|
827
|
-
const render = options.block !== false ? toMermaidBlock : toMermaid;
|
|
828
|
-
process.stdout.write(render(workflow, { direction, title }) + "\n");
|
|
804
|
+
.option("-o, --output <path>", "Write the diagram to a file instead of stdout")
|
|
805
|
+
.action(function diagramAction(file, options) {
|
|
806
|
+
runWorkflowDiagram(file, options, { loadWorkflowFile, command: this });
|
|
829
807
|
});
|
|
830
808
|
workflowCmd
|
|
831
809
|
.command("export <name>")
|