blokctl 1.1.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.
Files changed (38) hide show
  1. package/dist/commands/create/project.d.ts +5 -0
  2. package/dist/commands/create/project.js +164 -27
  3. package/dist/commands/create/utils/Examples.d.ts +4 -4
  4. package/dist/commands/create/utils/Examples.js +77 -105
  5. package/dist/commands/create/workflow.js +8 -6
  6. package/dist/commands/dev/index.js +39 -1
  7. package/dist/commands/install/node.d.ts +1 -0
  8. package/dist/commands/install/node.js +3 -39
  9. package/dist/commands/migrate/index.js +25 -0
  10. package/dist/commands/migrate/nodesTs.d.ts +31 -0
  11. package/dist/commands/migrate/nodesTs.js +484 -0
  12. package/dist/commands/migrate/refs.d.ts +15 -0
  13. package/dist/commands/migrate/refs.js +706 -0
  14. package/dist/commands/nodes/index.js +10 -0
  15. package/dist/commands/nodes/listNodes.d.ts +2 -0
  16. package/dist/commands/nodes/listNodes.js +20 -15
  17. package/dist/commands/nodes/syncNodes.d.ts +7 -0
  18. package/dist/commands/nodes/syncNodes.js +138 -0
  19. package/dist/commands/nodes/syncNodes.test.d.ts +1 -0
  20. package/dist/commands/nodes/syncNodes.test.js +322 -0
  21. package/dist/commands/publish/workflow.d.ts +1 -0
  22. package/dist/commands/publish/workflow.js +9 -0
  23. package/dist/commands/publish/workflow.test.d.ts +1 -0
  24. package/dist/commands/publish/workflow.test.js +22 -0
  25. package/dist/commands/runtime/add.js +92 -29
  26. package/dist/commands/runtime/index.js +1 -0
  27. package/dist/services/observability-stack.real-prometheus.test.d.ts +1 -0
  28. package/dist/services/observability-stack.real-prometheus.test.js +95 -0
  29. package/dist/services/observability-stack.real-tempo.test.d.ts +1 -0
  30. package/dist/services/observability-stack.real-tempo.test.js +86 -0
  31. package/dist/services/runtime-detector.d.ts +2 -0
  32. package/dist/services/runtime-detector.js +35 -11
  33. package/dist/services/runtime-setup.d.ts +1 -0
  34. package/dist/services/runtime-setup.js +17 -7
  35. package/dist/studio-dist/assets/index-QEvKRE4T.js +42 -0
  36. package/dist/studio-dist/index.html +1 -1
  37. package/package.json +3 -2
  38. package/dist/studio-dist/assets/index-CnFqCRQe.js +0 -42
