CLI: reduce channels add startup memory (#46784)
* CLI: lazy-load channel subcommand handlers * Channels: defer add command dependencies * CLI: skip status JSON plugin preload * CLI: cover status JSON route preload * Status: trim JSON security audit path * Status: update JSON fast-path tests * CLI: cover root help fast path * CLI: fast-path root help * Status: keep JSON security parity * Status: restore JSON security tests * CLI: document status plugin preload * Channels: reuse Telegram account import
This commit is contained in:
parent
8d44b16b7c
commit
67b2d1b8e8
@ -1,13 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
channelsAddCommand,
|
||||
channelsCapabilitiesCommand,
|
||||
channelsListCommand,
|
||||
channelsLogsCommand,
|
||||
channelsRemoveCommand,
|
||||
channelsResolveCommand,
|
||||
channelsStatusCommand,
|
||||
} from "../commands/channels.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
@ -96,6 +87,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsListCommand } = await import("../commands/channels.js");
|
||||
await channelsListCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -108,6 +100,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsStatusCommand } = await import("../commands/channels.js");
|
||||
await channelsStatusCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -122,6 +115,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsCapabilitiesCommand } = await import("../commands/channels.js");
|
||||
await channelsCapabilitiesCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -136,6 +130,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (entries, opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsResolveCommand } = await import("../commands/channels.js");
|
||||
await channelsResolveCommand(
|
||||
{
|
||||
channel: opts.channel as string | undefined,
|
||||
@ -157,6 +152,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsLogsCommand } = await import("../commands/channels.js");
|
||||
await channelsLogsCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -200,6 +196,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--use-env", "Use env token (default account only)", false)
|
||||
.action(async (opts, command) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsAddCommand } = await import("../commands/channels.js");
|
||||
const hasFlags = hasExplicitOptions(command, optionNamesAdd);
|
||||
await channelsAddCommand(opts, defaultRuntime, { hasFlags });
|
||||
});
|
||||
@ -213,6 +210,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--delete", "Delete config entries (no prompt)", false)
|
||||
.action(async (opts, command) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsRemoveCommand } = await import("../commands/channels.js");
|
||||
const hasFlags = hasExplicitOptions(command, optionNamesRemove);
|
||||
await channelsRemoveCommand(opts, defaultRuntime, { hasFlags });
|
||||
});
|
||||
|
||||
@ -235,6 +235,10 @@ function collectCoreCliCommandNames(predicate?: (command: CoreCliCommandDescript
|
||||
return names;
|
||||
}
|
||||
|
||||
export function getCoreCliCommandDescriptors(): ReadonlyArray<CoreCliCommandDescriptor> {
|
||||
return coreEntries.flatMap((entry) => entry.commands);
|
||||
}
|
||||
|
||||
export function getCoreCliCommandNames(): string[] {
|
||||
return collectCoreCliCommandNames();
|
||||
}
|
||||
|
||||
29
src/cli/program/root-help.ts
Normal file
29
src/cli/program/root-help.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Command } from "commander";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { getCoreCliCommandDescriptors } from "./command-registry.js";
|
||||
import { configureProgramHelp } from "./help.js";
|
||||
import { getSubCliEntries } from "./register.subclis.js";
|
||||
|
||||
function buildRootHelpProgram(): Command {
|
||||
const program = new Command();
|
||||
configureProgramHelp(program, {
|
||||
programVersion: VERSION,
|
||||
channelOptions: [],
|
||||
messageChannelOptions: "",
|
||||
agentChannelOptions: "",
|
||||
});
|
||||
|
||||
for (const command of getCoreCliCommandDescriptors()) {
|
||||
program.command(command.name).description(command.description);
|
||||
}
|
||||
for (const command of getSubCliEntries()) {
|
||||
program.command(command.name).description(command.description);
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export function outputRootHelp(): void {
|
||||
const program = buildRootHelpProgram();
|
||||
program.outputHelp();
|
||||
}
|
||||
@ -32,7 +32,7 @@ describe("program routes", () => {
|
||||
await expect(route?.run(argv)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
it("matches status route and always loads plugins for security parity", () => {
|
||||
it("matches status route and always preloads plugins", () => {
|
||||
const route = expectRoute(["status"]);
|
||||
expect(route?.loadPlugins).toBe(true);
|
||||
});
|
||||
|
||||
@ -7,6 +7,8 @@ const normalizeEnvMock = vi.hoisted(() => vi.fn());
|
||||
const ensurePathMock = vi.hoisted(() => vi.fn());
|
||||
const assertRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const outputRootHelpMock = vi.hoisted(() => vi.fn());
|
||||
const buildProgramMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./route.js", () => ({
|
||||
tryRouteCli: tryRouteCliMock,
|
||||
@ -32,6 +34,14 @@ vi.mock("../memory/search-manager.js", () => ({
|
||||
closeAllMemorySearchManagers: closeAllMemorySearchManagersMock,
|
||||
}));
|
||||
|
||||
vi.mock("./program/root-help.js", () => ({
|
||||
outputRootHelp: outputRootHelpMock,
|
||||
}));
|
||||
|
||||
vi.mock("./program.js", () => ({
|
||||
buildProgram: buildProgramMock,
|
||||
}));
|
||||
|
||||
const { runCli } = await import("./run-main.js");
|
||||
|
||||
describe("runCli exit behavior", () => {
|
||||
@ -52,4 +62,19 @@ describe("runCli exit behavior", () => {
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders root help without building the full program", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`unexpected process.exit(${String(code)})`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
await runCli(["node", "openclaw", "--help"]);
|
||||
|
||||
expect(tryRouteCliMock).not.toHaveBeenCalled();
|
||||
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
|
||||
expect(buildProgramMock).not.toHaveBeenCalled();
|
||||
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
shouldEnsureCliPath,
|
||||
shouldRegisterPrimarySubcommand,
|
||||
shouldSkipPluginCommandRegistration,
|
||||
shouldUseRootHelpFastPath,
|
||||
} from "./run-main.js";
|
||||
|
||||
describe("rewriteUpdateFlagArgv", () => {
|
||||
@ -126,3 +127,12 @@ describe("shouldEnsureCliPath", () => {
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "acp", "-v"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseRootHelpFastPath", () => {
|
||||
it("uses the fast path for root help only", () => {
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help"])).toBe(true);
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--profile", "work", "-h"])).toBe(true);
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "status", "--help"])).toBe(false);
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help", "status"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,12 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
import { enableConsoleCapture } from "../logging.js";
|
||||
import { getCommandPathWithRootOptions, getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
|
||||
import {
|
||||
getCommandPathWithRootOptions,
|
||||
getPrimaryCommand,
|
||||
hasHelpOrVersion,
|
||||
isRootHelpInvocation,
|
||||
} from "./argv.js";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
|
||||
import { tryRouteCli } from "./route.js";
|
||||
import { normalizeWindowsArgv } from "./windows-argv.js";
|
||||
@ -71,6 +76,10 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldUseRootHelpFastPath(argv: string[]): boolean {
|
||||
return isRootHelpInvocation(argv);
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[] = process.argv) {
|
||||
let normalizedArgv = normalizeWindowsArgv(argv);
|
||||
const parsedProfile = parseCliProfileArgs(normalizedArgv);
|
||||
@ -92,6 +101,12 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
assertSupportedRuntime();
|
||||
|
||||
try {
|
||||
if (shouldUseRootHelpFastPath(normalizedArgv)) {
|
||||
const { outputRootHelp } = await import("./program/root-help.js");
|
||||
outputRootHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await tryRouteCli(normalizedArgv)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js";
|
||||
import { deleteTelegramUpdateOffset } from "../../../extensions/telegram/src/update-offset-store.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
|
||||
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
|
||||
@ -11,13 +9,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
|
||||
import { buildAgentSummaries } from "../agents.config.js";
|
||||
import { setupChannels } from "../onboard-channels.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
} from "../onboarding/plugin-install.js";
|
||||
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
||||
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
||||
|
||||
@ -56,6 +48,10 @@ export async function channelsAddCommand(
|
||||
|
||||
const useWizard = shouldUseWizard(params);
|
||||
if (useWizard) {
|
||||
const [{ buildAgentSummaries }, { setupChannels }] = await Promise.all([
|
||||
import("../agents.config.js"),
|
||||
import("../onboard-channels.js"),
|
||||
]);
|
||||
const prompter = createClackPrompter();
|
||||
let selection: ChannelChoice[] = [];
|
||||
const accountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||
@ -176,6 +172,8 @@ export async function channelsAddCommand(
|
||||
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
|
||||
|
||||
if (!channel && catalogEntry) {
|
||||
const { ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry } =
|
||||
await import("../onboarding/plugin-install.js");
|
||||
const prompter = createClackPrompter();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
@ -269,10 +267,20 @@ export async function channelsAddCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTelegramToken =
|
||||
channel === "telegram"
|
||||
? resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim()
|
||||
: "";
|
||||
let previousTelegramToken = "";
|
||||
let resolveTelegramAccount:
|
||||
| ((
|
||||
params: Parameters<
|
||||
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
|
||||
>[0],
|
||||
) => ReturnType<
|
||||
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
|
||||
>)
|
||||
| undefined;
|
||||
if (channel === "telegram") {
|
||||
({ resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js"));
|
||||
previousTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
|
||||
}
|
||||
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
|
||||
@ -288,7 +296,9 @@ export async function channelsAddCommand(
|
||||
input,
|
||||
});
|
||||
|
||||
if (channel === "telegram") {
|
||||
if (channel === "telegram" && resolveTelegramAccount) {
|
||||
const { deleteTelegramUpdateOffset } =
|
||||
await import("../../../extensions/telegram/src/update-offset-store.js");
|
||||
const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
|
||||
if (previousTelegramToken !== nextTelegramToken) {
|
||||
// Clear stale polling offsets after Telegram token rotation.
|
||||
|
||||
@ -417,6 +417,12 @@ describe("statusCommand", () => {
|
||||
expect(payload.securityAudit.summary.warn).toBe(1);
|
||||
expect(payload.gatewayService.label).toBe("LaunchAgent");
|
||||
expect(payload.nodeService.label).toBe("LaunchAgent");
|
||||
expect(mocks.runSecurityAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces unknown usage when totalTokens is missing", async () => {
|
||||
@ -505,8 +511,8 @@ describe("statusCommand", () => {
|
||||
|
||||
await statusCommand({ json: true }, runtime as never);
|
||||
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
|
||||
expect(payload.gateway.error).toContain("gateway.auth.token");
|
||||
expect(payload.gateway.error).toContain("SecretRef");
|
||||
expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull();
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces channel runtime errors from the gateway", async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user