mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:10:49 +00:00
perf(cli): lazy-load doctor plugin paths (#69840)
Merged via squash.
Prepared head SHA: ebf93ad913
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
0e1d324dd8
commit
66add9fcd9
@@ -2,6 +2,12 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.
|
||||
|
||||
## 2026.4.21
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -13,6 +13,27 @@ read_when:
|
||||
Every plugin exports a default entry object. The SDK provides three helpers for
|
||||
creating them.
|
||||
|
||||
For installed plugins, `package.json` should point runtime loading at built
|
||||
JavaScript when available:
|
||||
|
||||
```json
|
||||
{
|
||||
"openclaw": {
|
||||
"extensions": ["./src/index.ts"],
|
||||
"runtimeExtensions": ["./dist/index.js"],
|
||||
"setupEntry": "./src/setup-entry.ts",
|
||||
"runtimeSetupEntry": "./dist/setup-entry.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`extensions` and `setupEntry` remain valid source entries for workspace and git
|
||||
checkout development. `runtimeExtensions` and `runtimeSetupEntry` are preferred
|
||||
when OpenClaw loads an installed package and let npm packages avoid runtime
|
||||
TypeScript compilation. If an installed package only declares a TypeScript
|
||||
source entry, OpenClaw will use a matching built `dist/*.js` peer when one
|
||||
exists, then fall back to the TypeScript source.
|
||||
|
||||
<Tip>
|
||||
**Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins)
|
||||
or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides.
|
||||
|
||||
@@ -297,7 +297,8 @@ Code plugins must include the required OpenClaw metadata in `package.json`:
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"],
|
||||
"extensions": ["./src/index.ts"],
|
||||
"runtimeExtensions": ["./dist/index.js"],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.3.24-beta.2",
|
||||
"minGatewayVersion": "2026.3.24-beta.2"
|
||||
@@ -310,6 +311,11 @@ Code plugins must include the required OpenClaw metadata in `package.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Published packages should ship built JavaScript and point `runtimeExtensions`
|
||||
at that output. Git checkout installs can still fall back to TypeScript source
|
||||
when no built files exist, but built runtime entries avoid runtime TypeScript
|
||||
compilation in startup, doctor, and plugin loading paths.
|
||||
|
||||
## Advanced details (technical)
|
||||
|
||||
### Versioning and tags
|
||||
|
||||
@@ -68,6 +68,30 @@ describe("channel plugin module loader helpers", () => {
|
||||
expect(isJavaScriptModulePath("/tmp/entry.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses native require for eligible JavaScript modules before falling back to Jiti", async () => {
|
||||
const createJiti = vi.fn(() => vi.fn(() => ({ ok: false })));
|
||||
vi.doMock("jiti", () => ({
|
||||
createJiti,
|
||||
}));
|
||||
const loaderModule = await importFreshModule<typeof import("./module-loader.js")>(
|
||||
import.meta.url,
|
||||
"./module-loader.js?scope=native-require",
|
||||
);
|
||||
const rootDir = createTempDir();
|
||||
const modulePath = path.join(rootDir, "dist", "extensions", "demo", "index.cjs");
|
||||
fs.mkdirSync(path.dirname(modulePath), { recursive: true });
|
||||
fs.writeFileSync(modulePath, "module.exports = { ok: true };\n", "utf8");
|
||||
|
||||
expect(
|
||||
loaderModule.loadChannelPluginModule({
|
||||
modulePath,
|
||||
rootDir,
|
||||
shouldTryNativeRequire: () => true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
expect(createJiti).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Windows dist loads off Jiti native import", async () => {
|
||||
const createJiti = vi.fn(() => vi.fn(() => ({ ok: true })));
|
||||
vi.doMock("jiti", () => ({
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { openBoundaryFileSync } from "../../infra/boundary-file-read.js";
|
||||
import {
|
||||
getCachedPluginJitiLoader,
|
||||
type PluginJitiLoaderCache,
|
||||
} from "../../plugins/jiti-loader-cache.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
import { tryNativeRequireJavaScriptModule } from "../../plugins/native-module-require.js";
|
||||
export { isJavaScriptModulePath } from "../../plugins/native-module-require.js";
|
||||
|
||||
function createModuleLoader() {
|
||||
const jitiLoaders: PluginJitiLoaderCache = new Map();
|
||||
@@ -27,12 +25,6 @@ function createModuleLoader() {
|
||||
|
||||
let loadModule = createModuleLoader();
|
||||
|
||||
export function isJavaScriptModulePath(modulePath: string): boolean {
|
||||
return [".js", ".mjs", ".cjs"].includes(
|
||||
normalizeLowercaseStringOrEmpty(path.extname(modulePath)),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCompiledBundledModulePath(modulePath: string): string {
|
||||
const compiledDistModulePath = modulePath.replace(
|
||||
`${path.sep}dist-runtime${path.sep}`,
|
||||
@@ -91,11 +83,12 @@ export function loadChannelPluginModule(params: {
|
||||
}
|
||||
const safePath = opened.path;
|
||||
fs.closeSync(opened.fd);
|
||||
if (process.platform === "win32" && params.shouldTryNativeRequire?.(safePath)) {
|
||||
try {
|
||||
return nodeRequire(safePath);
|
||||
} catch {
|
||||
// Fall back to the Jiti loader path when require() cannot handle the entry.
|
||||
if (params.shouldTryNativeRequire?.(safePath)) {
|
||||
const nativeModule = tryNativeRequireJavaScriptModule(safePath, {
|
||||
allowWindows: true,
|
||||
});
|
||||
if (nativeModule.ok) {
|
||||
return nativeModule.moduleExport;
|
||||
}
|
||||
}
|
||||
return loadModule(safePath)(safePath);
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { findLegacyConfigIssues } from "../config/legacy.js";
|
||||
import { CONFIG_PATH } from "../config/paths.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
collectRelevantDoctorPluginIds,
|
||||
listPluginDoctorLegacyConfigRules,
|
||||
} from "../plugins/doctor-contract-registry.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { noteOpencodeProviderOverrides } from "./doctor-config-analysis.js";
|
||||
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
|
||||
@@ -14,12 +9,6 @@ import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
|
||||
import type { DoctorOptions } from "./doctor-prompter.js";
|
||||
import { emitDoctorNotes } from "./doctor/emit-notes.js";
|
||||
import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js";
|
||||
import { runDoctorRepairSequence } from "./doctor/repair-sequencing.js";
|
||||
import {
|
||||
collectChannelDoctorMutableAllowlistWarnings,
|
||||
collectChannelDoctorStaleConfigMutations,
|
||||
runChannelDoctorConfigSequences,
|
||||
} from "./doctor/shared/channel-doctor.js";
|
||||
import {
|
||||
applyLegacyCompatibilityStep,
|
||||
applyUnknownConfigKeyStep,
|
||||
@@ -29,7 +18,6 @@ import {
|
||||
collectMissingDefaultAccountBindingWarnings,
|
||||
collectMissingExplicitDefaultAccountWarnings,
|
||||
} from "./doctor/shared/default-account-warnings.js";
|
||||
import { collectDoctorPreviewWarnings } from "./doctor/shared/preview-warnings.js";
|
||||
|
||||
function hasLegacyInternalHookHandlers(raw: unknown): boolean {
|
||||
const handlers = (raw as { hooks?: { internal?: { handlers?: unknown } } })?.hooks?.internal
|
||||
@@ -37,6 +25,17 @@ function hasLegacyInternalHookHandlers(raw: unknown): boolean {
|
||||
return Array.isArray(handlers) && handlers.length > 0;
|
||||
}
|
||||
|
||||
function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
|
||||
const channels =
|
||||
cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels)
|
||||
? cfg.channels
|
||||
: null;
|
||||
if (!channels) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(channels).filter((channelId) => channelId !== "defaults");
|
||||
}
|
||||
|
||||
export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
options: DoctorOptions;
|
||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||
@@ -58,16 +57,20 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
doctorFixCommand,
|
||||
});
|
||||
({ cfg, candidate, pendingChanges, fixHints } = legacyStep.state);
|
||||
const pluginLegacyIssues =
|
||||
snapshot.parsed === snapshot.sourceConfig
|
||||
? []
|
||||
: findLegacyConfigIssues(
|
||||
snapshot.parsed,
|
||||
snapshot.parsed,
|
||||
listPluginDoctorLegacyConfigRules({
|
||||
pluginIds: collectRelevantDoctorPluginIds(snapshot.parsed),
|
||||
}),
|
||||
);
|
||||
const pluginLegacyIssues = await (async () => {
|
||||
if (snapshot.parsed === snapshot.sourceConfig) {
|
||||
return [];
|
||||
}
|
||||
const { collectRelevantDoctorPluginIds, listPluginDoctorLegacyConfigRules } =
|
||||
await import("../plugins/doctor-contract-registry.js");
|
||||
return findLegacyConfigIssues(
|
||||
snapshot.parsed,
|
||||
snapshot.parsed,
|
||||
listPluginDoctorLegacyConfigRules({
|
||||
pluginIds: collectRelevantDoctorPluginIds(snapshot.parsed),
|
||||
}),
|
||||
);
|
||||
})();
|
||||
const seenLegacyIssues = new Set(
|
||||
snapshot.legacyIssues.map((issue) => `${issue.path}:${issue.message}`),
|
||||
);
|
||||
@@ -117,6 +120,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
}));
|
||||
}
|
||||
|
||||
const { applyPluginAutoEnable } = await import("../config/plugin-auto-enable.js");
|
||||
const autoEnable = applyPluginAutoEnable({ config: candidate, env: process.env });
|
||||
if (autoEnable.changes.length > 0) {
|
||||
note(autoEnable.changes.join("\n"), "Doctor changes");
|
||||
@@ -128,28 +132,38 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
}));
|
||||
}
|
||||
|
||||
const channelDoctorSequence = await runChannelDoctorConfigSequences({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
shouldRepair,
|
||||
});
|
||||
emitDoctorNotes({
|
||||
note,
|
||||
changeNotes: channelDoctorSequence.changeNotes,
|
||||
warningNotes: channelDoctorSequence.warningNotes,
|
||||
});
|
||||
|
||||
for (const staleCleanup of await collectChannelDoctorStaleConfigMutations(candidate)) {
|
||||
if (staleCleanup.changes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
note(staleCleanup.changes.join("\n"), "Doctor changes");
|
||||
({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({
|
||||
state: { cfg, candidate, pendingChanges, fixHints },
|
||||
mutation: staleCleanup,
|
||||
const hasConfiguredChannels = collectConfiguredChannelIds(candidate).length > 0;
|
||||
let collectMutableAllowlistWarnings:
|
||||
| typeof import("./doctor/shared/channel-doctor.js").collectChannelDoctorMutableAllowlistWarnings
|
||||
| undefined;
|
||||
if (hasConfiguredChannels) {
|
||||
const channelDoctor = await import("./doctor/shared/channel-doctor.js");
|
||||
collectMutableAllowlistWarnings = channelDoctor.collectChannelDoctorMutableAllowlistWarnings;
|
||||
const channelDoctorSequence = await channelDoctor.runChannelDoctorConfigSequences({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
shouldRepair,
|
||||
fixHint: `Run "${doctorFixCommand}" to remove stale channel plugin references.`,
|
||||
}));
|
||||
});
|
||||
emitDoctorNotes({
|
||||
note,
|
||||
changeNotes: channelDoctorSequence.changeNotes,
|
||||
warningNotes: channelDoctorSequence.warningNotes,
|
||||
});
|
||||
|
||||
for (const staleCleanup of await channelDoctor.collectChannelDoctorStaleConfigMutations(
|
||||
candidate,
|
||||
)) {
|
||||
if (staleCleanup.changes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
note(staleCleanup.changes.join("\n"), "Doctor changes");
|
||||
({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({
|
||||
state: { cfg, candidate, pendingChanges, fixHints },
|
||||
mutation: staleCleanup,
|
||||
shouldRepair,
|
||||
fixHint: `Run "${doctorFixCommand}" to remove stale channel plugin references.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const missingDefaultAccountBindingWarnings =
|
||||
@@ -163,6 +177,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
}
|
||||
|
||||
if (shouldRepair) {
|
||||
const { runDoctorRepairSequence } = await import("./doctor/repair-sequencing.js");
|
||||
const repairSequence = await runDoctorRepairSequence({
|
||||
state: { cfg, candidate, pendingChanges, fixHints },
|
||||
doctorFixCommand,
|
||||
@@ -174,6 +189,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
warningNotes: repairSequence.warningNotes,
|
||||
});
|
||||
} else {
|
||||
const { collectDoctorPreviewWarnings } = await import("./doctor/shared/preview-warnings.js");
|
||||
emitDoctorNotes({
|
||||
note,
|
||||
warningNotes: await collectDoctorPreviewWarnings({
|
||||
@@ -183,9 +199,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const mutableAllowlistWarnings = await collectChannelDoctorMutableAllowlistWarnings({
|
||||
cfg: candidate,
|
||||
});
|
||||
const mutableAllowlistWarnings = collectMutableAllowlistWarnings
|
||||
? await collectMutableAllowlistWarnings({
|
||||
cfg: candidate,
|
||||
})
|
||||
: [];
|
||||
if (mutableAllowlistWarnings.length > 0) {
|
||||
note(mutableAllowlistWarnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { doctorCommand } from "../flows/doctor-health.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { DoctorOptions } from "./doctor-prompter.js";
|
||||
|
||||
export async function doctorCommand(runtime?: RuntimeEnv, options?: DoctorOptions): Promise<void> {
|
||||
const doctorHealth = await import("../flows/doctor-health.js");
|
||||
await doctorHealth.doctorCommand(runtime, options);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import {
|
||||
getModelRefStatus,
|
||||
resolveConfiguredModelRef,
|
||||
resolveHooksGmailModel,
|
||||
} from "../agents/model-selection.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { maybeRepairLegacyOAuthProfileIds } from "../commands/doctor-auth-legacy-oauth.js";
|
||||
import { noteAuthProfileHealth, noteLegacyCodexProviderOverride } from "../commands/doctor-auth.js";
|
||||
import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js";
|
||||
import { noteChromeMcpBrowserReadiness } from "../commands/doctor-browser.js";
|
||||
import { maybeRepairBundledPluginRuntimeDeps } from "../commands/doctor-bundled-plugin-runtime-deps.js";
|
||||
import { noteClaudeCliHealth } from "../commands/doctor-claude-cli.js";
|
||||
import { doctorShellCompletion } from "../commands/doctor-completion.js";
|
||||
import { maybeRepairLegacyCronStore } from "../commands/doctor-cron.js";
|
||||
import { noteDevicePairingHealth } from "../commands/doctor-device-pairing.js";
|
||||
import { maybeRepairGatewayDaemon } from "../commands/doctor-gateway-daemon-flow.js";
|
||||
import { checkGatewayHealth, probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
|
||||
import {
|
||||
maybeRepairGatewayServiceConfig,
|
||||
maybeScanExtraGatewayServices,
|
||||
} from "../commands/doctor-gateway-services.js";
|
||||
import {
|
||||
maybeRepairMemoryRecallHealth,
|
||||
noteMemoryRecallHealth,
|
||||
noteMemorySearchHealth,
|
||||
} from "../commands/doctor-memory-search.js";
|
||||
import {
|
||||
noteMacLaunchAgentOverrides,
|
||||
noteMacLaunchctlGatewayEnvOverrides,
|
||||
} from "../commands/doctor-platform-notes.js";
|
||||
import { maybeRepairLegacyPluginManifestContracts } from "../commands/doctor-plugin-manifests.js";
|
||||
import type { probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
|
||||
import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js";
|
||||
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "../commands/doctor-sandbox.js";
|
||||
import { noteSecurityWarnings } from "../commands/doctor-security.js";
|
||||
import { noteSessionLockHealth } from "../commands/doctor-session-locks.js";
|
||||
import { noteStateIntegrity, noteWorkspaceBackupTip } from "../commands/doctor-state-integrity.js";
|
||||
import {
|
||||
detectLegacyStateMigrations,
|
||||
runLegacyStateMigrations,
|
||||
} from "../commands/doctor-state-migrations.js";
|
||||
import { noteWorkspaceStatus } from "../commands/doctor-workspace-status.js";
|
||||
import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } from "../commands/doctor-workspace.js";
|
||||
import { noteOpenAIOAuthTlsPrerequisites } from "../commands/oauth-tls-preflight.js";
|
||||
import { applyWizardMetadata, randomToken } from "../commands/onboard-helpers.js";
|
||||
import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js";
|
||||
import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import type { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { maybeRunDoctorStartupChannelMaintenance } from "./doctor-startup-channel-maintenance.js";
|
||||
import type { FlowContribution } from "./types.js";
|
||||
|
||||
export type DoctorFlowMode = "local" | "remote";
|
||||
@@ -115,6 +60,9 @@ function createDoctorHealthContribution(params: {
|
||||
}
|
||||
|
||||
async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { formatCliCommand } = await import("../cli/command-format.js");
|
||||
const { hasAmbiguousGatewayAuthModeConfig } = await import("../gateway/auth-mode-policy.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
if (!ctx.cfg.gateway?.mode) {
|
||||
const lines = [
|
||||
"gateway.mode is unset; gateway start will be blocked.",
|
||||
@@ -140,6 +88,12 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
}
|
||||
|
||||
async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairLegacyOAuthProfileIds } =
|
||||
await import("../commands/doctor-auth-legacy-oauth.js");
|
||||
const { noteAuthProfileHealth, noteLegacyCodexProviderOverride } =
|
||||
await import("../commands/doctor-auth.js");
|
||||
const { buildGatewayConnectionDetails } = await import("../gateway/call.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
ctx.cfg = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
|
||||
await noteAuthProfileHealth({
|
||||
cfg: ctx.cfg,
|
||||
@@ -154,6 +108,10 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
}
|
||||
|
||||
async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { resolveSecretInputRef } = await import("../config/types.secrets.js");
|
||||
const { resolveGatewayAuth } = await import("../gateway/auth.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
const { randomToken } = await import("../commands/onboard-helpers.js");
|
||||
if (resolveDoctorMode(ctx.cfg) !== "local" || !ctx.sourceConfigValid) {
|
||||
return;
|
||||
}
|
||||
@@ -213,10 +171,14 @@ async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
}
|
||||
|
||||
async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteClaudeCliHealth } = await import("../commands/doctor-claude-cli.js");
|
||||
noteClaudeCliHealth(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
|
||||
await import("../commands/doctor-state-migrations.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
const legacyState = await detectLegacyStateMigrations({ cfg: ctx.cfg });
|
||||
if (legacyState.preview.length === 0) {
|
||||
return;
|
||||
@@ -244,6 +206,8 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
}
|
||||
|
||||
async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairLegacyPluginManifestContracts } =
|
||||
await import("../commands/doctor-plugin-manifests.js");
|
||||
await maybeRepairLegacyPluginManifestContracts({
|
||||
env: process.env,
|
||||
runtime: ctx.runtime,
|
||||
@@ -252,6 +216,8 @@ async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Prom
|
||||
}
|
||||
|
||||
async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairBundledPluginRuntimeDeps } =
|
||||
await import("../commands/doctor-bundled-plugin-runtime-deps.js");
|
||||
await maybeRepairBundledPluginRuntimeDeps({
|
||||
runtime: ctx.runtime,
|
||||
prompter: ctx.prompter,
|
||||
@@ -260,14 +226,17 @@ async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext):
|
||||
}
|
||||
|
||||
async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteStateIntegrity } = await import("../commands/doctor-state-integrity.js");
|
||||
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath);
|
||||
}
|
||||
|
||||
async function runSessionLocksHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteSessionLockHealth } = await import("../commands/doctor-session-locks.js");
|
||||
await noteSessionLockHealth({ shouldRepair: ctx.prompter.shouldRepair });
|
||||
}
|
||||
|
||||
async function runLegacyCronHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairLegacyCronStore } = await import("../commands/doctor-cron.js");
|
||||
await maybeRepairLegacyCronStore({
|
||||
cfg: ctx.cfg,
|
||||
options: ctx.options,
|
||||
@@ -276,11 +245,17 @@ async function runLegacyCronHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
}
|
||||
|
||||
async function runSandboxHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairSandboxImages, noteSandboxScopeWarnings } =
|
||||
await import("../commands/doctor-sandbox.js");
|
||||
ctx.cfg = await maybeRepairSandboxImages(ctx.cfg, ctx.runtime, ctx.prompter);
|
||||
noteSandboxScopeWarnings(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices } =
|
||||
await import("../commands/doctor-gateway-services.js");
|
||||
const { noteMacLaunchAgentOverrides, noteMacLaunchctlGatewayEnvOverrides } =
|
||||
await import("../commands/doctor-platform-notes.js");
|
||||
await maybeScanExtraGatewayServices(ctx.options, ctx.runtime, ctx.prompter);
|
||||
await maybeRepairGatewayServiceConfig(
|
||||
ctx.cfg,
|
||||
@@ -293,6 +268,8 @@ async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise<v
|
||||
}
|
||||
|
||||
async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRunDoctorStartupChannelMaintenance } =
|
||||
await import("./doctor-startup-channel-maintenance.js");
|
||||
await maybeRunDoctorStartupChannelMaintenance({
|
||||
cfg: ctx.cfg,
|
||||
env: process.env,
|
||||
@@ -302,14 +279,17 @@ async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext):
|
||||
}
|
||||
|
||||
async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteSecurityWarnings } = await import("../commands/doctor-security.js");
|
||||
await noteSecurityWarnings(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js");
|
||||
await noteChromeMcpBrowserReadiness(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runOpenAIOAuthTlsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteOpenAIOAuthTlsPrerequisites } = await import("../commands/oauth-tls-preflight.js");
|
||||
await noteOpenAIOAuthTlsPrerequisites({
|
||||
cfg: ctx.cfg,
|
||||
deep: ctx.options.deep === true,
|
||||
@@ -320,6 +300,11 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
if (!ctx.cfg.hooks?.gmail?.model?.trim()) {
|
||||
return;
|
||||
}
|
||||
const { DEFAULT_MODEL, DEFAULT_PROVIDER } = await import("../agents/defaults.js");
|
||||
const { loadModelCatalog } = await import("../agents/model-catalog.js");
|
||||
const { getModelRefStatus, resolveConfiguredModelRef, resolveHooksGmailModel } =
|
||||
await import("../agents/model-selection.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
const hooksModelRef = resolveHooksGmailModel({
|
||||
cfg: ctx.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
@@ -365,6 +350,9 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { resolveGatewayService } = await import("../daemon/service.js");
|
||||
const { ensureSystemdUserLingerInteractive } = await import("../commands/systemd-linger.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
const service = resolveGatewayService();
|
||||
let loaded = false;
|
||||
try {
|
||||
@@ -388,20 +376,25 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
}
|
||||
|
||||
async function runWorkspaceStatusHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteWorkspaceStatus } = await import("../commands/doctor-workspace-status.js");
|
||||
noteWorkspaceStatus(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteBootstrapFileSize } = await import("../commands/doctor-bootstrap-size.js");
|
||||
await noteBootstrapFileSize(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runShellCompletionHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { doctorShellCompletion } = await import("../commands/doctor-completion.js");
|
||||
await doctorShellCompletion(ctx.runtime, ctx.prompter, {
|
||||
nonInteractive: ctx.options.nonInteractive,
|
||||
});
|
||||
}
|
||||
|
||||
async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { checkGatewayHealth, probeGatewayMemoryStatus } =
|
||||
await import("../commands/doctor-gateway-health.js");
|
||||
const { healthOk } = await checkGatewayHealth({
|
||||
runtime: ctx.runtime,
|
||||
cfg: ctx.cfg,
|
||||
@@ -417,6 +410,8 @@ async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
}
|
||||
|
||||
async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairMemoryRecallHealth, noteMemoryRecallHealth, noteMemorySearchHealth } =
|
||||
await import("../commands/doctor-memory-search.js");
|
||||
if (ctx.prompter.shouldRepair) {
|
||||
await maybeRepairMemoryRecallHealth({
|
||||
cfg: ctx.cfg,
|
||||
@@ -432,6 +427,7 @@ async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext):
|
||||
}
|
||||
|
||||
async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteDevicePairingHealth } = await import("../commands/doctor-device-pairing.js");
|
||||
await noteDevicePairingHealth({
|
||||
cfg: ctx.cfg,
|
||||
healthOk: ctx.healthOk ?? false,
|
||||
@@ -439,6 +435,7 @@ async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
}
|
||||
|
||||
async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairGatewayDaemon } = await import("../commands/doctor-gateway-daemon-flow.js");
|
||||
await maybeRepairGatewayDaemon({
|
||||
cfg: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
@@ -450,6 +447,11 @@ async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise<voi
|
||||
}
|
||||
|
||||
async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { formatCliCommand } = await import("../cli/command-format.js");
|
||||
const { applyWizardMetadata } = await import("../commands/onboard-helpers.js");
|
||||
const { CONFIG_PATH, writeConfigFile } = await import("../config/config.js");
|
||||
const { logConfigUpdated } = await import("../config/logging.js");
|
||||
const { shortenHomePath } = await import("../utils.js");
|
||||
const shouldWriteConfig =
|
||||
ctx.configResult.shouldWriteConfig ||
|
||||
JSON.stringify(ctx.cfg) !== JSON.stringify(ctx.cfgForPersistence);
|
||||
@@ -475,6 +477,12 @@ async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Prom
|
||||
if (ctx.options.workspaceSuggestions === false) {
|
||||
return;
|
||||
}
|
||||
const { resolveAgentWorkspaceDir, resolveDefaultAgentId } =
|
||||
await import("../agents/agent-scope.js");
|
||||
const { noteWorkspaceBackupTip } = await import("../commands/doctor-state-integrity.js");
|
||||
const { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } =
|
||||
await import("../commands/doctor-workspace.js");
|
||||
const { note } = await import("../terminal/note.js");
|
||||
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
|
||||
noteWorkspaceBackupTip(workspaceDir);
|
||||
if (await shouldSuggestMemorySystem(workspaceDir)) {
|
||||
@@ -483,6 +491,7 @@ async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Prom
|
||||
}
|
||||
|
||||
async function runFinalConfigValidationHealth(_ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const finalSnapshot = await readConfigFileSnapshot();
|
||||
if (finalSnapshot.exists && !finalSnapshot.valid) {
|
||||
_ctx.runtime.error("Invalid config:");
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "../commands/doctor-config-flow.js";
|
||||
import { noteSourceInstallIssues } from "../commands/doctor-install.js";
|
||||
import { noteStartupOptimizationHints } from "../commands/doctor-platform-notes.js";
|
||||
import { createDoctorPrompter, type DoctorOptions } from "../commands/doctor-prompter.js";
|
||||
import { maybeRepairUiProtocolFreshness } from "../commands/doctor-ui.js";
|
||||
import { maybeOfferUpdateBeforeDoctor } from "../commands/doctor-update.js";
|
||||
import { printWizardHeader } from "../commands/onboard-helpers.js";
|
||||
import { CONFIG_PATH } from "../config/config.js";
|
||||
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
||||
import type { DoctorOptions } from "../commands/doctor-prompter.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import { runDoctorHealthContributions } from "./doctor-health-contributions.js";
|
||||
|
||||
const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message);
|
||||
const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message);
|
||||
|
||||
export async function doctorCommand(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
options: DoctorOptions = {},
|
||||
) {
|
||||
const prompter = createDoctorPrompter({ runtime, options });
|
||||
printWizardHeader(runtime);
|
||||
export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions = {}) {
|
||||
const effectiveRuntime = runtime ?? (await import("../runtime.js")).defaultRuntime;
|
||||
const { createDoctorPrompter } = await import("../commands/doctor-prompter.js");
|
||||
const { printWizardHeader } = await import("../commands/onboard-helpers.js");
|
||||
const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options });
|
||||
printWizardHeader(effectiveRuntime);
|
||||
intro("OpenClaw doctor");
|
||||
|
||||
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");
|
||||
const root = await resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const { maybeOfferUpdateBeforeDoctor } = await import("../commands/doctor-update.js");
|
||||
const updateResult = await maybeOfferUpdateBeforeDoctor({
|
||||
runtime,
|
||||
runtime: effectiveRuntime,
|
||||
options,
|
||||
root,
|
||||
confirm: (p) => prompter.confirm(p),
|
||||
@@ -41,16 +33,21 @@ export async function doctorCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
await maybeRepairUiProtocolFreshness(runtime, prompter);
|
||||
const { maybeRepairUiProtocolFreshness } = await import("../commands/doctor-ui.js");
|
||||
const { noteSourceInstallIssues } = await import("../commands/doctor-install.js");
|
||||
const { noteStartupOptimizationHints } = await import("../commands/doctor-platform-notes.js");
|
||||
await maybeRepairUiProtocolFreshness(effectiveRuntime, prompter);
|
||||
noteSourceInstallIssues(root);
|
||||
noteStartupOptimizationHints();
|
||||
|
||||
const { loadAndMaybeMigrateDoctorConfig } = await import("../commands/doctor-config-flow.js");
|
||||
const configResult = await loadAndMaybeMigrateDoctorConfig({
|
||||
options,
|
||||
confirm: (p) => prompter.confirm(p),
|
||||
});
|
||||
const { CONFIG_PATH } = await import("../config/config.js");
|
||||
const ctx = {
|
||||
runtime,
|
||||
runtime: effectiveRuntime,
|
||||
options,
|
||||
prompter,
|
||||
configResult,
|
||||
@@ -59,6 +56,7 @@ export async function doctorCommand(
|
||||
sourceConfigValid: configResult.sourceConfigValid ?? true,
|
||||
configPath: configResult.path ?? CONFIG_PATH,
|
||||
};
|
||||
const { runDoctorHealthContributions } = await import("./doctor-health-contributions.js");
|
||||
await runDoctorHealthContributions(ctx);
|
||||
|
||||
outro("Doctor complete.");
|
||||
|
||||
@@ -68,12 +68,20 @@ function writePluginPackageManifest(params: {
|
||||
packageDir: string;
|
||||
packageName: string;
|
||||
extensions: string[];
|
||||
runtimeExtensions?: string[];
|
||||
setupEntry?: string;
|
||||
runtimeSetupEntry?: string;
|
||||
}) {
|
||||
fs.writeFileSync(
|
||||
path.join(params.packageDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: params.packageName,
|
||||
openclaw: { extensions: params.extensions },
|
||||
openclaw: {
|
||||
extensions: params.extensions,
|
||||
...(params.runtimeExtensions ? { runtimeExtensions: params.runtimeExtensions } : {}),
|
||||
...(params.setupEntry ? { setupEntry: params.setupEntry } : {}),
|
||||
...(params.runtimeSetupEntry ? { runtimeSetupEntry: params.runtimeSetupEntry } : {}),
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
@@ -400,6 +408,109 @@ describe("discoverOpenClawPlugins", () => {
|
||||
expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] });
|
||||
});
|
||||
|
||||
it("uses explicit runtime extension entries for installed package plugins", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "runtime-pack");
|
||||
mkdirSafe(path.join(pluginDir, "src"));
|
||||
mkdirSafe(path.join(pluginDir, "dist"));
|
||||
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/runtime-pack",
|
||||
extensions: ["./src/index.ts"],
|
||||
runtimeExtensions: ["./dist/index.js"],
|
||||
setupEntry: "./src/setup-entry.ts",
|
||||
runtimeSetupEntry: "./dist/setup-entry.js",
|
||||
});
|
||||
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "setup-entry.js"));
|
||||
|
||||
const { candidates } = await discoverWithStateDir(stateDir, {});
|
||||
const candidate = findCandidateById(candidates, "runtime-pack");
|
||||
expect(fs.realpathSync(candidate?.source ?? "")).toBe(
|
||||
fs.realpathSync(path.join(pluginDir, "dist", "index.js")),
|
||||
);
|
||||
expect(fs.realpathSync(candidate?.setupSource ?? "")).toBe(
|
||||
fs.realpathSync(path.join(pluginDir, "dist", "setup-entry.js")),
|
||||
);
|
||||
});
|
||||
|
||||
it("infers built dist entries for installed TypeScript package plugins", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "built-peer-pack");
|
||||
mkdirSafe(path.join(pluginDir, "src"));
|
||||
mkdirSafe(path.join(pluginDir, "dist"));
|
||||
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/built-peer-pack",
|
||||
extensions: ["src/index.ts"],
|
||||
setupEntry: "src/setup-entry.ts",
|
||||
});
|
||||
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "src", "index.js"));
|
||||
writePluginEntry(path.join(pluginDir, "src", "setup-entry.js"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "setup-entry.js"));
|
||||
|
||||
const { candidates } = await discoverWithStateDir(stateDir, {});
|
||||
const candidate = findCandidateById(candidates, "built-peer-pack");
|
||||
expect(fs.realpathSync(candidate?.source ?? "")).toBe(
|
||||
fs.realpathSync(path.join(pluginDir, "dist", "index.js")),
|
||||
);
|
||||
expect(fs.realpathSync(candidate?.setupSource ?? "")).toBe(
|
||||
fs.realpathSync(path.join(pluginDir, "dist", "setup-entry.js")),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves nested entry paths when inferring installed dist entries", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "nested-pack");
|
||||
mkdirSafe(path.join(pluginDir, "plugin"));
|
||||
mkdirSafe(path.join(pluginDir, "dist", "plugin"));
|
||||
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/nested-pack",
|
||||
extensions: ["./plugin/index.ts"],
|
||||
});
|
||||
writePluginEntry(path.join(pluginDir, "plugin", "index.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "plugin", "index.js"));
|
||||
|
||||
const { candidates } = await discoverWithStateDir(stateDir, {});
|
||||
const candidate = findCandidateById(candidates, "nested-pack");
|
||||
expect(fs.realpathSync(candidate?.source ?? "")).toBe(
|
||||
fs.realpathSync(path.join(pluginDir, "dist", "plugin", "index.js")),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps workspace package TypeScript entries unless runtime entries are explicit", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-pack");
|
||||
mkdirSafe(path.join(pluginDir, "src"));
|
||||
mkdirSafe(path.join(pluginDir, "dist"));
|
||||
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/workspace-pack",
|
||||
extensions: ["./src/index.ts"],
|
||||
});
|
||||
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
|
||||
|
||||
const { candidates } = discoverOpenClawPlugins({
|
||||
workspaceDir,
|
||||
env: buildDiscoveryEnv(stateDir),
|
||||
});
|
||||
expect(fs.realpathSync(findCandidateById(candidates, "workspace-pack")?.source ?? "")).toBe(
|
||||
fs.realpathSync(path.join(pluginDir, "src", "index.ts")),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not discover nested node_modules copies under installed plugins", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "opik-openclaw");
|
||||
|
||||
@@ -611,6 +611,155 @@ function resolvePackageEntrySource(params: {
|
||||
return openCandidate(source);
|
||||
}
|
||||
|
||||
function isTypeScriptPackageEntry(entryPath: string): boolean {
|
||||
return [".ts", ".mts", ".cts"].includes(normalizeLowercaseStringOrEmpty(path.extname(entryPath)));
|
||||
}
|
||||
|
||||
function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
|
||||
return origin === "config" || origin === "global";
|
||||
}
|
||||
|
||||
function listBuiltRuntimeEntryCandidates(entryPath: string): string[] {
|
||||
if (!isTypeScriptPackageEntry(entryPath)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = entryPath.replace(/\\/g, "/");
|
||||
const withoutExtension = normalized.replace(/\.[^.]+$/u, "");
|
||||
const normalizedRelative = normalized.replace(/^\.\//u, "");
|
||||
const distWithoutExtension = normalizedRelative.startsWith("src/")
|
||||
? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}`
|
||||
: `./dist/${withoutExtension.replace(/^\.\//u, "")}`;
|
||||
const withJavaScriptExtensions = (basePath: string) => [
|
||||
`${basePath}.js`,
|
||||
`${basePath}.mjs`,
|
||||
`${basePath}.cjs`,
|
||||
];
|
||||
const candidates = [
|
||||
...withJavaScriptExtensions(distWithoutExtension),
|
||||
...withJavaScriptExtensions(withoutExtension),
|
||||
];
|
||||
return [...new Set(candidates)].filter((candidate) => candidate !== normalized);
|
||||
}
|
||||
|
||||
function resolveExistingPackageEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const source = path.resolve(params.packageDir, params.entryPath);
|
||||
if (!fs.existsSync(source)) {
|
||||
return null;
|
||||
}
|
||||
return resolvePackageEntrySource(params);
|
||||
}
|
||||
|
||||
function normalizePackageManifestStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
function resolvePackageRuntimeEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
runtimeEntryPath?: string;
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
if (params.runtimeEntryPath) {
|
||||
const runtimeSource = resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.runtimeEntryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (runtimeSource) {
|
||||
return runtimeSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInferBuiltRuntimeEntry(params.origin)) {
|
||||
for (const candidate of listBuiltRuntimeEntryCandidates(params.entryPath)) {
|
||||
const runtimeSource = resolveExistingPackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: candidate,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (runtimeSource) {
|
||||
return runtimeSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.entryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePackageSetupSource(params: {
|
||||
packageDir: string;
|
||||
manifest: PackageManifest | null;
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
||||
const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry);
|
||||
if (!setupEntryPath) {
|
||||
return null;
|
||||
}
|
||||
return resolvePackageRuntimeEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: setupEntryPath,
|
||||
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
|
||||
origin: params.origin,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePackageRuntimeExtensionEntries(params: {
|
||||
packageDir: string;
|
||||
manifest: PackageManifest | null;
|
||||
extensions: readonly string[];
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string[] {
|
||||
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
||||
const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions);
|
||||
return params.extensions.flatMap((entryPath, index) => {
|
||||
const source = resolvePackageRuntimeEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath,
|
||||
runtimeEntryPath:
|
||||
runtimeExtensions.length === params.extensions.length
|
||||
? runtimeExtensions[index]
|
||||
: undefined,
|
||||
origin: params.origin,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
return source ? [source] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function discoverInDirectory(params: {
|
||||
dir: string;
|
||||
origin: PluginOrigin;
|
||||
@@ -678,30 +827,26 @@ function discoverInDirectory(params: {
|
||||
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
||||
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
||||
const manifestId = resolveIdHintManifestId(fullPath, rejectHardlinks);
|
||||
const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry;
|
||||
const setupSource =
|
||||
typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0
|
||||
? resolvePackageEntrySource({
|
||||
packageDir: fullPath,
|
||||
entryPath: setupEntryPath,
|
||||
sourceLabel: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
})
|
||||
: null;
|
||||
const setupSource = resolvePackageSetupSource({
|
||||
packageDir: fullPath,
|
||||
manifest,
|
||||
origin: params.origin,
|
||||
sourceLabel: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
|
||||
if (extensions.length > 0) {
|
||||
for (const extPath of extensions) {
|
||||
const resolved = resolvePackageEntrySource({
|
||||
packageDir: fullPath,
|
||||
entryPath: extPath,
|
||||
sourceLabel: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
if (!resolved) {
|
||||
continue;
|
||||
}
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
|
||||
packageDir: fullPath,
|
||||
manifest,
|
||||
extensions,
|
||||
origin: params.origin,
|
||||
sourceLabel: fullPath,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
for (const resolved of resolvedRuntimeSources) {
|
||||
addCandidate({
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
@@ -818,30 +963,26 @@ function discoverFromPath(params: {
|
||||
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
||||
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
||||
const manifestId = resolveIdHintManifestId(resolved, rejectHardlinks);
|
||||
const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry;
|
||||
const setupSource =
|
||||
typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0
|
||||
? resolvePackageEntrySource({
|
||||
packageDir: resolved,
|
||||
entryPath: setupEntryPath,
|
||||
sourceLabel: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
})
|
||||
: null;
|
||||
const setupSource = resolvePackageSetupSource({
|
||||
packageDir: resolved,
|
||||
manifest,
|
||||
origin: params.origin,
|
||||
sourceLabel: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
|
||||
if (extensions.length > 0) {
|
||||
for (const extPath of extensions) {
|
||||
const source = resolvePackageEntrySource({
|
||||
packageDir: resolved,
|
||||
entryPath: extPath,
|
||||
sourceLabel: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
|
||||
packageDir: resolved,
|
||||
manifest,
|
||||
extensions,
|
||||
origin: params.origin,
|
||||
sourceLabel: resolved,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks,
|
||||
});
|
||||
for (const source of resolvedRuntimeSources) {
|
||||
addCandidate({
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
|
||||
@@ -63,26 +63,69 @@ describe("doctor-contract-registry getJiti", () => {
|
||||
|
||||
it("prefers doctor-contract-api over the broader contract-api surface", () => {
|
||||
const pluginRoot = makeTempDir();
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "doctor-contract-api.js"),
|
||||
"export default {};\n",
|
||||
path.join(pluginRoot, "doctor-contract-api.cjs"),
|
||||
"module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'demo', 'doctor'], message: 'doctor contract' }] };\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "contract-api.cjs"),
|
||||
"module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'demo', 'broad'], message: 'broad contract' }] };\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginRoot, "contract-api.js"), "export default {};\n", "utf-8");
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [{ id: "test-plugin", rootDir: pluginRoot }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
listPluginDoctorLegacyConfigRules({
|
||||
workspaceDir: pluginRoot,
|
||||
env: {},
|
||||
try {
|
||||
expect(
|
||||
listPluginDoctorLegacyConfigRules({
|
||||
workspaceDir: pluginRoot,
|
||||
env: {},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
path: ["plugins", "entries", "demo", "doctor"],
|
||||
message: "doctor contract",
|
||||
},
|
||||
]);
|
||||
expect(mocks.createJiti).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses native require for compatible JavaScript contract modules", () => {
|
||||
const pluginRoot = makeTempDir();
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "doctor-contract-api.cjs"),
|
||||
"module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'demo', 'legacy'], message: 'legacy demo key' }] };\n",
|
||||
"utf-8",
|
||||
);
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [{ id: "test-plugin", rootDir: pluginRoot }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(
|
||||
path.join(pluginRoot, "doctor-contract-api.js"),
|
||||
);
|
||||
try {
|
||||
expect(
|
||||
listPluginDoctorLegacyConfigRules({
|
||||
workspaceDir: pluginRoot,
|
||||
env: {},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
path: ["plugins", "entries", "demo", "legacy"],
|
||||
message: "legacy demo key",
|
||||
},
|
||||
]);
|
||||
expect(mocks.createJiti).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("narrows touched-path doctor ids for scoped dry-run validation", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { asNullableRecord } from "../shared/record-coerce.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
|
||||
import { resolvePluginCacheInputs, type PluginSourceRoots } from "./roots.js";
|
||||
|
||||
const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
|
||||
@@ -51,6 +52,14 @@ function getJiti(modulePath: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadPluginDoctorContractModule(modulePath: string): PluginDoctorContractModule {
|
||||
const nativeModule = tryNativeRequireJavaScriptModule(modulePath);
|
||||
if (nativeModule.ok) {
|
||||
return nativeModule.moduleExport as PluginDoctorContractModule;
|
||||
}
|
||||
return getJiti(modulePath)(modulePath) as PluginDoctorContractModule;
|
||||
}
|
||||
|
||||
function buildDoctorContractCacheKey(params: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -225,7 +234,7 @@ function loadPluginDoctorContractEntry(
|
||||
}
|
||||
let mod: PluginDoctorContractModule;
|
||||
try {
|
||||
mod = getJiti(contractSource)(contractSource) as PluginDoctorContractModule;
|
||||
mod = loadPluginDoctorContractModule(contractSource);
|
||||
} catch {
|
||||
cache.set(record.id, null);
|
||||
return null;
|
||||
|
||||
@@ -877,7 +877,9 @@ export type OpenClawPackageSetupFeatures = {
|
||||
|
||||
export type OpenClawPackageManifest = {
|
||||
extensions?: string[];
|
||||
runtimeExtensions?: string[];
|
||||
setupEntry?: string;
|
||||
runtimeSetupEntry?: string;
|
||||
setupFeatures?: OpenClawPackageSetupFeatures;
|
||||
channel?: PluginPackageChannel;
|
||||
install?: PluginPackageInstall;
|
||||
|
||||
25
src/plugins/native-module-require.ts
Normal file
25
src/plugins/native-module-require.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
|
||||
export function isJavaScriptModulePath(modulePath: string): boolean {
|
||||
return [".js", ".mjs", ".cjs"].includes(path.extname(modulePath).toLowerCase());
|
||||
}
|
||||
|
||||
export function tryNativeRequireJavaScriptModule(
|
||||
modulePath: string,
|
||||
options: { allowWindows?: boolean } = {},
|
||||
): { ok: true; moduleExport: unknown } | { ok: false } {
|
||||
if (process.platform === "win32" && options.allowWindows !== true) {
|
||||
return { ok: false };
|
||||
}
|
||||
if (!isJavaScriptModulePath(modulePath)) {
|
||||
return { ok: false };
|
||||
}
|
||||
try {
|
||||
return { ok: true, moduleExport: nodeRequire(modulePath) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user