blokctl 0.6.20 → 0.7.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.
Files changed (77) hide show
  1. package/dist/__tests__/modular-observability.capstone.e2e.test.js +72 -0
  2. package/dist/commands/create/node.js +46 -66
  3. package/dist/commands/create/project.js +55 -9
  4. package/dist/commands/create/utils/Examples.d.ts +8 -20
  5. package/dist/commands/create/utils/Examples.js +138 -412
  6. package/dist/commands/dev/index.js +40 -1
  7. package/dist/commands/gen/appTypes.js +40 -1
  8. package/dist/commands/generate/NodeGenerator.d.ts +0 -2
  9. package/dist/commands/generate/NodeGenerator.js +0 -20
  10. package/dist/commands/generate/RuntimeGenerator.d.ts +0 -2
  11. package/dist/commands/generate/RuntimeGenerator.js +0 -19
  12. package/dist/commands/generate/RuntimeGenerator.test.js +0 -29
  13. package/dist/commands/generate/TriggerGenerator.d.ts +0 -2
  14. package/dist/commands/generate/TriggerGenerator.js +0 -19
  15. package/dist/commands/generate/WorkflowGenerator.d.ts +0 -2
  16. package/dist/commands/generate/WorkflowGenerator.js +0 -19
  17. package/dist/commands/generate/e2e/NodeGenerator.e2e.test.js +0 -12
  18. package/dist/commands/generate/e2e/RuntimeGenerator.e2e.test.js +0 -12
  19. package/dist/commands/generate/e2e/TriggerGenerator.e2e.test.js +0 -14
  20. package/dist/commands/monitor/monitor-component.js +5 -5
  21. package/dist/commands/observability/add.d.ts +2 -0
  22. package/dist/commands/observability/add.js +113 -0
  23. package/dist/commands/observability/alerting-module.test.js +43 -0
  24. package/dist/commands/observability/apply.d.ts +10 -0
  25. package/dist/commands/observability/apply.js +11 -0
  26. package/dist/commands/observability/descriptor.d.ts +37 -0
  27. package/dist/commands/observability/descriptor.js +203 -0
  28. package/dist/commands/observability/descriptor.test.d.ts +1 -0
  29. package/dist/commands/observability/descriptor.test.js +40 -0
  30. package/dist/commands/observability/index.d.ts +1 -0
  31. package/dist/commands/observability/index.js +53 -0
  32. package/dist/commands/observability/list.d.ts +2 -0
  33. package/dist/commands/observability/list.js +45 -0
  34. package/dist/commands/observability/logging-module.test.d.ts +1 -0
  35. package/dist/commands/observability/logging-module.test.js +43 -0
  36. package/dist/commands/observability/obs-stack-module.test.d.ts +1 -0
  37. package/dist/commands/observability/obs-stack-module.test.js +33 -0
  38. package/dist/commands/observability/remove.d.ts +2 -0
  39. package/dist/commands/observability/remove.js +62 -0
  40. package/dist/commands/observability/shared.d.ts +6 -0
  41. package/dist/commands/observability/shared.js +23 -0
  42. package/dist/commands/observability/status.d.ts +2 -0
  43. package/dist/commands/observability/status.js +36 -0
  44. package/dist/commands/observability/tracing-module.test.d.ts +1 -0
  45. package/dist/commands/observability/tracing-module.test.js +42 -0
  46. package/dist/commands/profile/index.js +7 -10
  47. package/dist/commands/watch/format.d.ts +23 -0
  48. package/dist/commands/watch/format.js +60 -0
  49. package/dist/commands/watch/index.d.ts +1 -0
  50. package/dist/commands/watch/index.js +53 -0
  51. package/dist/commands/watch/sse.d.ts +16 -0
  52. package/dist/commands/watch/sse.js +82 -0
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +4 -0
  55. package/dist/services/obs-setup.d.ts +5 -0
  56. package/dist/services/obs-setup.js +68 -0
  57. package/dist/services/obs-setup.test.d.ts +1 -0
  58. package/dist/services/obs-setup.test.js +71 -0
  59. package/dist/services/obs-tiers.d.ts +9 -0
  60. package/dist/services/obs-tiers.js +16 -0
  61. package/dist/services/observability-mutations.d.ts +4 -0
  62. package/dist/services/observability-mutations.js +46 -0
  63. package/dist/services/observability-mutations.test.d.ts +1 -0
  64. package/dist/services/observability-mutations.test.js +57 -0
  65. package/dist/services/runtime-setup.d.ts +12 -1
  66. package/dist/services/runtime-setup.js +274 -14
  67. package/dist/studio-dist/assets/{index-BD8_9YPN.js → index-CnFqCRQe.js} +17 -17
  68. package/dist/studio-dist/index.html +1 -1
  69. package/package.json +3 -3
  70. package/dist/commands/generate/GenerationAnalytics.d.ts +0 -61
  71. package/dist/commands/generate/GenerationAnalytics.js +0 -163
  72. package/dist/commands/generate/GenerationAnalytics.test.js +0 -407
  73. package/dist/commands/generate/PromptVersioning.d.ts +0 -25
  74. package/dist/commands/generate/PromptVersioning.js +0 -71
  75. package/dist/commands/generate/PromptVersioning.test.js +0 -120
  76. /package/dist/{commands/generate/GenerationAnalytics.test.d.ts → __tests__/modular-observability.capstone.e2e.test.d.ts} +0 -0
  77. /package/dist/commands/{generate/PromptVersioning.test.d.ts → observability/alerting-module.test.d.ts} +0 -0
