blokctl 1.2.0 → 1.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.
@@ -2,6 +2,9 @@ import type { OptionValues } from "commander";
2
2
  export declare function createProject(opts: OptionValues, version: string, currentPath?: boolean, localRepoPath?: string): Promise<void>;
3
3
  export declare function generateSharedNodesFile(_triggers: string[], _repoSource: string): string;
4
4
  export declare function generateSharedWorkflowsFile(triggers: string[], runtimeKinds?: string[], examples?: boolean): string;
5
+ export declare function generateTriggerEntryFile(triggerKind: string, selectedTriggers?: string[]): string;
6
+ export declare function generateCronExampleWorkflowFile(): string;
7
+ export declare function generateCronServerFile(): string;
5
8
  export declare function updateQueueProvider(triggerDestDir: string, provider: string, explicit: boolean): void;
6
9
  export declare function getProviderDependencies(triggers: string[], pubsubProvider: string, queueProvider: string, explicitQueueProvider?: boolean): Record<string, string>;
7
10
  export declare function getProviderEnvVars(triggers: string[], pubsubProvider: string, queueProvider: string, explicitQueueProvider?: boolean): string;
@@ -14,7 +14,7 @@ import { rewriteObservabilityEnvBlock } from "../../services/observability-mutat
14
14
  import { manager as pm } from "../../services/package-manager.js";
15
15
  import { detectRuntimes } from "../../services/runtime-detector.js";
16
16
  import { createTriggerConfig, generateRuntimeEnvVars, generateSupervisordConfig, generateTriggerEnvVars, generateTriggerSupervisordConfig, setupRuntime, writeProjectConfig, } from "../../services/runtime-setup.js";
17
- import { computeDefaultConstraint } from "../../services/semver-utils.js";
17
+ import { computeDefaultConstraint, formatVersionMismatch, satisfiesConstraint } from "../../services/semver-utils.js";
18
18
  import { resolveObservabilitySelection } from "../observability/apply.js";
19
19
  import { OBSERVABILITY_MODULE_IDS, allObservabilityModules, getObservabilityModule, } from "../observability/descriptor.js";
20
20
  import { agents_md, claude_md, examples_url, node_file, package_dependencies, package_dev_dependencies, } from "./utils/Examples.js";
@@ -22,7 +22,7 @@ const exec = util.promisify(child_process.exec);
22
22
  const HOME_DIR = `${os.homedir()}/.blok`;
23
23
  const GITHUB_REPO_LOCAL = `${HOME_DIR}/blok`;
24
24
  const GITHUB_REPO_REMOTE = "https://github.com/well-prado/blok.git";
