DEV Community

Cover image for Building a Type-Safe Runtime from Scratch: Kernel, Host & Inferred CLI Types in Velnora
Veys Aliyev
Veys Aliyev

Posted on • Originally published at velnora.notion.site

Building a Type-Safe Runtime from Scratch: Kernel, Host & Inferred CLI Types in Velnora

Starting the Schemas

So, discovery is working. Now we need to define what we are discovering.

This is where the schemas come in.

The goal is to have a strict contract for every part of the system. If Velnora detects a package, it needs to know exactly what that package is capable of.

The first question was: what does a discovered project actually look like at runtime? I needed a Project interface — a strict contract that captures everything the system needs to know about a package.

But package.json alone isn't enough. It gives you the name, version, and dependencies — but nothing Velnora-specific. Where does the adapter config go? The framework hints? The build targets? Polluting package.json with custom fields felt wrong.

So the Project interface pulls from two sources of truth:

  • packageJson — the raw package.json, stored as-is so adapters and plugins can inspect dependencies without re-reading the file.
  • config — the resolved VelnoraAppConfig from velnora.config.ts (or an empty object if none exists). This is where all Velnora-specific metadata lives.

On top of that, each project gets:

  • A stable, path-based name derived from the workspace root (e.g., packages/app-one).
  • A human-readable displayName from package.json (e.g., @example/app-one).
  • The absolute root path and a routable path for the Host.

Two Levels of Configuration

The configuration itself works at two distinct levels:

  1. VelnoraConfig (Global): Lives in the workspace root. Defines the rules for the entire repo — plugins, shared defaults, and global overrides.
  2. VelnoraAppConfig (App-Specific): Lives inside each project folder. Defines the specific framework, build targets, and unique needs of that application. This is what ends up in Project.config.

By separating them, we get strict typing for each context while keeping the overall system cohesive.

The Pivot to Execution

But as satisfying as it was to define these interfaces, I realized something.

It is okay to have perfect types, but right now, running the app is the first priority.

There is no point in building a complex config parser if the core runtime isn't spinning up the application yet. So I decided to simplify.

I am shifting focus:

  1. First: Make the app run.
  2. Then: Parse velnora.config.ts and apply the fine-grained configs.

The schemas are defined, but the implementation of their full power can wait. Execution comes first.

Kernel & Host Implementation

So that is exactly what I did. I went ahead and implemented the Kernel and Host—the two core runtime pieces that boot up Velnora. The Kernel orchestrates everything: loads the config, resolves projects, and hands off to the Host. The Host spins up the HTTP server and mounts adapter middleware.

But as soon as the runtime started taking shape, I hit a wall. Typing issues.

The dev command needs options like --host, --port, --mode, --root. These options are parsed by the CLI, but they are also needed deeper in the system—by the Host when it starts listening, and by the Kernel when it calls bootHost(). So the question becomes: where do the types live?

The obvious answer would be to use something like Commander.js and just pass the parsed result around. But here is the problem: libraries like Commander have insane adoption—almost every Node.js CLI uses it—yet the typing story is practically nonexistent. You define commands, options, arguments… and then you get back any or loosely typed objects. You lose all the safety the moment you try to pass parsed options to another part of your system.

@velnora/cli-helper & The Lazy Developer's Split

I wanted both: great runtime ergonomics (chainable API, descriptions, defaults) and full type inference (so that when I define --port <number>, the result type knows port is a number, not string | undefined). That is why I built @velnora/cli-helper—a thin, Commander-like API that infers the full option type from the definition using inferCommandType<typeof command>.

With that in place, the lazy developer in me took it one step further: split the CLI into two separate packages. Same practice from previous versions.

  1. @velnora/commands (the helper package) — exports the command definitions and their inferred types.
  2. @velnora/cli — imports those commands and executes them. Thin wrapper. No business logic.

The commands package defines each command and exports both the command object and its inferred type:

export const devCommand = program
  .command("dev")
  .description("Start the development server in watch mode.")
  .option("--host <string>", { description: "Host to run the development server on" })
  .option("--port <number>, -p", { description: "Port to run the development server on" })
  .option("--watch, -w", { description: "Enable watch mode", default: false })
  .option("--mode <development|production>", { description: "Set the mode", default: "development" })
  .option("--root <string>", { description: "Root directory of the project", default: "." });

export type DevCommandOptions = inferCommandType<typeof devCommand>;
Enter fullscreen mode Exit fullscreen mode

View the full source on GitHub

By exporting DevCommandOptions from the commands package, I use it as the typing for the Host application and the Kernel's bootHost() functionality. No duplication. The types are inferred directly from the command definition—if I add a new flag, the entire chain updates automatically.

@velnora/commands  →  defines commands + exports types (DevCommandOptions)
       ↓
@velnora/kernel   →  imports DevCommandOptions for bootHost()
       ↓
@velnora/host     →  imports DevCommandOptions for server startup
       ↓
@velnora/cli      →  imports commands, executes them, passes options down
Enter fullscreen mode Exit fullscreen mode

This is the kind of lazy that pays off. One source of truth for the command shape, and TypeScript does the rest.

v0.0.1 — It Runs 😄

Not ready for publish (don't get excited), but it runs. And that matters.

Here is what velnora dev gives you today. A request to the root / returns a JSON overview of the entire workspace:

{
  "velnora": true,
  "projects": [
    {
      "name": "packages/app-one",
      "displayName": "@example/app-one",
      "path": "/@example/app-one"
    },
    {
      "name": "libs/lib-one",
      "displayName": "@example/lib-one",
      "path": "/@example/lib-one"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This is temporary—eventually the root will serve the Velnora dashboard. But right now, it proves the pipeline works: CLI → Kernel → Host → discovered projects → HTTP response.

And for each project path, the Host returns a small status object:

{
  "name": "libs/lib-one",
  "displayName": "@example/lib-one",
  "root": "<project-root>/libs/lib-one",
  "status": "registered"
}
Enter fullscreen mode Exit fullscreen mode

Discovery works. The Kernel boots. The Host listens. Projects are registered and routed. It is not much—but it is real. The skeleton is alive.

Top comments (0)