Runtime Package Interface (Design)

Status: proposal — not yet implemented.

The Package Manager (electron/src/packageManager.ts) currently branches per install backend (conda / npm / electron) for status, install, uninstall, update, and consumer resolution. This design replaces the branching with a single interface every runtime package implements, regardless of backend.

Goals

Core interface

interface RuntimePackage {
  // Identity
  readonly id: string;
  readonly name: string;
  readonly description: string;
  readonly category: "language" | "tool" | "library";
  readonly approxSizeMB?: number;
  readonly homepage?: string;
  readonly platforms?: NodeJS.Platform[];   // omit = all
  readonly dependsOn?: string[];            // other runtime ids

  // Version policy — declared compatible range
  readonly versionRange: string;            // semver range, e.g. ">=6 <7"

  // Probe — pure, fast, no side effects
  status(ctx: RuntimeContext): Promise<RuntimeStatus>;

  // Lifecycle — each independent and idempotent
  install(ctx: RuntimeContext, signal: AbortSignal): AsyncIterable<RuntimeProgress>;
  update(ctx: RuntimeContext, signal: AbortSignal): AsyncIterable<RuntimeProgress>;
  repair(ctx: RuntimeContext, signal: AbortSignal): AsyncIterable<RuntimeProgress>;
  uninstall(ctx: RuntimeContext): Promise<void>;

  // Consumer-facing resolution — what nodes need to USE the runtime
  resolve(ctx: RuntimeContext): Promise<RuntimeResolution | null>;
}

Supporting types

interface RuntimeContext {
  userDataDir: string;
  condaEnvPath: string;
  optionalNodeRoot: string;
  platform: NodeJS.Platform;
  arch: string;
  log(level: "info" | "warn" | "error", msg: string): void;
}

interface RuntimeStatus {
  installed: boolean;
  installedVersion?: string;
  latestVersion?: string;        // populated by registry
  updateAvailable?: boolean;     // computed by registry
  brokenReason?: string;         // partial / corrupted install
}

type RuntimeProgress =
  | { type: "stage"; label: string }
  | { type: "percent"; value: number }            // 0..100
  | { type: "log"; line: string; level?: "info" | "warn" }
  | { type: "done" }
  | { type: "error"; message: string };

interface RuntimeResolution {
  binPaths?: string[];                            // prepend to PATH for child procs
  env?: Record<string, string>;                   // env vars to inject
  binaries?: Record<string, string>;              // logical name → absolute path
  nodeModulePaths?: string[];                     // for require/import resolution
}

Design decisions

Versioning: ranges, not pins

versionRange is a semver range. The registry resolves the newest version satisfying the range when installing or updating.

Update is separate from install

Server cannot resolve runtimes (Electron-only for v1)

The registry lives in Electron. CLI / npm run dev:server cannot call resolve(). If a node needs a runtime in the server context, it must fail with a clear error directing the user to run via Electron or install the runtime manually.

Future expansion (moving the registry to a shared package) is possible but out of scope.

Repair is separate from install

repair() and status() share internal probe helpers; repair() re-probes from scratch rather than trusting a stale brokenReason from a previous status() call.

Resolution is literal

RuntimeResolution.binaries is a flat map of logical name → absolute path ({ ffmpeg: "/abs/path/to/ffmpeg" }). No semantic capability layer ({ canRunPython: true, ... }). Simpler, and good enough — multi-source capabilities (e.g. system Python vs conda Python) can be layered on later if ever needed.

Consumer usage

// In a node:
const ffmpeg = await registry.resolve("ffmpeg");
if (!ffmpeg) throw new Error("FFmpeg runtime not installed");
spawn(ffmpeg.binaries!.ffmpeg, ["-i", input, output], {
  env: { ...process.env, ...ffmpeg.env },
});

// In the UI:
const controller = new AbortController();
for await (const ev of pkg.install(ctx, controller.signal)) {
  if (ev.type === "percent") setProgress(ev.value);
  if (ev.type === "stage") setStage(ev.label);
  if (ev.type === "error") showError(ev.message);
}

Backend implementations

Most packages share install logic. Provide base classes:

Adding a new conda tool becomes one new CondaRuntimePackage({...}) entry, not a new class. Bespoke packages with non-standard install flows can implement RuntimePackage directly.

Registry responsibilities

The registry (replaces RUNTIME_DEFINITIONS) wraps the package list and provides cross-cutting concerns:

Migration

  1. Define interface and base classes in electron/src/runtime/packages/.
  2. Port existing entries from RUNTIME_DEFINITIONS to base-class instances.
  3. Replace installRuntimePackage / uninstallRuntimePackage callsites with registry methods.
  4. Add resolve() callsites in nodes that currently hardcode conda env paths.
  5. Remove the old RUNTIME_DEFINITIONS map and per-type branches.

Open items