blokctl 0.6.21 → 1.0.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 (81) 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 +60 -9
  4. package/dist/commands/create/utils/Examples.d.ts +11 -23
  5. package/dist/commands/create/utils/Examples.js +206 -421
  6. package/dist/commands/dev/index.js +40 -1
  7. package/dist/commands/generate/NodeGenerator.d.ts +0 -2
  8. package/dist/commands/generate/NodeGenerator.js +0 -20
  9. package/dist/commands/generate/RuntimeGenerator.d.ts +0 -2
  10. package/dist/commands/generate/RuntimeGenerator.js +0 -19
  11. package/dist/commands/generate/RuntimeGenerator.test.js +0 -29
  12. package/dist/commands/generate/TriggerGenerator.d.ts +0 -2
  13. package/dist/commands/generate/TriggerGenerator.js +0 -19
  14. package/dist/commands/generate/WorkflowGenerator.d.ts +0 -2
  15. package/dist/commands/generate/WorkflowGenerator.js +4 -23
  16. package/dist/commands/generate/WorkflowGenerator.test.js +2 -2
  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/generate/prompts/create-fn-node.system.js +10 -17
  21. package/dist/commands/generate/prompts/create-workflow.system.js +114 -375
  22. package/dist/commands/generate/validators/WorkflowValidator.js +80 -5
  23. package/dist/commands/generate/validators/WorkflowValidator.test.js +66 -0
  24. package/dist/commands/monitor/monitor-component.js +5 -5
  25. package/dist/commands/observability/add.d.ts +2 -0
  26. package/dist/commands/observability/add.js +113 -0
  27. package/dist/commands/observability/alerting-module.test.js +43 -0
  28. package/dist/commands/observability/apply.d.ts +10 -0
  29. package/dist/commands/observability/apply.js +11 -0
  30. package/dist/commands/observability/descriptor.d.ts +37 -0
  31. package/dist/commands/observability/descriptor.js +203 -0
  32. package/dist/commands/observability/descriptor.test.d.ts +1 -0
  33. package/dist/commands/observability/descriptor.test.js +40 -0
  34. package/dist/commands/observability/index.d.ts +1 -0
  35. package/dist/commands/observability/index.js +53 -0
  36. package/dist/commands/observability/list.d.ts +2 -0
  37. package/dist/commands/observability/list.js +45 -0
  38. package/dist/commands/observability/logging-module.test.d.ts +1 -0
  39. package/dist/commands/observability/logging-module.test.js +43 -0
  40. package/dist/commands/observability/obs-stack-module.test.d.ts +1 -0
  41. package/dist/commands/observability/obs-stack-module.test.js +33 -0
  42. package/dist/commands/observability/remove.d.ts +2 -0
  43. package/dist/commands/observability/remove.js +62 -0
  44. package/dist/commands/observability/shared.d.ts +6 -0
  45. package/dist/commands/observability/shared.js +23 -0
  46. package/dist/commands/observability/status.d.ts +2 -0
  47. package/dist/commands/observability/status.js +36 -0
  48. package/dist/commands/observability/tracing-module.test.d.ts +1 -0
  49. package/dist/commands/observability/tracing-module.test.js +42 -0
  50. package/dist/commands/profile/index.js +7 -10
  51. package/dist/commands/watch/format.d.ts +23 -0
  52. package/dist/commands/watch/format.js +60 -0
  53. package/dist/commands/watch/index.d.ts +1 -0
  54. package/dist/commands/watch/index.js +53 -0
  55. package/dist/commands/watch/sse.d.ts +16 -0
  56. package/dist/commands/watch/sse.js +82 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +4 -0
  59. package/dist/services/obs-setup.d.ts +5 -0
  60. package/dist/services/obs-setup.js +68 -0
  61. package/dist/services/obs-setup.test.d.ts +1 -0
  62. package/dist/services/obs-setup.test.js +71 -0
  63. package/dist/services/obs-tiers.d.ts +9 -0
  64. package/dist/services/obs-tiers.js +16 -0
  65. package/dist/services/observability-mutations.d.ts +4 -0
  66. package/dist/services/observability-mutations.js +46 -0
  67. package/dist/services/observability-mutations.test.d.ts +1 -0
  68. package/dist/services/observability-mutations.test.js +57 -0
  69. package/dist/services/runtime-setup.d.ts +12 -1
  70. package/dist/services/runtime-setup.js +274 -14
  71. package/dist/studio-dist/assets/{index-BD8_9YPN.js → index-CnFqCRQe.js} +17 -17
  72. package/dist/studio-dist/index.html +1 -1
  73. package/package.json +3 -3
  74. package/dist/commands/generate/GenerationAnalytics.d.ts +0 -61
  75. package/dist/commands/generate/GenerationAnalytics.js +0 -163
  76. package/dist/commands/generate/GenerationAnalytics.test.js +0 -407
  77. package/dist/commands/generate/PromptVersioning.d.ts +0 -25
  78. package/dist/commands/generate/PromptVersioning.js +0 -71
  79. package/dist/commands/generate/PromptVersioning.test.js +0 -120
  80. /package/dist/{commands/generate/GenerationAnalytics.test.d.ts → __tests__/modular-observability.capstone.e2e.test.d.ts} +0 -0
  81. /package/dist/commands/{generate/PromptVersioning.test.d.ts → observability/alerting-module.test.d.ts} +0 -0
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveObservabilitySelection } from "./apply.js";
3
+ import { allObservabilityModules, resolveWithDependencies } from "./descriptor.js";
4
+ describe("resolveWithDependencies", () => {
5
+ it("pulls in a transitive dependency (alerting → metrics)", () => {
6
+ const { resolved, added } = resolveWithDependencies(["alerting"]);
7
+ expect(resolved).toContain("metrics");
8
+ expect(resolved).toContain("alerting");
9
+ expect(added).toEqual(["metrics"]);
10
+ });
11
+ it("logging → trace-store", () => {
12
+ expect(resolveWithDependencies(["logging"]).resolved).toContain("trace-store");
13
+ });
14
+ it("no spurious deps for a leaf module, and no duplicates", () => {
15
+ const { resolved, added } = resolveWithDependencies(["tracing", "tracing"]);
16
+ expect(resolved).toEqual(["tracing"]);
17
+ expect(added).toEqual([]);
18
+ });
19
+ it("throws on an unknown module id", () => {
20
+ expect(() => resolveWithDependencies(["nope"])).toThrow(/Unknown observability module/);
21
+ });
22
+ it("every dependency id is itself a real module", () => {
23
+ const ids = new Set(allObservabilityModules().map((m) => m.id));
24
+ for (const m of allObservabilityModules())
25
+ for (const dep of m.dependencies)
26
+ expect(ids.has(dep)).toBe(true);
27
+ });
28
+ });
29
+ describe("resolveObservabilitySelection (create-time)", () => {
30
+ const opts = { addedAt: "2026-01-01T00:00:00.000Z", version: "0.6.0", projectDir: "/tmp/x" };
31
+ it("empty selection → empty everything", () => {
32
+ expect(resolveObservabilitySelection([], opts)).toEqual({ configMap: {}, envBlocks: [], added: [] });
33
+ });
34
+ it("builds a config map for the resolved set incl. deps", () => {
35
+ const { configMap, added } = resolveObservabilitySelection(["alerting"], opts);
36
+ expect(Object.keys(configMap).sort()).toEqual(["alerting", "metrics"]);
37
+ expect(configMap.metrics).toEqual({ enabled: true, addedAt: opts.addedAt, version: opts.version });
38
+ expect(added).toEqual(["metrics"]);
39
+ });
40
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { Command } from "commander";
2
+ import { program } from "../../services/commander.js";
3
+ import { observabilityAdd } from "./add.js";
4
+ import { OBSERVABILITY_MODULE_IDS } from "./descriptor.js";
5
+ import { observabilityList } from "./list.js";
6
+ import { observabilityRemove } from "./remove.js";
7
+ import { observabilityStatus } from "./status.js";
8
+ const MODULE_LIST = OBSERVABILITY_MODULE_IDS.join(", ");
9
+ const observability = new Command("observability")
10
+ .alias("obs")
11
+ .description("Add, remove, list, or check observability modules (metrics, tracing, logging, …) in a project");
12
+ observability.action(() => {
13
+ observability.help();
14
+ });
15
+ observability
16
+ .command("add")
17
+ .description(`Enable an observability module (${MODULE_LIST})`)
18
+ .argument("[module]", "Module to add (omit for an interactive picker)")
19
+ .option("-d, --directory <path>", "Project directory (default: current directory)")
20
+ .option("--force", "Re-apply even if the module is already enabled")
21
+ .option("--tier <tier>", "obs-stack only: none | lite | full (default: lite)")
22
+ .option("--local <path>", "obs-stack only: copy infra from a local blok repo instead of fetching")
23
+ .option("-y, --yes", "Skip prompts (non-interactive; auto-enables dependencies)")
24
+ .action(async (moduleArg, options) => {
25
+ await observabilityAdd(moduleArg, options);
26
+ });
27
+ observability
28
+ .command("remove")
29
+ .alias("rm")
30
+ .description("Disable an observability module")
31
+ .argument("<module>", "Module to remove")
32
+ .option("-d, --directory <path>", "Project directory (default: current directory)")
33
+ .option("-y, --yes", "Skip confirmation (non-interactive)")
34
+ .action(async (moduleArg, options) => {
35
+ await observabilityRemove(moduleArg, options);
36
+ });
37
+ observability
38
+ .command("list")
39
+ .alias("ls")
40
+ .description("List enabled modules and which are available to add")
41
+ .option("-d, --directory <path>", "Project directory (default: current directory)")
42
+ .option("--json", "Output as JSON")
43
+ .action(async (options) => {
44
+ await observabilityList(options);
45
+ });
46
+ observability
47
+ .command("status")
48
+ .description("Report the health of each enabled observability module")
49
+ .option("-d, --directory <path>", "Project directory (default: current directory)")
50
+ .action(async (options) => {
51
+ await observabilityStatus(options);
52
+ });
53
+ program.addCommand(observability);
@@ -0,0 +1,2 @@
1
+ import type { OptionValues } from "../../services/commander.js";
2
+ export declare function observabilityList(options: OptionValues): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import * as p from "@clack/prompts";
2
+ import color from "picocolors";
3
+ import { allObservabilityModules } from "./descriptor.js";
4
+ import { readConfigSafe, reportObservabilityError, resolveProjectRoot } from "./shared.js";
5
+ export async function observabilityList(options) {
6
+ try {
7
+ const root = resolveProjectRoot(options.directory);
8
+ const enabled = readConfigSafe(root).observability ?? {};
9
+ const modules = allObservabilityModules();
10
+ if (options.json) {
11
+ console.log(JSON.stringify(modules.map((m) => ({
12
+ id: m.id,
13
+ label: m.label,
14
+ enabled: Boolean(enabled[m.id]?.enabled),
15
+ addedAt: enabled[m.id]?.addedAt,
16
+ dependencies: m.dependencies,
17
+ })), null, 2));
18
+ return;
19
+ }
20
+ p.intro(color.inverse(" Observability modules "));
21
+ const on = modules.filter((m) => enabled[m.id]?.enabled);
22
+ const off = modules.filter((m) => !enabled[m.id]?.enabled);
23
+ if (on.length > 0) {
24
+ p.note(on
25
+ .map((m) => {
26
+ const added = enabled[m.id]?.addedAt;
27
+ const when = added ? color.dim(` added ${added.slice(0, 10)}`) : "";
28
+ return `${color.green("✓")} ${color.bold(m.label.padEnd(26))} ${color.dim(m.id.padEnd(13))}${when}`;
29
+ })
30
+ .join("\n"), `Enabled (${on.length})`);
31
+ }
32
+ else {
33
+ p.log.info(color.dim("No observability modules enabled yet."));
34
+ }
35
+ if (off.length > 0) {
36
+ p.note(off
37
+ .map((m) => `${color.bold(m.label.padEnd(26))} ${color.dim(m.description)}\n ${color.dim(`blokctl observability add ${m.id}`)}`)
38
+ .join("\n"), `Available to add (${off.length})`);
39
+ }
40
+ p.outro(color.dim("Add with `blokctl observability add <id>`, remove with `… remove <id>`."));
41
+ }
42
+ catch (err) {
43
+ reportObservabilityError(err);
44
+ }
45
+ }
@@ -0,0 +1,43 @@
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 logging = getObservabilityModule("logging");
7
+ describe("logging module (MO-LOGGING)", () => {
8
+ it("depends on trace-store and declares loki + alloy compose services", () => {
9
+ expect(logging.dependencies).toEqual(["trace-store"]);
10
+ expect(logging.composeServices).toEqual(["loki", "alloy"]);
11
+ });
12
+ it("envBlock turns structured logging on", () => {
13
+ expect(logging.envBlock({ projectDir: "/tmp/x" })).toContain("CONSOLE_LOG_ACTIVE=true");
14
+ });
15
+ describe("verify()", () => {
16
+ let tmp;
17
+ beforeEach(() => {
18
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-logging-"));
19
+ });
20
+ afterEach(() => {
21
+ fs.rmSync(tmp, { recursive: true, force: true });
22
+ });
23
+ it("reports OFF when CONSOLE_LOG_ACTIVE isn't true", async () => {
24
+ fs.writeFileSync(path.join(tmp, ".env.local"), "PORT=4000\n# CONSOLE_LOG_ACTIVE=true\n");
25
+ const r = await logging.verify?.(tmp);
26
+ expect(r?.message).toMatch(/OFF/);
27
+ });
28
+ it("reports on-but-no-shipper when logging is active without the alloy config", async () => {
29
+ fs.writeFileSync(path.join(tmp, ".env.local"), "CONSOLE_LOG_ACTIVE=true\n");
30
+ const r = await logging.verify?.(tmp);
31
+ expect(r?.message).toMatch(/add obs-stack=full/);
32
+ expect(r?.dashboardUrl).toBeUndefined();
33
+ });
34
+ it("reports shipping when active AND the alloy config is present", async () => {
35
+ fs.writeFileSync(path.join(tmp, ".env.local"), "CONSOLE_LOG_ACTIVE=true\n");
36
+ fs.mkdirSync(path.join(tmp, "infra", "metrics"), { recursive: true });
37
+ fs.writeFileSync(path.join(tmp, "infra", "metrics", "alloy-config.alloy"), "// alloy\n");
38
+ const r = await logging.verify?.(tmp);
39
+ expect(r?.message).toMatch(/shipping JSON logs to Loki/);
40
+ expect(r?.dashboardUrl).toContain("/explore");
41
+ });
42
+ });
43
+ });
@@ -0,0 +1,33 @@
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 obsStack = getObservabilityModule("obs-stack");
7
+ describe("obs-stack module retrofit (MO-STACK T4)", () => {
8
+ let tmp;
9
+ beforeEach(() => {
10
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blok-obsstack-"));
11
+ });
12
+ afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
13
+ it("scaffold with tier=none is a no-op (writes nothing)", async () => {
14
+ const r = await obsStack.scaffold?.({ projectDir: tmp, nonInteractive: true, tier: "none" });
15
+ expect(r).toEqual({ filesCreated: [] });
16
+ expect(fs.existsSync(path.join(tmp, "infra"))).toBe(false);
17
+ });
18
+ it("verify reports tier-none when no stack is present", async () => {
19
+ expect((await obsStack.verify?.(tmp))?.message).toMatch(/tier none/);
20
+ });
21
+ it("verify reports the service count when a stack is present", async () => {
22
+ fs.mkdirSync(path.join(tmp, "infra", "metrics"), { recursive: true });
23
+ fs.writeFileSync(path.join(tmp, "infra", "metrics", "docker-compose.yml"), "services:\n prometheus: {}\n grafana: {}\n");
24
+ const r = await obsStack.verify?.(tmp);
25
+ expect(r?.message).toMatch(/2 service/);
26
+ expect(r?.dashboardUrl).toContain("3000");
27
+ });
28
+ it("cleanup removes the copied infra/metrics (the remove contract)", async () => {
29
+ fs.mkdirSync(path.join(tmp, "infra", "metrics"), { recursive: true });
30
+ await obsStack.cleanup?.({ projectDir: tmp, nonInteractive: true });
31
+ expect(fs.existsSync(path.join(tmp, "infra", "metrics"))).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,2 @@
1
+ import type { OptionValues } from "../../services/commander.js";
2
+ export declare function observabilityRemove(moduleArg: string, options: OptionValues): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import * as p from "@clack/prompts";
4
+ import color from "picocolors";
5
+ import { isNonInteractive } from "../../services/non-interactive.js";
6
+ import { rewriteObservabilityEnvBlock, withoutObservabilityModule } from "../../services/observability-mutations.js";
7
+ import { OBSERVABILITY_MODULE_IDS, getObservabilityModule } from "./descriptor.js";
8
+ import { ObservabilityCommandError, readConfigSafe, reportObservabilityError, resolveProjectRoot } from "./shared.js";
9
+ export async function observabilityRemove(moduleArg, options) {
10
+ try {
11
+ const id = moduleArg.trim().toLowerCase();
12
+ const mod = getObservabilityModule(id);
13
+ if (!mod) {
14
+ throw new ObservabilityCommandError(`Unknown observability module "${id}". Known: ${OBSERVABILITY_MODULE_IDS.join(", ")}.`);
15
+ }
16
+ const root = resolveProjectRoot(options.directory);
17
+ const config = readConfigSafe(root);
18
+ const enabled = config.observability ?? {};
19
+ const nonInteractive = isNonInteractive() || options.yes === true;
20
+ p.intro(color.inverse(` Remove ${mod.label} `));
21
+ if (!enabled[mod.id]) {
22
+ p.outro(color.dim(`${mod.label} isn't enabled in this project — nothing to remove.`));
23
+ return;
24
+ }
25
+ const dependents = Object.keys(enabled).filter((eid) => getObservabilityModule(eid)?.dependencies.includes(mod.id));
26
+ if (dependents.length > 0) {
27
+ const labels = dependents.map((d) => getObservabilityModule(d)?.label ?? d).join(", ");
28
+ p.log.warn(`${color.yellow(labels)} ${dependents.length > 1 ? "depend" : "depends"} on ${color.bold(mod.label)} — remove ${dependents.length > 1 ? "them" : "it"} too, or expect reduced function.`);
29
+ }
30
+ if (!nonInteractive) {
31
+ const ok = await p.confirm({ message: `Remove the ${mod.label} module?`, initialValue: false });
32
+ if (p.isCancel(ok) || !ok) {
33
+ p.outro(color.dim("Left unchanged."));
34
+ return;
35
+ }
36
+ }
37
+ if (mod.cleanup)
38
+ await mod.cleanup({ projectDir: root, nonInteractive });
39
+ const nextConfig = withoutObservabilityModule(config, mod.id);
40
+ fs.mkdirSync(path.join(root, ".blok"), { recursive: true });
41
+ fs.writeFileSync(path.join(root, ".blok", "config.json"), `${JSON.stringify(nextConfig, null, 2)}\n`);
42
+ const remainingIds = Object.keys(nextConfig.observability ?? {});
43
+ const envBlocks = remainingIds.map((eid) => getObservabilityModule(eid)?.envBlock({ projectDir: root }) ?? "");
44
+ const envPath = path.join(root, ".env.local");
45
+ if (fs.existsSync(envPath)) {
46
+ fs.writeFileSync(envPath, rewriteObservabilityEnvBlock(fs.readFileSync(envPath, "utf8"), envBlocks));
47
+ }
48
+ p.note([
49
+ `${color.red("−")} .blok/config.json ${color.dim(`observability.${mod.id}`)}`,
50
+ `${color.red("−")} .env.local ${color.dim(`${mod.label} env block`)}`,
51
+ mod.infraFiles.length > 0 && !mod.cleanup
52
+ ? `${color.yellow("•")} infra files left in place ${color.dim("(remove by hand if unused)")}`
53
+ : "",
54
+ ]
55
+ .filter(Boolean)
56
+ .join("\n"), `${mod.label} removed`);
57
+ p.outro(color.dim(`Re-add anytime: blokctl observability add ${mod.id}.`));
58
+ }
59
+ catch (err) {
60
+ reportObservabilityError(err);
61
+ }
62
+ }
@@ -0,0 +1,6 @@
1
+ import { readConfigSafe, resolveProjectRoot } from "../runtime/shared.js";
2
+ export { readConfigSafe, resolveProjectRoot };
3
+ export declare class ObservabilityCommandError extends Error {
4
+ }
5
+ export declare function readFrameworkVersion(projectRoot: string): string | undefined;
6
+ export declare function reportObservabilityError(err: unknown): void;
@@ -0,0 +1,23 @@
1
+ import * as p from "@clack/prompts";
2
+ import color from "picocolors";
3
+ import { RuntimeCommandError, readConfigSafe, readFrameworkTag, resolveProjectRoot } from "../runtime/shared.js";
4
+ export { readConfigSafe, resolveProjectRoot };
5
+ export class ObservabilityCommandError extends Error {
6
+ }
7
+ export function readFrameworkVersion(projectRoot) {
8
+ try {
9
+ return readFrameworkTag(projectRoot)?.replace(/^v/, "");
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ export function reportObservabilityError(err) {
16
+ if (err instanceof ObservabilityCommandError || err instanceof RuntimeCommandError) {
17
+ p.cancel(err.message);
18
+ }
19
+ else {
20
+ p.cancel(color.red(`Unexpected error: ${err?.message ?? String(err)}`));
21
+ }
22
+ process.exitCode = 1;
23
+ }
@@ -0,0 +1,2 @@
1
+ import type { OptionValues } from "../../services/commander.js";
2
+ export declare function observabilityStatus(options: OptionValues): Promise<void>;
@@ -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>;