blokctl 1.0.0 → 1.2.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/dist/commands/create/project.d.ts +2 -0
- package/dist/commands/create/project.js +47 -28
- package/dist/commands/create/utils/Examples.d.ts +3 -3
- package/dist/commands/create/utils/Examples.js +91 -121
- package/dist/commands/create/workflow.js +8 -6
- package/dist/commands/dev/index.js +26 -0
- package/dist/commands/install/node.d.ts +1 -0
- package/dist/commands/install/node.js +3 -39
- package/dist/commands/migrate/index.js +25 -0
- package/dist/commands/migrate/nodesTs.d.ts +31 -0
- package/dist/commands/migrate/nodesTs.js +484 -0
- package/dist/commands/migrate/refs.d.ts +15 -0
- package/dist/commands/migrate/refs.js +706 -0
- package/dist/commands/nodes/index.js +10 -0
- package/dist/commands/nodes/listNodes.d.ts +2 -0
- package/dist/commands/nodes/listNodes.js +20 -15
- package/dist/commands/nodes/syncNodes.d.ts +7 -0
- package/dist/commands/nodes/syncNodes.js +138 -0
- package/dist/commands/nodes/syncNodes.test.d.ts +1 -0
- package/dist/commands/nodes/syncNodes.test.js +322 -0
- package/dist/commands/publish/workflow.d.ts +1 -0
- package/dist/commands/publish/workflow.js +9 -0
- package/dist/commands/publish/workflow.test.d.ts +1 -0
- package/dist/commands/publish/workflow.test.js +22 -0
- package/dist/services/observability-stack.real-prometheus.test.d.ts +1 -0
- package/dist/services/observability-stack.real-prometheus.test.js +95 -0
- package/dist/services/observability-stack.real-tempo.test.d.ts +1 -0
- package/dist/services/observability-stack.real-tempo.test.js +86 -0
- package/dist/studio-dist/assets/index-QEvKRE4T.js +42 -0
- package/dist/studio-dist/index.html +1 -1
- package/package.json +3 -2
- package/dist/studio-dist/assets/index-CnFqCRQe.js +0 -42
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { program } from "../../services/commander.js";
|
|
3
3
|
import { listNodes } from "./listNodes.js";
|
|
4
|
+
import { syncNodes } from "./syncNodes.js";
|
|
4
5
|
const nodes = new Command("nodes").description("Inspect the node catalog of a running Blok server");
|
|
5
6
|
const list = new Command("list")
|
|
6
7
|
.description("List every node across all runtimes (hits GET /__blok/nodes on a running server)")
|
|
@@ -9,5 +10,14 @@ const list = new Command("list")
|
|
|
9
10
|
.action(async (options) => {
|
|
10
11
|
await listNodes(options);
|
|
11
12
|
});
|
|
13
|
+
const sync = new Command("sync")
|
|
14
|
+
.description("Generate typed runtimeNode stubs (one file per runtime) from the catalog at GET /__blok/nodes")
|
|
15
|
+
.option("-u, --url <value>", "Base URL of the running Blok server", "http://localhost:4000")
|
|
16
|
+
.option("-o, --out <dir>", "Output directory for the generated stubs", "nodes-gen")
|
|
17
|
+
.option("--check", "CI mode: exit non-zero if checked-in stubs drift from freshly generated (writes nothing)")
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
await syncNodes(options);
|
|
20
|
+
});
|
|
12
21
|
nodes.addCommand(list);
|
|
22
|
+
nodes.addCommand(sync);
|
|
13
23
|
program.addCommand(nodes);
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { OptionValues } from "commander";
|
|
2
2
|
export interface NodeEntry {
|
|
3
3
|
name: string;
|
|
4
|
+
ref?: string;
|
|
4
5
|
runtime: string;
|
|
5
6
|
description?: string;
|
|
6
7
|
inputSchema: unknown | null;
|
|
7
8
|
outputSchema: unknown | null;
|
|
8
9
|
tags?: string[];
|
|
9
10
|
}
|
|
11
|
+
export declare function fetchCatalog(url: string | undefined): Promise<NodeEntry[] | null>;
|
|
10
12
|
export declare function schemaMark(node: NodeEntry): string;
|
|
11
13
|
export declare function formatCatalog(nodes: readonly NodeEntry[]): string;
|
|
12
14
|
export declare function listNodes(opts: OptionValues): Promise<void>;
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import color from "picocolors";
|
|
2
|
+
export async function fetchCatalog(url) {
|
|
3
|
+
const baseUrl = (url ?? "http://localhost:4000").replace(/\/+$/, "");
|
|
4
|
+
const endpoint = `${baseUrl}/__blok/nodes`;
|
|
5
|
+
try {
|
|
6
|
+
const res = await fetch(endpoint);
|
|
7
|
+
if (!res.ok) {
|
|
8
|
+
console.log(color.red(`❌ ${endpoint} returned HTTP ${res.status}.`));
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const body = (await res.json());
|
|
12
|
+
return body.nodes ?? [];
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
console.log(color.red(`❌ Could not reach ${color.cyan(endpoint)} — is the Blok server running? ` +
|
|
16
|
+
`Pass --url <baseUrl> to point elsewhere. (${err.message})`));
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
2
20
|
export function schemaMark(node) {
|
|
3
21
|
const parts = [];
|
|
4
22
|
if (node.inputSchema)
|
|
@@ -28,24 +46,11 @@ export function formatCatalog(nodes) {
|
|
|
28
46
|
}
|
|
29
47
|
export async function listNodes(opts) {
|
|
30
48
|
const baseUrl = (opts.url ?? "http://localhost:4000").replace(/\/+$/, "");
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
const res = await fetch(endpoint);
|
|
35
|
-
if (!res.ok) {
|
|
36
|
-
console.log(color.red(`❌ ${endpoint} returned HTTP ${res.status}.`));
|
|
37
|
-
process.exit(1);
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
body = (await res.json());
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
console.log(color.red(`❌ Could not reach ${color.cyan(endpoint)} — is the Blok server running? ` +
|
|
44
|
-
`Pass --url <baseUrl> to point elsewhere. (${err.message})`));
|
|
49
|
+
const nodes = await fetchCatalog(opts.url);
|
|
50
|
+
if (nodes === null) {
|
|
45
51
|
process.exit(1);
|
|
46
52
|
return;
|
|
47
53
|
}
|
|
48
|
-
const nodes = body.nodes ?? [];
|
|
49
54
|
if (opts.json === true) {
|
|
50
55
|
console.log(JSON.stringify(nodes, null, 2));
|
|
51
56
|
return;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OptionValues } from "commander";
|
|
2
|
+
import { type NodeEntry } from "./listNodes.js";
|
|
3
|
+
export declare function jsonSchemaToTs(schema: unknown): string;
|
|
4
|
+
export declare function generateRuntimeStubs(nodes: readonly NodeEntry[]): Map<string, string>;
|
|
5
|
+
export declare function regenRuntimeStubs(baseUrl: string | undefined, outDir: string): Promise<number>;
|
|
6
|
+
export declare function diffStubs(files: Map<string, string>, outDir: string): Promise<string[]>;
|
|
7
|
+
export declare function syncNodes(opts: OptionValues): Promise<void>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { promises as fsp } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import color from "picocolors";
|
|
4
|
+
import { nameToIdentifier } from "../gen/appTypes.js";
|
|
5
|
+
import { fetchCatalog } from "./listNodes.js";
|
|
6
|
+
const BANNER = "// AUTO-GENERATED by `blokctl nodes sync` — do not edit by hand.";
|
|
7
|
+
export function jsonSchemaToTs(schema) {
|
|
8
|
+
if (!schema || typeof schema !== "object")
|
|
9
|
+
return "unknown";
|
|
10
|
+
const s = schema;
|
|
11
|
+
if (Array.isArray(s.enum)) {
|
|
12
|
+
return s.enum.map((v) => JSON.stringify(v)).join(" | ") || "unknown";
|
|
13
|
+
}
|
|
14
|
+
switch (s.type) {
|
|
15
|
+
case "string":
|
|
16
|
+
return "string";
|
|
17
|
+
case "number":
|
|
18
|
+
case "integer":
|
|
19
|
+
return "number";
|
|
20
|
+
case "boolean":
|
|
21
|
+
return "boolean";
|
|
22
|
+
case "null":
|
|
23
|
+
return "null";
|
|
24
|
+
case "array":
|
|
25
|
+
return `Array<${jsonSchemaToTs(s.items)}>`;
|
|
26
|
+
case "object":
|
|
27
|
+
case undefined: {
|
|
28
|
+
const props = s.properties;
|
|
29
|
+
if (!props || typeof props !== "object")
|
|
30
|
+
return "Record<string, unknown>";
|
|
31
|
+
const required = new Set(Array.isArray(s.required) ? s.required : []);
|
|
32
|
+
const fields = Object.entries(props).map(([key, value]) => {
|
|
33
|
+
const k = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
34
|
+
const opt = required.has(key) ? "" : "?";
|
|
35
|
+
return `${k}${opt}: ${jsonSchemaToTs(value)}`;
|
|
36
|
+
});
|
|
37
|
+
return fields.length === 0 ? "Record<string, unknown>" : `{ ${fields.join("; ")} }`;
|
|
38
|
+
}
|
|
39
|
+
default:
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function generateRuntimeStubs(nodes) {
|
|
44
|
+
const byRuntime = new Map();
|
|
45
|
+
for (const n of nodes) {
|
|
46
|
+
if (n.runtime === "module")
|
|
47
|
+
continue;
|
|
48
|
+
const list = byRuntime.get(n.runtime) ?? [];
|
|
49
|
+
list.push(n);
|
|
50
|
+
byRuntime.set(n.runtime, list);
|
|
51
|
+
}
|
|
52
|
+
const files = new Map();
|
|
53
|
+
for (const [runtime, entries] of [...byRuntime].sort(([a], [b]) => a.localeCompare(b))) {
|
|
54
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
const used = new Map();
|
|
56
|
+
const lines = [BANNER, `// Runtime: ${runtime}.`, "", 'import { runtimeNode } from "@blokjs/core";', ""];
|
|
57
|
+
for (const n of entries) {
|
|
58
|
+
let ident = nameToIdentifier(n.name);
|
|
59
|
+
if (used.has(ident) && used.get(ident) !== n.name) {
|
|
60
|
+
let i = 2;
|
|
61
|
+
while (used.has(`${ident}${i}`))
|
|
62
|
+
i++;
|
|
63
|
+
ident = `${ident}${i}`;
|
|
64
|
+
}
|
|
65
|
+
used.set(ident, n.name);
|
|
66
|
+
const inT = jsonSchemaToTs(n.inputSchema);
|
|
67
|
+
const outT = jsonSchemaToTs(n.outputSchema);
|
|
68
|
+
const ref = n.ref ?? `${runtime}:${n.name}`;
|
|
69
|
+
lines.push(`export const ${ident} = runtimeNode<${inT}, ${outT}>(${JSON.stringify(n.name)}, ${JSON.stringify(ref)});`);
|
|
70
|
+
}
|
|
71
|
+
files.set(`${runtime}.ts`, `${lines.join("\n")}\n`);
|
|
72
|
+
}
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
export async function regenRuntimeStubs(baseUrl, outDir) {
|
|
76
|
+
const nodes = await fetchCatalog(baseUrl);
|
|
77
|
+
if (nodes === null)
|
|
78
|
+
throw new Error("catalog fetch failed");
|
|
79
|
+
const files = generateRuntimeStubs(nodes);
|
|
80
|
+
if (files.size === 0)
|
|
81
|
+
return 0;
|
|
82
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
83
|
+
for (const [filename, source] of files) {
|
|
84
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
85
|
+
}
|
|
86
|
+
return files.size;
|
|
87
|
+
}
|
|
88
|
+
export async function diffStubs(files, outDir) {
|
|
89
|
+
const drifted = [];
|
|
90
|
+
for (const [filename, expected] of files) {
|
|
91
|
+
let actual = null;
|
|
92
|
+
try {
|
|
93
|
+
actual = await fsp.readFile(path.join(outDir, filename), "utf8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
actual = null;
|
|
97
|
+
}
|
|
98
|
+
if (actual !== expected)
|
|
99
|
+
drifted.push(filename);
|
|
100
|
+
}
|
|
101
|
+
return drifted.sort();
|
|
102
|
+
}
|
|
103
|
+
export async function syncNodes(opts) {
|
|
104
|
+
const cwd = process.cwd();
|
|
105
|
+
const outDir = path.isAbsolute(opts.out ?? "")
|
|
106
|
+
? opts.out
|
|
107
|
+
: path.join(cwd, opts.out ?? "nodes-gen");
|
|
108
|
+
const nodes = await fetchCatalog(opts.url);
|
|
109
|
+
if (nodes === null) {
|
|
110
|
+
process.exit(1);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const files = generateRuntimeStubs(nodes);
|
|
114
|
+
if (files.size === 0) {
|
|
115
|
+
console.log(color.yellow("No runtime nodes found — nothing to generate (module nodes are imported directly)."));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (opts.check) {
|
|
119
|
+
const drifted = await diffStubs(files, outDir);
|
|
120
|
+
if (drifted.length > 0) {
|
|
121
|
+
console.log(color.red("❌ Stub drift detected — checked-in stubs are stale:"));
|
|
122
|
+
for (const filename of drifted) {
|
|
123
|
+
console.log(color.red(` • ${path.join(path.relative(cwd, outDir), filename)}`));
|
|
124
|
+
}
|
|
125
|
+
console.log(color.dim("\nRun `blokctl nodes sync` to regenerate.\n"));
|
|
126
|
+
process.exit(1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.log(color.green(`✅ Stubs in sync (${files.size} file(s) checked).`));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
133
|
+
for (const [filename, source] of files) {
|
|
134
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
135
|
+
console.log(color.green(`✅ ${color.cyan(path.join(path.relative(cwd, outDir), filename))}`));
|
|
136
|
+
}
|
|
137
|
+
console.log(color.dim(`\nGenerated ${files.size} stub file(s). Import the named factories into your workflows.\n`));
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { promises as fsp } from "node:fs";
|
|
2
|
+
import os, { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { diffStubs, generateRuntimeStubs, jsonSchemaToTs, regenRuntimeStubs, syncNodes } from "./syncNodes.js";
|
|
6
|
+
describe("jsonSchemaToTs", () => {
|
|
7
|
+
it("degrades null / empty / unknown schema to unknown", () => {
|
|
8
|
+
expect(jsonSchemaToTs(null)).toBe("unknown");
|
|
9
|
+
expect(jsonSchemaToTs(undefined)).toBe("unknown");
|
|
10
|
+
expect(jsonSchemaToTs({ type: "weird-future-type" })).toBe("unknown");
|
|
11
|
+
});
|
|
12
|
+
it("renders an object schema with required/optional fields", () => {
|
|
13
|
+
const schema = {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { prompt: { type: "string" }, count: { type: "integer" } },
|
|
16
|
+
required: ["prompt"],
|
|
17
|
+
};
|
|
18
|
+
expect(jsonSchemaToTs(schema)).toBe("{ prompt: string; count?: number }");
|
|
19
|
+
});
|
|
20
|
+
it("renders arrays and enums", () => {
|
|
21
|
+
expect(jsonSchemaToTs({ type: "array", items: { type: "string" } })).toBe("Array<string>");
|
|
22
|
+
expect(jsonSchemaToTs({ enum: ["a", "b"] })).toBe('"a" | "b"');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("generateRuntimeStubs", () => {
|
|
26
|
+
const fixture = [
|
|
27
|
+
{
|
|
28
|
+
name: "ask",
|
|
29
|
+
ref: "runtime.python3:ask",
|
|
30
|
+
runtime: "runtime.python3",
|
|
31
|
+
inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
|
|
32
|
+
outputSchema: { type: "object", properties: { answer: { type: "string" } }, required: ["answer"] },
|
|
33
|
+
},
|
|
34
|
+
{ name: "embed", ref: "runtime.python3:embed", runtime: "runtime.python3", inputSchema: null, outputSchema: null },
|
|
35
|
+
{ name: "@blokjs/respond", ref: "@blokjs/respond", runtime: "module", inputSchema: {}, outputSchema: null },
|
|
36
|
+
{ name: "fast", ref: "runtime.rust:fast", runtime: "runtime.rust", inputSchema: null, outputSchema: null },
|
|
37
|
+
];
|
|
38
|
+
it("emits one file per runtime kind, skipping module nodes", () => {
|
|
39
|
+
const files = generateRuntimeStubs(fixture);
|
|
40
|
+
expect([...files.keys()].sort()).toEqual(["runtime.python3.ts", "runtime.rust.ts"]);
|
|
41
|
+
});
|
|
42
|
+
it("emits the banner, the import, a typed factory, and an unknown fallback factory", () => {
|
|
43
|
+
const py = generateRuntimeStubs(fixture).get("runtime.python3.ts");
|
|
44
|
+
if (!py)
|
|
45
|
+
throw new Error("expected runtime.python3.ts");
|
|
46
|
+
expect(py).toContain("// AUTO-GENERATED by `blokctl nodes sync` — do not edit by hand.");
|
|
47
|
+
expect(py).toContain('import { runtimeNode } from "@blokjs/core";');
|
|
48
|
+
expect(py).toContain('export const ask = runtimeNode<{ prompt: string }, { answer: string }>("ask", "runtime.python3:ask");');
|
|
49
|
+
expect(py).toContain('export const embed = runtimeNode<unknown, unknown>("embed", "runtime.python3:embed");');
|
|
50
|
+
});
|
|
51
|
+
it("disambiguates identical identifiers across nodes", () => {
|
|
52
|
+
const files = generateRuntimeStubs([
|
|
53
|
+
{ name: "do-thing", ref: "runtime.go:do-thing", runtime: "runtime.go", inputSchema: null, outputSchema: null },
|
|
54
|
+
{ name: "do.thing", ref: "runtime.go:do.thing", runtime: "runtime.go", inputSchema: null, outputSchema: null },
|
|
55
|
+
]);
|
|
56
|
+
const go = files.get("runtime.go.ts") ?? "";
|
|
57
|
+
expect(go).toContain('runtimeNode<unknown, unknown>("do-thing", "runtime.go:do-thing")');
|
|
58
|
+
expect(go).toMatch(/export const doThing2 = runtimeNode<unknown, unknown>\("do\.thing"/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("regenRuntimeStubs (blokctl dev hook)", () => {
|
|
62
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
63
|
+
const catalog = [
|
|
64
|
+
{ name: "ask", ref: "runtime.python3:ask", runtime: "runtime.python3", inputSchema: null, outputSchema: null },
|
|
65
|
+
{ name: "@blokjs/respond", ref: "@blokjs/respond", runtime: "module", inputSchema: null, outputSchema: null },
|
|
66
|
+
];
|
|
67
|
+
function stubFetch(impl) {
|
|
68
|
+
vi.stubGlobal("fetch", vi.fn(impl));
|
|
69
|
+
}
|
|
70
|
+
it("fetches the live catalog and writes one stub file per runtime kind", async () => {
|
|
71
|
+
stubFetch(() => new Response(JSON.stringify({ nodes: catalog }), { status: 200 }));
|
|
72
|
+
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), "blok-regen-"));
|
|
73
|
+
const count = await regenRuntimeStubs("http://localhost:4000", outDir);
|
|
74
|
+
expect(count).toBe(1);
|
|
75
|
+
const written = (await fsp.readdir(outDir)).sort();
|
|
76
|
+
expect(written).toEqual(["runtime.python3.ts"]);
|
|
77
|
+
const src = await fsp.readFile(path.join(outDir, "runtime.python3.ts"), "utf8");
|
|
78
|
+
expect(src).toContain('runtimeNode<unknown, unknown>("ask", "runtime.python3:ask")');
|
|
79
|
+
});
|
|
80
|
+
it("returns 0 and writes nothing when the catalog has only module nodes", async () => {
|
|
81
|
+
stubFetch(() => new Response(JSON.stringify({ nodes: [catalog[1]] }), { status: 200 }));
|
|
82
|
+
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), "blok-regen-"));
|
|
83
|
+
const count = await regenRuntimeStubs("http://localhost:4000", outDir);
|
|
84
|
+
expect(count).toBe(0);
|
|
85
|
+
await expect(fsp.readdir(outDir)).resolves.toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
it("throws when the server is unreachable (caller decides failure policy)", async () => {
|
|
88
|
+
stubFetch(() => {
|
|
89
|
+
throw new Error("ECONNREFUSED");
|
|
90
|
+
});
|
|
91
|
+
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), "blok-regen-"));
|
|
92
|
+
await expect(regenRuntimeStubs("http://localhost:4000", outDir)).rejects.toThrow("catalog fetch failed");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("diffStubs (--check)", () => {
|
|
96
|
+
const fixture = [
|
|
97
|
+
{ name: "ask", ref: "runtime.python3:ask", runtime: "runtime.python3", inputSchema: null, outputSchema: null },
|
|
98
|
+
{ name: "fast", ref: "runtime.rust:fast", runtime: "runtime.rust", inputSchema: null, outputSchema: null },
|
|
99
|
+
];
|
|
100
|
+
let outDir;
|
|
101
|
+
beforeEach(async () => {
|
|
102
|
+
outDir = await fsp.mkdtemp(path.join(tmpdir(), "blok-sync-check-"));
|
|
103
|
+
});
|
|
104
|
+
afterEach(async () => {
|
|
105
|
+
await fsp.rm(outDir, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
it("(a) reports no drift when on-disk stubs match generated", async () => {
|
|
108
|
+
const files = generateRuntimeStubs(fixture);
|
|
109
|
+
for (const [filename, source] of files) {
|
|
110
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
111
|
+
}
|
|
112
|
+
expect(await diffStubs(files, outDir)).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
it("(b) reports drift for a stale on-disk stub", async () => {
|
|
115
|
+
const files = generateRuntimeStubs(fixture);
|
|
116
|
+
for (const [filename, source] of files) {
|
|
117
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
118
|
+
}
|
|
119
|
+
await fsp.writeFile(path.join(outDir, "runtime.python3.ts"), "// stale hand-edit\n", "utf8");
|
|
120
|
+
expect(await diffStubs(files, outDir)).toEqual(["runtime.python3.ts"]);
|
|
121
|
+
});
|
|
122
|
+
it("(c) reports drift for a missing on-disk file", async () => {
|
|
123
|
+
const files = generateRuntimeStubs(fixture);
|
|
124
|
+
await fsp.writeFile(path.join(outDir, "runtime.rust.ts"), files.get("runtime.rust.ts") ?? "", "utf8");
|
|
125
|
+
expect(await diffStubs(files, outDir)).toEqual(["runtime.python3.ts"]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
129
|
+
function isSafeTypeLiteral(ts) {
|
|
130
|
+
if (ts.length === 0)
|
|
131
|
+
return false;
|
|
132
|
+
const balanced = (open, close) => {
|
|
133
|
+
let depth = 0;
|
|
134
|
+
for (const ch of ts) {
|
|
135
|
+
if (ch === open)
|
|
136
|
+
depth++;
|
|
137
|
+
else if (ch === close && --depth < 0)
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return depth === 0;
|
|
141
|
+
};
|
|
142
|
+
if (!balanced("{", "}") || !balanced("<", ">") || !balanced("[", "]"))
|
|
143
|
+
return false;
|
|
144
|
+
return !/\bany\b|\$ref|#\//.test(ts);
|
|
145
|
+
}
|
|
146
|
+
describe("#367 — bad node names produce safe identifiers", () => {
|
|
147
|
+
it("emits valid TS identifiers for names that are not valid identifiers, preserving the original name", () => {
|
|
148
|
+
const go = generateRuntimeStubs([
|
|
149
|
+
{
|
|
150
|
+
name: "weird name!",
|
|
151
|
+
ref: "runtime.go:weird name!",
|
|
152
|
+
runtime: "runtime.go",
|
|
153
|
+
inputSchema: null,
|
|
154
|
+
outputSchema: null,
|
|
155
|
+
},
|
|
156
|
+
{ name: "123start", ref: "runtime.go:123start", runtime: "runtime.go", inputSchema: null, outputSchema: null },
|
|
157
|
+
{ name: "a-b.c", ref: "runtime.go:a-b.c", runtime: "runtime.go", inputSchema: null, outputSchema: null },
|
|
158
|
+
]).get("runtime.go.ts") ?? "";
|
|
159
|
+
const idents = [...go.matchAll(/export const (\S+) =/g)].map((m) => m[1]);
|
|
160
|
+
expect(idents.length).toBe(3);
|
|
161
|
+
for (const ident of idents)
|
|
162
|
+
expect(ident).toMatch(IDENT_RE);
|
|
163
|
+
expect(go).toContain('runtimeNode<unknown, unknown>("weird name!", "runtime.go:weird name!")');
|
|
164
|
+
expect(go).toContain('runtimeNode<unknown, unknown>("123start", "runtime.go:123start")');
|
|
165
|
+
expect(go).toContain('runtimeNode<unknown, unknown>("a-b.c", "runtime.go:a-b.c")');
|
|
166
|
+
expect(go).not.toMatch(/export const 1/);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("#367 — JSON-Schema dialect quirks degrade safely (grounded in #364 spike)", () => {
|
|
170
|
+
const dialectCases = [
|
|
171
|
+
{
|
|
172
|
+
label: "$ref + $defs (Rust/C#/Python nested model)",
|
|
173
|
+
schema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: { user: { $ref: "#/$defs/User" }, tags: { type: "array", items: { type: "string" } } },
|
|
176
|
+
required: ["user"],
|
|
177
|
+
$defs: { User: { type: "object", properties: { id: { type: "string" } } } },
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
label: "anyOf nullable (Pydantic Optional)",
|
|
182
|
+
schema: { type: "object", properties: { nickname: { anyOf: [{ type: "string" }, { type: "null" }] } } },
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
label: "oneOf union (Rust enum)",
|
|
186
|
+
schema: { oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }] },
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
label: "format: date-time string",
|
|
190
|
+
schema: { type: "string", format: "date-time" },
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
label: "type-array nullable [string, null]",
|
|
194
|
+
schema: { type: ["string", "null"] },
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
for (const { label, schema } of dialectCases) {
|
|
198
|
+
it(`degrades ${label} to a safe type without throwing`, () => {
|
|
199
|
+
let out = "";
|
|
200
|
+
expect(() => {
|
|
201
|
+
out = jsonSchemaToTs(schema);
|
|
202
|
+
}).not.toThrow();
|
|
203
|
+
expect(isSafeTypeLiteral(out)).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
it("format: date-time preserves the base `string` type (lossless degrade)", () => {
|
|
207
|
+
expect(jsonSchemaToTs({ type: "string", format: "date-time" })).toBe("string");
|
|
208
|
+
});
|
|
209
|
+
it("type-array nullable degrades to `unknown` (most-conservative safe type)", () => {
|
|
210
|
+
expect(jsonSchemaToTs({ type: ["string", "null"] })).toBe("unknown");
|
|
211
|
+
});
|
|
212
|
+
it("a whole dialect catalog compiles to valid stub TS without throwing", () => {
|
|
213
|
+
const nodes = dialectCases.map((c, i) => ({
|
|
214
|
+
name: `n${i}`,
|
|
215
|
+
ref: `runtime.python3:n${i}`,
|
|
216
|
+
runtime: "runtime.python3",
|
|
217
|
+
inputSchema: c.schema,
|
|
218
|
+
outputSchema: null,
|
|
219
|
+
}));
|
|
220
|
+
const src = generateRuntimeStubs(nodes).get("runtime.python3.ts") ?? "";
|
|
221
|
+
const factoryLines = src.split("\n").filter((l) => l.startsWith("export const "));
|
|
222
|
+
expect(factoryLines.length).toBe(dialectCases.length);
|
|
223
|
+
for (const line of factoryLines)
|
|
224
|
+
expect(line).not.toMatch(/\bany\b|\$ref|#\//);
|
|
225
|
+
expect(isSafeTypeLiteral(src)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
describe("#371 — `--check` drift detection (CLI flow)", () => {
|
|
229
|
+
const catalog = [
|
|
230
|
+
{ name: "ask", ref: "runtime.python3:ask", runtime: "runtime.python3", inputSchema: null, outputSchema: null },
|
|
231
|
+
{ name: "fast", ref: "runtime.rust:fast", runtime: "runtime.rust", inputSchema: null, outputSchema: null },
|
|
232
|
+
];
|
|
233
|
+
let outDir;
|
|
234
|
+
let exitSpy;
|
|
235
|
+
beforeEach(async () => {
|
|
236
|
+
outDir = await fsp.mkdtemp(path.join(tmpdir(), "blok-check-cli-"));
|
|
237
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code) => undefined));
|
|
238
|
+
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
239
|
+
});
|
|
240
|
+
afterEach(async () => {
|
|
241
|
+
vi.unstubAllGlobals();
|
|
242
|
+
vi.restoreAllMocks();
|
|
243
|
+
await fsp.rm(outDir, { recursive: true, force: true });
|
|
244
|
+
});
|
|
245
|
+
function stubFetch(impl) {
|
|
246
|
+
vi.stubGlobal("fetch", vi.fn(impl));
|
|
247
|
+
}
|
|
248
|
+
const okCatalog = () => new Response(JSON.stringify({ nodes: catalog }), { status: 200 });
|
|
249
|
+
it("(a) in-sync stubs → no drift, never exits non-zero", async () => {
|
|
250
|
+
stubFetch(okCatalog);
|
|
251
|
+
for (const [filename, source] of generateRuntimeStubs(catalog)) {
|
|
252
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
253
|
+
}
|
|
254
|
+
await syncNodes({ url: "http://localhost:4000", out: outDir, check: true });
|
|
255
|
+
for (const call of exitSpy.mock.calls)
|
|
256
|
+
expect(call[0]).not.toBe(1);
|
|
257
|
+
});
|
|
258
|
+
it("(b) stale on-disk stub → exits non-zero", async () => {
|
|
259
|
+
stubFetch(okCatalog);
|
|
260
|
+
await fsp.writeFile(path.join(outDir, "runtime.python3.ts"), "// stale\n", "utf8");
|
|
261
|
+
for (const [filename, source] of generateRuntimeStubs(catalog)) {
|
|
262
|
+
if (filename === "runtime.python3.ts")
|
|
263
|
+
continue;
|
|
264
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
265
|
+
}
|
|
266
|
+
await syncNodes({ url: "http://localhost:4000", out: outDir, check: true });
|
|
267
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
268
|
+
});
|
|
269
|
+
it("(c) UNREACHABLE runtime → exits 1, NEVER a false 'in sync' (key safety case)", async () => {
|
|
270
|
+
stubFetch(() => {
|
|
271
|
+
throw new Error("ECONNREFUSED");
|
|
272
|
+
});
|
|
273
|
+
for (const [filename, source] of generateRuntimeStubs(catalog)) {
|
|
274
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
275
|
+
}
|
|
276
|
+
await syncNodes({ url: "http://localhost:4000", out: outDir, check: true });
|
|
277
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
278
|
+
const logged = console.log.mock.calls.flat().join("\n");
|
|
279
|
+
expect(logged).not.toMatch(/in sync/i);
|
|
280
|
+
});
|
|
281
|
+
it("(c') non-ok HTTP (server up, endpoint errors) → exits 1, no false 'in sync'", async () => {
|
|
282
|
+
stubFetch(() => new Response("boom", { status: 500 }));
|
|
283
|
+
for (const [filename, source] of generateRuntimeStubs(catalog)) {
|
|
284
|
+
await fsp.writeFile(path.join(outDir, filename), source, "utf8");
|
|
285
|
+
}
|
|
286
|
+
await syncNodes({ url: "http://localhost:4000", out: outDir, check: true });
|
|
287
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
288
|
+
const logged = console.log.mock.calls.flat().join("\n");
|
|
289
|
+
expect(logged).not.toMatch(/in sync/i);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
describe("#369 — regen does not clobber good stubs on an empty catalog", () => {
|
|
293
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
294
|
+
function stubFetch(impl) {
|
|
295
|
+
vi.stubGlobal("fetch", vi.fn(impl));
|
|
296
|
+
}
|
|
297
|
+
it("module-only / empty catalog returns 0 and leaves pre-existing stub files untouched", async () => {
|
|
298
|
+
const outDir = await fsp.mkdtemp(path.join(tmpdir(), "blok-noclobber-"));
|
|
299
|
+
const goodStub = generateRuntimeStubs([
|
|
300
|
+
{ name: "ask", ref: "runtime.python3:ask", runtime: "runtime.python3", inputSchema: null, outputSchema: null },
|
|
301
|
+
]).get("runtime.python3.ts");
|
|
302
|
+
if (!goodStub)
|
|
303
|
+
throw new Error("expected a good stub fixture");
|
|
304
|
+
const goodPath = path.join(outDir, "runtime.python3.ts");
|
|
305
|
+
await fsp.writeFile(goodPath, goodStub, "utf8");
|
|
306
|
+
stubFetch(() => new Response(JSON.stringify({
|
|
307
|
+
nodes: [
|
|
308
|
+
{
|
|
309
|
+
name: "@blokjs/respond",
|
|
310
|
+
ref: "@blokjs/respond",
|
|
311
|
+
runtime: "module",
|
|
312
|
+
inputSchema: null,
|
|
313
|
+
outputSchema: null,
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
}), { status: 200 }));
|
|
317
|
+
const count = await regenRuntimeStubs("http://localhost:4000", outDir);
|
|
318
|
+
expect(count).toBe(0);
|
|
319
|
+
expect(await fsp.readFile(goodPath, "utf8")).toBe(goodStub);
|
|
320
|
+
await fsp.rm(outDir, { recursive: true, force: true });
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command, type OptionValues } from "../../services/commander.js";
|
|
2
|
+
export declare function assertPublishableWorkflow(workflow: unknown, name: string): void;
|
|
2
3
|
export declare function publish(opts: OptionValues): Promise<void>;
|
|
3
4
|
declare const _default: Command;
|
|
4
5
|
export default _default;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as p from "@clack/prompts";
|
|
4
|
+
import { validateWorkflow } from "@blokjs/helper";
|
|
4
5
|
import { Command, trackCommandExecution } from "../../services/commander.js";
|
|
5
6
|
import { BLOK_URL } from "../../services/constants.js";
|
|
6
7
|
import { tokenManager } from "../../services/local-token-manager.js";
|
|
@@ -52,7 +53,15 @@ async function loadWorkflowFiles(directory) {
|
|
|
52
53
|
};
|
|
53
54
|
});
|
|
54
55
|
}
|
|
56
|
+
export function assertPublishableWorkflow(workflow, name) {
|
|
57
|
+
const result = validateWorkflow(workflow);
|
|
58
|
+
if (result.ok)
|
|
59
|
+
return;
|
|
60
|
+
const details = result.errors.map((e) => ` • ${e.path ? `${e.path}: ` : ""}${e.message}`).join("\n");
|
|
61
|
+
throw new Error(`Workflow "${name}" failed validation — not published:\n${details}`);
|
|
62
|
+
}
|
|
55
63
|
async function publishWorkflow(token, workflow, name) {
|
|
64
|
+
assertPublishableWorkflow(workflow, name);
|
|
56
65
|
const response = await fetch(`${BLOK_URL}/publish-workflow`, {
|
|
57
66
|
method: "POST",
|
|
58
67
|
headers: {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assertPublishableWorkflow } from "./workflow";
|
|
3
|
+
const validV2 = {
|
|
4
|
+
name: "auth-check",
|
|
5
|
+
version: "1.0.0",
|
|
6
|
+
trigger: { http: { method: "GET", path: "/x" } },
|
|
7
|
+
steps: [{ id: "check", use: "@blokjs/respond" }],
|
|
8
|
+
};
|
|
9
|
+
describe("assertPublishableWorkflow (#385) — IR admission gate before publish", () => {
|
|
10
|
+
it("passes a valid v2 workflow", () => {
|
|
11
|
+
expect(() => assertPublishableWorkflow(validV2, "auth-check")).not.toThrow();
|
|
12
|
+
});
|
|
13
|
+
it("rejects a non-object (malformed IR)", () => {
|
|
14
|
+
expect(() => assertPublishableWorkflow(null, "bad")).toThrow(/failed validation/);
|
|
15
|
+
});
|
|
16
|
+
it("rejects an object with no steps array (not a workflow)", () => {
|
|
17
|
+
expect(() => assertPublishableWorkflow({ name: "x", version: "1.0.0" }, "x")).toThrow(/failed validation/);
|
|
18
|
+
});
|
|
19
|
+
it("names the workflow in the error so the operator knows which file is bad", () => {
|
|
20
|
+
expect(() => assertPublishableWorkflow({}, "myflow")).toThrow(/"myflow"/);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|