@@ -0,0 +1,36 @@
1
+ import * as p from "@clack/prompts";
2
+ import color from "picocolors";
3
+ import { getObservabilityModule } from "./descriptor.js";
4
+ import { readConfigSafe, reportObservabilityError, resolveProjectRoot } from "./shared.js";
5
+ export async function observabilityStatus(options) {
6
+ try {
7
+ const root = resolveProjectRoot(options.directory);
8
+ const enabled = readConfigSafe(root).observability ?? {};
9
+ const enabledIds = Object.keys(enabled).filter((id) => enabled[id]?.enabled);
10
+ p.intro(color.inverse(" Observability status "));
11
+ if (enabledIds.length === 0) {
12
+ p.outro(color.dim("No observability modules enabled. Add one with `blokctl observability add <id>`."));
13
+ return;
14
+ }
15
+ const rows = [];
16
+ for (const id of enabledIds) {
17
+ const mod = getObservabilityModule(id);
18
+ if (!mod)
19
+ continue;
20
+ if (mod.verify) {
21
+ const res = await mod.verify(root);
22
+ const mark = res.ok ? color.green("✓") : color.yellow("!");
23
+ const link = res.dashboardUrl ? color.dim(` ${res.dashboardUrl}`) : "";
24
+ rows.push(`${mark} ${color.bold(mod.label.padEnd(26))} ${res.message}${link}`);
25
+ }
26
+ else {
27
+ rows.push(`${color.dim("•")} ${color.bold(mod.label.padEnd(26))} ${color.dim("enabled (no health check yet)")}`);
28
+ }
29
+ }
30
+ p.note(rows.join("\n"), `Enabled (${enabledIds.length})`);
31
+ p.outro(color.dim("Health probes land with each module epic."));
32
+ }
33
+ catch (err) {
34
+ reportObservabilityError(err);
35
+ }
36
+ }
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { getObservabilityModule } from "./descriptor.js";
6
+ const tracing = getObservabilityModule("tracing");
7
+ describe("tracing module (MO-TRACING)", () => {
8
+ it("ships inert — every OTEL_EXPORTER_OTLP_ENDPOINT line is commented out", () => {
9
+ const env = tracing.envBlock({ projectDir: "/tmp/x" });
10
+ expect(env).toContain("OTEL_EXPORTER_OTLP_ENDPOINT");
11
+ for (const line of env.split("\n")) {
12
+ if (/OTEL_EXPORTER_OTLP_ENDPOINT=/.test(line))
13
+ expect(line.trim().startsWith("#")).toBe(true);
14
+ }
15
+ });
16
+ it("declares tempo as its compose service, with no deps + no infra files", () => {
17
+ expect(tracing.composeServices).toEqual(["tempo"]);
18
+ expect(tracing.packageDeps).toEqual({});
19
+ expect(tracing.infraFiles).toEqual([]);
20
+ expect(tracing.dependencies).toEqual([]);
21
+ });
22
+ describe("verify()", () => {
23
+ let tmp;
24
+ beforeEach(() => {
25
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-tracing-"));
26
+ });
27
+ afterEach(() => {
28
+ fs.rmSync(tmp, { recursive: true, force: true });
29
+ });
30
+ it("reports inert when the endpoint is unset/commented", async () => {
31
+ fs.writeFileSync(path.join(tmp, ".env.local"), "PORT=4000\n# OTEL_EXPORTER_OTLP_ENDPOINT=http://x\n");
32
+ const r = await tracing.verify?.(tmp);
33
+ expect(r?.ok).toBe(true);
34
+ expect(r?.message).toMatch(/inert/);
35
+ });
36
+ it("reports active when the endpoint is uncommented", async () => {
37
+ fs.writeFileSync(path.join(tmp, ".env.local"), "OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318\n");
38
+ const r = await tracing.verify?.(tmp);
39
+ expect(r?.message).toContain("exporting spans to http://tempo:4318");
40
+ });
41
+ });
42
+ });
@@ -43,19 +43,16 @@ program
43
43
  const host = options.host || "http://localhost:9090";
