blokctl 0.6.16 → 0.6.18

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.
@@ -17,7 +17,7 @@ const exec = util.promisify(child_process.exec);
17
17
  const HOME_DIR = `${os.homedir()}/.blok`;
18
18
  const GITHUB_REPO_LOCAL = `${HOME_DIR}/blok`;
19
19
  const GITHUB_REPO_REMOTE = "https://github.com/well-prado/blok.git";
20
- const GITHUB_REPO_RELEASE_TAG = "v0.6.16";
20
+ const GITHUB_REPO_RELEASE_TAG = "v0.6.18";
21
21
  fsExtra.ensureDirSync(HOME_DIR);
22
22
  const options = {
23
23
  baseDir: HOME_DIR,
@@ -487,7 +487,7 @@ export async function createProject(opts, version, currentPath = false, localRep
487
487
  "@blokjs/trigger-websocket": "triggers/websocket",
488
488
  "@blokjs/trigger-worker": "triggers/worker",
489
489
  };
490
- const BLOKJS_DEP_RANGE = "^0.6.16";
490
+ const BLOKJS_DEP_RANGE = "^0.6.18";
491
491
  for (const depGroup of ["dependencies", "devDependencies", "peerDependencies"]) {
492
492
  const deps = packageJsonContent[depGroup];
493
493
  if (!deps)
@@ -35,7 +35,7 @@ declare const php_dockerfile = "FROM php:8.2-cli-alpine AS builder\nWORKDIR /app
35
35
  declare const ruby_node_file = "require_relative '../../lib/blok'\n\nmodule Blok\n module Nodes\n class {{NODE_NAME_PASCAL}}Node < Blok::NodeHandler\n def execute(ctx, config)\n # Access request body\n name = ctx.request.body.is_a?(Hash) ? ctx.request.body['name'] : nil\n name ||= 'World'\n\n # Access configuration\n prefix = config['prefix'] || 'Hello'\n\n message = \"#{prefix}, #{name}!\"\n\n # Store in context for downstream nodes\n ctx.vars['greeting'] = message\n\n # Return response\n {\n 'message' => message,\n 'timestamp' => Time.now.utc.iso8601,\n 'language' => 'Ruby'\n }\n end\n end\n end\nend\n";
36
36
  declare const ruby_gemfile = "source 'https://rubygems.org'\n\nruby '>= 3.1'\n\ngem 'sinatra', '~> 4.0'\ngem 'puma', '~> 6.4'\ngem 'rackup', '~> 2.1'\n";
37
37
  declare const ruby_dockerfile = "FROM ruby:3.2-alpine AS builder\nRUN apk add --no-cache build-base\nWORKDIR /app\nCOPY Gemfile Gemfile.lock ./\nRUN bundle install --without development test\n\nFROM ruby:3.2-alpine\nRUN apk --no-cache add ca-certificates wget\nWORKDIR /app\nCOPY --from=builder /usr/local/bundle /usr/local/bundle\nCOPY . .\n\nEXPOSE 8080\nENV PORT=8080\nENV RACK_ENV=production\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1\n\nCMD [\"bundle\", \"exec\", \"puma\", \"-b\", \"tcp://0.0.0.0:8080\"]\n";
38
- declare const agents_md = "# Blok Project\n\nBlok is a TypeScript-first workflow orchestration framework. It executes declarative workflows (JSON or TypeScript DSL) composed of steps (nodes) that run across 8 language runtimes: NodeJS, Python3, Go, Rust, Java, C#, PHP, and Ruby.\n\n## Project Structure\n\n```\n\u251C\u2500\u2500 src/\n\u2502 \u2514\u2500\u2500 nodes/ # TypeScript node implementations\n\u251C\u2500\u2500 runtimes/ # Non-NodeJS runtime nodes (Go, Python3, etc.)\n\u2502 \u2514\u2500\u2500 {lang}/nodes/ # Language-specific node implementations\n\u251C\u2500\u2500 workflows/\n\u2502 \u251C\u2500\u2500 json/ # Workflow definitions (JSON)\n\u2502 \u251C\u2500\u2500 yaml/ # Workflow definitions (YAML)\n\u2502 \u2514\u2500\u2500 toml/ # Workflow definitions (TOML)\n\u251C\u2500\u2500 .blok/\n\u2502 \u251C\u2500\u2500 config.json # Runtime configuration (ports, start commands)\n\u2502 \u2514\u2500\u2500 runtimes/ # Auto-generated runtime scaffolds\n\u251C\u2500\u2500 .env.local # Environment variables (ports, paths)\n\u2514\u2500\u2500 supervisord.conf # Process management config\n```\n\n## Commands\n\n```bash\nnpm run dev # Start dev server (or blokctl dev for multi-runtime)\nnpm run build # Build project\nnpm test # Run tests\nblokctl create node <name> # Scaffold a new node\nblokctl create workflow <n># Scaffold a new workflow\nblokctl trace # Open Blok Studio (trace visualization)\nblokctl studio # Alias for blokctl trace\n```\n\n## Context \u2014 Critical Data Flow\n\nThe Context type is the central execution state passed through every step.\n\n```typescript\ntype Context = {\n id: string; // Unique request ID\n request: RequestContext; // Incoming request (body, headers, params, query)\n response: ResponseContext; // Current step output \u2014 OVERWRITTEN every step\n vars?: VarsContext; // Persistent variables \u2014 PERSISTS across workflow\n config: ConfigContext; // Node config (inputs resolved by Mapper)\n env?: EnvContext; // process.env access\n logger: LoggerContext;\n error: ErrorContext;\n};\n```\n\n### The Two Critical Rules\n\n**Rule 1: \\`ctx.prev\\` carries the immediately previous step's output.**\nEach step's output replaces \\`ctx.prev\\`. Use it for adjacent-step access only.\n\n**Rule 2: \\`ctx.state[id]\\` PERSISTS across the entire workflow.**\nEvery step's output is auto-stored at \\`ctx.state[<step-id>]\\` (the v2 default-store rule). Downstream steps reference it via \\`$.state.<id>\\` (TS DSL) or \\`\"$.state.<id>\"\\` / \\`\"js/ctx.state.<id>\"\\` (JSON). Opt out per step with \\`ephemeral: true\\`.\n\n### Data Flow Example\n\n```\nStep 1: id \"fetch-user\"\n \u2192 ctx.state[\"fetch-user\"] = { id: \"123\", name: \"Alice\" }\n \u2192 ctx.prev = { id: \"123\", name: \"Alice\" }\n\nStep 2: id \"transform\"\n \u2192 ctx.state[\"transform\"] = { result: \"done\" }\n \u2192 ctx.prev = { result: \"done\" } \u2190 Step 1 output GONE from prev\n \u2192 ctx.state[\"fetch-user\"] still available\n\nStep 3: id \"output\"\n \u2192 Can read ctx.state[\"fetch-user\"].name \u2190 still \"Alice\"\n```\n\n### Blueprint Mapper \u2014 Expression Resolution\n\nNode inputs support dynamic expressions resolved BEFORE node execution:\n\n```json\n{\n \"inputs\": {\n \"userId\": \"js/ctx.request.body.userId\",\n \"chain\": \"js/ctx.vars['previous-step'].chain\",\n \"previous\": \"js/ctx.response.data.result\"\n }\n}\n```\n\nAvailable in js/ expressions: \\`ctx\\` (full context), \\`data\\` (ctx.prev.data), \\`func\\` (ctx.func), \\`vars\\` (alias for ctx.state).\n\n---\n\n## Creating Nodes with defineNode\n\nUse \\`defineNode()\\` for all new nodes. Never use the legacy class-based pattern.\n\n```typescript\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches user by ID\",\n\n input: z.object({\n userId: z.string().uuid(),\n }),\n\n output: z.object({\n user: z.object({\n id: z.string(),\n name: z.string(),\n email: z.string().email(),\n }),\n }),\n\n async execute(ctx, input) {\n const user = await fetchUser(input.userId);\n return { user };\n },\n});\n```\n\n### Key Behaviors\n\n- Zod input/output validation runs automatically\n- ZodError is mapped to GlobalError with HTTP 400\n- \\`flow: true\\` nodes return NodeBase[] for conditional execution\n- \\`contentType\\` sets response Content-Type (e.g., \"text/html\")\n- Always \\`export default defineNode(...)\\`\n\n---\n\n## Workflow Structure (JSON)\n\n```json\n{\n \"name\": \"My Workflow\",\n \"version\": \"1.0.0\",\n \"trigger\": {\n \"http\": { \"method\": \"POST\", \"path\": \"/api/process\", \"accept\": \"application/json\" }\n },\n \"steps\": [\n { \"id\": \"fetch\", \"use\": \"@blokjs/api-call\", \"inputs\": { \"url\": \"https://api.example.com\", \"method\": \"GET\" } },\n { \"id\": \"process\", \"use\": \"my-node\", \"inputs\": { \"data\": \"$.state.fetch\" } },\n { \"id\": \"go-step\", \"use\": \"chain-test\", \"type\": \"runtime.go\", \"inputs\": { \"processed\": \"$.state.process\" } }\n ]\n}\n```\n\n### Step Types\n\n| Type | Description |\n|------|-------------|\n| \\`module\\` | TypeScript node from registered modules |\n| \\`local\\` | TypeScript node from filesystem (NODES_PATH) |\n| \\`runtime.python3\\` | Python3 SDK container (port 9007) |\n| \\`runtime.go\\` | Go SDK container (port 9001) |\n| \\`runtime.rust\\` | Rust SDK container (port 9002) |\n| \\`runtime.java\\` | Java SDK container (port 9003) |\n| \\`runtime.csharp\\` | C# SDK container (port 9004) |\n| \\`runtime.php\\` | PHP SDK container (port 9005) |\n| \\`runtime.ruby\\` | Ruby SDK container (port 9006) |\n\n### Conditional Workflow (if-else)\n\n```json\n{\n \"nodes\": {\n \"filter\": {\n \"conditions\": [\n {\n \"type\": \"if\",\n \"condition\": \"ctx.request.query.active === \\\\\"true\\\\\"\",\n \"steps\": [{ \"name\": \"active-path\", \"node\": \"handle-active\", \"type\": \"module\" }]\n },\n {\n \"type\": \"else\",\n \"steps\": [{ \"name\": \"default-path\", \"node\": \"handle-default\", \"type\": \"module\" }]\n }\n ]\n }\n }\n}\n```\n\n---\n\n## Trigger Types\n\n| Trigger | Example Config |\n|---------|---------------|\n| \\`http\\` | \\`{ \"method\": \"GET\", \"path\": \"/\", \"accept\": \"application/json\" }\\` |\n| \\`grpc\\` | \\`{ \"service\": \"UserService\", \"method\": \"GetUser\" }\\` |\n| \\`cron\\` | \\`{ \"schedule\": \"0 * * * *\", \"timezone\": \"UTC\" }\\` |\n| \\`queue\\` | \\`{ \"provider\": \"kafka\", \"topic\": \"events\" }\\` |\n| \\`pubsub\\` | \\`{ \"provider\": \"gcp\", \"topic\": \"updates\" }\\` |\n| \\`webhook\\` | \\`{ \"source\": \"github\", \"events\": [\"push\"] }\\` |\n| \\`websocket\\` | \\`{ \"events\": [\"message\"], \"path\": \"/ws\" }\\` |\n| \\`sse\\` | \\`{ \"events\": [\"update\"], \"path\": \"/stream\" }\\` |\n| \\`worker\\` | \\`{ \"queue\": \"jobs\", \"concurrency\": 5, \"retries\": 3 }\\` |\n\n### Worker Trigger\n\nThe worker trigger processes background jobs from a queue with retry logic and concurrency control.\n\n\\`\\`\\`typescript\nWorkflow({ name: \"Process Job\", version: \"1.0.0\" })\n .addTrigger(\"worker\", { queue: \"background-jobs\" })\n .addStep({\n name: \"process\",\n node: \"my-processor\",\n type: \"module\",\n inputs: { payload: \"js/ctx.request.body\", jobId: \"js/ctx.request.params.jobId\" },\n });\n\\`\\`\\`\n\nJob context: \\`ctx.request.body\\` = payload, \\`ctx.request.params.queue\\` = queue name, \\`ctx.request.params.jobId\\` = job ID, \\`ctx.request.params.attempt\\` = attempt count, \\`ctx.vars._worker_job\\` = full metadata.\n\nAdapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev only).\n\n### NATS JetStream\n\nRecommended queue/worker backend. Environment variables:\n\\`\\`\\`\nNATS_SERVERS=localhost:4222\nNATS_STREAM_NAME=blok-queue # or blok-worker for worker trigger\nNATS_TOKEN= # optional auth\n\\`\\`\\`\n\nQueue providers: \\`kafka\\`, \\`rabbitmq\\`, \\`sqs\\`, \\`redis\\`, \\`beanstalk\\`, \\`nats\\`\n\n### Standalone Workers (Go, Rust, Python)\n\nGo, Rust, and Python SDKs include standalone NATS workers that connect directly to NATS without the TypeScript runner:\n\n\\`\\`\\`\nWORKER_CONCURRENCY=1 # Max concurrent jobs\nWORKER_MAX_RETRIES=3 # Max delivery attempts\nWORKER_QUEUES=queue1,queue2 # Queues to consume\n\\`\\`\\`\n\n---\n\n## Testing Utilities\n\n\\`@blokjs/runner\\` provides testing utilities for nodes and workflows.\n\n### NodeTestHarness \u2014 Unit test a single node:\n\\`\\`\\`typescript\nimport { NodeTestHarness } from \"@blokjs/runner\";\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ input: \"data\" });\nharness.assertSuccess(result);\nharness.assertOutput(result, { expected: \"output\" });\n\\`\\`\\`\n\n### WorkflowTestRunner \u2014 Integration test a workflow:\n\\`\\`\\`typescript\nimport { WorkflowTestRunner } from \"@blokjs/runner\";\nconst runner = new WorkflowTestRunner({ verbose: true });\nrunner.registerNode(\"validate\", ValidateNode);\nrunner.mockNode(\"external-api\", async (input) => ({ result: \"mocked\" }));\nrunner.loadWorkflow(workflowDefinition);\nconst result = await runner.execute({ input: \"data\" });\n// result.success, result.output, result.trace, result.nodeResults\n\\`\\`\\`\n\n---\n\n## Runtime Adapter System\n\nAll non-NodeJS SDKs communicate via HTTP:\n- **POST /execute** \u2014 Execute node with context\n- **GET /health** \u2014 Health check\n\nEnvironment variables: \\`RUNTIME_{LANG}_HOST\\` / \\`RUNTIME_{LANG}_PORT\\`\n\nRuntime nodes auto-save \\`result.data\\` to \\`ctx.vars[stepName]\\`.\n\n---\n\n## Blok Studio\n\nReal-time workflow trace visualization UI.\n\n- Launch: \\`blokctl trace\\` or \\`blokctl studio\\`\n- API: \\`/__blok/runs\\`, \\`/__blok/runs/:id\\`, \\`/__blok/runs/:id/stream\\` (SSE)\n- Disable: \\`BLOK_TRACE_ENABLED=false\\`\n\n---\n\n## Do NOT\n\n- Do NOT rely on \\`ctx.response.data\\` for data from non-previous steps \u2014 it gets overwritten\n- Do NOT create class-based nodes \u2014 use \\`defineNode()\\` instead\n- Do NOT use \\`any\\` type \u2014 use \\`unknown\\` and narrow with Zod\n- Do NOT hardcode runtime ports \u2014 use environment variables\n- Do NOT skip Zod input/output schemas\n- Do NOT edit files in \\`.blok/runtimes/\\` \u2014 they are auto-generated\n\n## Do\n\n- Use \\`$.state.<id>\\` (or \\`js/ctx.state.<id>\\`) to pass data between non-adjacent steps \u2014 every step default-stores its output there\n- Opt out per step with \\`ephemeral: true\\` when the step is a side effect only\n- Use Zod schemas for all input/output validation\n- Use \\`defineNode()\\` for all new nodes\n- Handle errors via GlobalError with appropriate HTTP status codes\n- Keep nodes focused \u2014 one responsibility per node\n";
39
- declare const claude_md = "# Blok Project \u2014 Claude Code Guide\n\nRead \\`AGENTS.md\\` for full architecture and API details. This file contains Claude-specific guidance.\n\n## Quick Commands\n\n\\`\\`\\`bash\nnpm run dev # Start dev server\nblokctl dev # Multi-runtime dev server\nblokctl create node <name> # Scaffold new node\nblokctl create workflow <name> # Scaffold new workflow\nblokctl trace # Open Blok Studio\nnpm test # Run tests\n\\`\\`\\`\n\n## Context Rules (Memorize These)\n\n1. **\\`ctx.prev\\` is the immediately previous step's output.** Overwritten every step.\n2. **\\`ctx.state[<id>]\\` PERSISTS across the workflow.** Every step default-stores its output there; reference via \\`$.state.<id>\\` or \\`js/ctx.state.<id>\\`. Opt out with \\`ephemeral: true\\`.\n3. **Blueprint Mapper resolves \\`$.<path>\\` and \\`js/\\` expressions BEFORE node execution.**\n\nWhen users have data flow issues, check these three things first.\n\n## Debugging Workflows\n\n1. **Verify structure**: Every step has an \\`id\\` and a \\`use\\` (v2). v1's \\`name\\` + \\`nodes{}\\` still works but is normalized at load time.\n2. **Trace data flow**: Does the target step reference the correct source id (\\`$.state.<id>\\`)? Did the source step have \\`ephemeral: true\\` accidentally?\n3. **Check runtimes**: SDK containers running? \\`GET http://localhost:{port}/health\\`\n4. **Check Studio traces**: \\`/__blok/runs/:id\\` shows step-by-step inputs/outputs/errors\n\n### Common Errors\n\n| Error | Fix |\n|-------|-----|\n| \\`Node type X not found\\` | Wrong \\`type\\` in step \u2014 use module, local, or runtime.* |\n| \\`Validation failed\\` | Zod schema mismatch \u2014 check input schema vs actual data |\n| \\`Runtime execution error\\` | SDK container not running \u2014 check health endpoint |\n| \\`ctx.state['X'] undefined\\` | Source step has \\`ephemeral: true\\`, or the id doesn't match what's referenced in \\`$.state.<id>\\` |\n| \\`set_var, which was removed in v0.5\\` | Drop \\`set_var: true\\` (it's the default) or replace \\`set_var: false\\` with \\`ephemeral: true\\`. Run \\`blokctl migrate workflows\\`. |\n\n## Generating Code\n\nAlways use \\`defineNode()\\`. Never class-based BlokService.\n\n\\`\\`\\`typescript\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"node-name\",\n description: \"What this node does\",\n input: z.object({ /* Zod schema */ }),\n output: z.object({ /* Zod schema */ }),\n async execute(ctx, input) {\n return { /* must match output schema */ };\n },\n});\n\\`\\`\\`\n\n### Checklist:\n- Zod input schema covers all inputs\n- Zod output schema matches execute() return\n- Node name matches workflow references\n- No \\`any\\` types \u2014 use \\`z.unknown()\\` if dynamic\n- \\`export default defineNode(...)\\`\n\n## Worker Workflows\n\nWorker trigger processes background jobs from a queue:\n\n\\`\\`\\`typescript\nWorkflow({ name: \"Process Job\", version: \"1.0.0\" })\n .addTrigger(\"worker\", { queue: \"background-jobs\" })\n .addStep({ name: \"process\", node: \"my-processor\", type: \"module\",\n inputs: { payload: \"js/ctx.request.body\", jobId: \"js/ctx.request.params.jobId\" } });\n\\`\\`\\`\n\nJob data: \\`ctx.request.body\\` = payload, \\`ctx.request.params.queue/jobId/attempt\\` = metadata.\nAdapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev).\n\n## Testing\n\n\\`\\`\\`typescript\nimport { NodeTestHarness, WorkflowTestRunner } from \"@blokjs/runner\";\n\n// Unit test a node\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ input: \"data\" });\nharness.assertSuccess(result);\n\n// Integration test a workflow\nconst runner = new WorkflowTestRunner({ mockAllNodes: true });\nrunner.loadWorkflow(definition);\nconst wfResult = await runner.execute({ input: \"data\" });\n\\`\\`\\`\n\n## Blok Studio Help\n\n- Launch: \\`blokctl trace\\` or navigate to \\`/__blok\\`\n- \"No output\" \u2192 Node not returning data or Zod output validation failed\n- \"Step error\" \u2192 Expand error \u2014 check if 400 (validation) or 500 (runtime)\n- \"State not passing\" \u2192 Source step has \\`ephemeral: true\\`, OR target's \\`$.state.<id>\\` references a non-existent step id\n\n## Debugging Workers\n\n- NATS not reachable \u2192 Check \\`NATS_SERVERS\\` env var, ensure NATS is running\n- Job timeout \u2192 Increase \\`timeout\\` in trigger config or optimize node\n- Max retries exceeded \u2192 Check node errors, job moves to DLQ\n\n## Do NOT\n\n- Do NOT suggest class-based BlokService for new nodes\n- Do NOT generate code with \\`any\\` types\n- Do NOT assume \\`ctx.response.data\\` persists across steps\n- Do NOT skip Zod schemas when creating nodes\n- Do NOT edit files in \\`.blok/runtimes/\\`\n";
38
+ declare const agents_md = "# Blok Project\n\nBlok is a TypeScript-first workflow orchestration framework. It executes declarative workflows (JSON or TypeScript DSL) composed of steps (nodes) that run across 8 language runtimes: NodeJS, Python3, Go, Rust, Java, C#, PHP, and Ruby.\n\n## Project Structure\n\n```\n\u251C\u2500\u2500 src/\n\u2502 \u2514\u2500\u2500 nodes/ # TypeScript node implementations\n\u251C\u2500\u2500 runtimes/ # Non-NodeJS runtime nodes (Go, Python3, etc.)\n\u2502 \u2514\u2500\u2500 {lang}/nodes/ # Language-specific node implementations\n\u251C\u2500\u2500 workflows/\n\u2502 \u251C\u2500\u2500 json/ # Workflow definitions (JSON)\n\u2502 \u251C\u2500\u2500 yaml/ # Workflow definitions (YAML)\n\u2502 \u2514\u2500\u2500 toml/ # Workflow definitions (TOML)\n\u251C\u2500\u2500 .blok/\n\u2502 \u251C\u2500\u2500 config.json # Runtime configuration (ports, start commands)\n\u2502 \u2514\u2500\u2500 runtimes/ # Auto-generated runtime scaffolds\n\u251C\u2500\u2500 .env.local # Environment variables (ports, paths)\n\u2514\u2500\u2500 supervisord.conf # Process management config\n```\n\n## Commands\n\n```bash\nnpm run dev # Start dev server (or blokctl dev for multi-runtime)\nnpm run build # Build project\nnpm test # Run tests\nblokctl create node <name> # Scaffold a new node\nblokctl create workflow <n># Scaffold a new workflow\nblokctl trace # Open Blok Studio (trace visualization)\nblokctl studio # Alias for blokctl trace\n```\n\n## Context \u2014 Critical Data Flow\n\nThe Context type is the central execution state passed through every step.\n\n```typescript\ntype Context = {\n id: string; // Unique request ID\n request: RequestContext; // Incoming request (body, headers, params, query)\n response: ResponseContext; // Current step output \u2014 OVERWRITTEN every step\n vars?: VarsContext; // Persistent variables \u2014 PERSISTS across workflow\n config: ConfigContext; // Node config (inputs resolved by Mapper)\n env?: EnvContext; // process.env access\n logger: LoggerContext;\n error: ErrorContext;\n};\n```\n\n### The Two Critical Rules\n\n**Rule 1: \\`ctx.prev\\` carries the immediately previous step's output.**\nEach step's output replaces \\`ctx.prev\\`. Use it for adjacent-step access only.\n\n**Rule 2: \\`ctx.state[id]\\` PERSISTS across the entire workflow.**\nEvery step's output is auto-stored at \\`ctx.state[<step-id>]\\` (the v2 default-store rule). Downstream steps reference it via \\`$.state.<id>\\` (TS DSL) or \\`\"$.state.<id>\"\\` / \\`\"js/ctx.state.<id>\"\\` (JSON). Opt out per step with \\`ephemeral: true\\`.\n\n### Data Flow Example\n\n```\nStep 1: id \"fetch-user\"\n \u2192 ctx.state[\"fetch-user\"] = { id: \"123\", name: \"Alice\" }\n \u2192 ctx.prev = { id: \"123\", name: \"Alice\" }\n\nStep 2: id \"transform\"\n \u2192 ctx.state[\"transform\"] = { result: \"done\" }\n \u2192 ctx.prev = { result: \"done\" } \u2190 Step 1 output GONE from prev\n \u2192 ctx.state[\"fetch-user\"] still available\n\nStep 3: id \"output\"\n \u2192 Can read ctx.state[\"fetch-user\"].name \u2190 still \"Alice\"\n```\n\n### Blueprint Mapper \u2014 Expression Resolution\n\nNode inputs support dynamic expressions resolved BEFORE node execution:\n\n```json\n{\n \"inputs\": {\n \"userId\": \"js/ctx.request.body.userId\",\n \"chain\": \"js/ctx.vars['previous-step'].chain\",\n \"previous\": \"js/ctx.response.data.result\"\n }\n}\n```\n\nAvailable in js/ expressions: \\`ctx\\` (full context), \\`data\\` (ctx.prev.data), \\`func\\` (ctx.func), \\`vars\\` (alias for ctx.state).\n\n---\n\n## Creating Nodes with defineNode\n\nUse \\`defineNode()\\` for all new nodes. Never use the legacy class-based pattern.\n\n```typescript\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches user by ID\",\n\n input: z.object({\n userId: z.string().uuid(),\n }),\n\n output: z.object({\n user: z.object({\n id: z.string(),\n name: z.string(),\n email: z.string().email(),\n }),\n }),\n\n async execute(ctx, input) {\n const user = await fetchUser(input.userId);\n return { user };\n },\n});\n```\n\n### Key Behaviors\n\n- Zod input/output validation runs automatically\n- ZodError is mapped to GlobalError with HTTP 400\n- \\`flow: true\\` nodes return NodeBase[] for conditional execution\n- \\`contentType\\` sets response Content-Type (e.g., \"text/html\")\n- Always \\`export default defineNode(...)\\`\n\n---\n\n## Workflow Structure (JSON)\n\n```json\n{\n \"name\": \"My Workflow\",\n \"version\": \"1.0.0\",\n \"trigger\": {\n \"http\": { \"method\": \"POST\", \"path\": \"/api/process\", \"accept\": \"application/json\" }\n },\n \"steps\": [\n { \"id\": \"fetch\", \"use\": \"@blokjs/api-call\", \"inputs\": { \"url\": \"https://api.example.com\", \"method\": \"GET\" } },\n { \"id\": \"process\", \"use\": \"my-node\", \"inputs\": { \"data\": \"$.state.fetch\" } },\n { \"id\": \"go-step\", \"use\": \"chain-test\", \"type\": \"runtime.go\", \"inputs\": { \"processed\": \"$.state.process\" } }\n ]\n}\n```\n\n### Workflow Naming\n\nEvery workflow's \\`name\\` must be UNIQUE across the project. The\n\\`WorkflowRegistry\\` rejects duplicate names at boot, so a collision\nmeans only one of the colliding workflows ever registers.\n\nPrefer a dotted \\`domain.action\\` convention \u2014 \\`countries.list\\`,\n\\`users.create\\`, \\`orders.refund\\`. The typed client\n(\\`@blokjs/client\\`) and \\`blokctl gen app-types\\` nest workflows by\ntheir dotted name, so a clean name surfaces as\n\\`blok.countries.list(...)\\` instead of a quoted\n\\`blok[\"World Countries\"]\\` accessor. Duplicate names also make\n\\`gen app-types\\` report a collision and DROP one workflow from the\ngenerated \\`BlokApp\\` type.\n\n### Step Types\n\n| Type | Description |\n|------|-------------|\n| \\`module\\` | TypeScript node from registered modules |\n| \\`local\\` | TypeScript node from filesystem (NODES_PATH) |\n| \\`runtime.python3\\` | Python3 SDK container (port 9007) |\n| \\`runtime.go\\` | Go SDK container (port 9001) |\n| \\`runtime.rust\\` | Rust SDK container (port 9002) |\n| \\`runtime.java\\` | Java SDK container (port 9003) |\n| \\`runtime.csharp\\` | C# SDK container (port 9004) |\n| \\`runtime.php\\` | PHP SDK container (port 9005) |\n| \\`runtime.ruby\\` | Ruby SDK container (port 9006) |\n\n### Conditional Workflow (if-else)\n\n```json\n{\n \"nodes\": {\n \"filter\": {\n \"conditions\": [\n {\n \"type\": \"if\",\n \"condition\": \"ctx.request.query.active === \\\\\"true\\\\\"\",\n \"steps\": [{ \"name\": \"active-path\", \"node\": \"handle-active\", \"type\": \"module\" }]\n },\n {\n \"type\": \"else\",\n \"steps\": [{ \"name\": \"default-path\", \"node\": \"handle-default\", \"type\": \"module\" }]\n }\n ]\n }\n }\n}\n```\n\n---\n\n## Trigger Types\n\n| Trigger | Example Config |\n|---------|---------------|\n| \\`http\\` | \\`{ \"method\": \"GET\", \"path\": \"/\", \"accept\": \"application/json\" }\\` |\n| \\`grpc\\` | \\`{ \"service\": \"UserService\", \"method\": \"GetUser\" }\\` |\n| \\`cron\\` | \\`{ \"schedule\": \"0 * * * *\", \"timezone\": \"UTC\" }\\` |\n| \\`queue\\` | \\`{ \"provider\": \"kafka\", \"topic\": \"events\" }\\` |\n| \\`pubsub\\` | \\`{ \"provider\": \"gcp\", \"topic\": \"updates\" }\\` |\n| \\`webhook\\` | \\`{ \"source\": \"github\", \"events\": [\"push\"] }\\` |\n| \\`websocket\\` | \\`{ \"events\": [\"message\"], \"path\": \"/ws\" }\\` |\n| \\`sse\\` | \\`{ \"events\": [\"update\"], \"path\": \"/stream\" }\\` |\n| \\`worker\\` | \\`{ \"queue\": \"jobs\", \"concurrency\": 5, \"retries\": 3 }\\` |\n\n### Worker Trigger\n\nThe worker trigger processes background jobs from a queue with retry logic and concurrency control.\n\n\\`\\`\\`typescript\nWorkflow({ name: \"Process Job\", version: \"1.0.0\" })\n .addTrigger(\"worker\", { queue: \"background-jobs\" })\n .addStep({\n name: \"process\",\n node: \"my-processor\",\n type: \"module\",\n inputs: { payload: \"js/ctx.request.body\", jobId: \"js/ctx.request.params.jobId\" },\n });\n\\`\\`\\`\n\nJob context: \\`ctx.request.body\\` = payload, \\`ctx.request.params.queue\\` = queue name, \\`ctx.request.params.jobId\\` = job ID, \\`ctx.request.params.attempt\\` = attempt count, \\`ctx.vars._worker_job\\` = full metadata.\n\nAdapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev only).\n\n### NATS JetStream\n\nRecommended queue/worker backend. Environment variables:\n\\`\\`\\`\nNATS_SERVERS=localhost:4222\nNATS_STREAM_NAME=blok-queue # or blok-worker for worker trigger\nNATS_TOKEN= # optional auth\n\\`\\`\\`\n\nQueue providers: \\`kafka\\`, \\`rabbitmq\\`, \\`sqs\\`, \\`redis\\`, \\`beanstalk\\`, \\`nats\\`\n\n### Standalone Workers (Go, Rust, Python)\n\nGo, Rust, and Python SDKs include standalone NATS workers that connect directly to NATS without the TypeScript runner:\n\n\\`\\`\\`\nWORKER_CONCURRENCY=1 # Max concurrent jobs\nWORKER_MAX_RETRIES=3 # Max delivery attempts\nWORKER_QUEUES=queue1,queue2 # Queues to consume\n\\`\\`\\`\n\n---\n\n## Testing Utilities\n\n\\`@blokjs/runner\\` provides testing utilities for nodes and workflows.\n\n### NodeTestHarness \u2014 Unit test a single node:\n\\`\\`\\`typescript\nimport { NodeTestHarness } from \"@blokjs/runner\";\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ input: \"data\" });\nharness.assertSuccess(result);\nharness.assertOutput(result, { expected: \"output\" });\n\\`\\`\\`\n\n### WorkflowTestRunner \u2014 Integration test a workflow:\n\\`\\`\\`typescript\nimport { WorkflowTestRunner } from \"@blokjs/runner\";\nconst runner = new WorkflowTestRunner({ verbose: true });\nrunner.registerNode(\"validate\", ValidateNode);\nrunner.mockNode(\"external-api\", async (input) => ({ result: \"mocked\" }));\nrunner.loadWorkflow(workflowDefinition);\nconst result = await runner.execute({ input: \"data\" });\n// result.success, result.output, result.trace, result.nodeResults\n\\`\\`\\`\n\n---\n\n## Runtime Adapter System\n\nAll non-NodeJS SDKs communicate via HTTP:\n- **POST /execute** \u2014 Execute node with context\n- **GET /health** \u2014 Health check\n\nEnvironment variables: \\`RUNTIME_{LANG}_HOST\\` / \\`RUNTIME_{LANG}_PORT\\`\n\nRuntime nodes auto-save \\`result.data\\` to \\`ctx.vars[stepName]\\`.\n\n---\n\n## Blok Studio\n\nReal-time workflow trace visualization UI.\n\n- Launch: \\`blokctl trace\\` or \\`blokctl studio\\`\n- API: \\`/__blok/runs\\`, \\`/__blok/runs/:id\\`, \\`/__blok/runs/:id/stream\\` (SSE)\n- Disable: \\`BLOK_TRACE_ENABLED=false\\`\n\n---\n\n## Do NOT\n\n- Do NOT rely on \\`ctx.response.data\\` for data from non-previous steps \u2014 it gets overwritten\n- Do NOT create class-based nodes \u2014 use \\`defineNode()\\` instead\n- Do NOT use \\`any\\` type \u2014 use \\`unknown\\` and narrow with Zod\n- Do NOT hardcode runtime ports \u2014 use environment variables\n- Do NOT skip Zod input/output schemas\n- Do NOT edit files in \\`.blok/runtimes/\\` \u2014 they are auto-generated\n\n## Do\n\n- Use \\`$.state.<id>\\` (or \\`js/ctx.state.<id>\\`) to pass data between non-adjacent steps \u2014 every step default-stores its output there\n- Opt out per step with \\`ephemeral: true\\` when the step is a side effect only\n- Use Zod schemas for all input/output validation\n- Use \\`defineNode()\\` for all new nodes\n- Handle errors via GlobalError with appropriate HTTP status codes\n- Keep nodes focused \u2014 one responsibility per node\n";
39
+ declare const claude_md = "# Blok Project \u2014 Claude Code Guide\n\nRead \\`AGENTS.md\\` for full architecture and API details. This file contains Claude-specific guidance.\n\n## Quick Commands\n\n\\`\\`\\`bash\nnpm run dev # Start dev server\nblokctl dev # Multi-runtime dev server\nblokctl create node <name> # Scaffold new node\nblokctl create workflow <name> # Scaffold new workflow\nblokctl trace # Open Blok Studio\nnpm test # Run tests\n\\`\\`\\`\n\n## Context Rules (Memorize These)\n\n1. **\\`ctx.prev\\` is the immediately previous step's output.** Overwritten every step.\n2. **\\`ctx.state[<id>]\\` PERSISTS across the workflow.** Every step default-stores its output there; reference via \\`$.state.<id>\\` or \\`js/ctx.state.<id>\\`. Opt out with \\`ephemeral: true\\`.\n3. **Blueprint Mapper resolves \\`$.<path>\\` and \\`js/\\` expressions BEFORE node execution.**\n\nWhen users have data flow issues, check these three things first.\n\n## Workflow Naming\n\nWorkflow \\`name\\` must be UNIQUE across the project \u2014 the\n\\`WorkflowRegistry\\` rejects duplicates at boot. Use a dotted\n\\`domain.action\\` convention (\\`countries.list\\`, \\`users.create\\`)\nso the typed client (\\`@blokjs/client\\`) and \\`blokctl gen app-types\\`\nexpose clean nested accessors like \\`blok.countries.list(...)\\`. Duplicate\nnames make \\`gen app-types\\` flag a collision and drop one workflow from\nthe generated \\`BlokApp\\` type.\n\n## Debugging Workflows\n\n1. **Verify structure**: Every step has an \\`id\\` and a \\`use\\` (v2). v1's \\`name\\` + \\`nodes{}\\` still works but is normalized at load time.\n2. **Trace data flow**: Does the target step reference the correct source id (\\`$.state.<id>\\`)? Did the source step have \\`ephemeral: true\\` accidentally?\n3. **Check runtimes**: SDK containers running? \\`GET http://localhost:{port}/health\\`\n4. **Check Studio traces**: \\`/__blok/runs/:id\\` shows step-by-step inputs/outputs/errors\n\n### Common Errors\n\n| Error | Fix |\n|-------|-----|\n| \\`Node type X not found\\` | Wrong \\`type\\` in step \u2014 use module, local, or runtime.* |\n| \\`Validation failed\\` | Zod schema mismatch \u2014 check input schema vs actual data |\n| \\`Runtime execution error\\` | SDK container not running \u2014 check health endpoint |\n| \\`ctx.state['X'] undefined\\` | Source step has \\`ephemeral: true\\`, or the id doesn't match what's referenced in \\`$.state.<id>\\` |\n| \\`set_var, which was removed in v0.5\\` | Drop \\`set_var: true\\` (it's the default) or replace \\`set_var: false\\` with \\`ephemeral: true\\`. Run \\`blokctl migrate workflows\\`. |\n\n## Generating Code\n\nAlways use \\`defineNode()\\`. Never class-based BlokService.\n\n\\`\\`\\`typescript\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"node-name\",\n description: \"What this node does\",\n input: z.object({ /* Zod schema */ }),\n output: z.object({ /* Zod schema */ }),\n async execute(ctx, input) {\n return { /* must match output schema */ };\n },\n});\n\\`\\`\\`\n\n### Checklist:\n- Zod input schema covers all inputs\n- Zod output schema matches execute() return\n- Node name matches workflow references\n- No \\`any\\` types \u2014 use \\`z.unknown()\\` if dynamic\n- \\`export default defineNode(...)\\`\n\n## Worker Workflows\n\nWorker trigger processes background jobs from a queue:\n\n\\`\\`\\`typescript\nWorkflow({ name: \"Process Job\", version: \"1.0.0\" })\n .addTrigger(\"worker\", { queue: \"background-jobs\" })\n .addStep({ name: \"process\", node: \"my-processor\", type: \"module\",\n inputs: { payload: \"js/ctx.request.body\", jobId: \"js/ctx.request.params.jobId\" } });\n\\`\\`\\`\n\nJob data: \\`ctx.request.body\\` = payload, \\`ctx.request.params.queue/jobId/attempt\\` = metadata.\nAdapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev).\n\n## Testing\n\n\\`\\`\\`typescript\nimport { NodeTestHarness, WorkflowTestRunner } from \"@blokjs/runner\";\n\n// Unit test a node\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ input: \"data\" });\nharness.assertSuccess(result);\n\n// Integration test a workflow\nconst runner = new WorkflowTestRunner({ mockAllNodes: true });\nrunner.loadWorkflow(definition);\nconst wfResult = await runner.execute({ input: \"data\" });\n\\`\\`\\`\n\n## Blok Studio Help\n\n- Launch: \\`blokctl trace\\` or navigate to \\`/__blok\\`\n- \"No output\" \u2192 Node not returning data or Zod output validation failed\n- \"Step error\" \u2192 Expand error \u2014 check if 400 (validation) or 500 (runtime)\n- \"State not passing\" \u2192 Source step has \\`ephemeral: true\\`, OR target's \\`$.state.<id>\\` references a non-existent step id\n\n## Debugging Workers\n\n- NATS not reachable \u2192 Check \\`NATS_SERVERS\\` env var, ensure NATS is running\n- Job timeout \u2192 Increase \\`timeout\\` in trigger config or optimize node\n- Max retries exceeded \u2192 Check node errors, job moves to DLQ\n\n## Do NOT\n\n- Do NOT suggest class-based BlokService for new nodes\n- Do NOT generate code with \\`any\\` types\n- Do NOT assume \\`ctx.response.data\\` persists across steps\n- Do NOT skip Zod schemas when creating nodes\n- Do NOT edit files in \\`.blok/runtimes/\\`\n";
40
40
  declare const function_first_node_file = "import { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\n/**\n * A function-first node that demonstrates the modern defineNode pattern.\n * This node is type-safe, validated, and requires 60% less boilerplate.\n */\nexport default defineNode({\n\tname: \"{{NODE_NAME}}\",\n\tdescription: \"A function-first node with Zod validation\",\n\n\t// Input schema using Zod - automatically validated\n\tinput: z.object({\n\t\tmessage: z.string().optional().default(\"Hello World\"),\n\t}),\n\n\t// Output schema using Zod - automatically validated\n\toutput: z.object({\n\t\tmessage: z.string(),\n\t\ttimestamp: z.string(),\n\t}),\n\n\t// Execute function - type-safe with inferred types from Zod schemas\n\tasync execute(ctx, input) {\n\t\t// Your business logic here\n\t\t// - ctx.vars: Access workflow variables\n\t\t// - ctx.request: Access HTTP request data\n\t\t// - ctx.logger: Log messages\n\t\t// - ctx.env: Access environment variables\n\n\t\t// Example: Store data for downstream nodes\n\t\tctx.vars[\"processed-message\"] = input.message;\n\n\t\t// Return type-safe output (validated automatically)\n\t\treturn {\n\t\t\tmessage: `Processed: ${input.message}`,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t},\n});\n";
41
41
  export { node_file, package_dependencies, package_dev_dependencies, python3_file, examples_url, workflow_template, supervisord_nodejs, supervisord_python, go_node_file, go_mod_file, go_dockerfile, java_node_file, java_pom_file, java_dockerfile, rust_node_file, rust_cargo_file, rust_dockerfile, csharp_node_file, csharp_csproj_file, csharp_dockerfile, php_node_file, php_composer_file, php_dockerfile, ruby_node_file, ruby_gemfile, ruby_dockerfile, function_first_node_file, agents_md, claude_md, };
@@ -762,6 +762,21 @@ export default defineNode({
762
762
  }
763
763
  \`\`\`
764
764
 
765
+ ### Workflow Naming
766
+
767
+ Every workflow's \\\`name\\\` must be UNIQUE across the project. The
768
+ \\\`WorkflowRegistry\\\` rejects duplicate names at boot, so a collision
769
+ means only one of the colliding workflows ever registers.
770
+
771
+ Prefer a dotted \\\`domain.action\\\` convention — \\\`countries.list\\\`,
772
+ \\\`users.create\\\`, \\\`orders.refund\\\`. The typed client
773
+ (\\\`@blokjs/client\\\`) and \\\`blokctl gen app-types\\\` nest workflows by
774
+ their dotted name, so a clean name surfaces as
775
+ \\\`blok.countries.list(...)\\\` instead of a quoted
776
+ \\\`blok["World Countries"]\\\` accessor. Duplicate names also make
777
+ \\\`gen app-types\\\` report a collision and DROP one workflow from the
778
+ generated \\\`BlokApp\\\` type.
779
+
765
780
  ### Step Types
766
781
 
767
782
  | Type | Description |
@@ -945,6 +960,16 @@ npm test # Run tests
945
960
 
946
961
  When users have data flow issues, check these three things first.
947
962
 
963
+ ## Workflow Naming
964
+
965
+ Workflow \\\`name\\\` must be UNIQUE across the project — the
966
+ \\\`WorkflowRegistry\\\` rejects duplicates at boot. Use a dotted
967
+ \\\`domain.action\\\` convention (\\\`countries.list\\\`, \\\`users.create\\\`)
968
+ so the typed client (\\\`@blokjs/client\\\`) and \\\`blokctl gen app-types\\\`
969
+ expose clean nested accessors like \\\`blok.countries.list(...)\\\`. Duplicate
970
+ names make \\\`gen app-types\\\` flag a collision and drop one workflow from
971
+ the generated \\\`BlokApp\\\` type.
972
+
948
973
  ## Debugging Workflows
949
974
 
950
975
  1. **Verify structure**: Every step has an \\\`id\\\` and a \\\`use\\\` (v2). v1's \\\`name\\\` + \\\`nodes{}\\\` still works but is normalized at load time.
@@ -0,0 +1,13 @@
1
+ import type { OptionValues } from "commander";
2
+ export interface WorkflowEntry {
3
+ name: string;
4
+ file: string;
5
+ }
6
+ export declare function extractWorkflowName(source: string): string | null;
7
+ export declare function nameToIdentifier(name: string): string;
8
+ export declare function importSpecifier(outFile: string, workflowFile: string): string;
9
+ export declare function buildAppTypeSource(entries: readonly WorkflowEntry[], outFile: string): {
10
+ source: string;
11
+ collisions: string[];
12
+ };
13
+ export declare function generateAppTypes(opts: OptionValues): Promise<void>;
@@ -0,0 +1,195 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import color from "picocolors";
4
+ export function extractWorkflowName(source) {
5
+ const stripped = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
6
+ const factory = /\b(?:workflow|Workflow)\s*\(/.exec(stripped);
7
+ if (!factory)
8
+ return null;
9
+ const rest = stripped.slice(factory.index);
10
+ const nameMatch = /\bname\s*:\s*(["'`])([^"'`]+)\1/.exec(rest);
11
+ return nameMatch ? nameMatch[2].trim() : null;
12
+ }
13
+ export function nameToIdentifier(name) {
14
+ const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
15
+ const camel = parts.map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1))).join("");
16
+ const safe = camel.length === 0 ? "wf" : camel;
17
+ return /^[0-9]/.test(safe) ? `wf_${safe}` : safe;
18
+ }
19
+ export function importSpecifier(outFile, workflowFile) {
20
+ let rel = path.relative(path.dirname(outFile), workflowFile).replace(/\\/g, "/").replace(/\.ts$/, "");
21
+ if (!rel.startsWith("."))
22
+ rel = `./${rel}`;
23
+ return rel;
24
+ }
25
+ function isLeaf(node) {
26
+ return typeof node.__leaf === "string";
27
+ }
28
+ export function buildAppTypeSource(entries, outFile) {
29
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
30
+ const collisions = [];
31
+ const usedIdents = new Map();
32
+ const imports = [];
33
+ const tree = {};
34
+ for (const entry of sorted) {
35
+ let ident = nameToIdentifier(entry.name);
36
+ if (usedIdents.has(ident) && usedIdents.get(ident) !== entry.name) {
37
+ let n = 2;
38
+ while (usedIdents.has(`${ident}${n}`))
39
+ n++;
40
+ ident = `${ident}${n}`;
41
+ }
42
+ usedIdents.set(ident, entry.name);
43
+ imports.push(`import type ${ident} from "${importSpecifier(outFile, entry.file)}";`);
44
+ const segments = entry.name.split(".").filter(Boolean);
45
+ let node = tree;
46
+ let collided = false;
47
+ for (let i = 0; i < segments.length; i++) {
48
+ const seg = segments[i];
49
+ const last = i === segments.length - 1;
50
+ if (last) {
51
+ if (node[seg] !== undefined) {
52
+ collisions.push(entry.name);
53
+ collided = true;
54
+ break;
55
+ }
56
+ node[seg] = { __leaf: ident };
57
+ }
58
+ else {
59
+ const existing = node[seg];
60
+ if (existing === undefined) {
61
+ const child = {};
62
+ node[seg] = child;
63
+ node = child;
64
+ }
65
+ else if (isLeaf(existing)) {
66
+ collisions.push(entry.name);
67
+ collided = true;
68
+ break;
69
+ }
70
+ else {
71
+ node = existing;
72
+ }
73
+ }
74
+ }
75
+ if (collided)
76
+ imports.pop();
77
+ }
78
+ const render = (node, indent) => {
79
+ const lines = ["{"];
80
+ for (const key of Object.keys(node).sort()) {
81
+ const child = node[key];
82
+ const k = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
83
+ if (isLeaf(child)) {
84
+ lines.push(`${indent}\t${k}: ${child.__leaf};`);
85
+ }
86
+ else {
87
+ lines.push(`${indent}\t${k}: ${render(child, `${indent}\t`)};`);
88
+ }
89
+ }
90
+ lines.push(`${indent}}`);
91
+ return lines.join("\n");
92
+ };
93
+ const header = [
94
+ "// AUTO-GENERATED by `blokctl gen app-types`. Do not edit by hand.",
95
+ "// Regenerate after adding, removing, or renaming a workflow.",
96
+ "// Consumed by `@blokjs/client`: `createBlokClient<BlokApp>()`.",
97
+ "",
98
+ ].join("\n");
99
+ const body = imports.length === 0
100
+ ? "export type BlokApp = Record<string, never>;\n"
101
+ : `${imports.join("\n")}\n\nexport type BlokApp = ${render(tree, "")};\n`;
102
+ return { source: `${header}${body}`, collisions };
103
+ }
104
+ async function collectTsFiles(dir) {
105
+ const out = [];
106
+ let dirents;
107
+ try {
108
+ dirents = await fsp.readdir(dir, { withFileTypes: true });
109
+ }
110
+ catch {
111
+ return out;
112
+ }
113
+ for (const d of dirents) {
114
+ if (d.name.startsWith("_") || d.name.startsWith("."))
115
+ continue;
116
+ const full = path.join(dir, d.name);
117
+ if (d.isDirectory()) {
118
+ out.push(...(await collectTsFiles(full)));
119
+ }
120
+ else if (d.name.endsWith(".ts") &&
121
+ !d.name.endsWith(".test.ts") &&
122
+ !d.name.endsWith(".spec.ts") &&
123
+ !d.name.endsWith(".d.ts")) {
124
+ out.push(full);
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ async function resolveWorkflowsDir(cwd, explicit) {
130
+ const candidates = explicit
131
+ ? [explicit]
132
+ : ["triggers/http/src/workflows", "src/workflows", "workflows/ts", "workflows"];
133
+ for (const c of candidates) {
134
+ const abs = path.isAbsolute(c) ? c : path.join(cwd, c);
135
+ try {
136
+ const stat = await fsp.stat(abs);
137
+ if (stat.isDirectory())
138
+ return abs;
139
+ }
140
+ catch {
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+ export async function generateAppTypes(opts) {
146
+ const cwd = process.cwd();
147
+ const explicitDir = opts.dir ?? null;
148
+ console.log(color.cyan("\n🧬 Blok app-types generator"));
149
+ console.log(color.dim("Generates the typed `BlokApp` index consumed by @blokjs/client.\n"));
150
+ const dir = await resolveWorkflowsDir(cwd, explicitDir);
151
+ if (!dir) {
152
+ console.log(color.red("❌ Could not find a TS workflows directory. Looked in: triggers/http/src/workflows/, " +
153
+ "src/workflows/, workflows/. Pass --dir <path> to override."));
154
+ process.exit(1);
155
+ return;
156
+ }
157
+ const outFile = path.isAbsolute(opts.out ?? "")
158
+ ? opts.out
159
+ : path.join(cwd, opts.out ?? "blok-app.d.ts");
160
+ console.log(color.dim(`Scanning ${color.cyan(dir)} (recursive)\n`));
161
+ const files = await collectTsFiles(dir);
162
+ const entries = [];
163
+ const skipped = [];
164
+ for (const file of files) {
165
+ const src = await fsp.readFile(file, "utf8");
166
+ const name = extractWorkflowName(src);
167
+ if (name)
168
+ entries.push({ name, file });
169
+ else
170
+ skipped.push(path.relative(cwd, file));
171
+ }
172
+ if (entries.length === 0) {
173
+ console.log(color.yellow("No TS workflows with a literal `name:` found — nothing to generate."));
174
+ if (skipped.length > 0)
175
+ console.log(color.dim(`Skipped (no literal name): ${skipped.join(", ")}`));
176
+ return;
177
+ }
178
+ const { source, collisions } = buildAppTypeSource(entries, outFile);
179
+ if (opts.dryRun === true) {
180
+ console.log(color.dim(`— dry run — would write ${color.cyan(path.relative(cwd, outFile))}:\n`));
181
+ console.log(source);
182
+ }
183
+ else {
184
+ await fsp.mkdir(path.dirname(outFile), { recursive: true });
185
+ await fsp.writeFile(outFile, source, "utf8");
186
+ console.log(color.green(`✅ Wrote ${color.cyan(path.relative(cwd, outFile))} (${entries.length} workflow(s)).`));
187
+ }
188
+ for (const c of collisions) {
189
+ console.log(color.yellow(`⚠️ name collision — "${c}" overlaps another workflow's path and was dropped.`));
190
+ }
191
+ if (skipped.length > 0) {
192
+ console.log(color.dim(`ℹ️ Skipped ${skipped.length} file(s) without a literal workflow name (dynamic name or not a workflow): ${skipped.join(", ")}`));
193
+ }
194
+ console.log(color.dim('\nNext: `import type { BlokApp } from "<out>"` and `createBlokClient<BlokApp>({ baseUrl })`.\n'));
195
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { buildAppTypeSource, extractWorkflowName, generateAppTypes, importSpecifier, nameToIdentifier, } from "./appTypes.js";
6
+ describe("extractWorkflowName", () => {
7
+ it("extracts the name from a v2 workflow() factory call", () => {
8
+ const src = `import { workflow } from "@blokjs/helper";
9
+ export default workflow({ name: "users.list", version: "1.0.0", trigger: { http: { method: "GET" } }, steps: [{ id: "s", use: "@blokjs/respond", inputs: {} }] });`;
10
+ expect(extractWorkflowName(src)).toBe("users.list");
11
+ });
12
+ it("handles the legacy Workflow() factory and single quotes", () => {
13
+ expect(extractWorkflowName("const x = Workflow({ name: 'orders.create' })")).toBe("orders.create");
14
+ });
15
+ it("anchors on the factory call, not a step/node `name:`", () => {
16
+ const src = `export default workflow({
17
+ name: "the.workflow",
18
+ steps: [{ id: "x", use: "n", inputs: { name: "not-the-workflow" } }],
19
+ });`;
20
+ expect(extractWorkflowName(src)).toBe("the.workflow");
21
+ });
22
+ it("ignores a commented-out name", () => {
23
+ const src = `// name: "commented"
24
+ /* name: "blocked" */
25
+ export default workflow({ name: "real.name" });`;
26
+ expect(extractWorkflowName(src)).toBe("real.name");
27
+ });
28
+ it("returns null when there is no workflow factory call", () => {
29
+ expect(extractWorkflowName(`export const x = { name: "not.a.workflow" };`)).toBeNull();
30
+ });
31
+ it("returns null when the name is a non-literal (variable)", () => {
32
+ expect(extractWorkflowName(`export default workflow({ name: WF_NAME, version: "1" })`)).toBeNull();
33
+ });
34
+ });
35
+ describe("nameToIdentifier", () => {
36
+ it("camelCases a dotted name", () => {
37
+ expect(nameToIdentifier("users.list")).toBe("usersList");
38
+ expect(nameToIdentifier("a.b.c")).toBe("aBC");
39
+ });
40
+ it("passes a single segment through", () => {
41
+ expect(nameToIdentifier("health")).toBe("health");
42
+ });
43
+ it("prefixes identifiers that would start with a digit", () => {
44
+ expect(nameToIdentifier("2fa.verify")).toBe("wf_2faVerify");
45
+ });
46
+ });
47
+ describe("importSpecifier", () => {
48
+ it("computes a relative, extensionless, ./-prefixed specifier", () => {
49
+ const spec = importSpecifier("/proj/blok-app.d.ts", "/proj/triggers/http/src/workflows/users/list.ts");
50
+ expect(spec).toBe("./triggers/http/src/workflows/users/list");
51
+ });
52
+ it("emits ../ when the workflow is above the output dir", () => {
53
+ const spec = importSpecifier("/proj/web/blok-app.d.ts", "/proj/server/workflows/health.ts");
54
+ expect(spec).toBe("../server/workflows/health");
55
+ });
56
+ });
57
+ describe("buildAppTypeSource", () => {
58
+ const out = "/proj/blok-app.d.ts";
59
+ it("nests workflows by their dotted name and imports each by file", () => {
60
+ const entries = [
61
+ { name: "users.list", file: "/proj/wf/users/list.ts" },
62
+ { name: "users.create", file: "/proj/wf/users/create.ts" },
63
+ { name: "health", file: "/proj/wf/health.ts" },
64
+ ];
65
+ const { source, collisions } = buildAppTypeSource(entries, out);
66
+ expect(collisions).toEqual([]);
67
+ expect(source).toContain('import type usersList from "./wf/users/list";');
68
+ expect(source).toContain('import type usersCreate from "./wf/users/create";');
69
+ expect(source).toContain('import type health from "./wf/health";');
70
+ expect(source).toContain("export type BlokApp = {");
71
+ expect(source).toContain("users: {");
72
+ expect(source).toContain("list: usersList;");
73
+ expect(source).toContain("create: usersCreate;");
74
+ expect(source).toContain("health: health;");
75
+ });
76
+ it("flags a leaf-vs-group collision and drops the loser", () => {
77
+ const entries = [
78
+ { name: "users", file: "/proj/wf/users.ts" },
79
+ { name: "users.list", file: "/proj/wf/users/list.ts" },
80
+ ];
81
+ const { collisions } = buildAppTypeSource(entries, out);
82
+ expect(collisions.length).toBe(1);
83
+ });
84
+ it("quotes non-identifier path segments", () => {
85
+ const { source } = buildAppTypeSource([{ name: "weird-name.go", file: "/proj/wf/x.ts" }], out);
86
+ expect(source).toContain('"weird-name":');
87
+ });
88
+ it("emits an empty-but-valid type when there are no workflows", () => {
89
+ const { source } = buildAppTypeSource([], out);
90
+ expect(source).toContain("export type BlokApp = Record<string, never>;");
91
+ });
92
+ });
93
+ describe("generateAppTypes (fs integration)", () => {
94
+ const tmpDirs = [];
95
+ afterEach(async () => {
96
+ for (const d of tmpDirs)
97
+ await fsp.rm(d, { recursive: true, force: true });
98
+ tmpDirs.length = 0;
99
+ });
100
+ it("scans a workflows dir and writes blok-app.d.ts", async () => {
101
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), "blok-gen-"));
102
+ tmpDirs.push(root);
103
+ const wfDir = path.join(root, "workflows", "users");
104
+ await fsp.mkdir(wfDir, { recursive: true });
105
+ await fsp.writeFile(path.join(wfDir, "list.ts"), `import { workflow } from "@blokjs/helper";
106
+ export default workflow({ name: "users.list", version: "1.0.0", trigger: { http: { method: "GET" } }, steps: [] });`);
107
+ await fsp.writeFile(path.join(root, "workflows", "_helper.ts"), "export const x = 1;");
108
+ const out = path.join(root, "blok-app.d.ts");
109
+ await generateAppTypes({ dir: path.join(root, "workflows"), out });
110
+ const written = await fsp.readFile(out, "utf8");
111
+ expect(written).toContain("export type BlokApp = {");
112
+ expect(written).toContain("users: {");
113
+ expect(written).toContain("list: usersList;");
114
+ expect(written).toContain('import type usersList from "./workflows/users/list";');
115
+ });
116
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { Command } from "commander";
2
+ import { program } from "../../services/commander.js";
3
+ import { generateAppTypes } from "./appTypes.js";
4
+ const gen = new Command("gen").description("Generate typed client artifacts for @blokjs/client");
5
+ const appTypes = new Command("app-types")
6
+ .description("Generate the typed `BlokApp` index (blok-app.d.ts) from your TS workflow files")
7
+ .option("-d, --dir <value>", "TS workflows directory (defaults to triggers/http/src/workflows, src/workflows, or workflows)")
8
+ .option("-o, --out <value>", "Output file (default: ./blok-app.d.ts)")
9
+ .option("--dry-run", "Print the generated file without writing it")
10
+ .action(async (options) => {
11
+ await generateAppTypes(options);
12
+ });
13
+ gen.addCommand(appTypes);
14
+ program.addCommand(gen);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { Command } from "commander";
2
+ import { program } from "../../services/commander.js";
3
+ import { listNodes } from "./listNodes.js";
4
+ const nodes = new Command("nodes").description("Inspect the node catalog of a running Blok server");
5
+ const list = new Command("list")
6
+ .description("List every node across all runtimes (hits GET /__blok/nodes on a running server)")
7
+ .option("-u, --url <value>", "Base URL of the running Blok server", "http://localhost:4000")
8
+ .option("--json", "Output raw JSON instead of a table")
9
+ .action(async (options) => {
10
+ await listNodes(options);
11
+ });
12
+ nodes.addCommand(list);
13
+ program.addCommand(nodes);
@@ -0,0 +1,12 @@
1
+ import type { OptionValues } from "commander";
2
+ export interface NodeEntry {
3
+ name: string;
4
+ runtime: string;
5
+ description?: string;
6
+ inputSchema: unknown | null;
7
+ outputSchema: unknown | null;
8
+ tags?: string[];
9
+ }
10
+ export declare function schemaMark(node: NodeEntry): string;
11
+ export declare function formatCatalog(nodes: readonly NodeEntry[]): string;
12
+ export declare function listNodes(opts: OptionValues): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import color from "picocolors";
2
+ export function schemaMark(node) {
3
+ const parts = [];
4
+ if (node.inputSchema)
5
+ parts.push("in");
6
+ if (node.outputSchema)
7
+ parts.push("out");
8
+ return parts.length > 0 ? parts.join(",") : "—";
9
+ }
10
+ export function formatCatalog(nodes) {
11
+ if (nodes.length === 0)
12
+ return "No nodes found.";
13
+ const rows = nodes.map((n) => ({
14
+ name: n.name,
15
+ runtime: n.runtime,
16
+ schema: schemaMark(n),
17
+ description: n.description ?? "",
18
+ }));
19
+ const nameW = Math.max("NAME".length, ...rows.map((r) => r.name.length));
20
+ const rtW = Math.max("RUNTIME".length, ...rows.map((r) => r.runtime.length));
21
+ const schW = Math.max("SCHEMA".length, ...rows.map((r) => r.schema.length));
22
+ const header = `${"NAME".padEnd(nameW)} ${"RUNTIME".padEnd(rtW)} ${"SCHEMA".padEnd(schW)} DESCRIPTION`;
23
+ const lines = [header];
24
+ for (const r of rows) {
25
+ lines.push(`${r.name.padEnd(nameW)} ${r.runtime.padEnd(rtW)} ${r.schema.padEnd(schW)} ${r.description}`.trimEnd());
26
+ }
27
+ return lines.join("\n");
28
+ }
29
+ export async function listNodes(opts) {
30
+ const baseUrl = (opts.url ?? "http://localhost:4000").replace(/\/+$/, "");
31
+ const endpoint = `${baseUrl}/__blok/nodes`;
32
+ let body;
33
+ try {
34
+ const res = await fetch(endpoint);
35
+ if (!res.ok) {
36
+ console.log(color.red(`❌ ${endpoint} returned HTTP ${res.status}.`));
37
+ process.exit(1);
38
+ return;
39
+ }
40
+ body = (await res.json());
41
+ }
42
+ catch (err) {
43
+ console.log(color.red(`❌ Could not reach ${color.cyan(endpoint)} — is the Blok server running? ` +
44
+ `Pass --url <baseUrl> to point elsewhere. (${err.message})`));
45
+ process.exit(1);
46
+ return;
47
+ }
48
+ const nodes = body.nodes ?? [];
49
+ if (opts.json === true) {
50
+ console.log(JSON.stringify(nodes, null, 2));
51
+ return;
52
+ }
53
+ console.log(color.cyan(`\n📦 Node catalog — ${nodes.length} node(s) across runtimes (${baseUrl})\n`));
54
+ console.log(formatCatalog(nodes));
55
+ console.log("");
56
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { formatCatalog, listNodes, schemaMark } from "./listNodes.js";
3
+ describe("schemaMark", () => {
4
+ it("reports which schemas a node exposes", () => {
5
+ expect(schemaMark({ name: "a", runtime: "module", inputSchema: {}, outputSchema: {} })).toBe("in,out");
6
+ expect(schemaMark({ name: "a", runtime: "module", inputSchema: {}, outputSchema: null })).toBe("in");
7
+ expect(schemaMark({ name: "a", runtime: "module", inputSchema: null, outputSchema: {} })).toBe("out");
8
+ expect(schemaMark({ name: "a", runtime: "module", inputSchema: null, outputSchema: null })).toBe("—");
9
+ });
10
+ });
11
+ describe("formatCatalog", () => {
12
+ it("renders an aligned table with a header", () => {
13
+ const nodes = [
14
+ {
15
+ name: "@blokjs/respond",
16
+ runtime: "module",
17
+ description: "Shape the HTTP response",
18
+ inputSchema: {},
19
+ outputSchema: null,
20
+ },
21
+ {
22
+ name: "@py/search",
23
+ runtime: "runtime.python3",
24
+ description: "Semantic search",
25
+ inputSchema: {},
26
+ outputSchema: {},
27
+ },
28
+ ];
29
+ const out = formatCatalog(nodes);
30
+ const lines = out.split("\n");
31
+ expect(lines[0]).toMatch(/^NAME\s+RUNTIME\s+SCHEMA\s+DESCRIPTION$/);
32
+ expect(out).toContain("@blokjs/respond");
33
+ expect(out).toContain("runtime.python3");
34
+ expect(out).toContain("in,out");
35
+ expect(out).toContain("Semantic search");
36
+ const rtOffset = lines[0].indexOf("RUNTIME");
37
+ expect(lines[1].slice(rtOffset, rtOffset + 6)).toBe("module");
38
+ });
39
+ it("handles an empty catalog", () => {
40
+ expect(formatCatalog([])).toBe("No nodes found.");
41
+ });
42
+ });
43
+ describe("listNodes (fetch-mocked)", () => {
44
+ const realFetch = globalThis.fetch;
45
+ afterEach(() => {
46
+ globalThis.fetch = realFetch;
47
+ vi.restoreAllMocks();
48
+ });
49
+ it("fetches /__blok/nodes from the given --url and prints the table", async () => {
50
+ let seenUrl = "";
51
+ globalThis.fetch = vi.fn(async (url) => {
52
+ seenUrl = String(url);
53
+ return new Response(JSON.stringify({ count: 1, nodes: [{ name: "@x/a", runtime: "module", inputSchema: {}, outputSchema: null }] }), { status: 200, headers: { "content-type": "application/json" } });
54
+ });
55
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
56
+ await listNodes({ url: "https://api.test/" });
57
+ expect(seenUrl).toBe("https://api.test/__blok/nodes");
58
+ const printed = log.mock.calls.map((c) => String(c[0])).join("\n");
59
+ expect(printed).toContain("@x/a");
60
+ expect(printed).toContain("1 node(s)");
61
+ });
62
+ it("emits raw JSON with --json", async () => {
63
+ globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ nodes: [{ name: "@x/a", runtime: "module", inputSchema: null, outputSchema: null }] }), {
64
+ status: 200,
65
+ headers: { "content-type": "application/json" },
66
+ }));
67
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
68
+ await listNodes({ url: "http://localhost:4000", json: true });
69
+ const printed = log.mock.calls.map((c) => String(c[0])).join("\n");
70
+ expect(JSON.parse(printed)).toEqual([{ name: "@x/a", runtime: "module", inputSchema: null, outputSchema: null }]);
71
+ });
72
+ });
package/dist/index.d.ts CHANGED
@@ -8,6 +8,8 @@ import "./commands/publish/index.js";
8
8
  import "./commands/install/index.js";
9
9
  import "./commands/search/index.js";
10
10
  import "./commands/generate/index.js";
11
+ import "./commands/gen/index.js";
12
+ import "./commands/nodes/index.js";
11
13
  import "./commands/config/index.js";
12
14
  import "./commands/migrate/index.js";
13
15
  import "./commands/graph/index.js";
package/dist/index.js CHANGED
@@ -23,6 +23,8 @@ import "./commands/publish/index.js";
23
23
  import "./commands/install/index.js";
24
24
  import "./commands/search/index.js";
25
25
  import "./commands/generate/index.js";
26
+ import "./commands/gen/index.js";
27
+ import "./commands/nodes/index.js";
26
28
  import "./commands/config/index.js";
27
29
  import "./commands/migrate/index.js";
28
30
  import "./commands/graph/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blokctl",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "author": "Deskree Technologies Inc.",
5
5
  "license": "Apache-2.0",
6
6
  "description": "cli for blok",
@@ -30,7 +30,7 @@
30
30
  "keywords": ["blokctl", "cli", "blok", "blok"],
31
31
  "dependencies": {
32
32
  "@ai-sdk/openai": "^1.3.22",
33
- "@blokjs/runner": "^0.6.16",
33
+ "@blokjs/runner": "^0.6.18",
34
34
  "@clack/prompts": "^1.0.0",
35
35
  "ai": "^4.3.16",
36
36
  "better-sqlite3": "^12.6.2",