25
- const GITHUB_REPO_RELEASE_TAG = "v1.2.0";
25
+ const GITHUB_REPO_RELEASE_TAG = "v1.3.0";
26
26
  const RUNTIME_HELLO_EXAMPLES = {
27
27
  go: "runtime-go-hello.ts",
28
28
  rust: "runtime-rust-hello.ts",
@@ -55,7 +55,7 @@ export async function createProject(opts, version, currentPath = false, localRep
55
55
  let examples = opts.examples ?? false;
56
56
  let selectedRuntimeKinds = opts.runtimes ? parseCommaSeparated(opts.runtimes) : ["node"];
57
57
  let selectedManager = opts.packageManager || "npm";
58
- let pubsubProvider = opts.pubsubProvider || "gcp";
58
+ let pubsubProvider = opts.pubsubProvider || "nats";
59
59
  let queueProvider = opts.queueProvider || "kafka";
60
60
  let explicitQueueProvider = Boolean(opts.queueProvider);
61
61
  let selectedObsTier = opts.obsStack ? parseObsTier(opts.obsStack) : "none";
@@ -154,6 +154,7 @@ export async function createProject(opts, version, currentPath = false, localRep
154
154
  ? p.select({
155
155
  message: "Select Pub/Sub provider",
156
156
  options: [
157
+ { label: "NATS (local, zero cloud setup)", value: "nats" },
157
158
  { label: "Google Cloud Pub/Sub", value: "gcp" },
158
159
  { label: "AWS SNS/SQS", value: "aws" },
159
160
  { label: "Azure Service Bus", value: "azure" },
@@ -212,7 +213,7 @@ export async function createProject(opts, version, currentPath = false, localRep
212
213
  });
213
214
  projectName = blokctlProject.projectName;
214
215
  selectedTriggers = blokctlProject.triggers;
215
- pubsubProvider = blokctlProject.pubsubProvider || "gcp";
216
+ pubsubProvider = blokctlProject.pubsubProvider || "nats";
216
217
  explicitQueueProvider = blokctlProject.queueProvider != null;
217
218
  queueProvider = blokctlProject.queueProvider || "kafka";
218
219
  selectedRuntimeKinds = blokctlProject.runtimes;
@@ -453,6 +454,14 @@ export async function createProject(opts, version, currentPath = false, localRep
453
454
  fsExtra.ensureDirSync(wsServerDir);
454
455
  fsExtra.writeFileSync(`${wsServerDir}/WSServer.ts`, generateWSServerFile());
455
456
  }
457
+ if (selectedTriggers.includes("cron")) {
458
+ const cronServerDir = `${dirPath}/src/triggers/cron/runner`;
459
+ fsExtra.ensureDirSync(cronServerDir);
460
+ fsExtra.writeFileSync(`${cronServerDir}/CronServer.ts`, generateCronServerFile());
461
+ const cronWorkflowDir = `${dirPath}/src/workflows/cron`;
462
+ fsExtra.ensureDirSync(cronWorkflowDir);
463
+ fsExtra.writeFileSync(`${cronWorkflowDir}/heartbeat.ts`, generateCronExampleWorkflowFile());
464
+ }
456
465
  for (const triggerKind of selectedTriggers) {
457
466
  const triggerNodesDir = `${repoSource}/triggers/${triggerKind}/src/nodes`;
458
467
  if (fsExtra.existsSync(triggerNodesDir)) {
@@ -500,13 +509,7 @@ export async function createProject(opts, version, currentPath = false, localRep
500
509
  fsExtra.ensureDirSync(`${dirPath}/infra/milvus`);
501
510
  fsExtra.copySync(`${repoSource}/infra/development`, `${dirPath}/infra/postgresql`);
502
511
  fsExtra.copySync(`${repoSource}/infra/milvus`, `${dirPath}/infra/milvus`);
503
- const needsHelpers = selectedTriggers.includes("sse") || selectedTriggers.includes("websocket") || selectedTriggers.includes("mcp");
504
- const examplesNodesContent = needsHelpers
505
- ? node_file
506
- .replace(`import type { NodeBase } from "@blokjs/shared";`, `import type { NodeBase } from "@blokjs/shared";\nimport { HELPER_NODES } from "@blokjs/helpers";`)
507
- .replace(`} = {\n\t"@blokjs/api-call": ApiCall,`, `} = {\n\t...HELPER_NODES,\n\t"@blokjs/api-call": ApiCall,`)
508
- : node_file;
509
- fsExtra.writeFileSync(`${dirPath}/src/Nodes.ts`, examplesNodesContent);
512
+ fsExtra.writeFileSync(`${dirPath}/src/Nodes.ts`, node_file);
510
513
  fsExtra.copySync(`${repoSource}/sdk`, `${dirPath}/public/sdk`);
511
514
  const tsExamplesSrc = `${repoSource}/examples/ts-workflows`;
512
515
  const tsExamplesDest = `${dirPath}/src/workflows/examples`;
@@ -551,7 +554,7 @@ export async function createProject(opts, version, currentPath = false, localRep
551
554
  "@blokjs/trigger-worker": "triggers/worker",
552
555
  "@blokjs/core": "core/core",
553
556
  };
554
- const BLOKJS_DEP_RANGE = "^1.2.0";
557
+ const BLOKJS_DEP_RANGE = "^1.3.0";
555
558
  for (const depGroup of ["dependencies", "devDependencies", "peerDependencies"]) {
556
559
  const deps = packageJsonContent[depGroup];
557
560
  if (!deps)
@@ -636,6 +639,16 @@ export async function createProject(opts, version, currentPath = false, localRep
636
639
  ? `file:${path.resolve(repoSource, "triggers/worker")}`
637
640
  : BLOKJS_DEP_RANGE;
638
641
  }
642
+ if (selectedTriggers.includes("cron")) {
643
+ triggerPackageDeps["@blokjs/trigger-cron"] = localRepoPath
644
+ ? `file:${path.resolve(repoSource, "triggers/cron")}`
645
+ : BLOKJS_DEP_RANGE;
646
+ }
647
+ if (selectedTriggers.includes("grpc")) {
648
+ triggerPackageDeps["@blokjs/trigger-grpc"] = localRepoPath
649
+ ? `file:${path.resolve(repoSource, "triggers/grpc")}`
650
+ : BLOKJS_DEP_RANGE;
651
+ }
639
652
  if (selectedTriggers.includes("sse")) {
640
653
  const sseDeps = {
641
654
  "@blokjs/api-call": localRepoPath
@@ -705,6 +718,14 @@ export async function createProject(opts, version, currentPath = false, localRep
705
718
  const rt = detectedRuntimes.find((r) => r.kind === kind);
706
719
  if (!rt)
707
720
  continue;
721
+ if (rt.minVersion) {
722
+ const constraint = computeDefaultConstraint(rt.minVersion);
723
+ if (!rt.version || !satisfiesConstraint(rt.version, constraint)) {
724
+ console.log(`\n${formatVersionMismatch(rt.label, rt.version, constraint, rt.installHint)}`);
725
+ console.log(color.yellow(` Skipping ${rt.label} setup. After upgrading, add it with \`blokctl runtime add ${rt.kind}\`.\n`));
726
+ continue;
727
+ }
728
+ }
708
729
  try {
709
730
  const config = await setupRuntime(rt, repoSource, dirPath, s);
710
731
  runtimeConfigs.push(config);
@@ -902,23 +923,31 @@ export function generateSharedWorkflowsFile(triggers, runtimeKinds = [], example
902
923
  }
903
924
  else if (trigger === "sse") {
904
925
  imports.push('import SSEStreamDemo from "./workflows/sse/events/stream-demo";');
905
- workflowEntries.push('\t"sse-stream-demo": SSEStreamDemo,');
926
+ workflowEntries.push('\t"sse-stream-demo": await SSEStreamDemo,');
906
927
  if (triggers.includes("http")) {
907
928
  imports.push('import SSEPublishDemo from "./workflows/sse/events/publish-demo";');
908
- workflowEntries.push('\t"sse-publish-demo": SSEPublishDemo,');
929
+ workflowEntries.push('\t"sse-publish-demo": await SSEPublishDemo,');
909
930
  }
910
931
  }
911
932
  else if (trigger === "websocket") {
912
933
  imports.push('import WSEchoDemo from "./workflows/websocket/events/echo-demo";');
913
- workflowEntries.push('\t"ws-echo-demo": WSEchoDemo,');
934
+ workflowEntries.push('\t"ws-echo-demo": await WSEchoDemo,');
914
935
  }
915
936
  else if (trigger === "pubsub") {
916
937
  imports.push('import OnPubSubMessage from "./workflows/pubsub/messages/on-message";');
917
- workflowEntries.push('\t"on-pubsub-message": OnPubSubMessage,');
938
+ workflowEntries.push('\t"on-pubsub-message": await OnPubSubMessage,');
939
+ if (triggers.includes("http")) {
940
+ imports.push('import PublishOrder from "./workflows/pubsub/publish-order";');
941
+ workflowEntries.push('\t"publish-order": await PublishOrder,');
942
+ }
918
943
  }
919
944
  else if (trigger === "queue" || trigger === "worker") {
920
945
  imports.push(`import ProcessJob from "./workflows/${trigger}/jobs/process-job";`);
921
- workflowEntries.push('\t"process-job": ProcessJob,');
946
+ workflowEntries.push('\t"process-job": await ProcessJob,');
947
+ }
948
+ else if (trigger === "cron") {
949
+ imports.push('import CronHeartbeat from "./workflows/cron/heartbeat";');
950
+ workflowEntries.push('\t"cron-heartbeat": await CronHeartbeat,');
922
951
  }
923
952
  }
924
953
  if (examples) {
@@ -948,7 +977,7 @@ ${entriesSection}
948
977
  export default workflows;
949
978
  `;
950
979
  }
951
- function generateTriggerEntryFile(triggerKind, selectedTriggers = [triggerKind]) {
980
+ export function generateTriggerEntryFile(triggerKind, selectedTriggers = [triggerKind]) {
952
981
  if (triggerKind === "http") {
953
982
  const sseAlsoSelected = selectedTriggers.includes("sse");
954
983
  const wsAlsoSelected = selectedTriggers.includes("websocket");
@@ -1288,6 +1317,63 @@ export default class App {
1288
1317
  if (process.env.DISABLE_TRIGGER_RUN !== "true") {
1289
1318
  new App().run();
1290
1319
  }
1320
+ `;
1321
+ }
1322
+ if (triggerKind === "cron") {
1323
+ return `import { DefaultLogger } from "@blokjs/runner";
1324
+ import { type Span, metrics, trace } from "@opentelemetry/api";
1325
+ import CronServer from "./runner/CronServer";
1326
+
1327
+ export default class App {
1328
+ private cronServer: CronServer = <CronServer>{};
1329
+ protected trigger_initializer = 0;
1330
+ protected initializer = 0;
1331
+ protected tracer = trace.getTracer(
1332
+ process.env.PROJECT_NAME || "trigger-cron-server",
1333
+ process.env.PROJECT_VERSION || "0.0.1",
1334
+ );
1335
+ private logger = new DefaultLogger();
1336
+ protected app_cold_start = metrics.getMeter("default").createGauge("initialization", {
1337
+ description: "Application cold start",
1338
+ });
1339
+
1340
+ constructor() {
1341
+ this.initializer = performance.now();
1342
+ this.cronServer = new CronServer();
1343
+ }
1344
+
1345
+ async run() {
1346
+ this.tracer.startActiveSpan("initialization", async (span: Span) => {
1347
+ await this.cronServer.listen();
1348
+ this.initializer = performance.now() - this.initializer;
1349
+
1350
+ this.logger.log(\`Cron trigger initialized in \${(this.initializer).toFixed(2)}ms\`);
1351
+ this.app_cold_start.record(this.initializer, {
1352
+ pid: process.pid,
1353
+ env: process.env.NODE_ENV,
1354
+ app: process.env.APP_NAME,
1355
+ });
1356
+ span.end();
1357
+ });
1358
+ }
1359
+ }
1360
+
1361
+ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
1362
+ new App().run();
1363
+ }
1364
+ `;
1365
+ }
1366
+ if (triggerKind === "grpc") {
1367
+ return `import { GrpcServer } from "@blokjs/trigger-grpc";
1368
+ import nodes from "../../Nodes";
1369
+ import workflows from "../../Workflows";
1370
+
1371
+ const host = process.env.GRPC_HOST || "0.0.0.0";
1372
+ const port = Number(process.env.GRPC_PORT || process.env.PORT || 4003);
1373
+
1374
+ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
1375
+ new GrpcServer({ host, port, nodes, workflows }).start();
1376
+ }
1291
1377
  `;
1292
1378
  }
1293
1379
  return `// Entry point for ${triggerKind} trigger
@@ -1295,6 +1381,45 @@ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
1295
1381
  console.log("${triggerKind} trigger not yet implemented");
1296
1382
  `;
1297
1383
  }
1384
+ export function generateCronExampleWorkflowFile() {
1385
+ return `import { node, step, workflow } from "@blokjs/core";
1386
+
1387
+ /**
1388
+ * Cron heartbeat — fires on a schedule, NOT on an HTTP request. \`blokctl dev\`
1389
+ * boots it via src/triggers/cron/index.ts and it fires every minute. The
1390
+ * schedule accepts an optional leading seconds field (e.g. \`* * * * * *\` for
1391
+ * every second). Add more cron workflows by exporting them and registering
1392
+ * them in src/Workflows.ts.
1393
+ */
1394
+ export default workflow(
1395
+ "Cron Heartbeat",
1396
+ { version: "1.0.0", trigger: { cron: { schedule: "* * * * *", timezone: "UTC" } } },
1397
+ () => {
1398
+ step("heartbeat", node("@blokjs/expr"), { expression: "({ ok: true, at: Date.now() })" }, { ephemeral: true });
1399
+ },
1400
+ );
1401
+ `;
1402
+ }
1403
+ export function generateCronServerFile() {
1404
+ return `import { CronTrigger } from "@blokjs/trigger-cron";
1405
+ import nodes from "../../../Nodes";
1406
+ import workflows from "../../../Workflows";
1407
+
1408
+ /**
1409
+ * CronServer — the cron trigger for this project.
1410
+ *
1411
+ * CronTrigger.listen() populates the NodeMap, registers workflows with the
1412
+ * WorkflowRegistry, then schedules a CronJob for every workflow whose trigger
1413
+ * is \`{ cron: { schedule, timezone? } }\`. There is no port to bind — the
1414
+ * scheduled job timers keep the process alive. Non-cron workflows sharing this
1415
+ * Workflows.ts are ignored by the scheduler (they run under their own trigger).
1416
+ */
1417
+ export default class CronServer extends CronTrigger {
1418
+ protected nodes: Record<string, import("@blokjs/runner").BlokService<unknown>> = nodes;
1419
+ protected workflows: Record<string, import("@blokjs/helper").WorkflowV2Builder> = workflows;
1420
+ }
1421
+ `;
1422
+ }
1298
1423
  function generateSSEServerFile() {
1299
1424
  return `import { serve } from "@hono/node-server";
1300
1425
  import { DefaultLogger, NodeMap, WorkflowRegistry } from "@blokjs/runner";
@@ -1581,8 +1706,12 @@ function updatePubSubProvider(triggerDestDir, provider) {
1581
1706
  },
1582
1707
  };
1583
1708
  const config = adapterConfigs[provider];
1584
- if (!config)
1709
+ if (!config) {
1710
+ content = content.replace(/import \{ GCPPubSubAdapter, PubSubTrigger \} from ["']@blokjs\/trigger-pubsub["'];/, 'import { PubSubTrigger } from "@blokjs/trigger-pubsub";');
1711
+ content = content.replace(/\n\tprotected adapter = new GCPPubSubAdapter\(\{[\s\S]*?\}\);\n/, "");
1712
+ fsExtra.writeFileSync(serverPath, content);
1585
1713
  return;
1714
+ }
1586
1715
  content = content.replace(/import \{ (\w+), (\w+) \} from ["']@blokjs\/trigger-pubsub["'];/, `import { ${config.importName}, PubSubTrigger } from "@blokjs/trigger-pubsub";`);
1587
1716
  content = content.replace(/(export default class \w+ extends PubSubTrigger \{[\s\S]*?)\n\tprotected adapter = new \w+\(\{[\s\S]*?\}\);/, `$1\n\tprotected adapter = ${config.init};`);
1588
1717
  fsExtra.writeFileSync(serverPath, content);
@@ -1640,6 +1769,7 @@ export function updateQueueProvider(triggerDestDir, provider, explicit) {
1640
1769
  export function getProviderDependencies(triggers, pubsubProvider, queueProvider, explicitQueueProvider = false) {
1641
1770
  const deps = {};
1642
1771
  const pubsubProviderDeps = {
1772
+ nats: { nats: "^2.28.0" },
1643
1773
  gcp: { "@google-cloud/pubsub": "^5.0.0" },
1644
1774
  aws: { "@aws-sdk/client-sns": "^3.980.0", "@aws-sdk/client-sqs": "^3.980.0" },
1645
1775
  azure: { "@azure/service-bus": "^7.9.5" },
@@ -1664,6 +1794,10 @@ export function getProviderDependencies(triggers, pubsubProvider, queueProvider,
1664
1794
  export function getProviderEnvVars(triggers, pubsubProvider, queueProvider, explicitQueueProvider = false) {
1665
1795
  const lines = [];
1666
1796
  const pubsubEnvVars = {
1797
+ nats: `
1798
+ # NATS (local pub/sub broker — zero cloud setup)
1799
+ NATS_SERVERS=localhost:4222
1800
+ BLOK_PUBSUB_ADAPTER=nats`,
1667
1801
  gcp: `
1668
1802
  # Google Cloud Pub/Sub
1669
1803
  GCP_PROJECT_ID=my-project
@@ -1,4 +1,4 @@
1
- declare const node_file = "import { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport ApiCall from \"@blokjs/api-call\";\nimport IfElse from \"@blokjs/if-else\";\nimport { discoverNodes } from \"@blokjs/runner\";\nimport type { NodeBase } from \"@blokjs/shared\";\nimport ExampleNodes from \"./nodes/examples/index\";\n\n// Published nodes (npm) + the example bundle are registered explicitly below.\n// Your OWN nodes under 'nodes/<name>/index.ts' are AUTO-DISCOVERED and registered\n// by their defineNode({ name }) \u2014 you never edit this file to add a node.\nconst here = dirname(fileURLToPath(import.meta.url));\nconst local = await discoverNodes(join(here, \"nodes\"));\n\nconst explicit: NodeBase[] = [ApiCall, IfElse, ...(Object.values(ExampleNodes) as NodeBase[])];\n\n// Map keys are cosmetic \u2014 the runner registers each node under its own node.name\n// (the canonical 'use:' ref). Duplicate refs throw at startup.\nconst nodes: { [key: string]: NodeBase } = {};\nfor (const node of [...explicit, ...local]) {\n\tnodes[(node as { name: string }).name] = node;\n}\n\nexport default nodes;\n";
1
+ declare const node_file = "import { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport ApiCall from \"@blokjs/api-call\";\nimport { HELPER_NODES } from \"@blokjs/helpers\";\nimport IfElse from \"@blokjs/if-else\";\nimport { discoverNodes } from \"@blokjs/runner\";\nimport type { NodeBase } from \"@blokjs/shared\";\nimport ExampleNodes from \"./nodes/examples/index\";\n\n// Published nodes (npm) + the example bundle are registered explicitly below.\n// Your OWN nodes under 'nodes/<name>/index.ts' are AUTO-DISCOVERED and registered\n// by their defineNode({ name }) \u2014 you never edit this file to add a node.\nconst here = dirname(fileURLToPath(import.meta.url));\nconst local = await discoverNodes(join(here, \"nodes\"));\n\nconst explicit: NodeBase[] = [\n\tApiCall,\n\tIfElse,\n\t...(Object.values(HELPER_NODES) as unknown as NodeBase[]),\n\t...(Object.values(ExampleNodes) as NodeBase[]),\n];\n\n// Map keys are cosmetic \u2014 the runner registers each node under its own node.name\n// (the canonical 'use:' ref). Duplicate refs throw at startup.\nconst nodes: { [key: string]: NodeBase } = {};\nfor (const node of [...explicit, ...local]) {\n\tnodes[(node as { name: string }).name] = node;\n}\n\nexport default nodes;\n";
2
2
  declare const package_dependencies: {
3
3
  ai: string;
4
4
  "@ai-sdk/openai": string;
@@ -13,7 +13,7 @@ declare const package_dev_dependencies: {
13
13
  "@types/pg": string;
14
14
  };
15
15
  declare const python3_file = "from pydantic import BaseModel\n\nfrom blok import node\nfrom blok.types.context import Context\n\n\nclass Input(BaseModel):\n \"\"\"Validated inputs for the {{NODE_NAME}} node.\"\"\"\n\n name: str = \"world\"\n\n\nclass Output(BaseModel):\n message: str\n\n\n@node(\"{{NODE_NAME}}\", \"Describe what {{NODE_NAME}} does\")\ndef run(ctx: Context, input: Input) -> Output:\n # `input` is already validated against Input; the return is validated\n # against Output and serialized for you.\n return Output(message=f\"Hello, {input.name}!\")\n";
16
- declare const examples_url = "\nExamples:\n1- Open \"workflow-docs.json\" in your browser at http://localhost:4000/workflow-docs\n2- Open \"db-manager.json\" in your browser at http://localhost:4000/db-manager\n3- Open \"dashboard-gen.json\" in your browser at http://localhost:4000/dashboard-gen\n4- Open \"countries.json\" in your browser at http://localhost:4000/countries\n5- Open \"chat.json\" in your browser at http://localhost:4000/chat (set OPENROUTER_API_KEY first)\n6- Open \"chat-memory.json\" in your browser at http://localhost:4000/chat-memory (needs OPENROUTER_API_KEY + Redis at REDIS_URL)\n7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies \u2014 set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)\n8- LLM agent w/ tool calls: open http://localhost:4000/agent \u2014 model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)\n9- Worker fan-out: POST /fanout/jobs with body '{items:[...], tenantId?:\"...\"}' to enqueue N worker jobs (needs --triggers worker; BLOK_WORKER_ADAPTER=in-memory works single-process)\n10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order,websocket-echo}.json demonstrate the cron, pubsub, and websocket triggers \u2014 read AGENTS.md \"Choosing a trigger\" to pick the right one by intent instead of defaulting to HTTP.\n\nFor more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.\n";
16
+ declare const examples_url = "\nExamples:\n1- Open \"workflow-docs.json\" in your browser at http://localhost:4000/workflow-docs\n2- Open \"db-manager.json\" in your browser at http://localhost:4000/db-manager\n3- Open \"dashboard-gen.json\" in your browser at http://localhost:4000/dashboard-gen\n4- Open \"countries.json\" in your browser at http://localhost:4000/countries\n5- Open \"chat.json\" in your browser at http://localhost:4000/chat (set OPENROUTER_API_KEY first)\n6- Open \"chat-memory.json\" in your browser at http://localhost:4000/chat-memory (needs OPENROUTER_API_KEY + Redis at REDIS_URL)\n7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies \u2014 set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)\n8- LLM agent w/ tool calls: open http://localhost:4000/agent \u2014 model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)\n9- Worker fan-out: POST /fanout/jobs with body '{items:[...], tenantId?:\"...\"}' to enqueue N worker jobs (needs --triggers worker; BLOK_WORKER_ADAPTER=in-memory works single-process)\n10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order}.json demonstrate the cron and pubsub triggers; src/workflows/websocket/events/echo-demo.ts demonstrates websocket \u2014 read AGENTS.md \"Choosing a trigger\" to pick the right one by intent instead of defaulting to HTTP.\n\nFor more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.\n";
17
17
  declare const workflow_template = "import { http, node, step, workflow } from \"@blokjs/core\";\n\n/**\n * {{WORKFLOW_NAME}}\n *\n * Add steps with step(\"id\", node, inputs). Read a step's output downstream via\n * the handle it returns (const user = step(...); ... user.name). Control flow:\n * branch / forEach / switchOn / tryCatch \u2014 all from @blokjs/core.\n */\nexport default workflow(\n\t\"{{WORKFLOW_NAME}}\",\n\t{ version: \"1.0.0\", trigger: http.get(\"{{WORKFLOW_PATH}}\") },\n\t(req) => {\n\t\t// Echo the request body back. @blokjs/respond writes the HTTP response,\n\t\t// so mark it ephemeral (no state slot needed).\n\t\tstep(\"echo\", node(\"@blokjs/respond\"), { body: req.body }, { ephemeral: true });\n\t},\n);\n";
18
18
  declare const supervisord_nodejs = "\n[supervisord]\nnodaemon=true\n\n[program:nodejs_app]\ncommand=npm start\ndirectory=/app\nautostart=true\nautorestart=true\nstderr_logfile=/var/log/nodejs.err.log\nstdout_logfile=/var/log/nodejs.out.log\n";
19
19
  declare const supervisord_python = "\n[program:python_app]\ncommand=python3 /app/.blok/runtimes/python3/server.py\ndirectory=/app\nautostart=true\nautorestart=true\nstderr_logfile=/var/log/python.err.log\nstdout_logfile=/var/log/python.out.log\n";
@@ -1,6 +1,7 @@
1
1
  const node_file = `import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import ApiCall from "@blokjs/api-call";
4
+ import { HELPER_NODES } from "@blokjs/helpers";
4
5
  import IfElse from "@blokjs/if-else";
5
6
  import { discoverNodes } from "@blokjs/runner";
6
7
  import type { NodeBase } from "@blokjs/shared";
@@ -12,7 +13,12 @@ import ExampleNodes from "./nodes/examples/index";
12
13
  const here = dirname(fileURLToPath(import.meta.url));
13
14
  const local = await discoverNodes(join(here, "nodes"));
14
15
 
15
- const explicit: NodeBase[] = [ApiCall, IfElse, ...(Object.values(ExampleNodes) as NodeBase[])];
16
+ const explicit: NodeBase[] = [
17
+ ApiCall,
18
+ IfElse,
19
+ ...(Object.values(HELPER_NODES) as unknown as NodeBase[]),
20
+ ...(Object.values(ExampleNodes) as NodeBase[]),
21
+ ];
16
22
 
17
23
  // Map keys are cosmetic — the runner registers each node under its own node.name
18
24
  // (the canonical 'use:' ref). Duplicate refs throw at startup.
@@ -69,7 +75,7 @@ Examples:
69
75
  7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies — set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)
70
76
  8- LLM agent w/ tool calls: open http://localhost:4000/agent — model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)
71
77
  9- Worker fan-out: POST /fanout/jobs with body '{items:[...], tenantId?:"..."}' to enqueue N worker jobs (needs --triggers worker; BLOK_WORKER_ADAPTER=in-memory works single-process)
72
- 10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order,websocket-echo}.json demonstrate the cron, pubsub, and websocket triggers — read AGENTS.md "Choosing a trigger" to pick the right one by intent instead of defaulting to HTTP.
78
+ 10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order}.json demonstrate the cron and pubsub triggers; src/workflows/websocket/events/echo-demo.ts demonstrates websocket — read AGENTS.md "Choosing a trigger" to pick the right one by intent instead of defaulting to HTTP.
73
79
 
74
80
  For more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.
75
81
  `;
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import util from "node:util";
5
5
  import fsExtra from "fs-extra";
6
6
  import { waitForGrpcPort } from "../../services/health-probe.js";
7
- import { detectRr } from "../../services/runtime-detector.js";
7
+ import { detectJava, detectRr } from "../../services/runtime-detector.js";
8
8
  import { generateCSharpNodeRegistry, generateGoNodeRegistry, generateJavaNodeRegistry, generateRustNodeRegistry, readProjectConfig, validateProjectRuntimes, } from "../../services/runtime-setup.js";
9
9
  import { regenRuntimeStubs } from "../nodes/syncNodes.js";
10
10
  const exec = util.promisify(child_process.exec);
@@ -106,6 +106,12 @@ export async function devProject(opts) {
106
106
  bootCmd = `${rrBin}${bootCmd.slice(2)}`;
107
107
  }
108
108
  }
109
+ if (rt.kind === "java" && bootCmd.startsWith("java ")) {
110
+ const javaBin = detectJava();
111
+ if (javaBin && javaBin !== "java") {
112
+ bootCmd = `${javaBin}${bootCmd.slice(4)}`;
113
+ }
114
+ }
109
115
  const cmdParts = bootCmd.split(" ");
110
116
  const cmd = cmdParts[0];
111
117
  const args = cmdParts.slice(1);
@@ -198,6 +204,12 @@ export async function devProject(opts) {
198
204
  if (brokerConsumerKinds.has(trigger.kind)) {
199
205
  console.log(` ${trigger.label}: consumes from broker (no HTTP endpoint)`);
200
206
  }
207
+ else if (trigger.kind === "cron") {
208
+ console.log(` ${trigger.label}: scheduled (no HTTP endpoint)`);
209
+ }
210
+ else if (trigger.kind === "grpc") {
211
+ console.log(` ${trigger.label}: gRPC 127.0.0.1:${trigger.port}`);
212
+ }
201
213
  else {
202
214
  console.log(` ${trigger.label}: http://localhost:${trigger.port}/health-check`);
203
215
  }
@@ -5,7 +5,7 @@ import color from "picocolors";
5
5
  import { isNonInteractive } from "../../services/non-interactive.js";
6
6
  import { detectRuntimes, getRuntimeDefinition } from "../../services/runtime-detector.js";
7
7
  import { ensureRuntimeGitignore, rewriteRuntimeEnvBlock, rewriteSupervisordRuntimes, runtimeEnvKey, withRuntime, } from "../../services/runtime-mutations.js";
8
- import { setupRuntime } from "../../services/runtime-setup.js";
8
+ import { buildRuntimeConfig, setupRuntime, } from "../../services/runtime-setup.js";
9
9
  import { RuntimeCommandError, assertGrpcPortFree, assertSidecarKind, readConfigSafe, reportRuntimeError, resolveProjectRoot, resolveSdkSource, } from "./shared.js";
10
10
  export async function runtimeAdd(kindArg, options) {
11
11
  try {
@@ -54,6 +54,31 @@ export async function runtimeAdd(kindArg, options) {
54
54
  const sdkDir = path.join(root, ".blok", "runtimes", kind);
55
55
  const alreadyInstalled = Boolean(config.runtimes?.[kind]) || fs.existsSync(sdkDir);
56
56
  p.intro(color.inverse(` Add ${def.label} runtime `));
57
+ if (options.enable === true) {
58
+ if (config.runtimes?.[kind]) {
59
+ p.outro(color.dim(`${def.label} is already wired into .blok/config.json.`));
60
+ return;
61
+ }
62
+ if (!fs.existsSync(sdkDir)) {
63
+ throw new RuntimeCommandError(`${def.label} isn't scaffolded at ${path.relative(root, sdkDir)}. Run \`blokctl runtime add ${kind}\` (without --enable) to install it first.`);
64
+ }
65
+ const rt = (detected ?? (await detectRuntimes())).find((d) => d.kind === kind);
66
+ if (!rt)
67
+ throw new RuntimeCommandError(`Unknown runtime "${kind}".`);
68
+ const grpcPort = grpcPortOverride ?? rt.defaultGrpcPort;
69
+ const clash = Object.values(config.runtimes ?? {}).find((rc) => rc.kind !== kind && rc.grpcPort === grpcPort);
70
+ if (clash) {
71
+ throw new RuntimeCommandError(`gRPC port ${grpcPort} is already used by the ${clash.label} runtime. Pass --grpc-port <n> to pick another.`);
72
+ }
73
+ const rc = buildRuntimeConfig(rt, root);
74
+ if (grpcPortOverride !== undefined) {
75
+ rc.grpcPort = grpcPortOverride;
76
+ if (rc.grpcStartCmd)
77
+ rc.grpcStartCmd = rc.grpcStartCmd.split(String(rt.defaultGrpcPort)).join(String(grpcPortOverride));
78
+ }
79
+ finalizeRuntime(root, config, rc, kind, def.label);
80
+ return;
81
+ }
57
82
  if (alreadyInstalled && options.force !== true) {
58
83
  if (nonInteractive) {
59
84
  p.outro(color.dim(`${def.label} is already installed. Re-run with --force to reinstall.`));
@@ -107,37 +132,75 @@ export async function runtimeAdd(kindArg, options) {
107
132
  rc.grpcStartCmd = rc.grpcStartCmd.split(String(rt.defaultGrpcPort)).join(String(grpcPortOverride));
108
133
  }
109
134
  s.stop(`${def.label} runtime ready`);
110
- const nextConfig = withRuntime(config, rc);
111
- const remaining = Object.values(nextConfig.runtimes ?? {});
112
- fs.mkdirSync(path.join(root, ".blok"), { recursive: true });
113
- fs.writeFileSync(path.join(root, ".blok", "config.json"), `${JSON.stringify(nextConfig, null, 2)}\n`);
114
- const envPath = path.join(root, ".env.local");
115
- const envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
116
- fs.writeFileSync(envPath, rewriteRuntimeEnvBlock(envContent, remaining));
117
- const supervisordPath = path.join(root, "supervisord.conf");
118
- if (fs.existsSync(supervisordPath)) {
119
- fs.writeFileSync(supervisordPath, rewriteSupervisordRuntimes(fs.readFileSync(supervisordPath, "utf8"), remaining));
120
- }
121
- const gitignorePath = path.join(root, ".gitignore");
122
- if (fs.existsSync(gitignorePath)) {
123
- const before = fs.readFileSync(gitignorePath, "utf8");
124
- const after = ensureRuntimeGitignore(before);
125
- if (after !== before)
126
- fs.writeFileSync(gitignorePath, after);
135
+ if (stampHelloExample(root, source, kind)) {
136
+ p.log.success(`Shipped the ${def.label} hello example — ${color.cyan(`POST /runtimes/${kind}/hello`)} (registered in src/Workflows.ts)`);
127
137
  }
128
- p.note([
129
- `${color.green("✓")} .blok/config.json ${color.dim(`runtimes.${kind}`)}`,
130
- `${color.green("✓")} .env.local ${color.dim(`RUNTIME_${runtimeEnvKey(kind)}_GRPC_PORT=${rc.grpcPort}`)}`,
131
- fs.existsSync(supervisordPath)
132
- ? `${color.green("✓")} supervisord.conf ${color.dim(`[program:${kind}_runtime]`)}`
133
- : "",
134
- `${color.green("✓")} runtimes/${kind}/nodes/ ${color.dim("(your runtime nodes go here)")}`,
135
- ]
136
- .filter(Boolean)
137
- .join("\n"), `${def.label} added`);
138
- p.outro(`Run ${color.cyan("blokctl dev")} to start it, then add ${color.cyan(`type: "runtime.${kind}"`)} steps to your workflows.`);
138
+ finalizeRuntime(root, config, rc, kind, def.label);
139
139
  }
140
140
  catch (err) {
141
141
  reportRuntimeError(err);
142
142
  }
143
143
  }
144
+ function stampHelloExample(root, source, kind) {
145
+ const file = `runtime-${kind}-hello.ts`;
146
+ const srcFile = path.join(source, "examples", "ts-workflows", file);
147
+ const examplesDir = path.join(root, "src", "workflows", "examples");
148
+ const workflowsTs = path.join(root, "src", "Workflows.ts");
149
+ if (!fs.existsSync(srcFile) || !fs.existsSync(examplesDir) || !fs.existsSync(workflowsTs))
150
+ return false;
151
+ const importName = `Runtime${kind.charAt(0).toUpperCase()}${kind.slice(1)}Hello`;
152
+ const importLine = `import ${importName} from "./workflows/examples/runtime-${kind}-hello";`;
153
+ const entryLine = `\t"runtime-${kind}-hello": ${importName},`;
154
+ const content = fs.readFileSync(workflowsTs, "utf8");
155
+ if (content.includes(`./workflows/examples/runtime-${kind}-hello`)) {
156
+ fs.copyFileSync(srcFile, path.join(examplesDir, file));
157
+ return true;
158
+ }
159
+ const lines = content.split("\n");
160
+ const openIdx = lines.findIndex((l) => /^const workflows\b.*=\s*\{\s*$/.test(l));
161
+ let lastImport = -1;
162
+ for (let i = 0; i < (openIdx === -1 ? lines.length : openIdx); i++) {
163
+ if (/^import /.test(lines[i]))
164
+ lastImport = i;
165
+ }
166
+ if (openIdx === -1 || lastImport === -1) {
167
+ p.log.warn(`src/Workflows.ts doesn't look generated — register the ${kind} hello example yourself:\n ${importLine}\n ${entryLine.trim()}`);
168
+ return false;
169
+ }
170
+ lines.splice(openIdx + 1, 0, entryLine);
171
+ lines.splice(lastImport + 1, 0, importLine);
172
+ fs.copyFileSync(srcFile, path.join(examplesDir, file));
173
+ fs.writeFileSync(workflowsTs, lines.join("\n"));
174
+ return true;
175
+ }
176
+ function finalizeRuntime(root, config, rc, kind, label) {
177
+ const nextConfig = withRuntime(config, rc);
178
+ const remaining = Object.values(nextConfig.runtimes ?? {});
179
+ fs.mkdirSync(path.join(root, ".blok"), { recursive: true });
180
+ fs.writeFileSync(path.join(root, ".blok", "config.json"), `${JSON.stringify(nextConfig, null, 2)}\n`);
181
+ const envPath = path.join(root, ".env.local");
182
+ const envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
183
+ fs.writeFileSync(envPath, rewriteRuntimeEnvBlock(envContent, remaining));
184
+ const supervisordPath = path.join(root, "supervisord.conf");
185
+ if (fs.existsSync(supervisordPath)) {
186
+ fs.writeFileSync(supervisordPath, rewriteSupervisordRuntimes(fs.readFileSync(supervisordPath, "utf8"), remaining));
187
+ }
188
+ const gitignorePath = path.join(root, ".gitignore");
189
+ if (fs.existsSync(gitignorePath)) {
190
+ const before = fs.readFileSync(gitignorePath, "utf8");
191
+ const after = ensureRuntimeGitignore(before);
192
+ if (after !== before)
193
+ fs.writeFileSync(gitignorePath, after);
194
+ }
195
+ p.note([
196
+ `${color.green("✓")} .blok/config.json ${color.dim(`runtimes.${kind}`)}`,
197
+ `${color.green("✓")} .env.local ${color.dim(`RUNTIME_${runtimeEnvKey(kind)}_GRPC_PORT=${rc.grpcPort}`)}`,
198
+ fs.existsSync(supervisordPath)
199
+ ? `${color.green("✓")} supervisord.conf ${color.dim(`[program:${kind}_runtime]`)}`
200
+ : "",
201
+ `${color.green("✓")} runtimes/${kind}/nodes/ ${color.dim("(your runtime nodes go here)")}`,
202
+ ]
203
+ .filter(Boolean)
204
+ .join("\n"), `${label} added`);
205
+ p.outro(`Run ${color.cyan("blokctl dev")} to start it, then add ${color.cyan(`type: "runtime.${kind}"`)} steps to your workflows.`);
206
+ }
@@ -15,6 +15,7 @@ runtime
15
15
  .option("--local <path>", "Use a local blok repo for SDK source instead of fetching by version")
16
16
  .option("--grpc-port <port>", "Override the gRPC port for this runtime")
17
17
  .option("--force", "Reinstall if the runtime is already present")
18
+ .option("--enable", "Wire an already-scaffolded runtime (SDK dir on disk) into config.json without reinstalling")
18
19
  .option("--skip-toolchain-check", "Add even if the language toolchain isn't detected")
19
20
  .option("-y, --yes", "Skip prompts (non-interactive)")
20
21
  .action(async (runtimeArg, options) => {
@@ -3,6 +3,7 @@ export interface RuntimeInfo {
3
3
  label: string;
4
4
  available: boolean;
5
5
  version?: string;
6
+ minVersion?: string;
6
7
  installHint: string;
7
8
  defaultPort: number;
8
9
  defaultGrpcPort: number;
@@ -21,6 +22,7 @@ export interface RuntimeInfo {
21
22
  };
22
23
  }
23
24
  export declare function detectRr(): string | null;
25
+ export declare function detectJava(): string | null;
24
26
  export declare function detectRuntimes(): Promise<RuntimeInfo[]>;
25
27
  export declare function detectRuntimeVersion(kind: string): Promise<string | undefined>;
26
28
  export declare function getRuntimeDefinition(kind: string): Omit<RuntimeInfo, "available" | "version"> | undefined;
@@ -1,8 +1,24 @@
1
1
  import child_process from "node:child_process";
2
2
  import util from "node:util";
3
+ import { compareSemver } from "./semver-utils.js";
3
4
  const exec = util.promisify(child_process.exec);
4
5
  export function detectRr() {
5
6
  for (const bin of ["/opt/homebrew/bin/rr", "rr"]) {
7
+ try {
8
+ const out = child_process
9
+ .execSync(`${bin} --version`, { stdio: ["ignore", "pipe", "ignore"] })
10
+ .toString()
11
+ .trim();
12
+ if (/^rr version/i.test(out))
13
+ return bin;
14
+ }
15
+ catch {
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+ export function detectJava() {
21
+ for (const bin of ["java", "/opt/homebrew/opt/openjdk/bin/java"]) {
6
22
  try {
7
23
  child_process.execSync(`${bin} --version`, { stdio: "ignore" });
8
24
  return bin;
@@ -45,8 +61,9 @@ const RUNTIME_DEFINITIONS = [
45
61
  defaultGrpcPort: 10002,
46
62
  commands: ["rustc --version"],
47
63
  toolchain: "rustc + cargo",
48
- installDeps: "cargo build --release",
64
+ installDeps: "cargo build --features grpc",
49
65
  startCmd: "cargo run",
66
+ grpcStartCmd: "cargo run --features grpc",
50
67
  sdkDir: "rust",
51
68
  },
52
69
  {
@@ -85,6 +102,7 @@ const RUNTIME_DEFINITIONS = [
85
102
  {
86
103
  kind: "php",
87
104
  label: "PHP",
105
+ minVersion: "8.2.0",
88
106
  installHint: "Install PHP 8.2+ + RoadRunner: https://php.net/downloads (rr: brew install roadrunner)",
89
107
  defaultPort: 9005,
90
108
  defaultGrpcPort: 10005,
@@ -103,13 +121,14 @@ const RUNTIME_DEFINITIONS = [
103
121
  {
104
122
  kind: "ruby",
105
123
  label: "Ruby",
106
- installHint: "Install Ruby 3.2+: https://ruby-lang.org/en/downloads/",
124
+ minVersion: "3.1.0",
125
+ installHint: "Install Ruby 3.1+: https://ruby-lang.org/en/downloads/ (macOS: brew install ruby)",
107
126
  defaultPort: 9006,
108
127
  defaultGrpcPort: 10006,
109
128
  commands: ["ruby --version", "/opt/homebrew/opt/ruby/bin/ruby --version"],
110
129
  toolchain: "ruby + bundler",
111
130
  installDeps: "bundle install",
112
- startCmd: "bundle exec rackup --host 0.0.0.0 config.ru",
131
+ startCmd: "bundle exec ruby bin/serve.rb",
113
132
  sdkDir: "ruby",
114
133
  secondaryTool: {
115
134
  name: "Bundler",
@@ -171,10 +190,12 @@ export async function detectRuntimes() {
171
190
  };
172
191
  for (const cmd of def.commands) {
173
192
  const output = await tryExec(cmd);
174
- if (output) {
175
- info.available = true;
176
- info.version = parseVersion(output, def.kind);
177
- break;
193
+ if (!output)
194
+ continue;
195
+ info.available = true;
196
+ const parsed = parseVersion(output, def.kind);
197
+ if (parsed && (!info.version || compareSemver(parsed, info.version) > 0)) {
198
+ info.version = parsed;
178
199
  }
179
200
  }
180
201
  if (def.secondaryTool) {
@@ -196,13 +217,16 @@ export async function detectRuntimeVersion(kind) {
196
217
  const def = RUNTIME_DEFINITIONS.find((r) => r.kind === kind);
197
218
  if (!def)
198
219
  return undefined;
220
+ let best;
199
221
  for (const cmd of def.commands) {
200
222
  const output = await tryExec(cmd);
201
- if (output) {
202
- return parseVersion(output, def.kind);
203
- }
223
+ if (!output)
224
+ continue;
225
+ const parsed = parseVersion(output, def.kind);
226
+ if (parsed && (!best || compareSemver(parsed, best) > 0))
227
+ best = parsed;
204
228
  }
205
- return undefined;
229
+ return best;
206
230
  }
207
231
  export function getRuntimeDefinition(kind) {
208
232
  return RUNTIME_DEFINITIONS.find((r) => r.kind === kind);
@@ -36,6 +36,7 @@ export interface ProjectConfig {
36
36
  }
37
37
  export type ProjectRuntimeConfig = ProjectConfig;
38
38
  export declare function setupRuntime(runtime: RuntimeInfo, githubRepoLocal: string, projectDir: string, spinner: SpinnerHandler): Promise<RuntimeConfig>;
39
+ export declare function buildRuntimeConfig(runtime: RuntimeInfo, projectDir: string, startCmd?: string): RuntimeConfig;
39
40
  export declare function generateGoNodeRegistry(projectDir: string): string;
40
41
  export declare function generateRustNodeRegistry(projectDir: string): string;
41
42
  export declare function generateJavaNodeRegistry(projectDir: string): string;
@@ -21,7 +21,7 @@ export async function setupRuntime(runtime, githubRepoLocal, projectDir, spinner
21
21
  let startCmdOverride;
22
22
  switch (runtime.kind) {
23
23
  case "python3":
24
- await setupPython3(blokctlRuntimeDir, spinner);
24
+ startCmdOverride = await setupPython3(blokctlRuntimeDir, spinner);
25
25
  break;
26
26
  case "go":
27
27
  await setupGo(blokctlRuntimeDir, spinner);
@@ -39,7 +39,7 @@ export async function setupRuntime(runtime, githubRepoLocal, projectDir, spinner
39
39
  await setupPhp(blokctlRuntimeDir, spinner);
40
40
  break;
41
41
  case "ruby":
42
- startCmdOverride = await setupRuby(blokctlRuntimeDir, spinner, runtime.defaultPort);
42
+ startCmdOverride = await setupRuby(blokctlRuntimeDir, spinner);
43
43
  break;
44
44
  }
45
45
  if (runtime.kind === "go") {
@@ -55,16 +55,24 @@ export async function setupRuntime(runtime, githubRepoLocal, projectDir, spinner
55
55
  generateCSharpNodeRegistry(projectDir);
56
56
  }
57
57
  spinner.message(`${runtime.label} runtime setup complete.`);
58
+ return buildRuntimeConfig(runtime, projectDir, startCmdOverride || runtime.startCmd);
59
+ }
60
+ export function buildRuntimeConfig(runtime, projectDir, startCmd) {
61
+ const blokctlRuntimeDir = path.join(projectDir, ".blok", "runtimes", runtime.kind);
58
62
  return {
59
63
  port: runtime.defaultPort,
60
64
  grpcPort: runtime.defaultGrpcPort,
61
- startCmd: startCmdOverride || runtime.startCmd,
65
+ startCmd: startCmd ?? runtime.startCmd,
62
66
  grpcStartCmd: runtime.grpcStartCmd,
63
67
  cwd: path.relative(projectDir, blokctlRuntimeDir),
64
68
  kind: runtime.kind,
65
69
  label: runtime.label,
66
70
  version: runtime.version,
67
- requiredVersion: runtime.version ? computeDefaultConstraint(runtime.version) : undefined,
71
+ requiredVersion: runtime.minVersion
72
+ ? computeDefaultConstraint(runtime.minVersion)
73
+ : runtime.version
74
+ ? computeDefaultConstraint(runtime.version)
75
+ : undefined,
68
76
  transport: "grpc",
69
77
  };
70
78
  }
@@ -79,6 +87,7 @@ async function setupPython3(sdkDir, spinner) {
79
87
  await exec(`"${venvPip}" install -r "${requirementsFile}"`, { cwd: sdkDir });
80
88
  }
81
89
  spinner.message("Python3 packages installed.");
90
+ return "python3_runtime/bin/python3 bin/serve.py";
82
91
  }
83
92
  async function createPythonVenv(sdkDir) {
84
93
  await exec("python3 -m venv python3_runtime", { cwd: sdkDir, timeout: 60000 });
@@ -342,7 +351,7 @@ function collectFilesRecursive(dir, ext) {
342
351
  }
343
352
  async function setupRust(sdkDir, spinner) {
344
353
  spinner.message("Building Rust project (this may take a few minutes on first build)...");
345
- await exec("cargo build --release", { cwd: sdkDir, timeout: 600000 });
354
+ await exec("cargo build --features grpc", { cwd: sdkDir, timeout: 600000 });
346
355
  spinner.message("Rust project built.");
347
356
  }
348
357
  async function setupJava(sdkDir, spinner) {
@@ -373,7 +382,7 @@ async function setupPhp(sdkDir, spinner) {
373
382
  await exec("composer install --no-dev --optimize-autoloader", { cwd: sdkDir, timeout: 120000 });
374
383
  spinner.message("PHP dependencies installed.");
375
384
  }
376
- async function setupRuby(sdkDir, spinner, port) {
385
+ async function setupRuby(sdkDir, spinner) {
377
386
  const bundleCandidates = ["bundle", "/opt/homebrew/opt/ruby/bin/bundle"];
378
387
  let resolvedBundle = "bundle";
379
388
  for (const bin of bundleCandidates) {
@@ -392,7 +401,8 @@ async function setupRuby(sdkDir, spinner, port) {
392
401
  spinner.message("Installing Ruby dependencies...");
393
402
  await exec(`"${resolvedBundle}" install`, { cwd: sdkDir, timeout: 120000 });
394
403
  spinner.message("Ruby dependencies installed.");
395
- return `${resolvedBundle} exec rackup --host 0.0.0.0 -p ${port} config.ru`;
404
+ const resolvedRuby = resolvedBundle.includes("/") ? path.join(path.dirname(resolvedBundle), "ruby") : "ruby";
405
+ return `${resolvedBundle} exec ${resolvedRuby} bin/serve.rb`;
396
406
  }
397
407
  export function writeProjectConfig(projectDir, runtimeConfigs, triggerConfigs, observabilityConfigs) {
398
408
  const config = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blokctl",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "author": "Deskree Technologies Inc.",
5
5
  "license": "Apache-2.0",
6
6
  "description": "cli for blok",
@@ -30,8 +30,8 @@
30
30
  "keywords": ["blokctl", "cli", "blok", "blok"],
31
31
  "dependencies": {
32
32
  "@ai-sdk/openai": "^1.3.22",
33
- "@blokjs/helper": "^1.2.0",
34
- "@blokjs/runner": "^1.2.0",
33
+ "@blokjs/helper": "^1.3.0",
34
+ "@blokjs/runner": "^1.3.0",
35
35
  "@clack/prompts": "^1.0.0",
36
36
  "ai": "^4.3.16",
37
37
  "better-sqlite3": "^12.6.2",