@@ -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) => {
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { describe, expect, it } from "vitest";
4
+ const RUN = process.env.BLOK_INTEGRATION_PROMETHEUS;
5
+ const d = RUN ? describe : describe.skip;
6
+ const PROMETHEUS_API = process.env.BLOK_PROMETHEUS_API_URL ?? "http://localhost:9090";
7
+ function run(command, args) {
8
+ const result = spawnSync(command, args, { encoding: "utf8" });
9
+ if (result.status !== 0)
10
+ throw new Error(result.stderr || result.stdout || `${command} ${args.join(" ")} failed`);
11
+ return `${result.stdout}\n${result.stderr}`;
12
+ }
13
+ function removeContainer(name) {
14
+ spawnSync("docker", ["rm", "-f", name], { encoding: "utf8" });
15
+ }
16
+ async function queryPrometheus(query) {
17
+ const response = await fetch(`${PROMETHEUS_API}/api/v1/query?query=${encodeURIComponent(query)}`);
18
+ expect(response.status).toBe(200);
19
+ return (await response.json());
20
+ }
21
+ async function activeTargets() {
22
+ const response = await fetch(`${PROMETHEUS_API}/api/v1/targets?state=active`);
23
+ expect(response.status).toBe(200);
24
+ const body = (await response.json());
25
+ return body.data?.activeTargets ?? [];
26
+ }
27
+ async function waitForTarget(instance) {
28
+ let target;
29
+ for (let i = 0; i < 30; i++) {
30
+ target = (await activeTargets()).find((candidate) => candidate.labels.instance === instance);
31
+ if (target?.health === "up")
32
+ return target;
33
+ await new Promise((resolve) => setTimeout(resolve, 1000));
34
+ }
35
+ return target;
36
+ }
37
+ async function waitForQuery(query) {
38
+ let last;
39
+ for (let i = 0; i < 30; i++) {
40
+ last = await queryPrometheus(query);
41
+ if ((last.data?.result?.length ?? 0) > 0)
42
+ return last;
43
+ await new Promise((resolve) => setTimeout(resolve, 1000));
44
+ }
45
+ return last ?? queryPrometheus(query);
46
+ }
47
+ d("observability stack - Prometheus live", () => {
48
+ it("scrapes the gRPC metrics target on the shared metrics port", async () => {
49
+ const name = `blok-prom-target-${randomBytes(4).toString("hex")}`;
50
+ const metricsServer = [
51
+ "from http.server import BaseHTTPRequestHandler, HTTPServer",
52
+ "class H(BaseHTTPRequestHandler):",
53
+ " def log_message(self, *args): pass",
54
+ " def do_GET(self):",
55
+ " body = b'# TYPE blok_prom_port_live_test_total counter\\nblok_prom_port_live_test_total 7\\n'",
56
+ " self.send_response(200)",
57
+ " self.send_header('Content-Type', 'text/plain; version=0.0.4')",
58
+ " self.send_header('Content-Length', str(len(body)))",
59
+ " self.end_headers()",
60
+ " self.wfile.write(body)",
61
+ "HTTPServer(('0.0.0.0', 9464), H).serve_forever()",
62
+ ].join("\n");
63
+ try {
64
+ run("docker", [
65
+ "run",
66
+ "--rm",
67
+ "-d",
68
+ "--name",
69
+ name,
70
+ "--network",
71
+ "shared-network",
72
+ "--network-alias",
73
+ "blok-grpc",
74
+ "python:3.12-alpine",
75
+ "python",
76
+ "-c",
77
+ metricsServer,
78
+ ]);
79
+ const target = await waitForTarget("blok-grpc:9464");
80
+ expect(target?.scrapeUrl).toBe("http://blok-grpc:9464/metrics");
81
+ expect(target?.health).toBe("up");
82
+ const up = await waitForQuery('up{job="blok-grpc",instance="blok-grpc:9464"} == 1');
83
+ expect(up.status).toBe("success");
84
+ expect(up.data?.result).toHaveLength(1);
85
+ expect(up.data?.result?.[0]?.value[1]).toBe("1");
86
+ const metric = await waitForQuery("blok_prom_port_live_test_total");
87
+ expect(metric.status).toBe("success");
88
+ expect(metric.data?.result).toHaveLength(1);
89
+ expect(metric.data?.result?.[0]?.value[1]).toBe("7");
90
+ }
91
+ finally {
92
+ removeContainer(name);
93
+ }
94
+ }, 60_000);
95
+ });
@@ -0,0 +1,86 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ const RUN = process.env.BLOK_INTEGRATION_TEMPO;
7
+ const d = RUN ? describe : describe.skip;
8
+ const TEMPO_API = process.env.BLOK_TEMPO_API_URL ?? "http://localhost:3201";
9
+ const TEMPO_OTLP = process.env.BLOK_TEMPO_OTLP_URL ?? "http://localhost:4318/v1/traces";
10
+ function findRepoRoot() {
11
+ let dir = import.meta.dirname;
12
+ for (let i = 0; i < 8; i++) {
13
+ if (fs.existsSync(path.join(dir, "infra", "metrics", "tempo.yaml")))
14
+ return dir;
15
+ const up = path.dirname(dir);
16
+ if (up === dir)
17
+ break;
18
+ dir = up;
19
+ }
20
+ return null;
21
+ }
22
+ function hex(bytes) {
23
+ return randomBytes(bytes).toString("hex");
24
+ }
25
+ function dockerLogs(container) {
26
+ const result = spawnSync("docker", ["logs", container], { encoding: "utf8" });
27
+ if (result.status !== 0)
28
+ throw new Error(result.stderr || result.stdout || `docker logs ${container} failed`);
29
+ return `${result.stdout}\n${result.stderr}`;
30
+ }
31
+ async function waitForTrace(traceId) {
32
+ let last;
33
+ for (let i = 0; i < 20; i++) {
34
+ last = await fetch(`${TEMPO_API}/api/traces/${traceId}`);
35
+ if (last.ok)
36
+ return last;
37
+ await new Promise((resolve) => setTimeout(resolve, 500));
38
+ }
39
+ return last ?? fetch(`${TEMPO_API}/api/traces/${traceId}`);
40
+ }
41
+ d("observability stack - Tempo live", () => {
42
+ it("boots without the dangling memcached cache and ingests an OTLP span", async () => {
43
+ const repo = findRepoRoot();
44
+ if (!repo)
45
+ throw new Error("repo root not found");
46
+ const tempoConfig = fs.readFileSync(path.join(repo, "infra", "metrics", "tempo.yaml"), "utf8");
47
+ expect(tempoConfig).not.toContain("memcached:11211");
48
+ const traceId = hex(16);
49
+ const spanId = hex(8);
50
+ const now = BigInt(Date.now()) * 1000000n;
51
+ const response = await fetch(TEMPO_OTLP, {
52
+ method: "POST",
53
+ headers: { "content-type": "application/json" },
54
+ body: JSON.stringify({
55
+ resourceSpans: [
56
+ {
57
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "blok-tempo-live-test" } }] },
58
+ scopeSpans: [
59
+ {
60
+ scope: { name: "blokctl-live-test" },
61
+ spans: [
62
+ {
63
+ traceId,
64
+ spanId,
65
+ name: "tempo-memcached-regression",
66
+ kind: 1,
67
+ startTimeUnixNano: now.toString(),
68
+ endTimeUnixNano: (now + 1000000n).toString(),
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ }),
76
+ });
77
+ expect(response.status).toBe(200);
78
+ const trace = await waitForTrace(traceId);
79
+ expect(trace.ok).toBe(true);
80
+ const traceBody = await trace.text();
81
+ expect(traceBody).toContain("blok-tempo-live-test");
82
+ expect(traceBody).toContain("tempo-memcached-regression");
83
+ const logs = dockerLogs("tempo");
84
+ expect(logs.toLowerCase()).not.toContain("memcached");
85
+ });
86
+ });
@@ -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 = {};