fix: unblock cli startup metadata

This commit is contained in:
Peter Steinberger
2026-04-04 02:35:23 +01:00
parent 143d377c5a
commit 1e6e685347
36 changed files with 674 additions and 79 deletions

View File

@@ -9,7 +9,12 @@ import {
shouldPreferNativeJiti,
} from "../../plugins/sdk-alias.js";
const CONTRACT_BASENAME = "contract-api.ts";
const CONTRACT_SURFACE_BASENAMES = [
"contract-surfaces.ts",
"contract-surfaces.js",
"contract-api.ts",
"contract-api.js",
] as const;
let cachedSurfaces: unknown[] | null = null;
let cachedSurfaceEntries: Array<{
@@ -41,6 +46,16 @@ function createModuleLoader() {
const loadModule = createModuleLoader();
function resolveContractSurfaceModulePath(rootDir: string): string | null {
for (const basename of CONTRACT_SURFACE_BASENAMES) {
const modulePath = path.join(rootDir, basename);
if (fs.existsSync(modulePath)) {
return modulePath;
}
}
return null;
}
function loadBundledChannelContractSurfaces(): unknown[] {
return loadBundledChannelContractSurfaceEntries().map((entry) => entry.surface);
}
@@ -61,8 +76,8 @@ function loadBundledChannelContractSurfaceEntries(): Array<{
if (manifest.origin !== "bundled" || manifest.channels.length === 0) {
continue;
}
const modulePath = path.join(manifest.rootDir, CONTRACT_BASENAME);
if (!fs.existsSync(modulePath)) {
const modulePath = resolveContractSurfaceModulePath(manifest.rootDir);
if (!modulePath) {
continue;
}
try {

View File

@@ -1,11 +1,16 @@
import { Command } from "commander";
import { getPluginCliCommandDescriptors } from "../../plugins/cli.js";
import type { OpenClawPluginCliCommandDescriptor } from "../../plugins/types.js";
import { VERSION } from "../../version.js";
import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js";
import { configureProgramHelp } from "./help.js";
import { getSubCliEntries } from "./subcli-descriptors.js";
async function buildRootHelpProgram(): Promise<Command> {
type RootHelpRenderOptions = {
pluginDescriptors?: OpenClawPluginCliCommandDescriptor[] | null;
};
async function buildRootHelpProgram(options?: RootHelpRenderOptions): Promise<Command> {
const program = new Command();
configureProgramHelp(program, {
programVersion: VERSION,
@@ -26,7 +31,11 @@ async function buildRootHelpProgram(): Promise<Command> {
program.command(command.name).description(command.description);
existingCommands.add(command.name);
}
for (const command of await getPluginCliCommandDescriptors()) {
const pluginDescriptors =
options && "pluginDescriptors" in options
? (options.pluginDescriptors ?? [])
: await getPluginCliCommandDescriptors();
for (const command of pluginDescriptors) {
if (existingCommands.has(command.name)) {
continue;
}
@@ -37,8 +46,8 @@ async function buildRootHelpProgram(): Promise<Command> {
return program;
}
export async function renderRootHelpText(): Promise<string> {
const program = await buildRootHelpProgram();
export async function renderRootHelpText(options?: RootHelpRenderOptions): Promise<string> {
const program = await buildRootHelpProgram(options);
let output = "";
const originalWrite = process.stdout.write.bind(process.stdout);
const captureWrite: typeof process.stdout.write = ((chunk: string | Uint8Array) => {
@@ -54,6 +63,6 @@ export async function renderRootHelpText(): Promise<string> {
return output;
}
export async function outputRootHelp(): Promise<void> {
process.stdout.write(await renderRootHelpText());
export async function outputRootHelp(options?: RootHelpRenderOptions): Promise<void> {
process.stdout.write(await renderRootHelpText(options));
}

View File

@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
let precomputedRootHelpText: string | null | undefined;
export function loadPrecomputedRootHelpText(): string | null {
if (precomputedRootHelpText !== undefined) {
return precomputedRootHelpText;
}
try {
const metadataPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"cli-startup-metadata.json",
);
const raw = fs.readFileSync(metadataPath, "utf8");
const parsed = JSON.parse(raw) as { rootHelpText?: unknown };
if (typeof parsed.rootHelpText === "string" && parsed.rootHelpText.length > 0) {
precomputedRootHelpText = parsed.rootHelpText;
return precomputedRootHelpText;
}
} catch {
// Fall back to live root-help rendering.
}
precomputedRootHelpText = null;
return null;
}
export function outputPrecomputedRootHelpText(): boolean {
const rootHelpText = loadPrecomputedRootHelpText();
if (!rootHelpText) {
return false;
}
process.stdout.write(rootHelpText);
return true;
}
export const __testing = {
resetPrecomputedRootHelpTextForTests(): void {
precomputedRootHelpText = undefined;
},
};

View File

@@ -12,6 +12,7 @@ const hasMemoryRuntimeMock = vi.hoisted(() => vi.fn(() => false));
const ensureTaskRegistryReadyMock = vi.hoisted(() => vi.fn());
const startTaskRegistryMaintenanceMock = vi.hoisted(() => vi.fn());
const outputRootHelpMock = vi.hoisted(() => vi.fn());
const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false));
const buildProgramMock = vi.hoisted(() => vi.fn());
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn<
@@ -64,6 +65,10 @@ vi.mock("./program/root-help.js", () => ({
outputRootHelp: outputRootHelpMock,
}));
vi.mock("./root-help-metadata.js", () => ({
outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock,
}));
vi.mock("./program.js", () => ({
buildProgram: buildProgramMock,
}));
@@ -72,6 +77,7 @@ describe("runCli exit behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
hasMemoryRuntimeMock.mockReturnValue(false);
outputPrecomputedRootHelpTextMock.mockReturnValue(false);
});
it("does not force process.exit after successful routed command", async () => {
@@ -100,6 +106,7 @@ describe("runCli exit behavior", () => {
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "--help"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1);
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();

View File

@@ -158,8 +158,11 @@ export async function runCli(argv: string[] = process.argv) {
try {
if (shouldUseRootHelpFastPath(normalizedArgv)) {
const { outputRootHelp } = await import("./program/root-help.js");
await outputRootHelp();
const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js");
if (!outputPrecomputedRootHelpText()) {
const { outputRootHelp } = await import("./program/root-help.js");
await outputRootHelp();
}
return;
}

View File

@@ -1,7 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { tryHandleRootHelpFastPath } from "./entry.js";
const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false));
vi.mock("./cli/root-help-metadata.js", () => ({
outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock,
}));
describe("entry root help fast path", () => {
it("prefers precomputed root help text when available", async () => {
outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true);
const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], {
env: {},
});
await vi.dynamicImportSettled();
expect(handled).toBe(true);
expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1);
});
it("renders root help without importing the full program", async () => {
const outputRootHelpMock = vi.fn();

View File

@@ -185,9 +185,13 @@ export function tryHandleRootHelpFastPath(
.catch(handleError);
return true;
}
import("./cli/program/root-help.js")
.then(({ outputRootHelp }) => {
return outputRootHelp();
import("./cli/root-help-metadata.js")
.then(async ({ outputPrecomputedRootHelpText }) => {
if (outputPrecomputedRootHelpText()) {
return;
}
const { outputRootHelp } = await import("./cli/program/root-help.js");
await outputRootHelp();
})
.catch(handleError);
return true;

View File

@@ -46,13 +46,13 @@ function mergeCliRegistrars(params: {
runtimeRegistry: PluginRegistry;
metadataRegistry: PluginRegistry;
}) {
const metadataCommands = new Set(
params.metadataRegistry.cliRegistrars.flatMap((entry) => entry.commands),
const runtimeCommands = new Set(
params.runtimeRegistry.cliRegistrars.flatMap((entry) => entry.commands),
);
return [
...params.metadataRegistry.cliRegistrars,
...params.runtimeRegistry.cliRegistrars.filter(
(entry) => !entry.commands.some((command) => metadataCommands.has(command)),
...params.runtimeRegistry.cliRegistrars,
...params.metadataRegistry.cliRegistrars.filter(
(entry) => !entry.commands.some((command) => runtimeCommands.has(command)),
),
];
}

View File

@@ -189,6 +189,224 @@ module.exports = {
);
});
it("skips bundled channel full entries that do not provide a dedicated cli-metadata entry", async () => {
const bundledRoot = makeTempDir();
const pluginDir = path.join(bundledRoot, "bundled-skip-channel");
const fullMarker = path.join(pluginDir, "full-loaded.txt");
fs.mkdirSync(pluginDir, { recursive: true });
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot;
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/bundled-skip-channel",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "bundled-skip-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["bundled-skip-channel"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
id: "bundled-skip-channel",
register() {
throw new Error("bundled channel full entry should not load during CLI metadata capture");
},
};`,
"utf-8",
);
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
allow: ["bundled-skip-channel"],
entries: {
"bundled-skip-channel": {
enabled: true,
},
},
},
},
});
expect(fs.existsSync(fullMarker)).toBe(false);
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain(
"bundled-skip-channel",
);
expect(registry.plugins.find((entry) => entry.id === "bundled-skip-channel")?.status).toBe(
"loaded",
);
});
it("prefers bundled channel cli-metadata entries over full channel entries", async () => {
const bundledRoot = makeTempDir();
const pluginDir = path.join(bundledRoot, "bundled-cli-channel");
const fullMarker = path.join(pluginDir, "full-loaded.txt");
const cliMarker = path.join(pluginDir, "cli-loaded.txt");
fs.mkdirSync(pluginDir, { recursive: true });
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot;
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/bundled-cli-channel",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "bundled-cli-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["bundled-cli-channel"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
id: "bundled-cli-channel",
register() {
throw new Error("bundled channel full entry should not load during CLI metadata capture");
},
};`,
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "cli-metadata.cjs"),
`module.exports = {
id: "bundled-cli-channel",
register(api) {
require("node:fs").writeFileSync(${JSON.stringify(cliMarker)}, "loaded", "utf-8");
api.registerCli(() => {}, {
descriptors: [
{
name: "bundled-cli-channel",
description: "Bundled channel CLI metadata",
hasSubcommands: true,
},
],
});
},
};`,
"utf-8",
);
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
allow: ["bundled-cli-channel"],
entries: {
"bundled-cli-channel": {
enabled: true,
},
},
},
},
});
expect(fs.existsSync(fullMarker)).toBe(false);
expect(fs.existsSync(cliMarker)).toBe(true);
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
"bundled-cli-channel",
);
});
it("skips bundled non-channel full entries that do not provide a dedicated cli-metadata entry", async () => {
const bundledRoot = makeTempDir();
const pluginDir = path.join(bundledRoot, "bundled-skip-provider");
const fullMarker = path.join(pluginDir, "full-loaded.txt");
fs.mkdirSync(pluginDir, { recursive: true });
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot;
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/bundled-skip-provider",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "bundled-skip-provider",
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
id: "bundled-skip-provider",
register() {
throw new Error("bundled provider full entry should not load during CLI metadata capture");
},
};`,
"utf-8",
);
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
allow: ["bundled-skip-provider"],
entries: {
"bundled-skip-provider": {
enabled: true,
},
},
},
},
});
expect(fs.existsSync(fullMarker)).toBe(false);
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain(
"bundled-skip-provider",
);
expect(registry.plugins.find((entry) => entry.id === "bundled-skip-provider")?.status).toBe(
"loaded",
);
});
it("collects channel CLI metadata during full plugin loads", () => {
useNoBundledPlugins();
const pluginDir = makeTempDir();

View File

@@ -103,6 +103,13 @@ export type PluginLoadOptions = {
throwOnLoadError?: boolean;
};
const CLI_METADATA_ENTRY_BASENAMES = [
"cli-metadata.ts",
"cli-metadata.js",
"cli-metadata.mjs",
"cli-metadata.cjs",
] as const;
export class PluginLoadFailureError extends Error {
readonly pluginIds: string[];
readonly registry: PluginRegistry;
@@ -1810,8 +1817,17 @@ export async function loadOpenClawPluginCliRegistry(
}
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir);
const sourceForCliMetadata =
candidate.origin === "bundled" ? cliMetadataSource : (cliMetadataSource ?? candidate.source);
if (!sourceForCliMetadata) {
record.status = "loaded";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
const opened = openBoundaryFileSync({
absolutePath: candidate.source,
absolutePath: sourceForCliMetadata,
rootPath: pluginRoot,
boundaryLabel: "plugin root",
rejectHardlinks: candidate.origin !== "bundled",
@@ -1943,3 +1959,13 @@ function safeRealpathOrResolve(value: string): string {
return path.resolve(value);
}
}
function resolveCliMetadataEntrySource(rootDir: string): string | null {
for (const basename of CLI_METADATA_ENTRY_BASENAMES) {
const candidate = path.join(rootDir, basename);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}

View File

@@ -1,5 +1,5 @@
import { listSecretTargetRegistryEntries } from "./target-registry.js";
import { UNSUPPORTED_SECRETREF_SURFACE_PATTERNS } from "./unsupported-surface-policy.js";
import { getUnsupportedSecretRefSurfacePatterns } from "./unsupported-surface-policy.js";
type CredentialMatrixEntry = {
id: string;
@@ -54,7 +54,7 @@ export function buildSecretRefCredentialMatrix(): SecretRefCredentialMatrixDocum
pathSyntax: 'Dot path with "*" for map keys and "[]" for arrays.',
scope:
"Credentials that are strictly user-supplied and not minted/rotated by OpenClaw runtime.",
excludedMutableOrRuntimeManaged: [...UNSUPPORTED_SECRETREF_SURFACE_PATTERNS],
excludedMutableOrRuntimeManaged: getUnsupportedSecretRefSurfacePatterns(),
entries,
};
}

View File

@@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
import {
collectUnsupportedSecretRefConfigCandidates,
UNSUPPORTED_SECRETREF_SURFACE_PATTERNS,
getUnsupportedSecretRefSurfacePatterns,
} from "./unsupported-surface-policy.js";
describe("unsupported SecretRef surface policy metadata", () => {
it("exposes the canonical unsupported surface patterns", () => {
expect(UNSUPPORTED_SECRETREF_SURFACE_PATTERNS).toEqual([
expect(getUnsupportedSecretRefSurfacePatterns()).toEqual([
"commands.ownerDisplaySecret",
"hooks.token",
"hooks.gmail.pushToken",

View File

@@ -26,10 +26,15 @@ function collectChannelUnsupportedSecretRefSurfacePatterns(): string[] {
);
}
export const UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [
...CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS,
...collectChannelUnsupportedSecretRefSurfacePatterns(),
] as const;
let cachedUnsupportedSecretRefSurfacePatterns: string[] | null = null;
export function getUnsupportedSecretRefSurfacePatterns(): string[] {
cachedUnsupportedSecretRefSurfacePatterns ??= [
...CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS,
...collectChannelUnsupportedSecretRefSurfacePatterns(),
];
return cachedUnsupportedSecretRefSurfacePatterns;
}
export type UnsupportedSecretRefConfigCandidate = {
path: string;