feat: 按 Extension ID 装载初始化配置

This commit is contained in:
李岩岩 2026-08-04 17:41:28 +08:00 committed by liyy
parent 8fdf29b4e9
commit 88d9a8f5d6
16 changed files with 464 additions and 71 deletions

View File

@ -17,6 +17,8 @@ pnpm dev
CLI 支持 `/new` 新建对话、`/switch <id>` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。
Runtime 会读取 `<LLM_TO_AGENT_HOME>/config/extensions.json` 和当前 Workspace 下同结构的 `config/extensions.json`;文件缺失时继续使用默认值,不会自动创建。
文档:
- [架构总览](docs/architecture.md)

View File

@ -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 负责解释和校验自己的片段。第一版配置只在启动时读取,不创建缺失文件、不写入默认值,也不支持热更新。
## 扩展点
多个实现向能力所有者登记,不再为扩展点增加新的架构层:

View File

@ -15,9 +15,12 @@
```text
<LLM_TO_AGENT_HOME>/
├── config/
│ └── extensions.json
├── workspaces/
│ └── <workspace-id>/
│ ├── 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

View File

@ -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/`:构建、开发和发布辅助。

79
src/config.test.ts Normal file
View File

@ -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/,
);
});

104
src/config.ts Normal file
View File

@ -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<string, ExtensionConfig>;
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<RuntimeConfig> {
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<ExtensionConfigs> {
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<string, unknown>;
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 };
}

View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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<ModelProvider>(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<string, string>).authorization,
"Bearer config-key",
);
assert.equal(JSON.parse(String(requestInit?.body)).model, "config-model");
await kernel.stop();
});

View File

@ -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<Response>;
}
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;

View File

@ -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;

View File

@ -1,6 +1,7 @@
import type { EventHandler, Unsubscribe } from "./events";
export type Awaitable<T> = T | Promise<T>;
export type ExtensionConfig = Record<string, unknown>;
export interface ExtensionSetupContext {
add<T>(name: string, value: T): void;
@ -15,10 +16,12 @@ export interface ExtensionRuntimeContext {
}
export interface Extension {
id: string;
setup(context: ExtensionSetupContext): Awaitable<void>;
start?(context: ExtensionRuntimeContext): Awaitable<void>;
stop?(context: ExtensionRuntimeContext): Awaitable<void>;
}
export type ExtensionFactory = () => Extension;
export interface ExtensionFactory {
id: string;
(options?: ExtensionConfig): Extension;
}

View File

@ -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<string, unknown>[] = [];
function createFirst(options: Record<string, unknown> = {}) {
received.push(options);
return { setup() {} };
}
createFirst.id = "first";
function createUnconfigured(options: Record<string, unknown> = {}) {
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();

View File

@ -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<string, ExtensionConfig>;
}
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<string, ExtensionConfig>;
#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);
}

View File

@ -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<void>((resolve) => {
kernel.on(Event.RuntimeStopRequested, resolve);