blokctl 0.3.0 → 0.6.1

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 (30) hide show
  1. package/dist/commands/check/index.d.ts +2 -0
  2. package/dist/commands/check/index.js +39 -0
  3. package/dist/commands/create/project.js +20 -5
  4. package/dist/commands/create/utils/Examples.d.ts +2 -2
  5. package/dist/commands/create/utils/Examples.js +28 -31
  6. package/dist/commands/dev/index.js +40 -57
  7. package/dist/commands/generate/GenerationAnalytics.js +2 -1
  8. package/dist/commands/generate/e2e/NodeGenerator.e2e.test.js +1 -1
  9. package/dist/commands/generate/e2e/TriggerGenerator.e2e.test.js +1 -1
  10. package/dist/commands/generate/e2e/WorkflowGenerator.e2e.test.js +1 -1
  11. package/dist/commands/migrate/index.js +11 -0
  12. package/dist/commands/migrate/paths.d.ts +2 -0
  13. package/dist/commands/migrate/paths.js +267 -0
  14. package/dist/index.js +14 -1
  15. package/dist/services/local-token-manager.js +1 -1
  16. package/dist/services/runtime-detector.d.ts +1 -0
  17. package/dist/services/runtime-detector.js +12 -0
  18. package/dist/services/runtime-setup.d.ts +12 -1
  19. package/dist/services/runtime-setup.js +28 -0
  20. package/dist/services/semver-utils.d.ts +18 -0
  21. package/dist/services/semver-utils.js +82 -0
  22. package/dist/services/semver-utils.test.d.ts +1 -0
  23. package/dist/services/semver-utils.test.js +233 -0
  24. package/dist/studio-dist/assets/{icons-N5J4OhGx.js → icons-D-BBts99.js} +110 -65
  25. package/dist/studio-dist/assets/index-BD8_9YPN.js +42 -0
  26. package/dist/studio-dist/assets/index-D4Bc9-mb.css +1 -0
  27. package/dist/studio-dist/index.html +3 -3
  28. package/package.json +2 -2
  29. package/dist/studio-dist/assets/index-D6JA5F-X.js +0 -42
  30. package/dist/studio-dist/assets/index-mdQkg9ul.css +0 -1
