blokctl 0.2.11 → 0.3.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.js +54 -0
- package/dist/commands/create/utils/Examples.d.ts +3 -3
- package/dist/commands/create/utils/Examples.js +109 -13
- package/dist/commands/dev/index.js +50 -13
- package/dist/commands/generate/validators/WorkflowValidator.js +1 -1
- package/dist/commands/migrate/index.js +11 -0
- package/dist/commands/migrate/workflows.d.ts +3 -0
- package/dist/commands/migrate/workflows.js +333 -0
- package/dist/commands/trace/index.js +6 -2
- package/dist/commands/trace/startStudio.d.ts +2 -0
- package/dist/commands/trace/startStudio.js +76 -11
- package/dist/index.js +1 -0
- package/dist/services/health-probe.d.ts +5 -0
- package/dist/services/health-probe.js +35 -0
- package/dist/services/runtime-detector.d.ts +3 -0
- package/dist/services/runtime-detector.js +21 -2
- package/dist/services/runtime-setup.d.ts +3 -0
- package/dist/services/runtime-setup.js +11 -2
- package/dist/studio-dist/assets/charts-Dh48HebV.js +68 -0
- package/dist/studio-dist/assets/graph-DWteCadQ.js +7 -0
- package/dist/studio-dist/assets/{icons-zP8LLgPh.js → icons-N5J4OhGx.js} +66 -51
- package/dist/studio-dist/assets/index-D6JA5F-X.js +42 -0
- package/dist/studio-dist/assets/index-mdQkg9ul.css +1 -0
- package/dist/studio-dist/assets/react-vendor-l0sNRNKZ.js +1 -0
- package/dist/studio-dist/assets/tanstack-query-Day3Mt-4.js +17 -0
- package/dist/studio-dist/assets/tanstack-router-BB95iErN.js +25 -0
- package/dist/studio-dist/assets/{tanstack-table-DhwRvuH2.js → tanstack-table-Biem1hxK.js} +1 -1
- package/dist/studio-dist/favicon.svg +7 -4
- package/dist/studio-dist/index.html +19 -10
- package/package.json +14 -11
- package/dist/studio-dist/assets/charts-Dso0hPUR.js +0 -68
- package/dist/studio-dist/assets/graph-CsV2nWGn.js +0 -23
- package/dist/studio-dist/assets/index-CLyEkXMx.css +0 -1
- package/dist/studio-dist/assets/index-CNXFX_ar.js +0 -27
- package/dist/studio-dist/assets/react-vendor--Eh9ivFN.js +0 -17
- package/dist/studio-dist/assets/tanstack-query-CiM1U6F5.js +0 -1
- package/dist/studio-dist/assets/tanstack-router-Btjy0MKq.js +0 -25
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { promises as fsp } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import color from "picocolors";
|
|
4
|
+
export async function migrateWorkflows(opts) {
|
|
5
|
+
const cwd = process.cwd();
|
|
6
|
+
const explicitDir = opts.dir ?? null;
|
|
7
|
+
const dryRun = opts.dryRun === true;
|
|
8
|
+
const writeBackup = opts.backup !== false;
|
|
9
|
+
console.log(color.cyan("\n🔄 Workflow v1 → v2 migrator"));
|
|
10
|
+
console.log(color.dim("Converts legacy JSON workflows to the canonical v2 shape.\n"));
|
|
11
|
+
const root = await resolveJsonRoot(cwd, explicitDir);
|
|
12
|
+
if (!root) {
|
|
13
|
+
console.log(color.red("❌ Could not find a JSON workflows directory. Looked in: " +
|
|
14
|
+
"workflows/json/, triggers/http/workflows/json/. Pass --dir <path> to override."));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
console.log(color.dim(`Scanning ${color.cyan(root)} (recursive)\n`));
|
|
18
|
+
const files = await collectJsonFiles(root);
|
|
19
|
+
if (files.length === 0) {
|
|
20
|
+
console.log(color.yellow("No JSON workflow files found."));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const results = [];
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const result = await migrateOne(file, { dryRun, writeBackup });
|
|
26
|
+
results.push(result);
|
|
27
|
+
printResult(result);
|
|
28
|
+
}
|
|
29
|
+
console.log("");
|
|
30
|
+
printSummary(results, dryRun, writeBackup);
|
|
31
|
+
}
|
|
32
|
+
async function migrateOne(file, opts) {
|
|
33
|
+
let raw;
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
raw = await fsp.readFile(file, "utf8");
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return { kind: "error", file, error: err.message };
|
|
41
|
+
}
|
|
42
|
+
if (!isPlainObject(parsed)) {
|
|
43
|
+
return { kind: "error", file, error: "Workflow must be a JSON object" };
|
|
44
|
+
}
|
|
45
|
+
const wf = parsed;
|
|
46
|
+
const triggerKind = detectTriggerKind(wf);
|
|
47
|
+
const v2 = convertToV2(wf);
|
|
48
|
+
const serialized = `${JSON.stringify(v2, null, "\t")}\n`;
|
|
49
|
+
if (serialized.trimEnd() === raw.trimEnd()) {
|
|
50
|
+
return { kind: "already-v2", file };
|
|
51
|
+
}
|
|
52
|
+
if (opts.dryRun) {
|
|
53
|
+
return { kind: "migrated", file };
|
|
54
|
+
}
|
|
55
|
+
if (opts.writeBackup) {
|
|
56
|
+
try {
|
|
57
|
+
await fsp.writeFile(`${file}.bak`, raw);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { kind: "error", file, error: `Failed to write backup: ${err.message}` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await fsp.writeFile(file, serialized);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return { kind: "error", file, error: err.message };
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
kind: triggerKind === "http" ? "migrated" : "not-http",
|
|
71
|
+
file,
|
|
72
|
+
trigger: triggerKind,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function convertToV2(wf) {
|
|
76
|
+
const out = {};
|
|
77
|
+
if (typeof wf.name === "string")
|
|
78
|
+
out.name = wf.name;
|
|
79
|
+
if (typeof wf.version === "string")
|
|
80
|
+
out.version = wf.version;
|
|
81
|
+
if (typeof wf.description === "string")
|
|
82
|
+
out.description = wf.description;
|
|
83
|
+
out.trigger = convertTrigger(wf.trigger);
|
|
84
|
+
out.steps = convertSteps(Array.isArray(wf.steps) ? wf.steps : [], isPlainObject(wf.nodes) ? wf.nodes : {});
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function convertTrigger(rawTrigger) {
|
|
88
|
+
if (!isPlainObject(rawTrigger))
|
|
89
|
+
return {};
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [kind, cfg] of Object.entries(rawTrigger)) {
|
|
92
|
+
if (kind === "http" && isPlainObject(cfg)) {
|
|
93
|
+
const httpCfg = { ...cfg };
|
|
94
|
+
if (httpCfg.method === "*")
|
|
95
|
+
httpCfg.method = "ANY";
|
|
96
|
+
out[kind] = httpCfg;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
out[kind] = cfg;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function convertSteps(steps, nodes) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const rawStep of steps) {
|
|
107
|
+
if (!isPlainObject(rawStep))
|
|
108
|
+
continue;
|
|
109
|
+
const step = rawStep;
|
|
110
|
+
const id = pickString(step.name) ?? pickString(step.id);
|
|
111
|
+
if (!id)
|
|
112
|
+
continue;
|
|
113
|
+
if (isPlainObject(step.branch)) {
|
|
114
|
+
const rawBranch = step.branch;
|
|
115
|
+
const branchStep = {
|
|
116
|
+
id,
|
|
117
|
+
branch: {
|
|
118
|
+
when: rewriteLegacyExpressions(typeof rawBranch.when === "string" ? rawBranch.when : "true"),
|
|
119
|
+
then: convertSteps(Array.isArray(rawBranch.then) ? rawBranch.then : [], {}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
if (Array.isArray(rawBranch.else)) {
|
|
123
|
+
branchStep.branch.else = convertSteps(rawBranch.else, {});
|
|
124
|
+
}
|
|
125
|
+
if (step.active === false)
|
|
126
|
+
branchStep.active = false;
|
|
127
|
+
if (step.stop === true)
|
|
128
|
+
branchStep.stop = true;
|
|
129
|
+
out.push(branchStep);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const nodeRef = pickString(step.node) ?? pickString(step.use);
|
|
133
|
+
if (!nodeRef)
|
|
134
|
+
continue;
|
|
135
|
+
const v1NodeConfig = isPlainObject(nodes[id]) ? nodes[id] : null;
|
|
136
|
+
const inputs = (() => {
|
|
137
|
+
if (isPlainObject(step.inputs))
|
|
138
|
+
return step.inputs;
|
|
139
|
+
if (v1NodeConfig && isPlainObject(v1NodeConfig.inputs))
|
|
140
|
+
return v1NodeConfig.inputs;
|
|
141
|
+
return null;
|
|
142
|
+
})();
|
|
143
|
+
if (v1NodeConfig && Array.isArray(v1NodeConfig.conditions)) {
|
|
144
|
+
const conds = v1NodeConfig.conditions;
|
|
145
|
+
const ifCond = conds.find((c) => isPlainObject(c) && c.type === "if");
|
|
146
|
+
const elseCond = conds.find((c) => isPlainObject(c) && c.type === "else");
|
|
147
|
+
if (ifCond) {
|
|
148
|
+
const branchStep = {
|
|
149
|
+
id,
|
|
150
|
+
branch: {
|
|
151
|
+
when: rewriteLegacyExpressions(typeof ifCond.condition === "string" ? ifCond.condition : "true"),
|
|
152
|
+
then: convertSteps(Array.isArray(ifCond.steps) ? ifCond.steps : [], {}),
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
if (elseCond) {
|
|
156
|
+
branchStep.branch.else = convertSteps(Array.isArray(elseCond.steps) ? elseCond.steps : [], {});
|
|
157
|
+
}
|
|
158
|
+
if (step.active === false)
|
|
159
|
+
branchStep.active = false;
|
|
160
|
+
if (step.stop === true)
|
|
161
|
+
branchStep.stop = true;
|
|
162
|
+
out.push(branchStep);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const v2Step = { id, use: nodeRef };
|
|
167
|
+
if (typeof step.type === "string")
|
|
168
|
+
v2Step.type = step.type;
|
|
169
|
+
if (inputs)
|
|
170
|
+
v2Step.inputs = rewriteLegacyExpressions(inputs);
|
|
171
|
+
if (step.set_var === false)
|
|
172
|
+
v2Step.ephemeral = true;
|
|
173
|
+
if (typeof step.as === "string")
|
|
174
|
+
v2Step.as = step.as;
|
|
175
|
+
if (step.spread === true)
|
|
176
|
+
v2Step.spread = true;
|
|
177
|
+
if (step.ephemeral === true)
|
|
178
|
+
v2Step.ephemeral = true;
|
|
179
|
+
if (step.active === false)
|
|
180
|
+
v2Step.active = false;
|
|
181
|
+
if (step.stop === true)
|
|
182
|
+
v2Step.stop = true;
|
|
183
|
+
if (typeof step.stream_logs === "boolean")
|
|
184
|
+
v2Step.stream_logs = step.stream_logs;
|
|
185
|
+
out.push(v2Step);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
export function rewriteLegacyExpressions(value) {
|
|
190
|
+
if (typeof value === "string") {
|
|
191
|
+
return rewriteOneString(value);
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(value)) {
|
|
194
|
+
return value.map((v) => rewriteLegacyExpressions(v));
|
|
195
|
+
}
|
|
196
|
+
if (isPlainObject(value)) {
|
|
197
|
+
const out = {};
|
|
198
|
+
for (const [k, v] of Object.entries(value)) {
|
|
199
|
+
out[k] = rewriteLegacyExpressions(v);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
function rewriteOneString(input) {
|
|
206
|
+
let s = input;
|
|
207
|
+
s = s.replace(/js\/ctx\.vars\b/g, "js/ctx.state");
|
|
208
|
+
s = s.replace(/js\/ctx\.response\.data\b/g, "js/ctx.prev.data");
|
|
209
|
+
s = s.replace(/\$\{ctx\.vars\b/g, "${ctx.state");
|
|
210
|
+
s = s.replace(/\$\{ctx\.response\.data\b/g, "${ctx.prev.data");
|
|
211
|
+
return s;
|
|
212
|
+
}
|
|
213
|
+
function detectTriggerKind(wf) {
|
|
214
|
+
if (!isPlainObject(wf.trigger))
|
|
215
|
+
return "unknown";
|
|
216
|
+
const keys = Object.keys(wf.trigger);
|
|
217
|
+
return keys[0] ?? "unknown";
|
|
218
|
+
}
|
|
219
|
+
async function resolveJsonRoot(cwd, explicit) {
|
|
220
|
+
if (explicit) {
|
|
221
|
+
const abs = path.isAbsolute(explicit) ? explicit : path.resolve(cwd, explicit);
|
|
222
|
+
if (await dirExists(abs))
|
|
223
|
+
return abs;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const candidates = [path.join(cwd, "workflows", "json"), path.join(cwd, "triggers", "http", "workflows", "json")];
|
|
227
|
+
for (const c of candidates) {
|
|
228
|
+
if (await dirExists(c))
|
|
229
|
+
return c;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
async function dirExists(p) {
|
|
234
|
+
try {
|
|
235
|
+
const stat = await fsp.stat(p);
|
|
236
|
+
return stat.isDirectory();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function collectJsonFiles(root) {
|
|
243
|
+
const out = [];
|
|
244
|
+
await walk(root, out);
|
|
245
|
+
out.sort();
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
async function walk(dir, out) {
|
|
249
|
+
let entries;
|
|
250
|
+
try {
|
|
251
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
for (const entry of entries) {
|
|
257
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("_"))
|
|
258
|
+
continue;
|
|
259
|
+
const full = path.join(dir, entry.name);
|
|
260
|
+
if (entry.isDirectory()) {
|
|
261
|
+
await walk(full, out);
|
|
262
|
+
}
|
|
263
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".json")) {
|
|
264
|
+
out.push(full);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function isPlainObject(value) {
|
|
269
|
+
if (value === null || value === undefined)
|
|
270
|
+
return false;
|
|
271
|
+
if (typeof value !== "object")
|
|
272
|
+
return false;
|
|
273
|
+
if (Array.isArray(value))
|
|
274
|
+
return false;
|
|
275
|
+
const proto = Object.getPrototypeOf(value);
|
|
276
|
+
return proto === null || proto === Object.prototype;
|
|
277
|
+
}
|
|
278
|
+
function pickString(value) {
|
|
279
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
280
|
+
}
|
|
281
|
+
function printResult(result) {
|
|
282
|
+
const file = path.relative(process.cwd(), result.file);
|
|
283
|
+
switch (result.kind) {
|
|
284
|
+
case "migrated":
|
|
285
|
+
console.log(` ${color.green("✓")} ${color.cyan(file)}`);
|
|
286
|
+
break;
|
|
287
|
+
case "already-v2":
|
|
288
|
+
console.log(` ${color.dim("⊙")} ${color.dim(file)} ${color.dim("(already v2)")}`);
|
|
289
|
+
break;
|
|
290
|
+
case "not-http":
|
|
291
|
+
console.log(` ${color.green("✓")} ${color.cyan(file)} ${color.dim(`(${result.trigger} trigger; no URL preserved)`)}`);
|
|
292
|
+
break;
|
|
293
|
+
case "skipped":
|
|
294
|
+
console.log(` ${color.dim("→")} ${color.dim(file)} ${color.dim(`(${result.reason})`)}`);
|
|
295
|
+
break;
|
|
296
|
+
case "error":
|
|
297
|
+
console.log(` ${color.red("✗")} ${color.cyan(file)} ${color.red(`— ${result.error}`)}`);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function printSummary(results, dryRun, writeBackup) {
|
|
302
|
+
const counts = {
|
|
303
|
+
migrated: 0,
|
|
304
|
+
alreadyV2: 0,
|
|
305
|
+
notHttp: 0,
|
|
306
|
+
skipped: 0,
|
|
307
|
+
error: 0,
|
|
308
|
+
};
|
|
309
|
+
for (const r of results) {
|
|
310
|
+
if (r.kind === "migrated")
|
|
311
|
+
counts.migrated++;
|
|
312
|
+
else if (r.kind === "already-v2")
|
|
313
|
+
counts.alreadyV2++;
|
|
314
|
+
else if (r.kind === "not-http")
|
|
315
|
+
counts.notHttp++;
|
|
316
|
+
else if (r.kind === "skipped")
|
|
317
|
+
counts.skipped++;
|
|
318
|
+
else if (r.kind === "error")
|
|
319
|
+
counts.error++;
|
|
320
|
+
}
|
|
321
|
+
const total = results.length;
|
|
322
|
+
const verb = dryRun ? "would migrate" : "migrated";
|
|
323
|
+
const summary = `Total: ${total} · ${color.green(`${verb}: ${counts.migrated + counts.notHttp}`)}${counts.alreadyV2 > 0 ? ` · ${color.dim(`already v2: ${counts.alreadyV2}`)}` : ""}${counts.error > 0 ? ` · ${color.red(`errors: ${counts.error}`)}` : ""}`;
|
|
324
|
+
console.log(summary);
|
|
325
|
+
if (dryRun) {
|
|
326
|
+
console.log(color.dim("\nDry run — no files written. Re-run without --dry-run to apply."));
|
|
327
|
+
}
|
|
328
|
+
else if (writeBackup && counts.migrated + counts.notHttp > 0) {
|
|
329
|
+
console.log(color.dim("\nBackups written next to each file as <name>.json.bak. Run with --no-backup to skip backups."));
|
|
330
|
+
}
|
|
331
|
+
if (counts.error > 0)
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
@@ -3,9 +3,11 @@ import { startStudio } from "./startStudio.js";
|
|
|
3
3
|
program
|
|
4
4
|
.command("trace")
|
|
5
5
|
.alias("studio")
|
|
6
|
-
.description("Open Blok Studio — real-time workflow trace UI")
|
|
6
|
+
.description("Open Blok Studio — real-time workflow trace UI (Prisma-Studio-style)")
|
|
7
7
|
.option("-p, --port <port>", "Studio UI port", "5555")
|
|
8
|
-
.option("-u, --url <url>", "Blok backend URL", "http://localhost:4000")
|
|
8
|
+
.option("-u, --url <url>", "Proxy mode: Blok backend URL to connect to", "http://localhost:4000")
|
|
9
|
+
.option("--db <path>", "Standalone mode: serve directly from a SQLite trace file (no trigger needed). Auto-detects .blok/trace.db when present.")
|
|
10
|
+
.option("--standalone", "Force standalone mode (mount /__blok/* on this server reading from .blok/trace.db)")
|
|
9
11
|
.option("--workflow <name>", "Open specific workflow")
|
|
10
12
|
.option("--run <id>", "Open specific run")
|
|
11
13
|
.option("--no-open", "Don't auto-open browser")
|
|
@@ -17,6 +19,8 @@ program
|
|
|
17
19
|
await startStudio({
|
|
18
20
|
port: Number.parseInt(options.port, 10),
|
|
19
21
|
url: options.url,
|
|
22
|
+
db: options.db,
|
|
23
|
+
standalone: options.standalone,
|
|
20
24
|
workflow: options.workflow,
|
|
21
25
|
run: options.run,
|
|
22
26
|
open: options.open,
|
|
@@ -4,6 +4,7 @@ import https from "node:https";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import * as p from "@clack/prompts";
|
|
7
|
+
import express from "express";
|
|
7
8
|
import open from "open";
|
|
8
9
|
import color from "picocolors";
|
|
9
10
|
import serveHandler from "serve-handler";
|
|
@@ -18,6 +19,10 @@ function resolveStaticPath() {
|
|
|
18
19
|
return workspace;
|
|
19
20
|
return null;
|
|
20
21
|
}
|
|
22
|
+
function autoDetectDbPath() {
|
|
23
|
+
const candidate = path.resolve(process.cwd(), ".blok", "trace.db");
|
|
24
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
25
|
+
}
|
|
21
26
|
async function checkBackendHealth(backendUrl) {
|
|
22
27
|
return new Promise((resolve) => {
|
|
23
28
|
const url = new URL("/__blok/health", backendUrl);
|
|
@@ -33,7 +38,7 @@ async function checkBackendHealth(backendUrl) {
|
|
|
33
38
|
});
|
|
34
39
|
}
|
|
35
40
|
function proxyRequest(req, res, backendUrl) {
|
|
36
|
-
const targetUrl = new URL(req.url, backendUrl);
|
|
41
|
+
const targetUrl = new URL(req.url ?? "/", backendUrl);
|
|
37
42
|
const client = targetUrl.protocol === "https:" ? https : http;
|
|
38
43
|
const proxyReq = client.request(targetUrl, {
|
|
39
44
|
method: req.method,
|
|
@@ -56,28 +61,83 @@ function proxyRequest(req, res, backendUrl) {
|
|
|
56
61
|
});
|
|
57
62
|
req.pipe(proxyReq, { end: true });
|
|
58
63
|
}
|
|
64
|
+
async function buildStandaloneApp(dbPath) {
|
|
65
|
+
const runner = (await import("@blokjs/runner"));
|
|
66
|
+
const { RunTracker, createStore, registerTraceRoutes } = runner;
|
|
67
|
+
const absoluteDb = path.resolve(dbPath);
|
|
68
|
+
const dir = path.dirname(absoluteDb);
|
|
69
|
+
if (dir && !fs.existsSync(dir)) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
const store = createStore({ type: "sqlite", sqlitePath: absoluteDb });
|
|
73
|
+
const tracker = new RunTracker(undefined, store);
|
|
74
|
+
RunTracker.instance = tracker;
|
|
75
|
+
const app = express();
|
|
76
|
+
app.use(express.json({ limit: "10mb" }));
|
|
77
|
+
const traceRouter = express.Router();
|
|
78
|
+
registerTraceRoutes(traceRouter, tracker);
|
|
79
|
+
app.use("/__blok", traceRouter);
|
|
80
|
+
return app;
|
|
81
|
+
}
|
|
59
82
|
export async function startStudio(options) {
|
|
60
83
|
const { port, url: backendUrl, open: shouldOpen } = options;
|
|
61
84
|
p.intro(color.bgCyan(color.black(" Blok Studio ")));
|
|
62
85
|
const staticPath = resolveStaticPath();
|
|
63
86
|
if (!staticPath) {
|
|
64
|
-
p.log.error(`Studio assets not found.\n
|
|
87
|
+
p.log.error(`Studio assets not found.\n Build them first: ${color.cyan("bun run --filter @blokjs/studio build")}`);
|
|
65
88
|
process.exit(1);
|
|
66
89
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
let mode = "proxy";
|
|
91
|
+
let dbPath = null;
|
|
92
|
+
if (options.db) {
|
|
93
|
+
mode = "standalone";
|
|
94
|
+
dbPath = path.resolve(options.db);
|
|
95
|
+
}
|
|
96
|
+
else if (options.standalone) {
|
|
97
|
+
mode = "standalone";
|
|
98
|
+
dbPath = autoDetectDbPath() ?? path.resolve(process.cwd(), ".blok", "trace.db");
|
|
74
99
|
}
|
|
75
100
|
else {
|
|
76
|
-
s.
|
|
101
|
+
const s = p.spinner();
|
|
102
|
+
s.start("Checking backend health...");
|
|
103
|
+
const healthy = await checkBackendHealth(backendUrl);
|
|
104
|
+
if (healthy) {
|
|
105
|
+
s.stop(color.green("Backend healthy"));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const auto = autoDetectDbPath();
|
|
109
|
+
if (auto) {
|
|
110
|
+
s.stop(color.green(`Backend not reachable; serving from ${color.cyan(path.relative(process.cwd(), auto))}`));
|
|
111
|
+
mode = "standalone";
|
|
112
|
+
dbPath = auto;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
s.stop(color.yellow("Backend not reachable"));
|
|
116
|
+
p.log.warn(`Blok backend not found at ${color.cyan(backendUrl)}\n` +
|
|
117
|
+
` Start it first: ${color.cyan("blokctl dev")}\n` +
|
|
118
|
+
` Or open a trace file directly: ${color.cyan("blokctl studio --db <path>")}`);
|
|
119
|
+
p.log.info("Starting Studio anyway — it will connect when the backend is up.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let standaloneApp = null;
|
|
124
|
+
if (mode === "standalone" && dbPath) {
|
|
125
|
+
try {
|
|
126
|
+
standaloneApp = await buildStandaloneApp(dbPath);
|
|
127
|
+
p.log.success(`Standalone mode · reading ${color.cyan(path.relative(process.cwd(), dbPath))}`);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
p.log.error(`Failed to open trace file ${color.cyan(dbPath)}\n ${e.message}\n` +
|
|
131
|
+
` Make sure better-sqlite3 is available: ${color.cyan("npm install better-sqlite3")}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
77
134
|
}
|
|
78
135
|
const server = http.createServer((req, res) => {
|
|
79
136
|
const url = req.url || "/";
|
|
80
137
|
if (url.startsWith("/__blok")) {
|
|
138
|
+
if (standaloneApp) {
|
|
139
|
+
return standaloneApp(req, res);
|
|
140
|
+
}
|
|
81
141
|
return proxyRequest(req, res, backendUrl);
|
|
82
142
|
}
|
|
83
143
|
serveHandler(req, res, {
|
|
@@ -95,7 +155,12 @@ export async function startStudio(options) {
|
|
|
95
155
|
studioUrl += `/runs/${encodeURIComponent(options.run)}`;
|
|
96
156
|
}
|
|
97
157
|
p.log.success(`Studio running at ${color.cyan(studioUrl)}`);
|
|
98
|
-
|
|
158
|
+
if (mode === "standalone" && dbPath) {
|
|
159
|
+
p.log.info(`Standalone · ${color.dim(`reading ${path.relative(process.cwd(), dbPath)}`)}`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
p.log.info(`Proxying to backend at ${color.dim(backendUrl)}`);
|
|
163
|
+
}
|
|
99
164
|
console.log(color.dim(" Press Ctrl+C to stop\n"));
|
|
100
165
|
if (shouldOpen) {
|
|
101
166
|
await open(studioUrl);
|
package/dist/index.js
CHANGED
|
@@ -168,6 +168,7 @@ async function main() {
|
|
|
168
168
|
program
|
|
169
169
|
.command("dev")
|
|
170
170
|
.description("Start the development server")
|
|
171
|
+
.option("--with-http-fallback", "Spawn SDKs on HTTP transport instead of gRPC. Use only when one of your SDKs lacks a working gRPC listener (e.g. PHP without RoadRunner). Removed in v0.4.0.")
|
|
171
172
|
.action(async (options) => {
|
|
172
173
|
await analytics.trackCommandExecution({
|
|
173
174
|
command: "dev",
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function tryConnect(host: string, port: number, timeoutMs: number): Promise<boolean>;
|
|
2
|
+
export declare function waitForGrpcPort(port: number, timeoutMs: number, proc?: {
|
|
3
|
+
exitCode: number | null;
|
|
4
|
+
on(event: "exit", listener: () => void): void;
|
|
5
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Socket } from "node:net";
|
|
2
|
+
export function tryConnect(host, port, timeoutMs) {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
const sock = new Socket();
|
|
5
|
+
const done = (ok) => {
|
|
6
|
+
sock.destroy();
|
|
7
|
+
resolve(ok);
|
|
8
|
+
};
|
|
9
|
+
sock.setTimeout(timeoutMs);
|
|
10
|
+
sock
|
|
11
|
+
.once("connect", () => done(true))
|
|
12
|
+
.once("timeout", () => done(false))
|
|
13
|
+
.once("error", () => done(false));
|
|
14
|
+
sock.connect(port, host);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export async function waitForGrpcPort(port, timeoutMs, proc) {
|
|
18
|
+
if (proc && proc.exitCode !== null)
|
|
19
|
+
return false;
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
let exited = false;
|
|
22
|
+
proc?.on("exit", () => {
|
|
23
|
+
exited = true;
|
|
24
|
+
});
|
|
25
|
+
while (Date.now() - start < timeoutMs) {
|
|
26
|
+
if (exited)
|
|
27
|
+
return false;
|
|
28
|
+
if (await tryConnect("127.0.0.1", port, 500))
|
|
29
|
+
return true;
|
|
30
|
+
if (await tryConnect("::1", port, 500))
|
|
31
|
+
return true;
|
|
32
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
@@ -5,10 +5,12 @@ export interface RuntimeInfo {
|
|
|
5
5
|
version?: string;
|
|
6
6
|
installHint: string;
|
|
7
7
|
defaultPort: number;
|
|
8
|
+
defaultGrpcPort: number;
|
|
8
9
|
commands: string[];
|
|
9
10
|
toolchain: string;
|
|
10
11
|
installDeps: string;
|
|
11
12
|
startCmd: string;
|
|
13
|
+
grpcStartCmd?: string;
|
|
12
14
|
sdkDir: string;
|
|
13
15
|
secondaryTool?: {
|
|
14
16
|
name: string;
|
|
@@ -18,6 +20,7 @@ export interface RuntimeInfo {
|
|
|
18
20
|
installHint: string;
|
|
19
21
|
};
|
|
20
22
|
}
|
|
23
|
+
export declare function detectRr(): string | null;
|
|
21
24
|
export declare function detectRuntimes(): Promise<RuntimeInfo[]>;
|
|
22
25
|
export declare function getRuntimeDefinition(kind: string): Omit<RuntimeInfo, "available" | "version"> | undefined;
|
|
23
26
|
export declare function getAllRuntimeDefinitions(): Omit<RuntimeInfo, "available" | "version">[];
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import child_process from "node:child_process";
|
|
2
2
|
import util from "node:util";
|
|
3
3
|
const exec = util.promisify(child_process.exec);
|
|
4
|
+
export function detectRr() {
|
|
5
|
+
for (const bin of ["/opt/homebrew/bin/rr", "rr"]) {
|
|
6
|
+
try {
|
|
7
|
+
child_process.execSync(`${bin} --version`, { stdio: "ignore" });
|
|
8
|
+
return bin;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
4
15
|
const RUNTIME_DEFINITIONS = [
|
|
5
16
|
{
|
|
6
17
|
kind: "python3",
|
|
7
18
|
label: "Python 3",
|
|
8
19
|
installHint: "Install Python: https://python.org/downloads/",
|
|
9
20
|
defaultPort: 9007,
|
|
21
|
+
defaultGrpcPort: 10007,
|
|
10
22
|
commands: ["python3 --version"],
|
|
11
23
|
toolchain: "python3",
|
|
12
24
|
installDeps: "pip3 install -r requirements.txt",
|
|
@@ -18,6 +30,7 @@ const RUNTIME_DEFINITIONS = [
|
|
|
18
30
|
label: "Go",
|
|
19
31
|
installHint: "Install Go: https://go.dev/dl/",
|
|
20
32
|
defaultPort: 9001,
|
|
33
|
+
defaultGrpcPort: 10001,
|
|
21
34
|
commands: ["go version"],
|
|
22
35
|
toolchain: "go",
|
|
23
36
|
installDeps: "go mod download",
|
|
@@ -29,6 +42,7 @@ const RUNTIME_DEFINITIONS = [
|
|
|
29
42
|
label: "Rust",
|
|
30
43
|
installHint: "Install Rust: https://rustup.rs/",
|
|
31
44
|
defaultPort: 9002,
|
|
45
|
+
defaultGrpcPort: 10002,
|
|
32
46
|
commands: ["rustc --version"],
|
|
33
47
|
toolchain: "rustc + cargo",
|
|
34
48
|
installDeps: "cargo build --release",
|
|
@@ -40,6 +54,7 @@ const RUNTIME_DEFINITIONS = [
|
|
|
40
54
|
label: "Java",
|
|
41
55
|
installHint: "Install JDK 17+: https://adoptium.net/",
|
|
42
56
|
defaultPort: 9003,
|
|
57
|
+
defaultGrpcPort: 10003,
|
|
43
58
|
commands: [
|
|
44
59
|
"java --version",
|
|
45
60
|
"/opt/homebrew/opt/openjdk/bin/java --version",
|
|
@@ -60,6 +75,7 @@ const RUNTIME_DEFINITIONS = [
|
|
|
60
75
|
label: "C# / .NET",
|
|
61
76
|
installHint: "Install .NET SDK: https://dotnet.microsoft.com/download",
|
|
62
77
|
defaultPort: 9004,
|
|
78
|
+
defaultGrpcPort: 10004,
|
|
63
79
|
commands: ["dotnet --version"],
|
|
64
80
|
toolchain: "dotnet",
|
|
65
81
|
installDeps: "dotnet restore",
|
|
@@ -69,12 +85,14 @@ const RUNTIME_DEFINITIONS = [
|
|
|
69
85
|
{
|
|
70
86
|
kind: "php",
|
|
71
87
|
label: "PHP",
|
|
72
|
-
installHint: "Install PHP 8.2
|
|
88
|
+
installHint: "Install PHP 8.2+ + RoadRunner: https://php.net/downloads (rr: brew install roadrunner)",
|
|
73
89
|
defaultPort: 9005,
|
|
90
|
+
defaultGrpcPort: 10005,
|
|
74
91
|
commands: ["php --version"],
|
|
75
|
-
toolchain: "php + composer",
|
|
92
|
+
toolchain: "php + composer + rr",
|
|
76
93
|
installDeps: "composer install",
|
|
77
94
|
startCmd: "php bin/serve.php",
|
|
95
|
+
grpcStartCmd: "rr serve -c .rr.yaml --override grpc.listen=tcp://127.0.0.1:10005",
|
|
78
96
|
sdkDir: "php",
|
|
79
97
|
secondaryTool: {
|
|
80
98
|
name: "Composer",
|
|
@@ -87,6 +105,7 @@ const RUNTIME_DEFINITIONS = [
|
|
|
87
105
|
label: "Ruby",
|
|
88
106
|
installHint: "Install Ruby 3.2+: https://ruby-lang.org/en/downloads/",
|
|
89
107
|
defaultPort: 9006,
|
|
108
|
+
defaultGrpcPort: 10006,
|
|
90
109
|
commands: ["ruby --version", "/opt/homebrew/opt/ruby/bin/ruby --version"],
|
|
91
110
|
toolchain: "ruby + bundler",
|
|
92
111
|
installDeps: "bundle install",
|
|
@@ -6,10 +6,13 @@ type SpinnerHandler = {
|
|
|
6
6
|
};
|
|
7
7
|
export interface RuntimeConfig {
|
|
8
8
|
port: number;
|
|
9
|
+
grpcPort?: number;
|
|
9
10
|
startCmd: string;
|
|
11
|
+
grpcStartCmd?: string;
|
|
10
12
|
cwd: string;
|
|
11
13
|
kind: string;
|
|
12
14
|
label: string;
|
|
15
|
+
transport?: "grpc" | "http";
|
|
13
16
|
}
|
|
14
17
|
export interface TriggerConfig {
|
|
15
18
|
kind: string;
|