fix: isolate CLI startup imports (#50212)
* fix: isolate CLI startup imports * fix: clarify CLI preflight behavior * fix: tighten main-module detection * fix: isolate CLI startup imports (#50212)
This commit is contained in:
parent
68bc6effc0
commit
d978ace90b
@ -220,6 +220,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
||||
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
|
||||
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
||||
- CLI/startup: stop `openclaw devices list` and similar loopback gateway commands from failing during startup by isolating heavy import-time side effects from the normal CLI path. (#50212) Thanks @obviyus.
|
||||
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readFileSyncMock = vi.hoisted(() => vi.fn());
|
||||
const listCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const listPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const ensurePluginRegistryLoadedMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
@ -22,25 +19,12 @@ vi.mock("../channels/registry.js", () => ({
|
||||
CHAT_CHANNEL_ORDER: ["telegram", "discord"],
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/catalog.js", () => ({
|
||||
listChannelPluginCatalogEntries: listCatalogMock,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: listPluginsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./plugin-registry.js", () => ({
|
||||
ensurePluginRegistryLoaded: ensurePluginRegistryLoadedMock,
|
||||
}));
|
||||
|
||||
async function loadModule() {
|
||||
return await import("./channel-options.js");
|
||||
}
|
||||
|
||||
describe("resolveCliChannelOptions", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS;
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@ -49,50 +33,26 @@ describe("resolveCliChannelOptions", () => {
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({ channelOptions: ["cached", "telegram", "cached"] }),
|
||||
);
|
||||
listCatalogMock.mockReturnValue([{ id: "catalog-only" }]);
|
||||
|
||||
const mod = await loadModule();
|
||||
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "catalog-only"]);
|
||||
expect(listCatalogMock).toHaveBeenCalledOnce();
|
||||
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram"]);
|
||||
});
|
||||
|
||||
it("falls back to dynamic catalog resolution when metadata is missing", async () => {
|
||||
it("falls back to core channel order when metadata is missing", async () => {
|
||||
readFileSyncMock.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
listCatalogMock.mockReturnValue([{ id: "feishu" }, { id: "telegram" }]);
|
||||
|
||||
const mod = await loadModule();
|
||||
expect(mod.resolveCliChannelOptions()).toEqual(["telegram", "discord", "feishu"]);
|
||||
expect(listCatalogMock).toHaveBeenCalledOnce();
|
||||
expect(mod.resolveCliChannelOptions()).toEqual(["telegram", "discord"]);
|
||||
});
|
||||
|
||||
it("respects eager mode and includes loaded plugin ids", async () => {
|
||||
process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS = "1";
|
||||
readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached"] }));
|
||||
listCatalogMock.mockReturnValue([{ id: "zalo" }]);
|
||||
listPluginsMock.mockReturnValue([{ id: "custom-a" }, { id: "custom-b" }]);
|
||||
|
||||
const mod = await loadModule();
|
||||
expect(mod.resolveCliChannelOptions()).toEqual([
|
||||
"telegram",
|
||||
"discord",
|
||||
"zalo",
|
||||
"custom-a",
|
||||
"custom-b",
|
||||
]);
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledOnce();
|
||||
expect(listPluginsMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps dynamic catalog resolution when external catalog env is set", async () => {
|
||||
it("ignores external catalog env during CLI bootstrap", async () => {
|
||||
process.env.OPENCLAW_PLUGIN_CATALOG_PATHS = "/tmp/plugins-catalog.json";
|
||||
readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached", "telegram"] }));
|
||||
listCatalogMock.mockReturnValue([{ id: "custom-catalog" }]);
|
||||
|
||||
const mod = await loadModule();
|
||||
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "custom-catalog"]);
|
||||
expect(listCatalogMock).toHaveBeenCalledOnce();
|
||||
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram"]);
|
||||
delete process.env.OPENCLAW_PLUGIN_CATALOG_PATHS;
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
||||
|
||||
function dedupe(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
@ -48,19 +44,8 @@ function loadPrecomputedChannelOptions(): string[] | null {
|
||||
}
|
||||
|
||||
export function resolveCliChannelOptions(): string[] {
|
||||
if (isTruthyEnvValue(process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS)) {
|
||||
const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
||||
const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]);
|
||||
ensurePluginRegistryLoaded();
|
||||
const pluginIds = listChannelPlugins().map((plugin) => plugin.id);
|
||||
return dedupe([...base, ...pluginIds]);
|
||||
}
|
||||
const precomputed = loadPrecomputedChannelOptions();
|
||||
const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
||||
const base = precomputed
|
||||
? dedupe([...precomputed, ...catalog])
|
||||
: dedupe([...CHAT_CHANNEL_ORDER, ...catalog]);
|
||||
return base;
|
||||
return precomputed ?? [...CHAT_CHANNEL_ORDER];
|
||||
}
|
||||
|
||||
export function formatCliChannelOptions(extra: string[] = []): string {
|
||||
|
||||
@ -4,8 +4,8 @@ import type { RuntimeEnv } from "../../runtime.js";
|
||||
const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn());
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../commands/doctor-config-flow.js", () => ({
|
||||
loadAndMaybeMigrateDoctorConfig: loadAndMaybeMigrateDoctorConfigMock,
|
||||
vi.mock("../../commands/doctor-config-preflight.js", () => ({
|
||||
runDoctorConfigPreflight: loadAndMaybeMigrateDoctorConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
@ -58,12 +58,17 @@ describe("ensureConfigReady", () => {
|
||||
}
|
||||
|
||||
function setInvalidSnapshot(overrides?: Partial<ReturnType<typeof makeSnapshot>>) {
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
const snapshot = {
|
||||
...makeSnapshot(),
|
||||
exists: true,
|
||||
valid: false,
|
||||
issues: [{ path: "channels.whatsapp", message: "invalid" }],
|
||||
...overrides,
|
||||
};
|
||||
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
|
||||
snapshot,
|
||||
baseConfig: {},
|
||||
});
|
||||
}
|
||||
|
||||
@ -78,6 +83,10 @@ describe("ensureConfigReady", () => {
|
||||
vi.clearAllMocks();
|
||||
resetConfigGuardStateForTests();
|
||||
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => ({
|
||||
snapshot: makeSnapshot(),
|
||||
baseConfig: {},
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
@ -94,6 +103,13 @@ describe("ensureConfigReady", () => {
|
||||
])("$name", async ({ commandPath, expectedDoctorCalls }) => {
|
||||
await runEnsureConfigReady(commandPath);
|
||||
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
|
||||
if (expectedDoctorCalls > 0) {
|
||||
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
||||
migrateState: false,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("exits for invalid config on non-allowlisted commands", async () => {
|
||||
@ -132,6 +148,10 @@ describe("ensureConfigReady", () => {
|
||||
it("prevents preflight stdout noise when suppression is enabled", async () => {
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||
process.stdout.write("Doctor warnings\n");
|
||||
return {
|
||||
snapshot: makeSnapshot(),
|
||||
baseConfig: {},
|
||||
};
|
||||
});
|
||||
const output = await withCapturedStdout(async () => {
|
||||
await runEnsureConfigReady(["message"], true);
|
||||
@ -142,6 +162,10 @@ describe("ensureConfigReady", () => {
|
||||
it("allows preflight stdout noise when suppression is not enabled", async () => {
|
||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||
process.stdout.write("Doctor warnings\n");
|
||||
return {
|
||||
snapshot: makeSnapshot(),
|
||||
baseConfig: {},
|
||||
};
|
||||
});
|
||||
const output = await withCapturedStdout(async () => {
|
||||
await runEnsureConfigReady(["message"], false);
|
||||
|
||||
@ -39,22 +39,25 @@ export async function ensureConfigReady(params: {
|
||||
suppressDoctorStdout?: boolean;
|
||||
}): Promise<void> {
|
||||
const commandPath = params.commandPath ?? [];
|
||||
let preflightSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | null = null;
|
||||
if (!didRunDoctorConfigFlow && shouldMigrateStateFromPath(commandPath)) {
|
||||
didRunDoctorConfigFlow = true;
|
||||
const runDoctorConfigFlow = async () =>
|
||||
(await import("../../commands/doctor-config-flow.js")).loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true },
|
||||
confirm: async () => false,
|
||||
const runDoctorConfigPreflight = async () =>
|
||||
(await import("../../commands/doctor-config-preflight.js")).runDoctorConfigPreflight({
|
||||
// Keep ordinary CLI startup on the lightweight validation path.
|
||||
migrateState: false,
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
});
|
||||
if (!params.suppressDoctorStdout) {
|
||||
await runDoctorConfigFlow();
|
||||
preflightSnapshot = (await runDoctorConfigPreflight()).snapshot;
|
||||
} else {
|
||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
const originalSuppressNotes = process.env.OPENCLAW_SUPPRESS_NOTES;
|
||||
process.stdout.write = (() => true) as unknown as typeof process.stdout.write;
|
||||
process.env.OPENCLAW_SUPPRESS_NOTES = "1";
|
||||
try {
|
||||
await runDoctorConfigFlow();
|
||||
preflightSnapshot = (await runDoctorConfigPreflight()).snapshot;
|
||||
} finally {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
if (originalSuppressNotes === undefined) {
|
||||
@ -66,7 +69,7 @@ export async function ensureConfigReady(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await getConfigSnapshot();
|
||||
const snapshot = preflightSnapshot ?? (await getConfigSnapshot());
|
||||
const commandName = commandPath[0];
|
||||
const subcommandName = commandPath[1];
|
||||
const allowInvalid = commandName
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
fetchTelegramChatId,
|
||||
inspectTelegramAccount,
|
||||
@ -13,7 +11,7 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gatewa
|
||||
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||
import { listRouteBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
|
||||
import { CONFIG_PATH, migrateLegacyConfig } from "../config/config.js";
|
||||
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
@ -51,17 +49,15 @@ import {
|
||||
isZalouserMutableGroupEntry,
|
||||
} from "../security/mutable-allowlist-detectors.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
import {
|
||||
formatConfigPath,
|
||||
noteIncludeConfinementWarning,
|
||||
noteOpencodeProviderOverrides,
|
||||
resolveConfigPathTarget,
|
||||
stripUnknownConfigKeys,
|
||||
} from "./doctor-config-analysis.js";
|
||||
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
|
||||
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
|
||||
import type { DoctorOptions } from "./doctor-prompter.js";
|
||||
import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
|
||||
|
||||
type TelegramAllowFromUsernameHit = { path: string; entry: string };
|
||||
|
||||
@ -1640,87 +1636,19 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): {
|
||||
return { config: next, changes };
|
||||
}
|
||||
|
||||
async function maybeMigrateLegacyConfig(): Promise<string[]> {
|
||||
const changes: string[] = [];
|
||||
const home = resolveHomeDir();
|
||||
if (!home) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
const targetDir = path.join(home, ".openclaw");
|
||||
const targetPath = path.join(targetDir, "openclaw.json");
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return changes;
|
||||
} catch {
|
||||
// missing config
|
||||
}
|
||||
|
||||
const legacyCandidates = [
|
||||
path.join(home, ".clawdbot", "clawdbot.json"),
|
||||
path.join(home, ".moldbot", "moldbot.json"),
|
||||
path.join(home, ".moltbot", "moltbot.json"),
|
||||
];
|
||||
|
||||
let legacyPath: string | null = null;
|
||||
for (const candidate of legacyCandidates) {
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
legacyPath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
if (!legacyPath) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
try {
|
||||
await fs.copyFile(legacyPath, targetPath, fs.constants.COPYFILE_EXCL);
|
||||
changes.push(`Migrated legacy config: ${legacyPath} -> ${targetPath}`);
|
||||
} catch {
|
||||
// If it already exists, skip silently.
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
options: DoctorOptions;
|
||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||
}) {
|
||||
const shouldRepair = params.options.repair === true || params.options.yes === true;
|
||||
const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
|
||||
if (stateDirResult.changes.length > 0) {
|
||||
note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
||||
}
|
||||
if (stateDirResult.warnings.length > 0) {
|
||||
note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
|
||||
}
|
||||
|
||||
const legacyConfigChanges = await maybeMigrateLegacyConfig();
|
||||
if (legacyConfigChanges.length > 0) {
|
||||
note(legacyConfigChanges.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
||||
}
|
||||
|
||||
let snapshot = await readConfigFileSnapshot();
|
||||
const baseCfg = snapshot.config ?? {};
|
||||
const preflight = await runDoctorConfigPreflight();
|
||||
let snapshot = preflight.snapshot;
|
||||
const baseCfg = preflight.baseConfig;
|
||||
let cfg: OpenClawConfig = baseCfg;
|
||||
let candidate = structuredClone(baseCfg);
|
||||
let pendingChanges = false;
|
||||
let shouldWriteConfig = false;
|
||||
const fixHints: string[] = [];
|
||||
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
||||
note("Config invalid; doctor will run with best-effort config.", "Config");
|
||||
noteIncludeConfinementWarning(snapshot);
|
||||
}
|
||||
const warnings = snapshot.warnings ?? [];
|
||||
if (warnings.length > 0) {
|
||||
const lines = formatConfigIssueLines(warnings, "-").join("\n");
|
||||
note(lines, "Config warnings");
|
||||
}
|
||||
|
||||
if (snapshot.legacyIssues.length > 0) {
|
||||
note(
|
||||
|
||||
109
src/commands/doctor-config-preflight.ts
Normal file
109
src/commands/doctor-config-preflight.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js";
|
||||
|
||||
async function maybeMigrateLegacyConfig(): Promise<string[]> {
|
||||
const changes: string[] = [];
|
||||
const home = resolveHomeDir();
|
||||
if (!home) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
const targetDir = path.join(home, ".openclaw");
|
||||
const targetPath = path.join(targetDir, "openclaw.json");
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return changes;
|
||||
} catch {
|
||||
// missing config
|
||||
}
|
||||
|
||||
const legacyCandidates = [
|
||||
path.join(home, ".clawdbot", "clawdbot.json"),
|
||||
path.join(home, ".moldbot", "moldbot.json"),
|
||||
path.join(home, ".moltbot", "moltbot.json"),
|
||||
];
|
||||
|
||||
let legacyPath: string | null = null;
|
||||
for (const candidate of legacyCandidates) {
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
legacyPath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
if (!legacyPath) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
try {
|
||||
await fs.copyFile(legacyPath, targetPath, fs.constants.COPYFILE_EXCL);
|
||||
changes.push(`Migrated legacy config: ${legacyPath} -> ${targetPath}`);
|
||||
} catch {
|
||||
// If it already exists, skip silently.
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
export type DoctorConfigPreflightResult = {
|
||||
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
|
||||
baseConfig: OpenClawConfig;
|
||||
};
|
||||
|
||||
export async function runDoctorConfigPreflight(
|
||||
options: {
|
||||
migrateState?: boolean;
|
||||
migrateLegacyConfig?: boolean;
|
||||
invalidConfigNote?: string | false;
|
||||
} = {},
|
||||
): Promise<DoctorConfigPreflightResult> {
|
||||
if (options.migrateState !== false) {
|
||||
const { autoMigrateLegacyStateDir } = await import("./doctor-state-migrations.js");
|
||||
const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
|
||||
if (stateDirResult.changes.length > 0) {
|
||||
note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
||||
}
|
||||
if (stateDirResult.warnings.length > 0) {
|
||||
note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.migrateLegacyConfig !== false) {
|
||||
const legacyConfigChanges = await maybeMigrateLegacyConfig();
|
||||
if (legacyConfigChanges.length > 0) {
|
||||
note(legacyConfigChanges.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const invalidConfigNote =
|
||||
options.invalidConfigNote ?? "Config invalid; doctor will run with best-effort config.";
|
||||
if (
|
||||
invalidConfigNote &&
|
||||
snapshot.exists &&
|
||||
!snapshot.valid &&
|
||||
snapshot.legacyIssues.length === 0
|
||||
) {
|
||||
note(invalidConfigNote, "Config");
|
||||
noteIncludeConfinementWarning(snapshot);
|
||||
}
|
||||
|
||||
const warnings = snapshot.warnings ?? [];
|
||||
if (warnings.length > 0) {
|
||||
note(formatConfigIssueLines(warnings, "-").join("\n"), "Config warnings");
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
baseConfig: snapshot.config ?? {},
|
||||
};
|
||||
}
|
||||
@ -21,6 +21,7 @@ describe("legacy root entry", () => {
|
||||
it("does not run CLI bootstrap when imported as a library dependency", async () => {
|
||||
const mod = await import("./index.js");
|
||||
|
||||
expect(typeof mod.applyTemplate).toBe("function");
|
||||
expect(typeof mod.runLegacyCliEntry).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
79
src/index.ts
79
src/index.ts
@ -5,36 +5,38 @@ import { formatUncaughtError } from "./infra/errors.js";
|
||||
import { isMainModule } from "./infra/is-main.js";
|
||||
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
||||
|
||||
const library = await import("./library.js");
|
||||
|
||||
export const assertWebChannel = library.assertWebChannel;
|
||||
export const applyTemplate = library.applyTemplate;
|
||||
export const createDefaultDeps = library.createDefaultDeps;
|
||||
export const deriveSessionKey = library.deriveSessionKey;
|
||||
export const describePortOwner = library.describePortOwner;
|
||||
export const ensureBinary = library.ensureBinary;
|
||||
export const ensurePortAvailable = library.ensurePortAvailable;
|
||||
export const getReplyFromConfig = library.getReplyFromConfig;
|
||||
export const handlePortError = library.handlePortError;
|
||||
export const loadConfig = library.loadConfig;
|
||||
export const loadSessionStore = library.loadSessionStore;
|
||||
export const monitorWebChannel = library.monitorWebChannel;
|
||||
export const normalizeE164 = library.normalizeE164;
|
||||
export const PortInUseError = library.PortInUseError;
|
||||
export const promptYesNo = library.promptYesNo;
|
||||
export const resolveSessionKey = library.resolveSessionKey;
|
||||
export const resolveStorePath = library.resolveStorePath;
|
||||
export const runCommandWithTimeout = library.runCommandWithTimeout;
|
||||
export const runExec = library.runExec;
|
||||
export const saveSessionStore = library.saveSessionStore;
|
||||
export const toWhatsappJid = library.toWhatsappJid;
|
||||
export const waitForever = library.waitForever;
|
||||
|
||||
type LegacyCliDeps = {
|
||||
installGaxiosFetchCompat: () => Promise<void>;
|
||||
runCli: (argv: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
type LibraryExports = typeof import("./library.js");
|
||||
|
||||
// These bindings are populated only for library consumers. The CLI entry stays
|
||||
// on the lean path and must not read them while running as main.
|
||||
export let assertWebChannel: LibraryExports["assertWebChannel"];
|
||||
export let applyTemplate: LibraryExports["applyTemplate"];
|
||||
export let createDefaultDeps: LibraryExports["createDefaultDeps"];
|
||||
export let deriveSessionKey: LibraryExports["deriveSessionKey"];
|
||||
export let describePortOwner: LibraryExports["describePortOwner"];
|
||||
export let ensureBinary: LibraryExports["ensureBinary"];
|
||||
export let ensurePortAvailable: LibraryExports["ensurePortAvailable"];
|
||||
export let getReplyFromConfig: LibraryExports["getReplyFromConfig"];
|
||||
export let handlePortError: LibraryExports["handlePortError"];
|
||||
export let loadConfig: LibraryExports["loadConfig"];
|
||||
export let loadSessionStore: LibraryExports["loadSessionStore"];
|
||||
export let monitorWebChannel: LibraryExports["monitorWebChannel"];
|
||||
export let normalizeE164: LibraryExports["normalizeE164"];
|
||||
export let PortInUseError: LibraryExports["PortInUseError"];
|
||||
export let promptYesNo: LibraryExports["promptYesNo"];
|
||||
export let resolveSessionKey: LibraryExports["resolveSessionKey"];
|
||||
export let resolveStorePath: LibraryExports["resolveStorePath"];
|
||||
export let runCommandWithTimeout: LibraryExports["runCommandWithTimeout"];
|
||||
export let runExec: LibraryExports["runExec"];
|
||||
export let saveSessionStore: LibraryExports["saveSessionStore"];
|
||||
export let toWhatsappJid: LibraryExports["toWhatsappJid"];
|
||||
export let waitForever: LibraryExports["waitForever"];
|
||||
|
||||
async function loadLegacyCliDeps(): Promise<LegacyCliDeps> {
|
||||
const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([
|
||||
import("./infra/gaxios-fetch-compat.js"),
|
||||
@ -57,6 +59,33 @@ const isMain = isMainModule({
|
||||
currentFile: fileURLToPath(import.meta.url),
|
||||
});
|
||||
|
||||
if (!isMain) {
|
||||
({
|
||||
assertWebChannel,
|
||||
applyTemplate,
|
||||
createDefaultDeps,
|
||||
deriveSessionKey,
|
||||
describePortOwner,
|
||||
ensureBinary,
|
||||
ensurePortAvailable,
|
||||
getReplyFromConfig,
|
||||
handlePortError,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
monitorWebChannel,
|
||||
normalizeE164,
|
||||
PortInUseError,
|
||||
promptYesNo,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
saveSessionStore,
|
||||
toWhatsappJid,
|
||||
waitForever,
|
||||
} = await import("./library.js"));
|
||||
}
|
||||
|
||||
if (isMain) {
|
||||
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
||||
// These log the error and exit gracefully instead of crashing without trace.
|
||||
|
||||
@ -78,15 +78,15 @@ describe("isMainModule", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to basename matching for relative or symlinked entrypoints", () => {
|
||||
it("returns false for another entrypoint with the same basename", () => {
|
||||
expect(
|
||||
isMainModule({
|
||||
currentFile: "/repo/dist/index.js",
|
||||
argv: ["node", "../other/index.js"],
|
||||
cwd: "/repo/dist",
|
||||
currentFile: "/repo/node_modules/openclaw/dist/index.js",
|
||||
argv: ["node", "/repo/dist/index.js"],
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no entrypoint candidate exists", () => {
|
||||
|
||||
@ -59,14 +59,5 @@ export function isMainModule({
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: basename match (relative paths, symlinked bins).
|
||||
if (
|
||||
normalizedCurrent &&
|
||||
normalizedArgv1 &&
|
||||
path.basename(normalizedCurrent) === path.basename(normalizedArgv1)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user