@@ -0,0 +1,267 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import color from "picocolors";
4
+ export async function migratePaths(opts) {
5
+ const cwd = process.cwd();
6
+ const explicitDir = opts.dir ?? null;
7
+ const dryRun = opts.dryRun === true;
8
+ const writeBackup = opts.backup !== false;
9
+ console.log(color.cyan("\nšŸ›£ Workflow path migrator"));
10
+ console.log(color.dim("Adds explicit `trigger.http.path` to every JSON HTTP workflow.\n"));
11
+ const root = await resolveJsonRoot(cwd, explicitDir);
12
+ if (!root) {
13
+ console.log(color.red("āŒ Could not find a JSON workflows directory. Looked in: " +
14
+ "workflows/json/, triggers/http/workflows/json/. Pass --dir <path> to override."));
15
+ process.exit(1);
16
+ }
17
+ console.log(color.dim(`Scanning ${color.cyan(root)} (recursive)\n`));
18
+ const files = await collectJsonFiles(root);
19
+ if (files.length === 0) {
20
+ console.log(color.yellow("No JSON workflow files found."));
21
+ return;
22
+ }
23
+ const results = [];
24
+ for (const file of files) {
25
+ const result = await migrateOne(file, root, { dryRun, writeBackup });
26
+ results.push(result);
27
+ printResult(result);
28
+ }
29
+ console.log("");
30
+ printSummary(results, dryRun, writeBackup);
31
+ const tsRoots = [path.join(cwd, "triggers", "http", "src", "workflows"), path.join(cwd, "src", "workflows")];
32
+ for (const tsRoot of tsRoots) {
33
+ if (await dirExists(tsRoot)) {
34
+ const tsFiles = await collectTsFiles(tsRoot);
35
+ if (tsFiles.length > 0) {
36
+ console.log("");
37
+ console.log(color.yellow("⚠ TS workflows detected — these are NOT migrated by this codemod."));
38
+ console.log(color.dim(` Found at: ${tsRoot}`));
39
+ console.log(color.dim(" Migrate them manually: ensure each workflow's `trigger.http.path` is set explicitly."));
40
+ console.log(color.dim(` Files: ${tsFiles.length}`));
41
+ }
42
+ }
43
+ }
44
+ }
45
+ async function migrateOne(file, root, opts) {
46
+ let raw;
47
+ let parsed;
48
+ try {
49
+ raw = await fsp.readFile(file, "utf8");
50
+ parsed = JSON.parse(raw);
51
+ }
52
+ catch (err) {
53
+ return { kind: "error", file, error: err.message };
54
+ }
55
+ if (!isPlainObject(parsed)) {
56
+ return { kind: "error", file, error: "Workflow must be a JSON object" };
57
+ }
58
+ const wf = parsed;
59
+ const trigger = wf.trigger;
60
+ if (!isPlainObject(trigger)) {
61
+ return { kind: "no-trigger", file };
62
+ }
63
+ const httpCfg = trigger.http;
64
+ if (!isPlainObject(httpCfg)) {
65
+ return { kind: "not-http", file };
66
+ }
67
+ const http = httpCfg;
68
+ const existingPath = typeof http.path === "string" ? http.path : undefined;
69
+ const relative = path.relative(root, file);
70
+ const derivedUrl = deriveUrlFromFilePath(relative);
71
+ if (existingPath === undefined) {
72
+ http.path = derivedUrl;
73
+ }
74
+ else if (existingPath === "/" && derivedUrl !== "/") {
75
+ http.path = derivedUrl;
76
+ const serialized = `${JSON.stringify(wf, null, "\t")}\n`;
77
+ if (serialized.trimEnd() === raw.trimEnd()) {
78
+ return { kind: "already-explicit", file, path: derivedUrl };
79
+ }
80
+ const writeResult = await maybeWrite(file, raw, serialized, opts);
81
+ if (writeResult)
82
+ return writeResult;
83
+ return { kind: "rewrote-root", file, from: "/", to: derivedUrl };
84
+ }
85
+ else {
86
+ return { kind: "already-explicit", file, path: existingPath };
87
+ }
88
+ const serialized = `${JSON.stringify(wf, null, "\t")}\n`;
89
+ if (serialized.trimEnd() === raw.trimEnd()) {
90
+ return { kind: "already-explicit", file, path: existingPath ?? derivedUrl };
91
+ }
92
+ const writeResult = await maybeWrite(file, raw, serialized, opts);
93
+ if (writeResult)
94
+ return writeResult;
95
+ return { kind: "added", file, path: http.path };
96
+ }
97
+ async function maybeWrite(file, raw, serialized, opts) {
98
+ if (opts.dryRun)
99
+ return null;
100
+ if (opts.writeBackup) {
101
+ try {
102
+ await fsp.writeFile(`${file}.bak`, raw);
103
+ }
104
+ catch (err) {
105
+ return { kind: "error", file, error: `Failed to write backup: ${err.message}` };
106
+ }
107
+ }
108
+ try {
109
+ await fsp.writeFile(file, serialized);
110
+ }
111
+ catch (err) {
112
+ return { kind: "error", file, error: err.message };
113
+ }
114
+ return null;
115
+ }
116
+ function deriveUrlFromFilePath(relativePath) {
117
+ const noExt = relativePath.replace(/\.json$/i, "");
118
+ const segments = noExt.split(path.sep).filter((s) => s.length > 0);
119
+ if (segments.length === 0)
120
+ return "/";
121
+ if (segments[segments.length - 1] === "index")
122
+ segments.pop();
123
+ if (segments.length === 0)
124
+ return "/";
125
+ const converted = segments.map((seg) => {
126
+ const match = seg.match(/^\[(\.{3})?([A-Za-z_][A-Za-z0-9_]*)\]$/);
127
+ if (!match)
128
+ return seg;
129
+ return `:${match[2]}`;
130
+ });
131
+ return `/${converted.join("/")}`;
132
+ }
133
+ function printResult(result) {
134
+ const rel = path.relative(process.cwd(), result.file);
135
+ switch (result.kind) {
136
+ case "added":
137
+ console.log(` ${color.green("āœ“")} ${rel} ${color.dim("→")} ${color.cyan(`path: "${result.path}"`)}`);
138
+ break;
139
+ case "rewrote-root":
140
+ console.log(` ${color.green("āœ“")} ${rel} ${color.dim("→")} ${color.cyan(`"${result.from}"`)} → ${color.cyan(`"${result.to}"`)}`);
141
+ break;
142
+ case "already-explicit":
143
+ console.log(` ${color.dim("Ā·")} ${rel} ${color.dim(`(already explicit: "${result.path}")`)}`);
144
+ break;
145
+ case "no-trigger":
146
+ console.log(` ${color.dim("Ā·")} ${rel} ${color.dim("(no trigger)")}`);
147
+ break;
148
+ case "not-http":
149
+ console.log(` ${color.dim("Ā·")} ${rel} ${color.dim("(non-HTTP trigger)")}`);
150
+ break;
151
+ case "error":
152
+ console.log(` ${color.red("āœ—")} ${rel} ${color.red(`error: ${result.error}`)}`);
153
+ break;
154
+ }
155
+ }
156
+ function printSummary(results, dryRun, writeBackup) {
157
+ const counts = {
158
+ added: results.filter((r) => r.kind === "added").length,
159
+ rewrote: results.filter((r) => r.kind === "rewrote-root").length,
160
+ already: results.filter((r) => r.kind === "already-explicit").length,
161
+ skipped: results.filter((r) => r.kind === "no-trigger" || r.kind === "not-http").length,
162
+ errors: results.filter((r) => r.kind === "error").length,
163
+ };
164
+ const action = dryRun ? "would be" : "were";
165
+ console.log(color.bold("Summary:"));
166
+ console.log(` ${color.green(`${counts.added} ${action} updated`)} (added explicit path)`);
167
+ if (counts.rewrote > 0)
168
+ console.log(` ${color.green(`${counts.rewrote} ${action} updated`)} (rewrote "/" → file-derived)`);
169
+ console.log(` ${color.dim(`${counts.already} already explicit`)}`);
170
+ if (counts.skipped > 0)
171
+ console.log(` ${color.dim(`${counts.skipped} skipped (non-HTTP / no trigger)`)}`);
172
+ if (counts.errors > 0)
173
+ console.log(` ${color.red(`${counts.errors} errors`)}`);
174
+ if (!dryRun && (counts.added > 0 || counts.rewrote > 0) && writeBackup) {
175
+ console.log("");
176
+ console.log(color.dim("Backups written as <name>.json.bak. Delete them once verified."));
177
+ }
178
+ if (dryRun && (counts.added > 0 || counts.rewrote > 0)) {
179
+ console.log("");
180
+ console.log(color.cyan("Re-run without --dry-run to apply."));
181
+ }
182
+ }
183
+ async function resolveJsonRoot(cwd, explicit) {
184
+ if (explicit) {
185
+ const abs = path.isAbsolute(explicit) ? explicit : path.resolve(cwd, explicit);
186
+ if (await dirExists(abs))
187
+ return abs;
188
+ return null;
189
+ }
190
+ const candidates = [path.join(cwd, "workflows", "json"), path.join(cwd, "triggers", "http", "workflows", "json")];
191
+ for (const c of candidates) {
192
+ if (await dirExists(c))
193
+ return c;
194
+ }
195
+ return null;
196
+ }
197
+ async function dirExists(p) {
198
+ try {
199
+ const stat = await fsp.stat(p);
200
+ return stat.isDirectory();
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
206
+ async function collectJsonFiles(root) {
207
+ const out = [];
208
+ await walkJson(root, out);
209
+ out.sort();
210
+ return out;
211
+ }
212
+ async function collectTsFiles(root) {
213
+ const out = [];
214
+ await walkTs(root, out);
215
+ out.sort();
216
+ return out;
217
+ }
218
+ async function walkJson(dir, out) {
219
+ let entries;
220
+ try {
221
+ entries = await fsp.readdir(dir, { withFileTypes: true });
222
+ }
223
+ catch {
224
+ return;
225
+ }
226
+ for (const entry of entries) {
227
+ if (entry.name.startsWith(".") || entry.name.startsWith("_"))
228
+ continue;
229
+ const full = path.join(dir, entry.name);
230
+ if (entry.isDirectory()) {
231
+ await walkJson(full, out);
232
+ }
233
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".json")) {
234
+ out.push(full);
235
+ }
236
+ }
237
+ }
238
+ async function walkTs(dir, out) {
239
+ let entries;
240
+ try {
241
+ entries = await fsp.readdir(dir, { withFileTypes: true });
242
+ }
243
+ catch {
244
+ return;
245
+ }
246
+ for (const entry of entries) {
247
+ if (entry.name.startsWith(".") || entry.name.startsWith("_") || entry.name === "node_modules")
248
+ continue;
249
+ const full = path.join(dir, entry.name);
250
+ if (entry.isDirectory()) {
251
+ await walkTs(full, out);
252
+ }
253
+ else if (entry.isFile() &&
254
+ (entry.name.toLowerCase().endsWith(".ts") || entry.name.toLowerCase().endsWith(".js")) &&
255
+ entry.name !== "index.ts" &&
256
+ entry.name !== "index.js") {
257
+ out.push(full);
258
+ }
259
+ }
260
+ }
261
+ function isPlainObject(value) {
262
+ if (value === null || value === undefined)
263
+ return false;
264
+ if (Array.isArray(value))
265
+ return false;
266
+ return typeof value === "object";
267
+ }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import util from "node:util";
5
5
  import * as p from "@clack/prompts";
