first commit
This commit is contained in:
19
packages/plugin-runtime/package.json
Normal file
19
packages/plugin-runtime/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@remoteconn/plugin-runtime",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remoteconn/shared": "1.0.0"
|
||||
}
|
||||
}
|
||||
311
packages/plugin-runtime/src/core/pluginManager.ts
Normal file
311
packages/plugin-runtime/src/core/pluginManager.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { PluginHostApis, PluginPackage, PluginRecord, PluginFsAdapter, PluginCommand } from "../types/plugin";
|
||||
import { validatePluginPackage } from "./validator";
|
||||
|
||||
interface RuntimeSlot {
|
||||
pluginId: string;
|
||||
cleanupFns: Array<() => void>;
|
||||
styleDisposer?: () => void;
|
||||
api?: {
|
||||
onload?: (ctx: unknown) => Promise<void> | void;
|
||||
onunload?: () => Promise<void> | void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产可用插件管理器:
|
||||
* 1. 安装校验
|
||||
* 2. 生命周期
|
||||
* 3. 单插件熔断
|
||||
* 4. 权限最小化
|
||||
*/
|
||||
export class PluginManager {
|
||||
private readonly records = new Map<string, PluginRecord>();
|
||||
private readonly runtime = new Map<string, RuntimeSlot>();
|
||||
private readonly commands = new Map<string, PluginCommand>();
|
||||
|
||||
public constructor(
|
||||
private readonly fs: PluginFsAdapter,
|
||||
private readonly apis: PluginHostApis,
|
||||
private readonly options: {
|
||||
appVersion: string;
|
||||
mountStyle: (pluginId: string, css: string) => () => void;
|
||||
logger: (level: "info" | "warn" | "error", pluginId: string, message: string) => void;
|
||||
}
|
||||
) {}
|
||||
|
||||
public async bootstrap(): Promise<void> {
|
||||
const stored = await this.fs.readStore<PluginRecord[]>("plugin_records", []);
|
||||
for (const record of stored) {
|
||||
this.records.set(record.id, record);
|
||||
}
|
||||
|
||||
const packages = await this.fs.listPackages();
|
||||
for (const item of packages) {
|
||||
if (!this.records.has(item.manifest.id)) {
|
||||
this.records.set(item.manifest.id, this.createRecord(item.manifest.id));
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistRecords();
|
||||
for (const record of this.records.values()) {
|
||||
if (record.enabled) {
|
||||
try {
|
||||
await this.enable(record.id);
|
||||
} catch (error) {
|
||||
this.options.logger("error", record.id, `自动启用失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async installPackage(payload: PluginPackage): Promise<void> {
|
||||
const valid = validatePluginPackage(payload);
|
||||
await this.fs.upsertPackage(valid);
|
||||
const record = this.records.get(valid.manifest.id) ?? this.createRecord(valid.manifest.id);
|
||||
record.status = "validated";
|
||||
record.updatedAt = new Date().toISOString();
|
||||
record.lastError = "";
|
||||
this.records.set(valid.manifest.id, record);
|
||||
await this.persistRecords();
|
||||
}
|
||||
|
||||
public async enable(pluginId: string): Promise<void> {
|
||||
const pluginPackage = await this.fs.getPackage(pluginId);
|
||||
if (!pluginPackage) {
|
||||
throw new Error("插件不存在");
|
||||
}
|
||||
|
||||
const validPackage = validatePluginPackage(pluginPackage);
|
||||
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
|
||||
|
||||
if (record.errorCount >= 3) {
|
||||
throw new Error("插件已熔断,请先重载后再启用");
|
||||
}
|
||||
|
||||
if (this.runtime.has(pluginId)) {
|
||||
await this.disable(pluginId);
|
||||
}
|
||||
|
||||
record.status = "loading";
|
||||
record.lastError = "";
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
|
||||
const runtime: RuntimeSlot = {
|
||||
pluginId,
|
||||
cleanupFns: []
|
||||
};
|
||||
|
||||
try {
|
||||
runtime.styleDisposer = this.options.mountStyle(pluginId, validPackage.stylesCss);
|
||||
const ctx = this.createContext(validPackage.manifest, runtime);
|
||||
runtime.api = this.loadPluginApi(validPackage.mainJs, ctx);
|
||||
|
||||
if (runtime.api?.onload) {
|
||||
await this.runWithTimeout(Promise.resolve(runtime.api.onload(ctx)), 3000, "onload 超时");
|
||||
}
|
||||
|
||||
record.enabled = true;
|
||||
record.status = "active";
|
||||
record.lastLoadedAt = new Date().toISOString();
|
||||
this.runtime.set(pluginId, runtime);
|
||||
await this.persistRecords();
|
||||
this.options.logger("info", pluginId, "插件已启用");
|
||||
} catch (error) {
|
||||
runtime.styleDisposer?.();
|
||||
record.enabled = false;
|
||||
record.status = "failed";
|
||||
record.errorCount += 1;
|
||||
record.lastError = (error as Error).message;
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
this.options.logger("error", pluginId, `启用失败: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async disable(pluginId: string): Promise<void> {
|
||||
const runtime = this.runtime.get(pluginId);
|
||||
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
|
||||
|
||||
if (runtime) {
|
||||
try {
|
||||
if (runtime.api?.onunload) {
|
||||
await this.runWithTimeout(Promise.resolve(runtime.api.onunload()), 3000, "onunload 超时");
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger("warn", pluginId, `onunload 异常: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
for (const cleanup of runtime.cleanupFns) {
|
||||
cleanup();
|
||||
}
|
||||
runtime.styleDisposer?.();
|
||||
this.runtime.delete(pluginId);
|
||||
}
|
||||
|
||||
for (const id of Array.from(this.commands.keys())) {
|
||||
if (id.startsWith(`${pluginId}:`)) {
|
||||
this.commands.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
record.enabled = false;
|
||||
if (record.status !== "failed") {
|
||||
record.status = "stopped";
|
||||
}
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
}
|
||||
|
||||
public async reload(pluginId: string): Promise<void> {
|
||||
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
|
||||
record.errorCount = 0;
|
||||
record.lastError = "";
|
||||
this.records.set(pluginId, record);
|
||||
await this.persistRecords();
|
||||
|
||||
await this.disable(pluginId);
|
||||
await this.enable(pluginId);
|
||||
}
|
||||
|
||||
public async remove(pluginId: string): Promise<void> {
|
||||
await this.disable(pluginId);
|
||||
await this.fs.removePackage(pluginId);
|
||||
this.records.delete(pluginId);
|
||||
await this.persistRecords();
|
||||
}
|
||||
|
||||
public listRecords(): PluginRecord[] {
|
||||
return Array.from(this.records.values());
|
||||
}
|
||||
|
||||
public listCommands(sessionState: "connected" | "disconnected"): PluginCommand[] {
|
||||
return Array.from(this.commands.values()).filter((command) => {
|
||||
if (command.when === "connected") {
|
||||
return sessionState === "connected";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public async runCommand(commandId: string): Promise<void> {
|
||||
const command = this.commands.get(commandId);
|
||||
if (!command) {
|
||||
throw new Error("命令不存在");
|
||||
}
|
||||
await Promise.resolve(command.handler());
|
||||
}
|
||||
|
||||
private createContext(manifest: PluginPackage["manifest"], runtime: RuntimeSlot): unknown {
|
||||
const assertPermission = (permission: string): void => {
|
||||
if (!manifest.permissions.includes(permission as never)) {
|
||||
throw new Error(`权限不足: ${permission}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
app: this.apis.getAppMeta(),
|
||||
commands: {
|
||||
register: (command: PluginCommand) => {
|
||||
assertPermission("commands.register");
|
||||
const id = `${manifest.id}:${command.id}`;
|
||||
this.commands.set(id, {
|
||||
...command,
|
||||
id
|
||||
});
|
||||
runtime.cleanupFns.push(() => this.commands.delete(id));
|
||||
}
|
||||
},
|
||||
session: {
|
||||
send: async (input: string) => {
|
||||
assertPermission("session.write");
|
||||
await this.apis.session.send(input);
|
||||
},
|
||||
on: (eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void) => {
|
||||
assertPermission("session.read");
|
||||
const off = this.apis.session.on(eventName, handler);
|
||||
runtime.cleanupFns.push(off);
|
||||
return off;
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
get: async (key: string) => {
|
||||
assertPermission("storage.read");
|
||||
const value = await this.fs.readStore<Record<string, unknown>>(`plugin_data_${manifest.id}`, {});
|
||||
return value[key];
|
||||
},
|
||||
set: async (key: string, value: unknown) => {
|
||||
assertPermission("storage.write");
|
||||
const previous = await this.fs.readStore<Record<string, unknown>>(`plugin_data_${manifest.id}`, {});
|
||||
previous[key] = value;
|
||||
await this.fs.writeStore(`plugin_data_${manifest.id}`, previous);
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
showNotice: (message: string, level: "info" | "warn" | "error" = "info") => {
|
||||
assertPermission("ui.notice");
|
||||
this.apis.showNotice(message, level);
|
||||
}
|
||||
},
|
||||
logger: {
|
||||
info: (...args: string[]) => this.options.logger("info", manifest.id, args.join(" ")),
|
||||
warn: (...args: string[]) => this.options.logger("warn", manifest.id, args.join(" ")),
|
||||
error: (...args: string[]) => this.options.logger("error", manifest.id, args.join(" "))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private loadPluginApi(mainJs: string, ctx: unknown): RuntimeSlot["api"] {
|
||||
const module = { exports: {} as RuntimeSlot["api"] };
|
||||
const exportsRef = module.exports;
|
||||
|
||||
const fn = new Function(
|
||||
"ctx",
|
||||
"module",
|
||||
"exports",
|
||||
`"use strict";
|
||||
const window = undefined;
|
||||
const document = undefined;
|
||||
const localStorage = undefined;
|
||||
const globalThis = undefined;
|
||||
${mainJs}
|
||||
return module.exports;`
|
||||
);
|
||||
|
||||
return (fn(ctx, module, exportsRef) as RuntimeSlot["api"]) ?? module.exports;
|
||||
}
|
||||
|
||||
private async runWithTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createRecord(id: string): PluginRecord {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
enabled: false,
|
||||
status: "discovered",
|
||||
errorCount: 0,
|
||||
lastError: "",
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
lastLoadedAt: ""
|
||||
};
|
||||
}
|
||||
|
||||
private async persistRecords(): Promise<void> {
|
||||
await this.fs.writeStore("plugin_records", Array.from(this.records.values()));
|
||||
}
|
||||
}
|
||||
23
packages/plugin-runtime/src/core/validator.test.ts
Normal file
23
packages/plugin-runtime/src/core/validator.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validatePluginPackage } from "./validator";
|
||||
|
||||
describe("plugin validator", () => {
|
||||
it("通过最小合法插件", () => {
|
||||
expect(() =>
|
||||
validatePluginPackage({
|
||||
manifest: {
|
||||
id: "codex-shortcuts",
|
||||
name: "Codex Shortcuts",
|
||||
version: "0.1.0",
|
||||
minAppVersion: "0.1.0",
|
||||
description: "test",
|
||||
entry: "main.js",
|
||||
style: "styles.css",
|
||||
permissions: ["commands.register"]
|
||||
},
|
||||
mainJs: "module.exports = {};",
|
||||
stylesCss: ".x { color: red; }"
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
55
packages/plugin-runtime/src/core/validator.ts
Normal file
55
packages/plugin-runtime/src/core/validator.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { PERMISSION_WHITELIST, type PluginPackage } from "../types/plugin";
|
||||
|
||||
/**
|
||||
* 插件包静态校验。
|
||||
*/
|
||||
export function validatePluginPackage(pluginPackage: PluginPackage): PluginPackage {
|
||||
const manifest = pluginPackage?.manifest;
|
||||
if (!manifest) {
|
||||
throw new Error("缺少 manifest");
|
||||
}
|
||||
|
||||
const required = ["id", "name", "version", "minAppVersion", "description", "entry", "style", "permissions"];
|
||||
for (const key of required) {
|
||||
if (!(key in manifest)) {
|
||||
throw new Error(`manifest 缺少字段: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(manifest.id)) {
|
||||
throw new Error("插件 id 不符合规范");
|
||||
}
|
||||
|
||||
if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) {
|
||||
throw new Error("version 必须是 SemVer,例如 0.1.0");
|
||||
}
|
||||
|
||||
if (!/^\d+\.\d+\.\d+$/.test(manifest.minAppVersion)) {
|
||||
throw new Error("minAppVersion 必须是 SemVer");
|
||||
}
|
||||
|
||||
if (manifest.entry !== "main.js" || manifest.style !== "styles.css") {
|
||||
throw new Error("entry/style 目前固定为 main.js / styles.css");
|
||||
}
|
||||
|
||||
const allowed = new Set(PERMISSION_WHITELIST);
|
||||
for (const permission of manifest.permissions || []) {
|
||||
if (!allowed.has(permission)) {
|
||||
throw new Error(`未知权限: ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pluginPackage.mainJs?.trim()) {
|
||||
throw new Error("mainJs 不能为空");
|
||||
}
|
||||
|
||||
if (pluginPackage.stylesCss == null) {
|
||||
throw new Error("stylesCss 不能为空");
|
||||
}
|
||||
|
||||
if (/^\s*(\*|body|html)\s*[{,]/m.test(pluginPackage.stylesCss)) {
|
||||
throw new Error("styles.css 禁止全局选择器(* / body / html)");
|
||||
}
|
||||
|
||||
return pluginPackage;
|
||||
}
|
||||
3
packages/plugin-runtime/src/index.ts
Normal file
3
packages/plugin-runtime/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types/plugin";
|
||||
export * from "./core/pluginManager";
|
||||
export * from "./core/validator";
|
||||
69
packages/plugin-runtime/src/types/plugin.ts
Normal file
69
packages/plugin-runtime/src/types/plugin.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export const PERMISSION_WHITELIST = [
|
||||
"commands.register",
|
||||
"session.read",
|
||||
"session.write",
|
||||
"ui.notice",
|
||||
"ui.statusbar",
|
||||
"storage.read",
|
||||
"storage.write",
|
||||
"logs.read"
|
||||
] as const;
|
||||
|
||||
export type PluginPermission = (typeof PERMISSION_WHITELIST)[number];
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
minAppVersion: string;
|
||||
description: string;
|
||||
entry: "main.js";
|
||||
style: "styles.css";
|
||||
permissions: PluginPermission[];
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface PluginPackage {
|
||||
manifest: PluginManifest;
|
||||
mainJs: string;
|
||||
stylesCss: string;
|
||||
}
|
||||
|
||||
export interface PluginRecord {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
status: "discovered" | "validated" | "loading" | "active" | "stopped" | "failed";
|
||||
errorCount: number;
|
||||
lastError?: string;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
lastLoadedAt?: string;
|
||||
}
|
||||
|
||||
export interface PluginCommand {
|
||||
id: string;
|
||||
title: string;
|
||||
when?: "always" | "connected";
|
||||
handler: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PluginFsAdapter {
|
||||
listPackages(): Promise<PluginPackage[]>;
|
||||
getPackage(pluginId: string): Promise<PluginPackage | null>;
|
||||
upsertPackage(pluginPackage: PluginPackage): Promise<void>;
|
||||
removePackage(pluginId: string): Promise<void>;
|
||||
readStore<T>(key: string, fallback: T): Promise<T>;
|
||||
writeStore<T>(key: string, value: T): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginSessionApi {
|
||||
send(input: string): Promise<void>;
|
||||
on(eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
export interface PluginHostApis {
|
||||
getAppMeta(): { version: string; platform: "web" | "ios" | "miniapp" };
|
||||
session: PluginSessionApi;
|
||||
showNotice(message: string, level: "info" | "warn" | "error"): void;
|
||||
}
|
||||
12
packages/plugin-runtime/tsconfig.json
Normal file
12
packages/plugin-runtime/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@remoteconn/shared": ["../shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
16
packages/shared/package.json
Normal file
16
packages/shared/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@remoteconn/shared",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
54
packages/shared/src/codex/orchestrator.test.ts
Normal file
54
packages/shared/src/codex/orchestrator.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCdCommand, buildCodexPlan } from "./orchestrator";
|
||||
|
||||
describe("buildCdCommand", () => {
|
||||
it("`~` 应展开为 HOME", () => {
|
||||
expect(buildCdCommand("~")).toBe('cd "$HOME"');
|
||||
});
|
||||
|
||||
it("`~/...` 应保留 HOME 前缀并安全引用余下路径", () => {
|
||||
expect(buildCdCommand("~/workspace/remoteconn")).toBe("cd \"$HOME\"/'workspace/remoteconn'");
|
||||
});
|
||||
|
||||
it("普通绝对路径应保持单引号安全转义", () => {
|
||||
expect(buildCdCommand("/var/www/my app")).toBe("cd '/var/www/my app'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCodexPlan", () => {
|
||||
it("首条命令应使用 cd 计划且包含 sandbox 启动命令", () => {
|
||||
const plan = buildCodexPlan({
|
||||
projectPath: "~/workspace/remoteconn",
|
||||
sandbox: "workspace-write"
|
||||
});
|
||||
|
||||
expect(plan).toHaveLength(3);
|
||||
const cdStep = plan[0];
|
||||
const checkStep = plan[1];
|
||||
const runStep = plan[2];
|
||||
expect(cdStep).toBeDefined();
|
||||
expect(checkStep).toBeDefined();
|
||||
expect(runStep).toBeDefined();
|
||||
if (!cdStep || !checkStep || !runStep) {
|
||||
throw new Error("Codex 计划步骤缺失");
|
||||
}
|
||||
|
||||
expect(cdStep).toEqual({
|
||||
step: "cd",
|
||||
command: "cd \"$HOME\"/'workspace/remoteconn'",
|
||||
markerType: "cd"
|
||||
});
|
||||
expect(checkStep.command).toBe("command -v codex");
|
||||
expect(runStep.command).toBe("codex --sandbox workspace-write");
|
||||
});
|
||||
|
||||
it("resumeLast=true 时应改为恢复最近一次会话", () => {
|
||||
const plan = buildCodexPlan({
|
||||
projectPath: "~/workspace/remoteconn",
|
||||
sandbox: "danger-full-access",
|
||||
resumeLast: true
|
||||
});
|
||||
|
||||
expect(plan[2]?.command).toBe("codex resume --last --sandbox danger-full-access");
|
||||
});
|
||||
});
|
||||
72
packages/shared/src/codex/orchestrator.ts
Normal file
72
packages/shared/src/codex/orchestrator.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Codex 模式编排命令。
|
||||
*/
|
||||
export interface CodexCommandPlan {
|
||||
step: "cd" | "check" | "run";
|
||||
command: string;
|
||||
markerType: "cd" | "check" | "run";
|
||||
}
|
||||
|
||||
export interface CodexRunOptions {
|
||||
projectPath: string;
|
||||
sandbox: "read-only" | "workspace-write" | "danger-full-access";
|
||||
/**
|
||||
* 是否改为恢复最近一次 Codex 会话:
|
||||
* - `true` 时生成 `codex resume --last --sandbox ...`;
|
||||
* - `false/undefined` 时沿用全新启动命令。
|
||||
*/
|
||||
resumeLast?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对路径做最小安全转义,防止单引号截断。
|
||||
*/
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 `cd` 命令:
|
||||
* - `~` 与 `~/...` 需要保留 HOME 展开语义,不能整体单引号包裹;
|
||||
* - 其他路径走单引号最小转义,避免空格/特殊字符破坏命令。
|
||||
*/
|
||||
export function buildCdCommand(projectPath: string): string {
|
||||
const normalized = String(projectPath || "~").trim() || "~";
|
||||
|
||||
if (normalized === "~") {
|
||||
return 'cd "$HOME"';
|
||||
}
|
||||
|
||||
if (normalized.startsWith("~/")) {
|
||||
const relative = normalized.slice(2);
|
||||
return relative ? `cd "$HOME"/${shellQuote(relative)}` : 'cd "$HOME"';
|
||||
}
|
||||
|
||||
return `cd ${shellQuote(normalized)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Codex 模式三步命令。
|
||||
*/
|
||||
export function buildCodexPlan(options: CodexRunOptions): CodexCommandPlan[] {
|
||||
const runCommand = options.resumeLast
|
||||
? `codex resume --last --sandbox ${options.sandbox}`
|
||||
: `codex --sandbox ${options.sandbox}`;
|
||||
return [
|
||||
{
|
||||
step: "cd",
|
||||
command: buildCdCommand(options.projectPath),
|
||||
markerType: "cd"
|
||||
},
|
||||
{
|
||||
step: "check",
|
||||
command: "command -v codex",
|
||||
markerType: "check"
|
||||
},
|
||||
{
|
||||
step: "run",
|
||||
command: runCommand,
|
||||
markerType: "run"
|
||||
}
|
||||
];
|
||||
}
|
||||
7
packages/shared/src/index.ts
Normal file
7
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./types/models";
|
||||
export * from "./session/stateMachine";
|
||||
export * from "./codex/orchestrator";
|
||||
export * from "./theme/contrast";
|
||||
export * from "./theme/presets";
|
||||
export * from "./logs/mask";
|
||||
export * from "./security/hostKey";
|
||||
15
packages/shared/src/logs/mask.ts
Normal file
15
packages/shared/src/logs/mask.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 日志脱敏函数,避免导出文本泄露密码和主机信息。
|
||||
*/
|
||||
export function maskSensitive(value: string): string {
|
||||
return String(value)
|
||||
.replace(/([0-9]{1,3}\.){3}[0-9]{1,3}/g, "***.***.***.***")
|
||||
.replace(/(token|password|passphrase|secret)\s*[=:]\s*[^\s]+/gi, "$1=***")
|
||||
.replace(/~\/.+?(?=\s|$)/g, "~/***");
|
||||
}
|
||||
|
||||
export function maskHost(host: string): string {
|
||||
return String(host)
|
||||
.replace(/([a-zA-Z0-9._%+-]+)@/, "***@")
|
||||
.replace(/([0-9]{1,3}\.){3}[0-9]{1,3}/, "***.***.***.***");
|
||||
}
|
||||
61
packages/shared/src/security/hostKey.ts
Normal file
61
packages/shared/src/security/hostKey.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 主机指纹策略。
|
||||
*/
|
||||
export type HostKeyPolicy = "strict" | "trustFirstUse" | "manualEachTime";
|
||||
|
||||
export interface KnownHostsRecord {
|
||||
[hostPort: string]: string;
|
||||
}
|
||||
|
||||
export interface VerifyHostKeyParams {
|
||||
hostPort: string;
|
||||
incomingFingerprint: string;
|
||||
policy: HostKeyPolicy;
|
||||
knownHosts: KnownHostsRecord;
|
||||
/**
|
||||
* 当策略需要用户确认时由上层注入。
|
||||
*/
|
||||
onConfirm: (payload: { hostPort: string; fingerprint: string; reason: string }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function verifyHostKey(params: VerifyHostKeyParams): Promise<{ accepted: boolean; updated: KnownHostsRecord }> {
|
||||
const { hostPort, incomingFingerprint, policy, onConfirm } = params;
|
||||
const knownHosts = { ...params.knownHosts };
|
||||
const stored = knownHosts[hostPort];
|
||||
|
||||
if (stored && stored !== incomingFingerprint) {
|
||||
return { accepted: false, updated: knownHosts };
|
||||
}
|
||||
|
||||
if (policy === "trustFirstUse") {
|
||||
if (!stored) {
|
||||
knownHosts[hostPort] = incomingFingerprint;
|
||||
}
|
||||
return { accepted: true, updated: knownHosts };
|
||||
}
|
||||
|
||||
if (policy === "strict") {
|
||||
if (stored) {
|
||||
return { accepted: true, updated: knownHosts };
|
||||
}
|
||||
const accepted = await onConfirm({
|
||||
hostPort,
|
||||
fingerprint: incomingFingerprint,
|
||||
reason: "首次连接,严格模式要求确认"
|
||||
});
|
||||
if (accepted) {
|
||||
knownHosts[hostPort] = incomingFingerprint;
|
||||
}
|
||||
return { accepted, updated: knownHosts };
|
||||
}
|
||||
|
||||
const accepted = await onConfirm({
|
||||
hostPort,
|
||||
fingerprint: incomingFingerprint,
|
||||
reason: "手动确认模式"
|
||||
});
|
||||
if (accepted) {
|
||||
knownHosts[hostPort] = incomingFingerprint;
|
||||
}
|
||||
return { accepted, updated: knownHosts };
|
||||
}
|
||||
28
packages/shared/src/session/stateMachine.ts
Normal file
28
packages/shared/src/session/stateMachine.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SessionState } from "../types/models";
|
||||
|
||||
/**
|
||||
* 会话状态机,确保连接生命周期可预测。
|
||||
*/
|
||||
const transitions: Record<SessionState, SessionState[]> = {
|
||||
idle: ["connecting", "disconnected"],
|
||||
connecting: ["auth_pending", "error", "disconnected"],
|
||||
auth_pending: ["connected", "error", "disconnected"],
|
||||
connected: ["reconnecting", "disconnected", "error"],
|
||||
reconnecting: ["connected", "error", "disconnected"],
|
||||
disconnected: ["connecting", "idle"],
|
||||
error: ["connecting", "disconnected"]
|
||||
};
|
||||
|
||||
export function canTransition(from: SessionState, to: SessionState): boolean {
|
||||
return transitions[from].includes(to);
|
||||
}
|
||||
|
||||
export function assertTransition(from: SessionState, to: SessionState): void {
|
||||
if (!canTransition(from, to)) {
|
||||
throw new Error(`非法状态跳转: ${from} -> ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function allStates(): SessionState[] {
|
||||
return Object.keys(transitions) as SessionState[];
|
||||
}
|
||||
17
packages/shared/src/theme/contrast.test.ts
Normal file
17
packages/shared/src/theme/contrast.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contrastRatio, pickBestBackground, pickShellAccentColor } from "./contrast";
|
||||
|
||||
describe("theme contrast", () => {
|
||||
it("计算对比度", () => {
|
||||
expect(contrastRatio("#ffffff", "#000000")).toBeGreaterThan(7);
|
||||
});
|
||||
|
||||
it("自动选择背景", () => {
|
||||
const selected = pickBestBackground("#e6f0ff", "#5bd2ff");
|
||||
expect(selected.startsWith("#")).toBe(true);
|
||||
});
|
||||
|
||||
it("终端强调色取背景和前景之间,并略偏前景", () => {
|
||||
expect(pickShellAccentColor("#192b4d", "#e6f0ff")).toBe("#9ca9bf");
|
||||
});
|
||||
});
|
||||
105
packages/shared/src/theme/contrast.ts
Normal file
105
packages/shared/src/theme/contrast.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 主题引擎:提供 WCAG 对比度计算与背景自动优化。
|
||||
*/
|
||||
|
||||
export interface RgbColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端强调色插值系数:
|
||||
* - 0.5 代表正中间;
|
||||
* - >0.5 代表向前景色偏移;
|
||||
* - 当前取 0.64,满足“中间色且略偏前景”的视觉要求。
|
||||
*/
|
||||
export const SHELL_ACCENT_BLEND_T = 0.64;
|
||||
|
||||
export function normalizeHex(input: string, fallback: string): string {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(input) ? input.toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): RgbColor {
|
||||
const value = normalizeHex(hex, "#000000");
|
||||
return {
|
||||
r: Number.parseInt(value.slice(1, 3), 16),
|
||||
g: Number.parseInt(value.slice(3, 5), 16),
|
||||
b: Number.parseInt(value.slice(5, 7), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number): number {
|
||||
const normalized = value / 255;
|
||||
if (normalized <= 0.03928) {
|
||||
return normalized / 12.92;
|
||||
}
|
||||
return ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
export function luminance(hex: string): number {
|
||||
const rgb = hexToRgb(hex);
|
||||
return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
|
||||
}
|
||||
|
||||
export function contrastRatio(a: string, b: string): number {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
const lighter = Math.max(la, lb);
|
||||
const darker = Math.min(la, lb);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
export function pickBestBackground(textColor: string, accentColor: string): string {
|
||||
const candidates = [
|
||||
"#0a1325",
|
||||
"#132747",
|
||||
"#102b34",
|
||||
"#2e223b",
|
||||
normalizeHex(accentColor, "#5bd2ff")
|
||||
];
|
||||
|
||||
let best = candidates[0] ?? "#0a1325";
|
||||
let bestScore = 0;
|
||||
for (const candidate of candidates) {
|
||||
const score = contrastRatio(normalizeHex(textColor, "#e6f0ff"), candidate);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色线性插值:
|
||||
* - t=0 表示返回背景色;
|
||||
* - t=1 表示返回前景色;
|
||||
* - 用于按钮色与终端强调色等“在 bg/text 之间取色”的场景。
|
||||
*/
|
||||
function mixColor(bgColor: string, textColor: string, t: number, fallbackBg: string, fallbackText: string): string {
|
||||
const bg = hexToRgb(normalizeHex(bgColor, fallbackBg));
|
||||
const text = hexToRgb(normalizeHex(textColor, fallbackText));
|
||||
const factor = Number.isFinite(t) ? Math.min(1, Math.max(0, t)) : 0.5;
|
||||
const r = Math.round(bg.r + (text.r - bg.r) * factor);
|
||||
const g = Math.round(bg.g + (text.g - bg.g) * factor);
|
||||
const b = Math.round(bg.b + (text.b - bg.b) * factor);
|
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮色自动推导:在背景色和文本色之间取色,偏向文本色一侧(t=0.72)。
|
||||
* 确保按钮与背景有足够对比度,同时色调协调。
|
||||
*/
|
||||
export function pickBtnColor(bgColor: string, textColor: string): string {
|
||||
return mixColor(bgColor, textColor, 0.72, "#192b4d", "#e6f0ff");
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端强调色自动推导:
|
||||
* - 在终端背景色与前景色之间取“中间偏前景”的颜色;
|
||||
* - 目标是避免强调色贴近背景导致识别度不足,同时避免过亮抢占正文层级。
|
||||
*/
|
||||
export function pickShellAccentColor(bgColor: string, textColor: string): string {
|
||||
return mixColor(bgColor, textColor, SHELL_ACCENT_BLEND_T, "#192b4d", "#e6f0ff");
|
||||
}
|
||||
11
packages/shared/src/theme/presets.test.ts
Normal file
11
packages/shared/src/theme/presets.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pickShellAccentColor } from "./contrast";
|
||||
import { getShellVariant } from "./presets";
|
||||
|
||||
describe("theme presets", () => {
|
||||
it("shell 变体的 cursor 按背景和前景自动推导", () => {
|
||||
const variant = getShellVariant("tide", "dark");
|
||||
expect(variant.cursor).toBe(pickShellAccentColor(variant.bg, variant.text));
|
||||
expect(variant.cursor).toBe("#9ca9bf");
|
||||
});
|
||||
});
|
||||
257
packages/shared/src/theme/presets.ts
Normal file
257
packages/shared/src/theme/presets.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { pickShellAccentColor } from "./contrast";
|
||||
|
||||
/**
|
||||
* 主题色板预设(按配置计划 §主题实现方案)。
|
||||
*
|
||||
* 每套主题包含:
|
||||
* - palette: 原始色板(按 Figma 顺序)
|
||||
* - dark: dark 变体(bg / text / accent / btn)
|
||||
* - light: light 变体(bg / text / accent / btn)
|
||||
*/
|
||||
|
||||
export type ThemePreset =
|
||||
| "tide"
|
||||
| "暮砂"
|
||||
| "霓潮"
|
||||
| "苔暮"
|
||||
| "焰岩"
|
||||
| "岩陶"
|
||||
| "靛雾"
|
||||
| "绛霓"
|
||||
| "玫蓝"
|
||||
| "珊湾"
|
||||
| "苔荧"
|
||||
| "铜暮"
|
||||
| "炽潮"
|
||||
| "藕夜"
|
||||
| "沙海"
|
||||
| "珀岚"
|
||||
| "炫虹"
|
||||
| "鎏霓"
|
||||
| "珊汐"
|
||||
| "黛苔"
|
||||
| "霜绯";
|
||||
|
||||
export interface ThemeVariant {
|
||||
/** 页面/卡片背景色 */
|
||||
bg: string;
|
||||
/** 正文字体色 */
|
||||
text: string;
|
||||
/** 强调色(链接、输入框聚焦线、开关等) */
|
||||
accent: string;
|
||||
/** 主按钮底色 */
|
||||
btn: string;
|
||||
}
|
||||
|
||||
export interface ThemeDefinition {
|
||||
name: ThemePreset;
|
||||
/** 色板原始顺序 */
|
||||
palette: string[];
|
||||
dark: ThemeVariant;
|
||||
light: ThemeVariant;
|
||||
/** shell 专用 dark 变体(终端背景/前景/光标) */
|
||||
shellDark: { bg: string; text: string; cursor: string };
|
||||
/** shell 专用 light 变体 */
|
||||
shellLight: { bg: string; text: string; cursor: string };
|
||||
}
|
||||
|
||||
export const THEME_PRESETS: Record<ThemePreset, ThemeDefinition> = {
|
||||
tide: {
|
||||
name: "tide",
|
||||
palette: ["#192b4d", "#5bd2ff", "#e6f0ff", "#3D86FF"],
|
||||
dark: { bg: "#192b4d", text: "#e6f0ff", accent: "#5bd2ff", btn: "#3D86FF" },
|
||||
light: { bg: "#e6f0ff", text: "#192b4d", accent: "#3D86FF", btn: "#3D86FF" },
|
||||
shellDark: { bg: "#192b4d", text: "#e6f0ff", cursor: "#5bd2ff" },
|
||||
shellLight: { bg: "#e6f0ff", text: "#192b4d", cursor: "#3D86FF" }
|
||||
},
|
||||
暮砂: {
|
||||
name: "暮砂",
|
||||
palette: ["#F4F1DE", "#EAB69F", "#E07A5F", "#8F5D5D", "#3D405B", "#5F797B", "#81B29A", "#9EB998", "#BABF95", "#F2CC8F"],
|
||||
dark: { bg: "#3D405B", text: "#F4F1DE", accent: "#81B29A", btn: "#E07A5F" },
|
||||
light: { bg: "#F4F1DE", text: "#3D405B", accent: "#E07A5F", btn: "#8F5D5D" },
|
||||
shellDark: { bg: "#3D405B", text: "#F4F1DE", cursor: "#81B29A" },
|
||||
shellLight: { bg: "#F4F1DE", text: "#3D405B", cursor: "#E07A5F" }
|
||||
},
|
||||
霓潮: {
|
||||
name: "霓潮",
|
||||
palette: ["#EF476F", "#F78C6B", "#FFD166", "#83D483", "#06D6A0", "#001914", "#118AB2", "#0F7799", "#0C637F", "#073B4C"],
|
||||
dark: { bg: "#073B4C", text: "#FFD166", accent: "#06D6A0", btn: "#118AB2" },
|
||||
light: { bg: "#FFD166", text: "#073B4C", accent: "#EF476F", btn: "#0F7799" },
|
||||
shellDark: { bg: "#073B4C", text: "#FFD166", cursor: "#06D6A0" },
|
||||
shellLight: { bg: "#FFD166", text: "#073B4C", cursor: "#EF476F" }
|
||||
},
|
||||
苔暮: {
|
||||
name: "苔暮",
|
||||
palette: ["#A8B868", "#798575", "#4A5282", "#393F7C", "#313679", "#282C75", "#3C3C9D", "#4E4CC3", "#645FD4", "#7A71E4"],
|
||||
dark: { bg: "#282C75", text: "#A8B868", accent: "#7A71E4", btn: "#84916c" },
|
||||
light: { bg: "#A8B868", text: "#282C75", accent: "#4E4CC3", btn: "#393F7C" },
|
||||
shellDark: { bg: "#282C75", text: "#A8B868", cursor: "#7A71E4" },
|
||||
shellLight: { bg: "#A8B868", text: "#282C75", cursor: "#4E4CC3" }
|
||||
},
|
||||
焰岩: {
|
||||
name: "焰岩",
|
||||
palette: ["#5F0F40", "#7D092F", "#9A031E", "#CB4721", "#FB8B24", "#EF781C", "#E36414", "#AE5E26", "#795838", "#0F4C5C"],
|
||||
dark: { bg: "#0F4C5C", text: "#FB8B24", accent: "#E36414", btn: "#CB4721" },
|
||||
light: { bg: "#FB8B24", text: "#0F4C5C", accent: "#CB4721", btn: "#9A031E" },
|
||||
shellDark: { bg: "#0F4C5C", text: "#FB8B24", cursor: "#E36414" },
|
||||
shellLight: { bg: "#FB8B24", text: "#0F4C5C", cursor: "#CB4721" }
|
||||
},
|
||||
岩陶: {
|
||||
name: "岩陶",
|
||||
palette: ["#283D3B", "#21585A", "#197278", "#83A8A6", "#EDDDD4", "#D99185", "#E9B5AF", "#9E3A2E", "#772E25"],
|
||||
dark: { bg: "#283D3B", text: "#EDDDD4", accent: "#E9B5AF", btn: "#D99185" },
|
||||
light: { bg: "#EDDDD4", text: "#283D3B", accent: "#D99185", btn: "#9E3A2E" },
|
||||
shellDark: { bg: "#283D3B", text: "#EDDDD4", cursor: "#E9B5AF" },
|
||||
shellLight: { bg: "#EDDDD4", text: "#283D3B", cursor: "#D99185" }
|
||||
},
|
||||
靛雾: {
|
||||
name: "靛雾",
|
||||
palette: ["#292281", "#2D2586", "#31278B", "#382D93", "#3F329B", "#4437A1", "#4A3BA6", "#9D96BA", "#C7C4C4", "#F1F0CD"],
|
||||
dark: { bg: "#292281", text: "#F1F0CD", accent: "#9D96BA", btn: "#b9b6b8" },
|
||||
light: { bg: "#F1F0CD", text: "#292281", accent: "#4A3BA6", btn: "#382D93" },
|
||||
shellDark: { bg: "#292281", text: "#F1F0CD", cursor: "#9D96BA" },
|
||||
shellLight: { bg: "#F1F0CD", text: "#292281", cursor: "#4A3BA6" }
|
||||
},
|
||||
绛霓: {
|
||||
name: "绛霓",
|
||||
palette: ["#F72585", "#B5179E", "#7209B7", "#560BAD", "#480CA8", "#3A0CA3", "#3F37C9", "#4361EE", "#4895EF", "#4CC9F0"],
|
||||
dark: { bg: "#3A0CA3", text: "#4CC9F0", accent: "#4895EF", btn: "#7209B7" },
|
||||
light: { bg: "#4CC9F0", text: "#3A0CA3", accent: "#F72585", btn: "#7209B7" },
|
||||
shellDark: { bg: "#3A0CA3", text: "#4CC9F0", cursor: "#4895EF" },
|
||||
shellLight: { bg: "#4CC9F0", text: "#3A0CA3", cursor: "#F72585" }
|
||||
},
|
||||
玫蓝: {
|
||||
name: "玫蓝",
|
||||
palette: ["#D06A79", "#984B8D", "#5E2BA1", "#482398", "#3D1F94", "#311B90", "#3D39B6", "#4857DC", "#567BE3", "#629FEB"],
|
||||
dark: { bg: "#3D1F94", text: "#629FEB", accent: "#D06A79", btn: "#567BE3" },
|
||||
light: { bg: "#629FEB", text: "#3D1F94", accent: "#D06A79", btn: "#4857DC" },
|
||||
shellDark: { bg: "#3D1F94", text: "#629FEB", cursor: "#567BE3" },
|
||||
shellLight: { bg: "#629FEB", text: "#3D1F94", cursor: "#D06A79" }
|
||||
},
|
||||
珊湾: {
|
||||
name: "珊湾",
|
||||
palette: ["#EC5B57", "#AD635D", "#6C6B64", "#526660", "#45645D", "#37615B", "#3E8983", "#44B0AB", "#4BC8C4", "#52DFDD"],
|
||||
dark: { bg: "#37615B", text: "#52DFDD", accent: "#EC5B57", btn: "#44B0AB" },
|
||||
light: { bg: "#52DFDD", text: "#37615B", accent: "#EC5B57", btn: "#3E8983" },
|
||||
shellDark: { bg: "#37615B", text: "#52DFDD", cursor: "#44B0AB" },
|
||||
shellLight: { bg: "#52DFDD", text: "#37615B", cursor: "#EC5B57" }
|
||||
},
|
||||
苔荧: {
|
||||
name: "苔荧",
|
||||
palette: ["#414731", "#515A23", "#616C15", "#99A32C", "#D1D942", "#C2CB37", "#B3BC2C", "#909636", "#6C6F41", "#252157"],
|
||||
dark: { bg: "#252157", text: "#D1D942", accent: "#99A32C", btn: "#C2CB37" },
|
||||
light: { bg: "#D1D942", text: "#252157", accent: "#909636", btn: "#99A32C" },
|
||||
shellDark: { bg: "#252157", text: "#D1D942", cursor: "#C2CB37" },
|
||||
shellLight: { bg: "#D1D942", text: "#252157", cursor: "#909636" }
|
||||
},
|
||||
铜暮: {
|
||||
name: "铜暮",
|
||||
palette: ["#502939", "#672F2A", "#7E351A", "#B27225", "#E6B030", "#D99F27", "#CB8E1E", "#9F782D", "#72623C", "#1A375A"],
|
||||
dark: { bg: "#1A375A", text: "#E6B030", accent: "#B27225", btn: "#D99F27" },
|
||||
light: { bg: "#E6B030", text: "#1A375A", accent: "#B27225", btn: "#9F782D" },
|
||||
shellDark: { bg: "#1A375A", text: "#E6B030", cursor: "#D99F27" },
|
||||
shellLight: { bg: "#E6B030", text: "#1A375A", cursor: "#B27225" }
|
||||
},
|
||||
炽潮: {
|
||||
name: "炽潮",
|
||||
palette: ["#5B2A28", "#771E1C", "#921211", "#C43133", "#F55054", "#E94347", "#DC363A", "#AA3E40", "#774547", "#125554"],
|
||||
dark: { bg: "#125554", text: "#F55054", accent: "#C43133", btn: "#E94347" },
|
||||
light: { bg: "#F55054", text: "#125554", accent: "#AA3E40", btn: "#DC363A" },
|
||||
shellDark: { bg: "#125554", text: "#F55054", cursor: "#E94347" },
|
||||
shellLight: { bg: "#F55054", text: "#125554", cursor: "#AA3E40" }
|
||||
},
|
||||
藕夜: {
|
||||
name: "藕夜",
|
||||
palette: ["#322F4F", "#433E71", "#554C93", "#98958C", "#DBDD85", "#D8DD7D", "#D5DB74", "#CED56E", "#C8CF67", "#BAC35A"],
|
||||
dark: { bg: "#322F4F", text: "#DBDD85", accent: "#554C93", btn: "#C8CF67" },
|
||||
light: { bg: "#DBDD85", text: "#322F4F", accent: "#D5DB74", btn: "#433E71" },
|
||||
shellDark: { bg: "#322F4F", text: "#DBDD85", cursor: "#C8CF67" },
|
||||
shellLight: { bg: "#DBDD85", text: "#322F4F", cursor: "#554C93" }
|
||||
},
|
||||
沙海: {
|
||||
name: "沙海",
|
||||
palette: ["#2B3B51", "#355971", "#3F7690", "#91A483", "#E2D075", "#E4C66F", "#E4BD69", "#E0B464", "#DBAA5F", "#D19654"],
|
||||
dark: { bg: "#2B3B51", text: "#E2D075", accent: "#3F7690", btn: "#DBAA5F" },
|
||||
light: { bg: "#E2D075", text: "#2B3B51", accent: "#355971", btn: "#D19654" },
|
||||
shellDark: { bg: "#2B3B51", text: "#E2D075", cursor: "#DBAA5F" },
|
||||
shellLight: { bg: "#E2D075", text: "#2B3B51", cursor: "#3F7690" }
|
||||
},
|
||||
珀岚: {
|
||||
name: "珀岚",
|
||||
palette: ["#274D4C", "#2B7171", "#2F9595", "#8B9395", "#E79094", "#EC878A", "#EF7D7F", "#EC7578", "#E86D6F", "#E15D5F"],
|
||||
dark: { bg: "#274D4C", text: "#E79094", accent: "#2F9595", btn: "#EC7578" },
|
||||
light: { bg: "#E79094", text: "#274D4C", accent: "#2B7171", btn: "#E15D5F" },
|
||||
shellDark: { bg: "#274D4C", text: "#E79094", cursor: "#EC7578" },
|
||||
shellLight: { bg: "#E79094", text: "#274D4C", cursor: "#2F9595" }
|
||||
},
|
||||
炫虹: {
|
||||
name: "炫虹",
|
||||
palette: ["#FFBE0B", "#FD8A09", "#FB5607", "#FD2B3B", "#FF006E", "#C11CAD", "#A22ACD", "#8338EC", "#5F5FF6", "#3A86FF"],
|
||||
dark: { bg: "#8338EC", text: "#FFBE0B", accent: "#FF006E", btn: "#3A86FF" },
|
||||
light: { bg: "#FFBE0B", text: "#8338EC", accent: "#FD2B3B", btn: "#5F5FF6" },
|
||||
shellDark: { bg: "#8338EC", text: "#FFBE0B", cursor: "#3A86FF" },
|
||||
shellLight: { bg: "#FFBE0B", text: "#8338EC", cursor: "#FF006E" }
|
||||
},
|
||||
鎏霓: {
|
||||
name: "鎏霓",
|
||||
palette: ["#F3D321", "#E7B019", "#DC8C10", "#D67039", "#D05460", "#A2529A", "#8C51B8", "#7550D5", "#5F5FE3", "#476CEF"],
|
||||
dark: { bg: "#7550D5", text: "#F3D321", accent: "#A2529A", btn: "#476CEF" },
|
||||
light: { bg: "#F3D321", text: "#7550D5", accent: "#D67039", btn: "#5F5FE3" },
|
||||
shellDark: { bg: "#7550D5", text: "#F3D321", cursor: "#476CEF" },
|
||||
shellLight: { bg: "#F3D321", text: "#7550D5", cursor: "#A2529A" }
|
||||
},
|
||||
珊汐: {
|
||||
name: "珊汐",
|
||||
palette: ["#FB5860", "#F74046", "#F2292C", "#F23433", "#F23E39", "#B86E68", "#9C867F", "#7F9E96", "#5FB4AE", "#3DCAC5"],
|
||||
dark: { bg: "#7F9E96", text: "#FB5860", accent: "#3DCAC5", btn: "#F23E39" },
|
||||
light: { bg: "#FB5860", text: "#7F9E96", accent: "#F2292C", btn: "#5FB4AE" },
|
||||
shellDark: { bg: "#7F9E96", text: "#FB5860", cursor: "#3DCAC5" },
|
||||
shellLight: { bg: "#FB5860", text: "#7F9E96", cursor: "#F2292C" }
|
||||
},
|
||||
黛苔: {
|
||||
name: "黛苔",
|
||||
palette: ["#2F2E3B", "#353159", "#3A3376", "#908EA6", "#E7E8D6", "#BEC388", "#949D3A", "#788031", "#5B6127"],
|
||||
dark: { bg: "#2F2E3B", text: "#E7E8D6", accent: "#788031", btn: "#949D3A" },
|
||||
light: { bg: "#E7E8D6", text: "#2F2E3B", accent: "#788031", btn: "#5B6127" },
|
||||
shellDark: { bg: "#2F2E3B", text: "#E7E8D6", cursor: "#949D3A" },
|
||||
shellLight: { bg: "#E7E8D6", text: "#2F2E3B", cursor: "#788031" }
|
||||
},
|
||||
霜绯: {
|
||||
name: "霜绯",
|
||||
palette: ["#293B3B", "#235959", "#1D7575", "#84A6A6", "#ECD7D8", "#D58A8A", "#BD3C3D", "#993333", "#732829"],
|
||||
dark: { bg: "#293B3B", text: "#ECD7D8", accent: "#1D7575", btn: "#BD3C3D" },
|
||||
light: { bg: "#ECD7D8", text: "#293B3B", accent: "#993333", btn: "#BD3C3D" },
|
||||
shellDark: { bg: "#293B3B", text: "#ECD7D8", cursor: "#BD3C3D" },
|
||||
shellLight: { bg: "#ECD7D8", text: "#293B3B", cursor: "#993333" }
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 按主题名和模式获取 UI 变体颜色。
|
||||
*/
|
||||
export function getThemeVariant(preset: ThemePreset, mode: "dark" | "light"): ThemeVariant {
|
||||
const def = THEME_PRESETS[preset] ?? THEME_PRESETS["tide"];
|
||||
return mode === "dark" ? def.dark : def.light;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主题名和模式获取 Shell 变体颜色。
|
||||
*/
|
||||
export function getShellVariant(
|
||||
preset: ThemePreset,
|
||||
mode: "dark" | "light"
|
||||
): { bg: string; text: string; cursor: string } {
|
||||
const def = THEME_PRESETS[preset] ?? THEME_PRESETS["tide"];
|
||||
const base = mode === "dark" ? def.shellDark : def.shellLight;
|
||||
return {
|
||||
bg: base.bg,
|
||||
text: base.text,
|
||||
// 终端强调色统一按“背景与前景之间、略偏前景”的规则实时推导。
|
||||
cursor: pickShellAccentColor(base.bg, base.text)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回所有可用主题预设名称列表。
|
||||
*/
|
||||
export const THEME_PRESET_NAMES: ThemePreset[] = Object.keys(THEME_PRESETS) as ThemePreset[];
|
||||
181
packages/shared/src/types/models.ts
Normal file
181
packages/shared/src/types/models.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 认证类型:首期支持密码和私钥,证书为二期预留。
|
||||
*/
|
||||
export type AuthType = "password" | "privateKey" | "certificate";
|
||||
|
||||
/**
|
||||
* 终端传输模式:Web/小程序通过网关,iOS 走原生插件。
|
||||
*/
|
||||
export type TransportMode = "gateway" | "ios-native";
|
||||
|
||||
/**
|
||||
* 服务器配置。
|
||||
*/
|
||||
export interface JumpHostProfile {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
authType: AuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳板机配置默认值:
|
||||
* - `enabled=false` 表示走现有单跳链路;
|
||||
* - 其余字段保留,便于前端表单直接双向绑定。
|
||||
*/
|
||||
export const DEFAULT_JUMP_HOST: JumpHostProfile = {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
authType: "password"
|
||||
};
|
||||
|
||||
export interface ServerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
authType: AuthType;
|
||||
projectPath: string;
|
||||
projectPresets: string[];
|
||||
tags: string[];
|
||||
timeoutSeconds: number;
|
||||
heartbeatSeconds: number;
|
||||
transportMode: TransportMode;
|
||||
jumpHost?: JumpHostProfile;
|
||||
/**
|
||||
* 服务器列表排序位次(值越小越靠前):
|
||||
* - 仅用于前端“我的服务器”列表排序持久化;
|
||||
* - 历史数据可能缺失,业务层会在加载时自动回填。
|
||||
*/
|
||||
sortOrder?: number;
|
||||
lastConnectedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据引用,不在业务对象中保存明文。
|
||||
*/
|
||||
export interface CredentialRef {
|
||||
id: string;
|
||||
type: AuthType;
|
||||
secureStoreKey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已解析凭据,通常仅在临时连接阶段短时存在内存里。
|
||||
*/
|
||||
export interface ResolvedCredential {
|
||||
type: AuthType;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
certificate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次终端建链中的“连接跳点”。
|
||||
* - `target`:最终业务主机;
|
||||
* - `jump`:可选跳板机。
|
||||
*/
|
||||
export interface GatewayConnectHop {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
credential: ResolvedCredential;
|
||||
knownHostFingerprint?: string;
|
||||
}
|
||||
|
||||
export type SessionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "auth_pending"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
|
||||
export interface CommandMarker {
|
||||
at: string;
|
||||
command: string;
|
||||
source: "manual" | "codex" | "copilot" | "plugin";
|
||||
markerType: "manual" | "cd" | "check" | "run";
|
||||
code: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface SessionLog {
|
||||
sessionId: string;
|
||||
serverId: string;
|
||||
startAt: string;
|
||||
endAt?: string;
|
||||
status: SessionState;
|
||||
commandMarkers: CommandMarker[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* stdin 输入来源标识:
|
||||
* - keyboard:常规键盘输入(含终端快捷键与控制字符)。
|
||||
* - assist:输入法/语音等“候选提交型”输入。
|
||||
*/
|
||||
export type StdinSource = "keyboard" | "assist";
|
||||
|
||||
/**
|
||||
* stdin 元信息:
|
||||
* - source 用于区分输入路径,便于网关侧做策略(如去重、观测)。
|
||||
* - txnId 用于 assist 路径幂等去重(同一事务只接受一次最终提交)。
|
||||
*/
|
||||
export interface StdinMeta {
|
||||
source: StdinSource;
|
||||
txnId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 网关协议。
|
||||
*/
|
||||
export type GatewayFrame =
|
||||
| {
|
||||
type: "init";
|
||||
payload: {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
credential: ResolvedCredential;
|
||||
jumpHost?: GatewayConnectHop;
|
||||
clientSessionKey?: string;
|
||||
/**
|
||||
* 终端续接驻留窗口(毫秒):
|
||||
* - 客户端切后台/离开终端页时,可请求网关将 SSH 会话驻留一段时间;
|
||||
* - 网关侧会再按服务端上限做收敛,避免单端无限占用资源。
|
||||
*/
|
||||
resumeGraceMs?: number;
|
||||
knownHostFingerprint?: string;
|
||||
pty: { cols: number; rows: number };
|
||||
};
|
||||
}
|
||||
| { type: "stdin"; payload: { data: string; meta?: StdinMeta } }
|
||||
| { type: "stdout"; payload: { data: string } }
|
||||
| { type: "stderr"; payload: { data: string } }
|
||||
| { type: "resize"; payload: { cols: number; rows: number } }
|
||||
| {
|
||||
type: "control";
|
||||
payload: {
|
||||
action: "ping" | "pong" | "disconnect" | "connected";
|
||||
reason?: string;
|
||||
fingerprint?: string;
|
||||
fingerprintHostPort?: string;
|
||||
resumed?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
payload: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user