From 59903d6d5e95c670d323d39ec7e0fdb442655a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Tue, 4 Aug 2026 17:41:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=20Extension=20ID=20=E8=A3=85?= =?UTF-8?q?=E8=BD=BD=E5=88=9D=E5=A7=8B=E5=8C=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/architecture.md | 12 +++ docs/data-layout.md | 23 ++++ docs/source-layout.md | 2 + src/config.test.ts | 79 ++++++++++++++ src/config.ts | 104 +++++++++++++++++++ src/extensions/chat.test.ts | 33 +++--- src/extensions/cli/index.ts | 4 +- src/extensions/shared/agent/index.ts | 4 +- src/extensions/shared/deepseek/index.test.ts | 60 +++++++++++ src/extensions/shared/deepseek/index.ts | 31 ++++-- src/extensions/shared/workspace/index.ts | 38 +++++-- src/kernel/extension.ts | 7 +- src/kernel/kernel.test.ts | 83 +++++++++++---- src/kernel/kernel.ts | 39 +++++-- src/main.ts | 14 ++- 16 files changed, 464 insertions(+), 71 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/extensions/shared/deepseek/index.test.ts diff --git a/README.md b/README.md index 4c7c210..dbef823 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ pnpm dev CLI 支持 `/new` 新建对话、`/switch ` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。 +Runtime 会读取 `/config/extensions.json` 和当前 Workspace 下同结构的 `config/extensions.json`;文件缺失时继续使用默认值,不会自动创建。 + 文档: - [架构总览](docs/architecture.md) diff --git a/docs/architecture.md b/docs/architecture.md index 3b29ab1..a5d2d1d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,18 @@ Extension 是 Runtime 的独立装配单位,分为三类: 最终能力归属见 [Extension 规划](extensions.md)。 +## 配置 + +Runtime 启动时读取全局和当前 Workspace 的可选配置,Workspace 配置按字段覆盖全局配置。产品装配向 Kernel 提供 Extension 工厂函数,每个工厂函数用自身的静态 `id` 表明身份: + +```ts +createDeepSeekExtension.id = ExtensionId.DeepSeek; +``` + +`kernel.use(createDeepSeekExtension)` 根据这个 `id` 取得对应配置,调用 `createDeepSeekExtension(options)`,再保存创建出的 Extension 实例。配置文件位置、作用域和合并规则都不进入 Kernel。 + +Extension 负责解释和校验自己的片段。第一版配置只在启动时读取,不创建缺失文件、不写入默认值,也不支持热更新。 + ## 扩展点 多个实现向能力所有者登记,不再为扩展点增加新的架构层: diff --git a/docs/data-layout.md b/docs/data-layout.md index 5b1b869..4c252e2 100644 --- a/docs/data-layout.md +++ b/docs/data-layout.md @@ -15,9 +15,12 @@ ```text / ├── config/ +│ └── extensions.json ├── workspaces/ │ └── / │ ├── workspace.json +│ ├── config/ +│ │ └── extensions.json │ ├── files/ │ ├── conversations/ │ ├── artifacts/ @@ -32,6 +35,26 @@ 目录按需要创建,不预建尚未使用的层级。 +## Extension 配置 + +全局和 Workspace 配置使用相同结构: + +```json +{ + "version": 1, + "extensions": { + "models": { + "defaultProvider": "deepseek" + }, + "deepseek": { + "model": "deepseek-v4-flash" + } + } +} +``` + +Runtime 先读取全局配置,再使用 Workspace 中同 Extension、同字段的值覆盖它。两个文件都是可选的;第一版只读,不自动创建、补全或修改配置文件。 + ## 数据归属 - `workspace` 管理 Workspace、Conversation、Message 和 Artifact; diff --git a/docs/source-layout.md b/docs/source-layout.md index 715e7b2..70aa3b6 100644 --- a/docs/source-layout.md +++ b/docs/source-layout.md @@ -13,6 +13,7 @@ llm-to-agent/ │ │ ├── web/ │ │ └── desktop/ │ ├── products/ +│ ├── config.ts │ └── main.ts ├── docs/ ├── tests/ @@ -28,6 +29,7 @@ llm-to-agent/ - `extensions/shared/`:跨产品使用的能力和 Provider; - `extensions/cli|web|desktop/`:产品输入、展示和平台集成; - `products/`:静态选择产品启用的 Extension,不写业务逻辑; +- `config.ts`:读取并合并全局与 Workspace Extension 配置; - `tests/`:跨 Extension、跨进程或跨版本测试; - `tooling/`:构建、开发和发布辅助。 diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..96ca37f --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { loadRuntimeConfig, workspaceIdFor } from "./config"; + +test("merges global and workspace extension configs", async (t) => { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-")); + const home = join(temporaryDirectory, "home"); + const projectPath = join(temporaryDirectory, "project"); + const workspaceId = workspaceIdFor(projectPath); + t.after(() => rm(temporaryDirectory, { recursive: true, force: true })); + + await mkdir(join(home, "config"), { recursive: true }); + await mkdir(join(home, "workspaces", workspaceId, "config"), { + recursive: true, + }); + await writeFile( + join(home, "config", "extensions.json"), + JSON.stringify({ + version: 1, + extensions: { + models: { defaultProvider: "deepseek" }, + shell: { timeoutMs: 30_000, maxOutputLength: 50_000 }, + }, + }), + ); + await writeFile( + join(home, "workspaces", workspaceId, "config", "extensions.json"), + JSON.stringify({ + version: 1, + extensions: { + shell: { timeoutMs: 120_000 }, + project: { checks: ["pnpm test"] }, + }, + }), + ); + + const config = await loadRuntimeConfig({ home, projectPath }); + + assert.equal(config.workspaceId, workspaceId); + assert.deepEqual(config.extensions, { + models: { defaultProvider: "deepseek" }, + shell: { timeoutMs: 120_000, maxOutputLength: 50_000 }, + project: { checks: ["pnpm test"] }, + }); +}); + +test("does not create missing config files", async (t) => { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-")); + const home = join(temporaryDirectory, "home"); + const projectPath = join(temporaryDirectory, "project"); + t.after(() => rm(temporaryDirectory, { recursive: true, force: true })); + + const config = await loadRuntimeConfig({ home, projectPath }); + + assert.deepEqual(config.extensions, {}); + assert.deepEqual(await readdir(temporaryDirectory), []); +}); + +test("rejects invalid extension config sections", async (t) => { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-")); + const home = join(temporaryDirectory, "home"); + const projectPath = join(temporaryDirectory, "project"); + t.after(() => rm(temporaryDirectory, { recursive: true, force: true })); + + await mkdir(join(home, "config"), { recursive: true }); + await writeFile( + join(home, "config", "extensions.json"), + JSON.stringify({ version: 1, extensions: { models: "deepseek" } }), + ); + + await assert.rejects( + loadRuntimeConfig({ home, projectPath }), + /invalid "models" section/, + ); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..56f0d62 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +import type { ExtensionConfig } from "./kernel"; + +export type ExtensionConfigs = Record; + +export interface RuntimeConfigOptions { + home?: string; + projectPath?: string; +} + +export interface RuntimeConfig { + home: string; + projectPath: string; + workspaceId: string; + extensions: ExtensionConfigs; +} + +export function workspaceIdFor(projectPath: string): string { + return createHash("sha256").update(resolve(projectPath)).digest("hex").slice(0, 16); +} + +export async function loadRuntimeConfig( + options: RuntimeConfigOptions = {}, +): Promise { + const home = resolve( + options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"), + ); + const projectPath = resolve(options.projectPath ?? process.cwd()); + const workspaceId = workspaceIdFor(projectPath); + const globalFile = join(home, "config", "extensions.json"); + const workspaceFile = join( + home, + "workspaces", + workspaceId, + "config", + "extensions.json", + ); + + async function readExtensions(file: string): Promise { + let content: string; + + try { + content = await readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw error; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(content); + } catch (error) { + throw new Error(`Invalid JSON in extension config "${file}".`, { + cause: error, + }); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Extension config "${file}" must be an object.`); + } + + const document = parsed as Record; + if (document.version !== 1) { + throw new Error(`Extension config "${file}" must use version 1.`); + } + if ( + !document.extensions || + typeof document.extensions !== "object" || + Array.isArray(document.extensions) + ) { + throw new Error(`Extension config "${file}" must contain extensions.`); + } + + const extensions: ExtensionConfigs = {}; + for (const [id, config] of Object.entries(document.extensions)) { + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error( + `Extension config "${file}" contains an invalid "${id}" section.`, + ); + } + extensions[id] = config as ExtensionConfig; + } + + return extensions; + } + + const globalExtensions = await readExtensions(globalFile); + const workspaceExtensions = await readExtensions(workspaceFile); + const extensions: ExtensionConfigs = {}; + + for (const [id, config] of Object.entries(globalExtensions)) { + extensions[id] = { ...config }; + } + for (const [id, config] of Object.entries(workspaceExtensions)) { + extensions[id] = { ...extensions[id], ...config }; + } + + return { home, projectPath, workspaceId, extensions }; +} diff --git a/src/extensions/chat.test.ts b/src/extensions/chat.test.ts index 58fc61d..4ba34c5 100644 --- a/src/extensions/chat.test.ts +++ b/src/extensions/chat.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import test from "node:test"; import { Kernel } from "../kernel"; -import { Hook } from "./catalog"; +import { ExtensionId, Hook } from "./catalog"; import { createAgentExtension, type AgentService, @@ -51,10 +51,14 @@ test("streams a reply, saves it, and restores the conversation after restart", a }); }; - const firstKernel = new Kernel().use( - createWorkspaceExtension({ home, projectPath }), - createDeepSeekExtension({ apiKey: "test-key", request }), - createAgentExtension(), + const extensionConfigs = { + [ExtensionId.Workspace]: { home, projectPath }, + [ExtensionId.DeepSeek]: { apiKey: "test-key", request }, + }; + const firstKernel = new Kernel({ extensionConfigs }).use( + createWorkspaceExtension, + createDeepSeekExtension, + createAgentExtension, ); await firstKernel.start(); @@ -67,10 +71,10 @@ test("streams a reply, saves it, and restores the conversation after restart", a assert.equal(firstAnswer, "第一次回答"); await firstKernel.stop(); - const secondKernel = new Kernel().use( - createWorkspaceExtension({ home, projectPath }), - createDeepSeekExtension({ apiKey: "test-key", request }), - createAgentExtension(), + const secondKernel = new Kernel({ extensionConfigs }).use( + createWorkspaceExtension, + createDeepSeekExtension, + createAgentExtension, ); await secondKernel.start(); @@ -109,8 +113,11 @@ test("creates a conversation and restores it after restart", async (t) => { await mkdir(projectPath); t.after(() => rm(temporaryDirectory, { recursive: true, force: true })); - const firstKernel = new Kernel().use( - createWorkspaceExtension({ home, projectPath }), + const extensionConfigs = { + [ExtensionId.Workspace]: { home, projectPath }, + }; + const firstKernel = new Kernel({ extensionConfigs }).use( + createWorkspaceExtension, ); await firstKernel.start(); @@ -129,8 +136,8 @@ test("creates a conversation and restores it after restart", async (t) => { assert.equal(await firstWorkspace.switchConversation(conversationId), true); await firstKernel.stop(); - const secondKernel = new Kernel().use( - createWorkspaceExtension({ home, projectPath }), + const secondKernel = new Kernel({ extensionConfigs }).use( + createWorkspaceExtension, ); await secondKernel.start(); diff --git a/src/extensions/cli/index.ts b/src/extensions/cli/index.ts index 8f748c7..d0e6483 100644 --- a/src/extensions/cli/index.ts +++ b/src/extensions/cli/index.ts @@ -15,8 +15,6 @@ export function createCliExtension(): Extension { let currentRequest: AbortController | undefined; return { - id: ExtensionId.Cli, - setup() {}, start(context) { @@ -125,3 +123,5 @@ export function createCliExtension(): Extension { }, }; } + +createCliExtension.id = ExtensionId.Cli; diff --git a/src/extensions/shared/agent/index.ts b/src/extensions/shared/agent/index.ts index ed244b4..32eb633 100644 --- a/src/extensions/shared/agent/index.ts +++ b/src/extensions/shared/agent/index.ts @@ -68,8 +68,6 @@ export function createAgentExtension(): Extension { }; return { - id: ExtensionId.Agent, - setup(context) { context.add(Hook.Agent, agent); }, @@ -93,3 +91,5 @@ export function createAgentExtension(): Extension { }, }; } + +createAgentExtension.id = ExtensionId.Agent; diff --git a/src/extensions/shared/deepseek/index.test.ts b/src/extensions/shared/deepseek/index.test.ts new file mode 100644 index 0000000..06d365c --- /dev/null +++ b/src/extensions/shared/deepseek/index.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Kernel } from "../../../kernel"; +import { ExtensionId, Hook } from "../../catalog"; +import type { ModelProvider } from "../agent"; +import { createDeepSeekExtension } from "."; + +test("reads DeepSeek settings from extension config", async (t) => { + const environment = { + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, + DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL, + DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL, + }; + delete process.env.DEEPSEEK_API_KEY; + delete process.env.DEEPSEEK_BASE_URL; + delete process.env.DEEPSEEK_MODEL; + t.after(() => { + for (const [name, value] of Object.entries(environment)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + let requestUrl = ""; + let requestInit: RequestInit | undefined; + const request = async (url: string, init: RequestInit) => { + requestUrl = url; + requestInit = init; + return new Response("data: [DONE]\n\n"); + }; + const kernel = new Kernel({ + extensionConfigs: { + [ExtensionId.DeepSeek]: { + apiKey: "config-key", + baseUrl: "https://example.test/v1/", + model: "config-model", + request, + }, + }, + }).use(createDeepSeekExtension); + await kernel.start(); + + const provider = kernel.all(Hook.ModelProviders)[0]; + assert.ok(provider); + + for await (const _ of provider.chat([ + { role: "user", content: "你好", createdAt: "2026-08-04T00:00:00.000Z" }, + ])) { + // The test response contains no text chunks. + } + + assert.equal(requestUrl, "https://example.test/v1/chat/completions"); + assert.equal( + (requestInit?.headers as Record).authorization, + "Bearer config-key", + ); + assert.equal(JSON.parse(String(requestInit?.body)).model, "config-model"); + await kernel.stop(); +}); diff --git a/src/extensions/shared/deepseek/index.ts b/src/extensions/shared/deepseek/index.ts index 4447489..a142642 100644 --- a/src/extensions/shared/deepseek/index.ts +++ b/src/extensions/shared/deepseek/index.ts @@ -1,4 +1,4 @@ -import type { Extension } from "../../../kernel"; +import type { Extension, ExtensionConfig } from "../../../kernel"; import { ExtensionId, Hook } from "../../catalog"; import type { ModelProvider } from "../agent"; @@ -9,15 +9,30 @@ export interface DeepSeekOptions { request?: (url: string, init: RequestInit) => Promise; } -export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extension { - const apiKey = options.apiKey ?? process.env.DEEPSEEK_API_KEY; +export function createDeepSeekExtension(options: ExtensionConfig = {}): Extension { + if (options.apiKey !== undefined && typeof options.apiKey !== "string") { + throw new Error("deepseek.apiKey must be a string."); + } + if (options.baseUrl !== undefined && typeof options.baseUrl !== "string") { + throw new Error("deepseek.baseUrl must be a string."); + } + if (options.model !== undefined && typeof options.model !== "string") { + throw new Error("deepseek.model must be a string."); + } + if (options.request !== undefined && typeof options.request !== "function") { + throw new Error("deepseek.request must be a function."); + } + + const config = options as DeepSeekOptions; + const apiKey = process.env.DEEPSEEK_API_KEY ?? config.apiKey; const baseUrl = ( - options.baseUrl ?? process.env.DEEPSEEK_BASE_URL ?? + config.baseUrl ?? "https://api.deepseek.com" ).replace(/\/+$/, ""); - const model = options.model ?? process.env.DEEPSEEK_MODEL ?? "deepseek-v4-flash"; - const request = options.request ?? fetch; + const model = + process.env.DEEPSEEK_MODEL ?? config.model ?? "deepseek-v4-flash"; + const request = config.request ?? fetch; const provider: ModelProvider = { id: "deepseek", @@ -83,8 +98,6 @@ export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extensio }; return { - id: ExtensionId.DeepSeek, - setup(context) { if (!apiKey) { throw new Error("DEEPSEEK_API_KEY is required."); @@ -94,3 +107,5 @@ export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extensio }, }; } + +createDeepSeekExtension.id = ExtensionId.DeepSeek; diff --git a/src/extensions/shared/workspace/index.ts b/src/extensions/shared/workspace/index.ts index c2a7075..a0dad83 100644 --- a/src/extensions/shared/workspace/index.ts +++ b/src/extensions/shared/workspace/index.ts @@ -1,9 +1,10 @@ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import type { Extension } from "../../../kernel"; +import { workspaceIdFor } from "../../../config"; +import type { Extension, ExtensionConfig } from "../../../kernel"; import { ExtensionId, Hook } from "../../catalog"; export type MessageRole = "system" | "user" | "assistant"; @@ -31,16 +32,33 @@ export interface WorkspaceOptions { conversationId?: string; } -export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extension { +export function createWorkspaceExtension(options: ExtensionConfig = {}): Extension { + if (options.home !== undefined && typeof options.home !== "string") { + throw new Error("workspace.home must be a string."); + } + if ( + options.projectPath !== undefined && + typeof options.projectPath !== "string" + ) { + throw new Error("workspace.projectPath must be a string."); + } + if ( + options.conversationId !== undefined && + typeof options.conversationId !== "string" + ) { + throw new Error("workspace.conversationId must be a string."); + } + + const config = options as WorkspaceOptions; const home = resolve( - options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"), + config.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"), ); - const projectPath = resolve(options.projectPath ?? process.cwd()); - const workspaceId = createHash("sha256").update(projectPath).digest("hex").slice(0, 16); + const projectPath = resolve(config.projectPath ?? process.cwd()); + const workspaceId = workspaceIdFor(projectPath); const workspaceDirectory = join(home, "workspaces", workspaceId); const conversationsDirectory = join(workspaceDirectory, "conversations"); const workspaceFile = join(workspaceDirectory, "workspace.json"); - let conversationId = options.conversationId ?? "default"; + let conversationId = config.conversationId ?? "default"; async function saveWorkspace() { await writeFile( @@ -123,12 +141,10 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens }; return { - id: ExtensionId.Workspace, - async setup(context) { await mkdir(workspaceDirectory, { recursive: true }); - if (!options.conversationId) { + if (!config.conversationId) { try { const savedWorkspace = JSON.parse(await readFile(workspaceFile, "utf8")); if (savedWorkspace.activeConversationId) { @@ -146,3 +162,5 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens }, }; } + +createWorkspaceExtension.id = ExtensionId.Workspace; diff --git a/src/kernel/extension.ts b/src/kernel/extension.ts index d0c0b50..e0eb152 100644 --- a/src/kernel/extension.ts +++ b/src/kernel/extension.ts @@ -1,6 +1,7 @@ import type { EventHandler, Unsubscribe } from "./events"; export type Awaitable = T | Promise; +export type ExtensionConfig = Record; export interface ExtensionSetupContext { add(name: string, value: T): void; @@ -15,10 +16,12 @@ export interface ExtensionRuntimeContext { } export interface Extension { - id: string; setup(context: ExtensionSetupContext): Awaitable; start?(context: ExtensionRuntimeContext): Awaitable; stop?(context: ExtensionRuntimeContext): Awaitable; } -export type ExtensionFactory = () => Extension; +export interface ExtensionFactory { + id: string; + (options?: ExtensionConfig): Extension; +} diff --git a/src/kernel/kernel.test.ts b/src/kernel/kernel.test.ts index d3fb59d..ff4e06c 100644 --- a/src/kernel/kernel.test.ts +++ b/src/kernel/kernel.test.ts @@ -1,13 +1,28 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { EventBus, Kernel, type Extension } from "./index"; +import { + EventBus, + Kernel, + type Extension, + type ExtensionFactory, +} from "./index"; + +function extensionFactory( + id: string, + createExtension: () => Extension, +): ExtensionFactory { + function create() { + return createExtension(); + } + create.id = id; + return create; +} test("sets up every extension before starting them and stops in reverse order", async () => { const calls: string[] = []; - const first: Extension = { - id: "first", + const first = extensionFactory("first", () => ({ setup(context) { calls.push("first.setup"); context.add("test.values", "first"); @@ -18,10 +33,9 @@ test("sets up every extension before starting them and stops in reverse order", stop() { calls.push("first.stop"); }, - }; + })); - const second: Extension = { - id: "second", + const second = extensionFactory("second", () => ({ setup(context) { calls.push("second.setup"); context.add("test.values", "second"); @@ -32,7 +46,7 @@ test("sets up every extension before starting them and stops in reverse order", stop() { calls.push("second.stop"); }, - }; + })); const kernel = new Kernel().use(first, second); await kernel.start(); @@ -49,23 +63,53 @@ test("sets up every extension before starting them and stops in reverse order", }); test("rejects duplicate extension ids without partially installing a batch", () => { - const first: Extension = { id: "first", setup() {} }; - const duplicate: Extension = { id: "first", setup() {} }; + const first = extensionFactory("first", () => ({ setup() {} })); + const duplicate = extensionFactory("first", () => ({ setup() {} })); const kernel = new Kernel(); assert.throws(() => kernel.use(first, duplicate), /already installed/); assert.deepEqual(kernel.installedExtensionIds, []); }); +test("passes extension config by id when constructing", async () => { + const extensionConfigs = { + first: { value: "configured" }, + second: { enabled: true }, + }; + const received: Record[] = []; + + function createFirst(options: Record = {}) { + received.push(options); + return { setup() {} }; + } + createFirst.id = "first"; + + function createUnconfigured(options: Record = {}) { + received.push(options); + return { setup() {} }; + } + createUnconfigured.id = "unconfigured"; + + const kernel = new Kernel({ extensionConfigs }).use( + createFirst, + createUnconfigured, + ); + + extensionConfigs.first.value = "changed after use"; + await kernel.start(); + + assert.deepEqual(received, [{ value: "configured" }, {}]); + await kernel.stop(); +}); + test("gets one value or collects multiple values", async () => { - const kernel = new Kernel().use({ - id: "values", + const kernel = new Kernel().use(extensionFactory("values", () => ({ setup(context) { context.add("single", "one"); context.add("multiple", "one"); context.add("multiple", "two"); }, - }); + }))); await kernel.start(); @@ -76,13 +120,12 @@ test("gets one value or collects multiple values", async () => { }); test("clears registrations when setup fails", async () => { - const kernel = new Kernel().use({ - id: "broken-setup", + const kernel = new Kernel().use(extensionFactory("broken-setup", () => ({ setup(context) { context.add("temporary", "value"); throw new Error("setup failed"); }, - }); + }))); await assert.rejects(kernel.start(), /setup failed/); @@ -93,21 +136,19 @@ test("clears registrations when setup fails", async () => { test("stops every active extension after cleanup errors", async () => { const calls: string[] = []; const kernel = new Kernel().use( - { - id: "first", + extensionFactory("first", () => ({ setup() {}, stop() { calls.push("first.stop"); }, - }, - { - id: "second", + })), + extensionFactory("second", () => ({ setup() {}, stop() { calls.push("second.stop"); throw new Error("cleanup failed"); }, - }, + })), ); await kernel.start(); diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 7faaef0..86bd6ec 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -1,23 +1,36 @@ import { EventBus, type EventHandler, type Unsubscribe } from "./events"; import type { Extension, + ExtensionConfig, + ExtensionFactory, ExtensionRuntimeContext, ExtensionSetupContext, } from "./extension"; import { ExtensionRegistry } from "./registry"; +export interface KernelOptions { + extensionConfigs?: Record; +} + +interface InstalledExtension { + id: string; + extension: Extension; +} + export class Kernel { - #extensions: Extension[] = []; + #extensions: InstalledExtension[] = []; #activeExtensions: Extension[] = []; #registry = new ExtensionRegistry(); #events = new EventBus(); #setupContext: ExtensionSetupContext; #runtimeContext: ExtensionRuntimeContext; + #extensionConfigs: Record; #state = "created"; - constructor() { + constructor(options: KernelOptions = {}) { const registry = this.#registry; const events = this.#events; + this.#extensionConfigs = options.extensionConfigs ?? {}; this.#setupContext = { add(name, value) { @@ -49,23 +62,27 @@ export class Kernel { } get installedExtensionIds(): string[] { - return this.#extensions.map((extension) => extension.id); + return this.#extensions.map(({ id }) => id); } - use(...extensions: Extension[]): this { + use(...factories: ExtensionFactory[]): this { if (this.#state !== "created") { throw new Error(`Cannot install extensions while kernel is ${this.#state}.`); } - const ids = new Set(this.#extensions.map((extension) => extension.id)); + const ids = new Set(this.#extensions.map(({ id }) => id)); - for (const extension of extensions) { - if (ids.has(extension.id)) { - throw new Error(`Extension "${extension.id}" is already installed.`); + for (const factory of factories) { + if (ids.has(factory.id)) { + throw new Error(`Extension "${factory.id}" is already installed.`); } - ids.add(extension.id); + ids.add(factory.id); } + const extensions = factories.map((factory) => ({ + id: factory.id, + extension: factory({ ...this.#extensionConfigs[factory.id] }), + })); this.#extensions.push(...extensions); return this; } @@ -94,11 +111,11 @@ export class Kernel { this.#state = "starting"; try { - for (const extension of this.#extensions) { + for (const { extension } of this.#extensions) { await extension.setup(this.#setupContext); } - for (const extension of this.#extensions) { + for (const { extension } of this.#extensions) { this.#activeExtensions.push(extension); await extension.start?.(this.#runtimeContext); } diff --git a/src/main.ts b/src/main.ts index 9f79375..f3d583b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ import { existsSync } from "node:fs"; import { loadEnvFile } from "node:process"; -import { Event } from "./extensions/catalog"; +import { loadRuntimeConfig } from "./config"; +import { Event, ExtensionId } from "./extensions/catalog"; import { Kernel } from "./kernel"; import { getProduct, sharedExtensions } from "./products"; @@ -9,13 +10,22 @@ if (existsSync(".env")) { loadEnvFile(); } +const runtimeConfig = await loadRuntimeConfig(); const productIds = process.argv.slice(2); const products = (productIds.length > 0 ? productIds : ["cli"]).map(getProduct); const factories = [ ...sharedExtensions, ...products.flatMap((product) => product.extensions), ]; -const kernel = new Kernel().use(...factories.map((factory) => factory())); +const extensionConfigs = { + ...runtimeConfig.extensions, + [ExtensionId.Workspace]: { + ...runtimeConfig.extensions[ExtensionId.Workspace], + home: runtimeConfig.home, + projectPath: runtimeConfig.projectPath, + }, +}; +const kernel = new Kernel({ extensionConfigs }).use(...factories); const stopped = new Promise((resolve) => { kernel.on(Event.RuntimeStopRequested, resolve);