6
6
  import fsExtra from "fs-extra";
7
7
  import color from "picocolors";
8
+ import { checkProject } from "./commands/check/index.js";
8
9
  import { createNode } from "./commands/create/node.js";
9
10
  import { createProject } from "./commands/create/project.js";
10
11
  import { createWorkflow } from "./commands/create/workflow.js";
@@ -165,10 +166,22 @@ async function main() {
165
166
  create.addCommand(node);
166
167
  create.addCommand(workflow);
167
168
  program.addCommand(create);
169
+ program
170
+ .command("check")
171
+ .description("Validate runtime version requirements")
172
+ .action(async (options) => {
173
+ await analytics.trackCommandExecution({
174
+ command: "check",
175
+ args: options,
176
+ execution: async () => {
177
+ await checkProject(options);
178
+ },
179
+ });
180
+ });
168
181
  program
169
182
  .command("dev")
170
183
  .description("Start the development server")
171
- .option("--with-http-fallback", "Spawn SDKs on HTTP transport instead of gRPC. Use only when one of your SDKs lacks a working gRPC listener (e.g. PHP without RoadRunner). Removed in v0.4.0.")
184
+ .option("--skip-version-check", "Skip runtime version validation")
172
185
  .action(async (options) => {
173
186
  await analytics.trackCommandExecution({
174
187
  command: "dev",
@@ -13,7 +13,7 @@ class LocalTokenManager {
13
13
  ensureStorage() {
14
14
  try {
15
15
  if (!fs.existsSync(this.storageDir)) {
16
- fs.mkdirSync(this.storageDir, { mode: 0o700 });
16
+ fs.mkdirSync(this.storageDir, { mode: 0o700, recursive: true });
17
17
  }
18
18
  else {
19
19
  fs.chmodSync(this.storageDir, 0o700);
@@ -22,5 +22,6 @@ export interface RuntimeInfo {
22
22
  }
23
23
  export declare function detectRr(): string | null;
24
24
  export declare function detectRuntimes(): Promise<RuntimeInfo[]>;
25
+ export declare function detectRuntimeVersion(kind: string): Promise<string | undefined>;
25
26
  export declare function getRuntimeDefinition(kind: string): Omit<RuntimeInfo, "available" | "version"> | undefined;
26
27
  export declare function getAllRuntimeDefinitions(): Omit<RuntimeInfo, "available" | "version">[];
@@ -192,6 +192,18 @@ export async function detectRuntimes() {
192
192
  }
193
193
  return results;
194
194
  }
195
+ export async function detectRuntimeVersion(kind) {
196
+ const def = RUNTIME_DEFINITIONS.find((r) => r.kind === kind);
197
+ if (!def)
198
+ return undefined;
199
+ for (const cmd of def.commands) {
200
+ const output = await tryExec(cmd);
201
+ if (output) {
202
+ return parseVersion(output, def.kind);
203
+ }
204
+ }
205
+ return undefined;
206
+ }
195
207
  export function getRuntimeDefinition(kind) {
196
208
  return RUNTIME_DEFINITIONS.find((r) => r.kind === kind);
197
209
  }
@@ -12,7 +12,9 @@ export interface RuntimeConfig {
12
12
  cwd: string;
13
13
  kind: string;
14
14
  label: string;
15
- transport?: "grpc" | "http";
15
+ version?: string;
16
+ requiredVersion?: string;
17
+ transport?: "grpc";
16
18
  }
17
19
  export interface TriggerConfig {
18
20
  kind: string;
@@ -31,6 +33,15 @@ export declare function writeProjectConfig(projectDir: string, runtimeConfigs: R
31
33
  export declare function readProjectConfig(projectDir: string): ProjectConfig | null;
32
34
  export declare function generateRuntimeEnvVars(runtimeConfigs: RuntimeConfig[]): string;
33
35
  export declare function generateSupervisordConfig(runtimeConfigs: RuntimeConfig[]): string;
36
+ export interface RuntimeValidationResult {
37
+ kind: string;
38
+ label: string;
39
+ required: string;
40
+ found: string | undefined;
41
+ satisfied: boolean;
42
+ message: string;
43
+ }
44
+ export declare function validateProjectRuntimes(projectDir: string): Promise<RuntimeValidationResult[]>;
34
45
  export declare function getTriggerPort(triggerKind: string): number;
35
46
  export declare function getTriggerLabel(triggerKind: string): string;
36
47
  export declare function createTriggerConfig(triggerKind: string): TriggerConfig;
@@ -2,6 +2,8 @@ import child_process from "node:child_process";
2
2
  import path from "node:path";
3
3
  import util from "node:util";
4
4
  import fsExtra from "fs-extra";
5
+ import { detectRuntimeVersion } from "./runtime-detector.js";
6
+ import { computeDefaultConstraint, formatVersionMismatch, formatVersionSuccess, satisfiesConstraint, } from "./semver-utils.js";
5
7
  const exec = util.promisify(child_process.exec);
6
8
  export async function setupRuntime(runtime, githubRepoLocal, projectDir, spinner) {
7
9
  const sdkSourcePath = path.join(githubRepoLocal, "sdks", runtime.sdkDir);
@@ -49,6 +51,8 @@ export async function setupRuntime(runtime, githubRepoLocal, projectDir, spinner
49
51
  cwd: path.relative(projectDir, blokctlRuntimeDir),
50
52
  kind: runtime.kind,
51
53
  label: runtime.label,
54
+ version: runtime.version,
55
+ requiredVersion: runtime.version ? computeDefaultConstraint(runtime.version) : undefined,
52
56
  transport: "grpc",
53
57
  };
54
58
  }
@@ -194,6 +198,30 @@ stdout_logfile=/var/log/${rc.kind}.out.log
194
198
  }
195
199
  return config;
196
200
  }
201
+ export async function validateProjectRuntimes(projectDir) {
202
+ const config = readProjectConfig(projectDir);
203
+ if (!config?.runtimes)
204
+ return [];
205
+ const results = [];
206
+ for (const [kind, rc] of Object.entries(config.runtimes)) {
207
+ if (!rc.requiredVersion)
208
+ continue;
209
+ const currentVersion = await detectRuntimeVersion(kind);
210
+ const satisfied = currentVersion ? satisfiesConstraint(currentVersion, rc.requiredVersion) : false;
211
+ const message = satisfied
212
+ ? formatVersionSuccess(rc.label, currentVersion, rc.requiredVersion)
213
+ : formatVersionMismatch(rc.label, currentVersion, rc.requiredVersion);
214
+ results.push({
215
+ kind,
216
+ label: rc.label,
217
+ required: rc.requiredVersion,
218
+ found: currentVersion,
219
+ satisfied,
220
+ message,
221
+ });
222
+ }
223
+ return results;
224
+ }
197
225
  const TRIGGER_PORTS = {
198
226
  http: 4000,
199
227
  sse: 4001,
@@ -0,0 +1,18 @@
1
+ export interface SemverParts {
2
+ major: number;
3
+ minor: number;
4
+ patch: number;
5
+ }
6
+ export interface ParsedConstraint {
7
+ operator: ">=" | "^" | "~" | "=" | "";
8
+ version: string;
9
+ parts: SemverParts;
10
+ }
11
+ export declare function parseSemver(version: string): SemverParts;
12
+ export declare function compareSemver(a: string, b: string): number;
13
+ export declare function semverGte(a: SemverParts, b: SemverParts): boolean;
14
+ export declare function parseConstraint(constraint: string): ParsedConstraint;
15
+ export declare function satisfiesConstraint(version: string, constraint: string): boolean;
16
+ export declare function computeDefaultConstraint(version: string): string;
17
+ export declare function formatVersionMismatch(runtime: string, found: string | undefined, required: string, installHint?: string): string;
18
+ export declare function formatVersionSuccess(runtime: string, found: string, required: string): string;
@@ -0,0 +1,82 @@
1
+ export function parseSemver(version) {
2
+ const cleaned = version.trim();
3
+ const parts = cleaned.split(".").map(Number);
4
+ return {
5
+ major: parts[0] || 0,
6
+ minor: parts[1] || 0,
7
+ patch: parts[2] || 0,
8
+ };
9
+ }
10
+ export function compareSemver(a, b) {
11
+ const pa = parseSemver(a);
12
+ const pb = parseSemver(b);
13
+ if (pa.major !== pb.major)
14
+ return pa.major - pb.major;
15
+ if (pa.minor !== pb.minor)
16
+ return pa.minor - pb.minor;
17
+ return pa.patch - pb.patch;
18
+ }
19
+ export function semverGte(a, b) {
20
+ if (a.major !== b.major)
21
+ return a.major > b.major;
22
+ if (a.minor !== b.minor)
23
+ return a.minor > b.minor;
24
+ return a.patch >= b.patch;
25
+ }
26
+ export function parseConstraint(constraint) {
27
+ const trimmed = constraint.trim();
28
+ if (trimmed.startsWith(">=")) {
29
+ const ver = trimmed.slice(2);
30
+ return { operator: ">=", version: ver, parts: parseSemver(ver) };
31
+ }
32
+ if (trimmed.startsWith("^")) {
33
+ const ver = trimmed.slice(1);
34
+ return { operator: "^", version: ver, parts: parseSemver(ver) };
35
+ }
36
+ if (trimmed.startsWith("~")) {
37
+ const ver = trimmed.slice(1);
38
+ return { operator: "~", version: ver, parts: parseSemver(ver) };
39
+ }
40
+ const ver = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed;
41
+ return { operator: trimmed.startsWith("=") ? "=" : "", version: ver, parts: parseSemver(ver) };
42
+ }
43
+ export function satisfiesConstraint(version, constraint) {
44
+ const actual = parseSemver(version);
45
+ const parsed = parseConstraint(constraint);
46
+ const base = parsed.parts;
47
+ switch (parsed.operator) {
48
+ case ">=":
49
+ return semverGte(actual, base);
50
+ case "^": {
51
+ if (base.major !== 0) {
52
+ return actual.major === base.major && semverGte(actual, base);
53
+ }
54
+ if (base.minor !== 0) {
55
+ return actual.major === 0 && actual.minor === base.minor && actual.patch >= base.patch;
56
+ }
57
+ return actual.major === 0 && actual.minor === 0 && actual.patch === base.patch;
58
+ }
59
+ case "~":
60
+ return actual.major === base.major && actual.minor === base.minor && actual.patch >= base.patch;
61
+ case "=":
62
+ case "":
63
+ return actual.major === base.major && actual.minor === base.minor && actual.patch === base.patch;
64
+ default:
65
+ return false;
66
+ }
67
+ }
68
+ export function computeDefaultConstraint(version) {
69
+ const parts = parseSemver(version);
70
+ return `>=${parts.major}.${parts.minor}.0`;
71
+ }
72
+ export function formatVersionMismatch(runtime, found, required, installHint) {
73
+ const lines = [` x ${runtime}`, ` Required: ${required}`, ` Found: ${found || "not installed"}`];
74
+ if (installHint) {
75
+ lines.push(` Fix: ${installHint}`);
76
+ }
77
+ lines.push(" Or update constraint in .blok/config.json");
78
+ return lines.join("\n");
79
+ }
80
+ export function formatVersionSuccess(runtime, found, required) {
81
+ return ` āœ“ ${runtime} ${found} (requires ${required})`;
82
+ }
@@ -0,0 +1 @@
1
+ export {};