44
44
  const token = options.token;
45
45
  const topN = Number.parseInt(options.top, 10) || 10;
46
- const [nodeTimeResults, _nodeCountResults, nodeMemResults, _nodeCpuResults, _nodeErrResults] = await Promise.all([
47
- queryPrometheus("node_time", host, token),
48
- queryPrometheus("node_total", host, token),
49
- queryPrometheus("node_memory", host, token),
50
- queryPrometheus("node_cpu", host, token),
51
- queryPrometheus("node_errors_total", host, token),
46
+ const [nodeTimeResults, nodeMemResults] = await Promise.all([
47
+ queryPrometheus("(blok_node_duration_seconds_sum / blok_node_duration_seconds_count) * 1000", host, token),
48
+ queryPrometheus("blok_node_memory_bytes / 1000000", host, token),
52
49
  ]);
53
50
  const PerformanceProfiler = await getPerformanceProfiler();
54
51
  const profiler = new PerformanceProfiler({ topN });
55
52
  let hasData = false;
56
53
  for (const result of nodeTimeResults) {
57
- const wf = result.metric.workflow || "unknown";
58
- const node = result.metric.node || result.metric.name || "unknown";
54
+ const wf = result.metric.workflow_name || result.metric.workflow || "unknown";
55
+ const node = result.metric.node_name || result.metric.node || result.metric.name || "unknown";
59
56
  if (workflowName && wf !== workflowName)
60
57
  continue;
61
58
  const timeMs = Number.parseFloat(result.value[1]) || 0;
@@ -65,8 +62,8 @@ program
65
62
  }
66
63
  }
67
64
  for (const result of nodeMemResults) {
68
- const wf = result.metric.workflow || "unknown";
69
- const node = result.metric.node || result.metric.name || "unknown";
65
+ const wf = result.metric.workflow_name || result.metric.workflow || "unknown";
66
+ const node = result.metric.node_name || result.metric.node || result.metric.name || "unknown";
70
67
  if (workflowName && wf !== workflowName)
71
68
  continue;
72
69
  const memMb = Number.parseFloat(result.value[1]) || 0;
@@ -0,0 +1,23 @@
1
+ export interface WatchRunEvent {
2
+ id: string;
3
+ type: string;
4
+ runId: string;
5
+ workflowName: string;
6
+ timestamp: number;
7
+ nodeName?: string;
8
+ nodeId?: string;
9
+ payload?: {
10
+ durationMs?: number;
11
+ error?: {
12
+ message?: string;
13
+ code?: number | string;
14
+ } | unknown;
15
+ reason?: string;
16
+ [k: string]: unknown;
17
+ };
18
+ }
19
+ export interface FormatOptions {
20
+ color?: boolean;
21
+ verbose?: boolean;
22
+ }
23
+ export declare function formatEvent(ev: WatchRunEvent, opts?: FormatOptions): string | null;
@@ -0,0 +1,60 @@
1
+ import { createColors } from "picocolors";
2
+ const NODE_INDENT = " ";
3
+ function shortRun(runId) {
4
+ return runId.length > 12 ? runId.slice(0, 12) : runId;
5
+ }
6
+ function durationMs(payload) {
7
+ const d = payload?.durationMs;
8
+ return typeof d === "number" && Number.isFinite(d) ? `${Math.round(d)}ms` : "";
9
+ }
10
+ function errorText(payload) {
11
+ const e = payload?.error;
12
+ if (!e || typeof e !== "object")
13
+ return "";
14
+ const code = e.code !== undefined && e.code !== null ? `${e.code} ` : "";
15
+ return `${code}${e.message ?? "error"}`.trim();
16
+ }
17
+ export function formatEvent(ev, opts = {}) {
18
+ const c = createColors(opts.color ?? true);
19
+ const verbose = opts.verbose ?? false;
20
+ const run = c.dim(shortRun(ev.runId));
21
+ const wf = c.bold(ev.workflowName || "(workflow)");
22
+ const node = ev.nodeName ?? "";
23
+ const ms = durationMs(ev.payload);
24
+ const err = errorText(ev.payload);
25
+ switch (ev.type) {
26
+ case "RUN_STARTED":
27
+ return `${c.cyan("▶")} ${wf} ${run} ${c.dim("started")}`;
28
+ case "NODE_STARTED":
29
+ return verbose ? `${NODE_INDENT}${c.dim("·")} ${node} ${c.dim("…")}` : null;
30
+ case "NODE_COMPLETED":
31
+ return `${NODE_INDENT}${c.green("✓")} ${node}${ms ? ` ${c.dim(ms)}` : ""}`;
32
+ case "NODE_CACHED":
33
+ return `${NODE_INDENT}${c.blue("◆")} ${node} ${c.dim("cached")}`;
34
+ case "NODE_SKIPPED":
35
+ return verbose ? `${NODE_INDENT}${c.dim("→")} ${node} ${c.dim("skipped")}` : null;
36
+ case "NODE_ATTEMPT_FAILED":
37
+ return `${NODE_INDENT}${c.yellow("↻")} ${node} ${c.yellow("attempt failed")}${err ? ` ${c.dim(err)}` : ""}`;
38
+ case "NODE_FAILED":
39
+ return `${NODE_INDENT}${c.red("✗")} ${node} ${c.red("FAILED")}${err ? ` ${c.dim(err)}` : ""}`;
40
+ case "RUN_COMPLETED":
41
+ return `${c.green("■")} ${wf} ${run} ${c.green("completed")}${ms ? ` ${c.dim(`(${ms})`)}` : ""}`;
42
+ case "RUN_FAILED":
43
+ return `${c.red("■")} ${wf} ${run} ${c.red("FAILED")}${ms ? ` ${c.dim(`(${ms})`)}` : ""}${err ? ` ${c.red(`· ${err}`)}` : ""}`;
44
+ case "RUN_CRASHED":
45
+ return `${c.red("■")} ${wf} ${run} ${c.red("CRASHED")}${err ? ` ${c.red(`· ${err}`)}` : ""}`;
46
+ case "RUN_TIMED_OUT":
47
+ return `${c.red("■")} ${wf} ${run} ${c.red("TIMED OUT")}${ms ? ` ${c.dim(`(${ms})`)}` : ""}`;
48
+ case "RUN_CANCELLED":
49
+ return `${c.yellow("■")} ${wf} ${run} ${c.yellow("cancelled")}`;
50
+ case "RUN_THROTTLED":
51
+ return `${c.yellow("■")} ${wf} ${run} ${c.yellow("throttled")}`;
52
+ case "RUN_QUEUED":
53
+ case "RUN_DELAYED":
54
+ case "RUN_DEBOUNCED":
55
+ case "RUN_EXPIRED":
56
+ return verbose ? `${c.dim("·")} ${wf} ${run} ${c.dim(ev.type.replace("RUN_", "").toLowerCase())}` : null;
57
+ default:
58
+ return null;
59
+ }
60
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import color from "picocolors";
2
+ import { program, trackCommandExecution } from "../../services/commander.js";
3
+ import { tokenManager } from "../../services/local-token-manager.js";
4
+ import { formatEvent } from "./format.js";
5
+ import { connectEventStream } from "./sse.js";
6
+ program
7
+ .command("watch")
8
+ .description("Watch workflow executions live in the terminal (streams /__blok/stream)")
9
+ .option("-u, --url <url>", "Blok backend URL", "http://localhost:4000")
10
+ .option("--token <token>", "Auth token for the trace API (required in production)")
11
+ .option("-w, --workflow <names>", "Comma-separated workflow names to watch (default: all)")
12
+ .option("--verbose", "Also show node-started / skipped / scheduling events")
13
+ .option("--no-color", "Disable ANSI colors (pipe-friendly)")
14
+ .action(async (options) => {
15
+ await trackCommandExecution({
16
+ command: "watch",
17
+ args: options,
18
+ execution: async () => {
19
+ const url = options.url || "http://localhost:4000";
20
+ const token = options.token ?? tokenManager.getToken() ?? undefined;
21
+ const useColor = options.color !== false;
22
+ const verbose = Boolean(options.verbose);
23
+ const workflows = options.workflow
24
+ ? String(options.workflow)
25
+ .split(",")
26
+ .map((s) => s.trim())
27
+ .filter(Boolean)
28
+ : undefined;
29
+ const controller = new AbortController();
30
+ const stop = (code) => {
31
+ controller.abort();
32
+ process.stdout.write("\n");
33
+ process.exit(code);
34
+ };
35
+ process.once("SIGINT", () => stop(0));
36
+ process.once("SIGTERM", () => stop(0));
37
+ const where = workflows ? ` (workflows: ${workflows.join(", ")})` : "";
38
+ process.stdout.write(color.dim(`Watching ${url}/__blok/stream${where} — Ctrl-C to stop\n\n`));
39
+ await connectEventStream(url, { token, workflows, signal: controller.signal }, {
40
+ onEvent: (event) => {
41
+ const line = formatEvent(event, { color: useColor, verbose });
42
+ if (line)
43
+ process.stdout.write(`${line}\n`);
44
+ },
45
+ onError: (err) => {
46
+ process.stderr.write(color.red(`\nstream error: ${err.message}\n`));
47
+ process.stderr.write(color.dim(`Is a Blok server running at ${url}? In production the trace API requires auth — pass --token.\n`));
48
+ process.exit(1);
49
+ },
50
+ });
51
+ },
52
+ });
53
+ });
@@ -0,0 +1,16 @@
1
+ import type { WatchRunEvent } from "./format.js";
2
+ export interface ConnectOptions {
3
+ token?: string;
4
+ workflows?: string[];
5
+ signal?: AbortSignal;
6
+ }
7
+ export interface StreamHandlers {
8
+ onOpen?: () => void;
9
+ onEvent: (event: WatchRunEvent) => void;
10
+ onError?: (error: Error) => void;
11
+ }
12
+ export declare function parseSseBuffer(buffer: string): {
13
+ events: WatchRunEvent[];
14
+ rest: string;
15
+ };
16
+ export declare function connectEventStream(baseUrl: string, opts: ConnectOptions, handlers: StreamHandlers): Promise<void>;
@@ -0,0 +1,82 @@
1
+ function parseFrame(frame) {
2
+ let eventType = null;
3
+ const dataLines = [];
4
+ for (const raw of frame.split("\n")) {
5
+ const line = raw.replace(/\r$/, "");
6
+ if (line.length === 0 || line.startsWith(":"))
7
+ continue;
8
+ if (line.startsWith("event:"))
9
+ eventType = line.slice(6).trim();
10
+ else if (line.startsWith("data:"))
11
+ dataLines.push(line.slice(5).replace(/^ /, ""));
12
+ }
13
+ if (dataLines.length === 0)
14
+ return null;
15
+ if (eventType === "connected" || eventType === "stream-end")
16
+ return null;
17
+ try {
18
+ const parsed = JSON.parse(dataLines.join("\n"));
19
+ if (parsed && typeof parsed === "object" && "runId" in parsed && "type" in parsed) {
20
+ return parsed;
21
+ }
22
+ }
23
+ catch {
24
+ }
25
+ return null;
26
+ }
27
+ export function parseSseBuffer(buffer) {
28
+ const events = [];
29
+ let rest = buffer.replace(/\r\n/g, "\n");
30
+ let sep = rest.indexOf("\n\n");
31
+ while (sep !== -1) {
32
+ const frame = rest.slice(0, sep);
33
+ rest = rest.slice(sep + 2);
34
+ const event = parseFrame(frame);
35
+ if (event)
36
+ events.push(event);
37
+ sep = rest.indexOf("\n\n");
38
+ }
39
+ return { events, rest };
40
+ }
41
+ export async function connectEventStream(baseUrl, opts, handlers) {
42
+ const url = new URL("/__blok/stream", baseUrl);
43
+ if (opts.workflows && opts.workflows.length > 0) {
44
+ url.searchParams.set("workflows", opts.workflows.join(","));
45
+ }
46
+ const headers = { accept: "text/event-stream" };
47
+ if (opts.token)
48
+ headers.authorization = `Bearer ${opts.token}`;
49
+ let res;
50
+ try {
51
+ res = await fetch(url, { headers, signal: opts.signal });
52
+ }
53
+ catch (err) {
54
+ if (err?.name !== "AbortError")
55
+ handlers.onError?.(err);
56
+ return;
57
+ }
58
+ if (!res.ok || !res.body) {
59
+ handlers.onError?.(new Error(`stream connect failed: ${res.status} ${res.statusText || ""}`.trim()));
60
+ return;
61
+ }
62
+ handlers.onOpen?.();
63
+ const reader = res.body.getReader();
64
+ const decoder = new TextDecoder();
65
+ let buffer = "";
66
+ try {
67
+ while (true) {
68
+ const { value, done } = await reader.read();
69
+ if (done)
70
+ break;
71
+ buffer += decoder.decode(value, { stream: true });
72
+ const { events, rest } = parseSseBuffer(buffer);
73
+ buffer = rest;
74
+ for (const event of events)
75
+ handlers.onEvent(event);
76
+ }
77
+ }
78
+ catch (err) {
79
+ if (err?.name !== "AbortError")
80
+ handlers.onError?.(err);
81
+ }
82
+ }
package/dist/index.d.ts CHANGED
@@ -13,8 +13,10 @@ import "./commands/nodes/index.js";
13
13
  import "./commands/config/index.js";
14
14
  import "./commands/migrate/index.js";
15
15
  import "./commands/runtime/index.js";
16
+ import "./commands/observability/index.js";
16
17
  import "./commands/graph/index.js";
17
18
  import "./commands/profile/index.js";
18
19
  import "./commands/cost/index.js";
19
20
  import "./commands/trace/index.js";
21
+ import "./commands/watch/index.js";
20
22
  export declare const CLI_NAME = "blokctl";
package/dist/index.js CHANGED
@@ -28,10 +28,12 @@ import "./commands/nodes/index.js";
28
28
  import "./commands/config/index.js";
29
29
  import "./commands/migrate/index.js";
30
30
  import "./commands/runtime/index.js";
31
+ import "./commands/observability/index.js";
31
32
  import "./commands/graph/index.js";
32
33
  import "./commands/profile/index.js";
33
34
  import "./commands/cost/index.js";
34
35
  import "./commands/trace/index.js";
36
+ import "./commands/watch/index.js";
35
37
  import { Command } from "commander";
36
38
  const version = await getPackageVersion();
37
39
  const exec = util.promisify(child_process.exec);
@@ -89,6 +91,8 @@ async function main() {
89
91
  .option("-m, --package-manager <value>", "Package manager: npm, yarn, pnpm, bun")
90
92
  .option("--pubsub-provider <value>", "Pub/Sub provider: gcp, aws, azure (default: gcp)")
91
93
  .option("--queue-provider <value>", "Queue provider: kafka, rabbitmq, sqs, redis (default: kafka)")
94
+ .option("--obs-stack <tier>", "Observability dev stack: none, lite, full (default: none)")
95
+ .option("--observability <list>", "Comma-separated observability modules: metrics,tracing,trace-store,logging,alerting,error-sink")
92
96
  .option("--examples", "Install example workflows and nodes")
93
97
  .action(async (options) => {
94
98
  await analytics.trackCommandExecution({
@@ -0,0 +1,5 @@
1
+ import { type ObsStackTier } from "./obs-tiers.js";
2
+ export declare function setupObservabilityStack(repoSource: string, projectDir: string, tier: ObsStackTier): {
3
+ copied: string[];
4
+ };
5
+ export declare function trimComposeServices(composePath: string, keep: string[]): void;
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import fsExtra from "fs-extra";
3
+ import { parse, stringify } from "yaml";
4
+ import { TIER_DEFINITIONS } from "./obs-tiers.js";
5
+ export function setupObservabilityStack(repoSource, projectDir, tier) {
6
+ const def = TIER_DEFINITIONS[tier];
7
+ if (tier === "none")
8
+ return { copied: [] };
9
+ const src = path.join(repoSource, "infra", "metrics");
10
+ const dest = path.join(projectDir, "infra", "metrics");
11
+ fsExtra.ensureDirSync(dest);
12
+ const copied = [];
13
+ if (def.files === "*") {
14
+ fsExtra.copySync(src, dest);
15
+ copied.push(...fsExtra.readdirSync(dest));
16
+ }
17
+ else {
18
+ for (const entry of def.files) {
19
+ const from = path.join(src, entry);
20
+ if (fsExtra.existsSync(from)) {
21
+ fsExtra.copySync(from, path.join(dest, entry));
22
+ copied.push(entry);
23
+ }
24
+ }
25
+ }
26
+ trimComposeServices(path.join(dest, "docker-compose.yml"), def.services);
27
+ return { copied };
28
+ }
29
+ export function trimComposeServices(composePath, keep) {
30
+ if (!fsExtra.existsSync(composePath))
31
+ return;
32
+ const doc = parse(fsExtra.readFileSync(composePath, "utf8"));
33
+ if (!doc.services)
34
+ return;
35
+ const keepSet = new Set(keep);
36
+ let changed = false;
37
+ for (const name of Object.keys(doc.services)) {
38
+ if (!keepSet.has(name)) {
39
+ delete doc.services[name];
40
+ changed = true;
41
+ }
42
+ }
43
+ for (const svc of Object.values(doc.services)) {
44
+ const dep = svc.depends_on;
45
+ if (Array.isArray(dep)) {
46
+ const next = dep.filter((d) => keepSet.has(d));
47
+ if (next.length !== dep.length) {
48
+ changed = true;
49
+ if (next.length > 0)
50
+ svc.depends_on = next;
51
+ else
52
+ svc.depends_on = undefined;
53
+ }
54
+ }
55
+ else if (dep && typeof dep === "object") {
56
+ for (const d of Object.keys(dep)) {
57
+ if (!keepSet.has(d)) {
58
+ delete dep[d];
59
+ changed = true;
60
+ }
61
+ }
62
+ if (Object.keys(dep).length === 0)
63
+ svc.depends_on = undefined;
64
+ }
65
+ }
66
+ if (changed)
67
+ fsExtra.writeFileSync(composePath, stringify(doc));
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { parse } from "yaml";
6
+ import { setupObservabilityStack } from "./obs-setup.js";
7
+ import { ALL_OBS_SERVICES } from "./obs-tiers.js";
8
+ function findRepoRoot() {
9
+ let dir = import.meta.dirname;
10
+ for (let i = 0; i < 8; i++) {
11
+ if (fs.existsSync(path.join(dir, "infra", "metrics", "docker-compose.yml")))
12
+ return dir;
13
+ const up = path.dirname(dir);
14
+ if (up === dir)
15
+ break;
16
+ dir = up;
17
+ }
18
+ return null;
19
+ }
20
+ const REPO = findRepoRoot();
21
+ const composeServices = (dir) => {
22
+ const doc = parse(fs.readFileSync(path.join(dir, "infra", "metrics", "docker-compose.yml"), "utf8"));
23
+ return Object.keys(doc.services ?? {});
24
+ };
25
+ describe.skipIf(!REPO)("setupObservabilityStack — tiers (MO-STACK)", () => {
26
+ const repo = REPO;
27
+ let tmp;
28
+ beforeEach(() => {
29
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-obs-tier-"));
30
+ });
31
+ afterEach(() => {
32
+ fs.rmSync(tmp, { recursive: true, force: true });
33
+ });
34
+ it("none: writes nothing — no infra/metrics dir", () => {
35
+ const res = setupObservabilityStack(repo, tmp, "none");
36
+ expect(res.copied).toEqual([]);
37
+ expect(fs.existsSync(path.join(tmp, "infra", "metrics"))).toBe(false);
38
+ });
39
+ it("lite: exactly prometheus + grafana, and no loki/tempo/alloy", () => {
40
+ setupObservabilityStack(repo, tmp, "lite");
41
+ expect(composeServices(tmp).sort()).toEqual(["grafana", "prometheus"]);
42
+ const m = path.join(tmp, "infra", "metrics");
43
+ expect(fs.existsSync(path.join(m, "prometheus.yml"))).toBe(true);
44
+ expect(fs.existsSync(path.join(m, "loki-config.yaml"))).toBe(false);
45
+ expect(fs.existsSync(path.join(m, "tempo.yaml"))).toBe(false);
46
+ });
47
+ it("lite: no kept service depends_on a trimmed-away service", () => {
48
+ setupObservabilityStack(repo, tmp, "lite");
49
+ const doc = parse(fs.readFileSync(path.join(tmp, "infra", "metrics", "docker-compose.yml"), "utf8"));
50
+ for (const svc of Object.values(doc.services ?? {})) {
51
+ const dep = svc.depends_on;
52
+ const deps = Array.isArray(dep) ? dep : dep ? Object.keys(dep) : [];
53
+ for (const d of deps)
54
+ expect(["prometheus", "grafana"]).toContain(d);
55
+ }
56
+ });
57
+ it("full: all services + the whole file set", () => {
58
+ setupObservabilityStack(repo, tmp, "full");
59
+ expect(composeServices(tmp).sort()).toEqual([...ALL_OBS_SERVICES].sort());
60
+ const m = path.join(tmp, "infra", "metrics");
61
+ expect(fs.existsSync(path.join(m, "loki-config.yaml"))).toBe(true);
62
+ expect(fs.existsSync(path.join(m, "tempo.yaml"))).toBe(true);
63
+ });
64
+ it("is idempotent — re-running lite yields an identical compose file", () => {
65
+ setupObservabilityStack(repo, tmp, "lite");
66
+ const composePath = path.join(tmp, "infra", "metrics", "docker-compose.yml");
67
+ const first = fs.readFileSync(composePath, "utf8");
68
+ setupObservabilityStack(repo, tmp, "lite");
69
+ expect(fs.readFileSync(composePath, "utf8")).toBe(first);
70
+ });
71
+ });
@@ -0,0 +1,9 @@
1
+ export type ObsStackTier = "none" | "lite" | "full";
2
+ export declare const OBS_STACK_TIERS: readonly ObsStackTier[];
3
+ export declare const ALL_OBS_SERVICES: readonly ["prometheus", "alertmanager", "grafana", "tempo", "loki", "nginx", "alloy"];
4
+ export interface TierDefinition {
5
+ services: string[];
6
+ files: string[] | "*";
7
+ }
8
+ export declare const TIER_DEFINITIONS: Record<ObsStackTier, TierDefinition>;
9
+ export declare function parseObsTier(value: string): ObsStackTier;
@@ -0,0 +1,16 @@
1
+ export const OBS_STACK_TIERS = ["none", "lite", "full"];
2
+ export const ALL_OBS_SERVICES = ["prometheus", "alertmanager", "grafana", "tempo", "loki", "nginx", "alloy"];
3
+ export const TIER_DEFINITIONS = {
4
+ none: { services: [], files: [] },
5
+ lite: {
6
+ services: ["prometheus", "grafana"],
7
+ files: ["docker-compose.yml", "prometheus.yml", "rules", "datasources.yml", "dashboards", "dashboard.json"],
8
+ },
9
+ full: { services: [...ALL_OBS_SERVICES], files: "*" },
10
+ };
11
+ export function parseObsTier(value) {
12
+ const v = value.trim().toLowerCase();
13
+ if (OBS_STACK_TIERS.includes(v))
14
+ return v;
15
+ throw new Error(`Invalid --obs-stack "${value}". Choose one of: ${OBS_STACK_TIERS.join(", ")}.`);
16
+ }
@@ -0,0 +1,4 @@
1
+ import type { ObservabilityModuleConfig, ProjectConfig } from "./runtime-setup.js";
2
+ export declare function withObservabilityModule(config: ProjectConfig, id: string, moduleConfig: ObservabilityModuleConfig): ProjectConfig;
3
+ export declare function withoutObservabilityModule(config: ProjectConfig, id: string): ProjectConfig;
4
+ export declare function rewriteObservabilityEnvBlock(envContent: string, moduleBlocks: string[]): string;
@@ -0,0 +1,46 @@
1
+ const OBS_START = "# >>> Blok observability (managed by blokctl) >>>";
2
+ const OBS_END = "# <<< Blok observability (managed by blokctl) <<<";
3
+ export function withObservabilityModule(config, id, moduleConfig) {
4
+ return { ...config, observability: { ...(config.observability ?? {}), [id]: moduleConfig } };
5
+ }
6
+ export function withoutObservabilityModule(config, id) {
7
+ if (!config.observability || !(id in config.observability))
8
+ return config;
9
+ const rest = Object.fromEntries(Object.entries(config.observability).filter(([k]) => k !== id));
10
+ return { ...config, observability: Object.keys(rest).length === 0 ? undefined : rest };
11
+ }
12
+ function stripManagedBlock(envContent) {
13
+ const out = [];
14
+ let inside = false;
15
+ for (const line of envContent.split("\n")) {
16
+ if (line.trim() === OBS_START) {
17
+ inside = true;
18
+ continue;
19
+ }
20
+ if (inside) {
21
+ if (line.trim() === OBS_END)
22
+ inside = false;
23
+ continue;
24
+ }
25
+ out.push(line);
26
+ }
27
+ return out.join("\n");
28
+ }
29
+ export function rewriteObservabilityEnvBlock(envContent, moduleBlocks) {
30
+ for (const block of moduleBlocks) {
31
+ if (/\bBLOK_METRICS_ENABLED\b/.test(block)) {
32
+ throw new Error("BLOK_METRICS_ENABLED is not a supported flag — metrics are ON by default; disable with BLOK_METRICS_DISABLED=1.");
33
+ }
34
+ }
35
+ const cleaned = stripManagedBlock(envContent)
36
+ .replace(/\n{3,}/g, "\n\n")
37
+ .replace(/\n+$/, "");
38
+ const body = moduleBlocks
39
+ .map((b) => b.trim())
40
+ .filter(Boolean)
41
+ .join("\n\n");
42
+ if (!body)
43
+ return cleaned.length > 0 ? `${cleaned}\n` : "";
44
+ const block = `${OBS_START}\n${body}\n${OBS_END}`;
45
+ return cleaned.length > 0 ? `${cleaned}\n\n${block}\n` : `${block}\n`;
46
+ }
@@ -0,0 +1 @@
1
+ export {};