refactor: dedupe cli config cron and install flows

This commit is contained in:
Peter Steinberger
2026-03-02 19:48:38 +00:00
parent 9d30159fcd
commit b1c30f0ba9
80 changed files with 1379 additions and 2027 deletions

View File

@@ -2,7 +2,12 @@ import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { callBrowserAct, requireRef, resolveBrowserActionContext } from "./shared.js";
import {
callBrowserAct,
logBrowserActionResult,
requireRef,
resolveBrowserActionContext,
} from "./shared.js";
export function registerBrowserElementCommands(
browser: Command,
@@ -41,12 +46,8 @@ export function registerBrowserElementCommands(
modifiers,
},
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const suffix = result.url ? ` on ${result.url}` : "";
defaultRuntime.log(`clicked ref ${refValue}${suffix}`);
logBrowserActionResult(parent, result, `clicked ref ${refValue}${suffix}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -80,11 +81,7 @@ export function registerBrowserElementCommands(
targetId: opts.targetId?.trim() || undefined,
},
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`typed into ref ${refValue}`);
logBrowserActionResult(parent, result, `typed into ref ${refValue}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -104,11 +101,7 @@ export function registerBrowserElementCommands(
profile,
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`pressed ${key}`);
logBrowserActionResult(parent, result, `pressed ${key}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -128,11 +121,7 @@ export function registerBrowserElementCommands(
profile,
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`hovered ref ${ref}`);
logBrowserActionResult(parent, result, `hovered ref ${ref}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -165,11 +154,7 @@ export function registerBrowserElementCommands(
},
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`scrolled into view: ${refValue}`);
logBrowserActionResult(parent, result, `scrolled into view: ${refValue}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -195,11 +180,7 @@ export function registerBrowserElementCommands(
targetId: opts.targetId?.trim() || undefined,
},
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`dragged ${startRef}${endRef}`);
logBrowserActionResult(parent, result, `dragged ${startRef}${endRef}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -225,11 +206,7 @@ export function registerBrowserElementCommands(
targetId: opts.targetId?.trim() || undefined,
},
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`selected ${values.join(", ")}`);
logBrowserActionResult(parent, result, `selected ${values.join(", ")}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);

View File

@@ -2,7 +2,12 @@ import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { callBrowserAct, readFields, resolveBrowserActionContext } from "./shared.js";
import {
callBrowserAct,
logBrowserActionResult,
readFields,
resolveBrowserActionContext,
} from "./shared.js";
export function registerBrowserFormWaitEvalCommands(
browser: Command,
@@ -30,11 +35,7 @@ export function registerBrowserFormWaitEvalCommands(
targetId: opts.targetId?.trim() || undefined,
},
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`filled ${fields.length} field(s)`);
logBrowserActionResult(parent, result, `filled ${fields.length} field(s)`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -83,11 +84,7 @@ export function registerBrowserFormWaitEvalCommands(
},
timeoutMs,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log("wait complete");
logBrowserActionResult(parent, result, "wait complete");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);

View File

@@ -40,6 +40,18 @@ export async function callBrowserAct<T = unknown>(params: {
);
}
export function logBrowserActionResult(
parent: BrowserParentOpts,
result: unknown,
successMessage: string,
) {
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(successMessage);
}
export function requireRef(ref: string | undefined) {
const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) {

View File

@@ -35,13 +35,7 @@ vi.mock("./cli-utils.js", () => ({
_runtime: unknown,
action: () => Promise<void>,
onError: (err: unknown) => void,
) => {
try {
await action();
} catch (err) {
onError(err);
}
},
) => await action().catch(onError),
}));
vi.mock("../runtime.js", () => ({

View File

@@ -9,6 +9,7 @@ import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import {
getCronChannelOptions,
parseAt,
parseCronStaggerMs,
parseDurationMs,
printCronList,
warnIfCronSchedulerDisabled,
@@ -129,19 +130,7 @@ export function registerCronAddCommand(cron: Command) {
}
return { kind: "every" as const, everyMs };
}
const staggerMs = (() => {
if (useExact) {
return 0;
}
if (!staggerRaw) {
return undefined;
}
const parsed = parseDurationMs(staggerRaw);
if (!parsed) {
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
}
return parsed;
})();
const staggerMs = parseCronStaggerMs({ staggerRaw, useExact });
return {
kind: "cron" as const,
expr: cronExpr,

View File

@@ -7,6 +7,7 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import {
getCronChannelOptions,
parseAt,
parseCronStaggerMs,
parseDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
@@ -98,19 +99,7 @@ export function registerCronEditCommand(cron: Command) {
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
const requestedStaggerMs = (() => {
if (useExact) {
return 0;
}
if (!staggerRaw) {
return undefined;
}
const parsed = parseDurationMs(staggerRaw);
if (!parsed) {
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
}
return parsed;
})();
const requestedStaggerMs = parseCronStaggerMs({ staggerRaw, useExact });
const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") {

View File

@@ -62,6 +62,23 @@ export function parseDurationMs(input: string): number | null {
return Math.floor(n * factor);
}
export function parseCronStaggerMs(params: {
staggerRaw: string;
useExact: boolean;
}): number | undefined {
if (params.useExact) {
return 0;
}
if (!params.staggerRaw) {
return undefined;
}
const parsed = parseDurationMs(params.staggerRaw);
if (!parsed) {
throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
}
return parsed;
}
export function parseAt(input: string): string | null {
const raw = input.trim();
if (!raw) {

View File

@@ -15,6 +15,32 @@ vi.mock("../../infra/ports.js", () => ({
const originalPlatform = process.platform;
async function inspectUnknownListenerFallback(params: {
runtime: { status: "running"; pid: number } | { status: "stopped" };
includeUnknownListenersAsStale: boolean;
}) {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
const service = {
readRuntime: vi.fn(async () => params.runtime),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
return inspectGatewayRestart({
service,
port: 18789,
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
});
}
describe("inspectGatewayRestart", () => {
beforeEach(() => {
inspectPortUsage.mockReset();
@@ -71,24 +97,8 @@ describe("inspectGatewayRestart", () => {
});
it("treats unknown listeners as stale on Windows when enabled", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
const service = {
readRuntime: vi.fn(async () => ({ status: "stopped" })),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
const snapshot = await inspectGatewayRestart({
service,
port: 18789,
const snapshot = await inspectUnknownListenerFallback({
runtime: { status: "stopped" },
includeUnknownListenersAsStale: true,
});
@@ -96,24 +106,8 @@ describe("inspectGatewayRestart", () => {
});
it("does not treat unknown listeners as stale when fallback is disabled", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
const service = {
readRuntime: vi.fn(async () => ({ status: "stopped" })),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
const snapshot = await inspectGatewayRestart({
service,
port: 18789,
const snapshot = await inspectUnknownListenerFallback({
runtime: { status: "stopped" },
includeUnknownListenersAsStale: false,
});
@@ -121,24 +115,8 @@ describe("inspectGatewayRestart", () => {
});
it("does not apply unknown-listener fallback while runtime is running", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
classifyPortListener.mockReturnValue("unknown");
const service = {
readRuntime: vi.fn(async () => ({ status: "running", pid: 10920 })),
} as unknown as GatewayService;
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "busy",
listeners: [{ pid: 10920, command: "unknown" }],
hints: [],
});
const { inspectGatewayRestart } = await import("./restart-health.js");
const snapshot = await inspectGatewayRestart({
service,
port: 18789,
const snapshot = await inspectUnknownListenerFallback({
runtime: { status: "running", pid: 10920 },
includeUnknownListenersAsStale: true,
});

View File

@@ -10,6 +10,7 @@ import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import { findExtraGatewayServices } from "../../daemon/inspect.js";
import type { ServiceConfigAudit } from "../../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../../daemon/service-audit.js";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { resolveGatewayBindHost } from "../../gateway/net.js";
import {
@@ -54,19 +55,7 @@ export type DaemonStatus = {
environment?: Record<string, string>;
sourcePath?: string;
} | null;
runtime?: {
status?: string;
state?: string;
subState?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
lastRunResult?: string;
lastRunTime?: string;
detail?: string;
cachedLabel?: boolean;
missingUnit?: boolean;
};
runtime?: GatewayServiceRuntime;
configAudit?: ServiceConfigAudit;
};
config?: {

View File

@@ -26,6 +26,7 @@ import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import {
buildNpmInstallRecordFields,
resolvePinnedNpmInstallRecordForCli,
@@ -660,15 +661,7 @@ export function registerHooksCli(program: Command): void {
process.exit(1);
}
const looksLikePath =
raw.startsWith(".") ||
raw.startsWith("~") ||
path.isAbsolute(raw) ||
raw.endsWith(".zip") ||
raw.endsWith(".tgz") ||
raw.endsWith(".tar.gz") ||
raw.endsWith(".tar");
if (looksLikePath) {
if (looksLikeLocalInstallSpec(raw, [".zip", ".tgz", ".tar.gz", ".tar"])) {
defaultRuntime.error(`Path not found: ${resolved}`);
process.exit(1);
}

10
src/cli/install-spec.ts Normal file
View File

@@ -0,0 +1,10 @@
import path from "node:path";
export function looksLikeLocalInstallSpec(spec: string, knownSuffixes: readonly string[]): boolean {
return (
spec.startsWith(".") ||
spec.startsWith("~") ||
path.isAbsolute(spec) ||
knownSuffixes.some((suffix) => spec.endsWith(suffix))
);
}

View File

@@ -1,11 +1,7 @@
export type NpmResolutionMetadata = {
name?: string;
version?: string;
resolvedSpec?: string;
integrity?: string;
shasum?: string;
resolvedAt?: string;
};
import {
buildNpmResolutionFields,
type NpmSpecResolution as NpmResolutionMetadata,
} from "../infra/install-source-utils.js";
export function resolvePinnedNpmSpec(params: {
rawSpec: string;
@@ -36,14 +32,7 @@ export function mapNpmResolutionMetadata(resolution?: NpmResolutionMetadata): {
shasum?: string;
resolvedAt?: string;
} {
return {
resolvedName: resolution?.name,
resolvedVersion: resolution?.version,
resolvedSpec: resolution?.resolvedSpec,
integrity: resolution?.integrity,
shasum: resolution?.shasum,
resolvedAt: resolution?.resolvedAt,
};
return buildNpmResolutionFields(resolution);
}
export function buildNpmInstallRecordFields(params: {
@@ -68,7 +57,7 @@ export function buildNpmInstallRecordFields(params: {
spec: params.spec,
installPath: params.installPath,
version: params.version,
...mapNpmResolutionMetadata(params.resolution),
...buildNpmResolutionFields(params.resolution),
};
}

View File

@@ -22,6 +22,7 @@ import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
import { setPluginEnabledInConfig } from "./plugins-config.js";
import { promptYesNo } from "./prompt.js";
@@ -603,19 +604,18 @@ export function registerPluginsCli(program: Command) {
process.exit(1);
}
const looksLikePath =
raw.startsWith(".") ||
raw.startsWith("~") ||
path.isAbsolute(raw) ||
raw.endsWith(".ts") ||
raw.endsWith(".js") ||
raw.endsWith(".mjs") ||
raw.endsWith(".cjs") ||
raw.endsWith(".tgz") ||
raw.endsWith(".tar.gz") ||
raw.endsWith(".tar") ||
raw.endsWith(".zip");
if (looksLikePath) {
if (
looksLikeLocalInstallSpec(raw, [
".ts",
".js",
".mjs",
".cjs",
".tgz",
".tar.gz",
".tar",
".zip",
])
) {
defaultRuntime.error(`Path not found: ${resolved}`);
process.exit(1);
}

View File

@@ -65,6 +65,18 @@ describe("cli program (nodes media)", () => {
await program.parseAsync(argv, { from: "user" });
}
async function expectCameraSnapParseFailure(args: string[], expectedError: RegExp) {
mockNodeGateway();
const parseProgram = new Command();
parseProgram.exitOverride();
registerNodesCli(parseProgram);
runtime.error.mockClear();
await expect(parseProgram.parseAsync(args, { from: "user" })).rejects.toThrow(/exit/i);
expect(runtime.error.mock.calls.some(([msg]) => expectedError.test(String(msg)))).toBe(true);
}
async function runAndExpectUrlPayloadMediaFile(params: {
command: "camera.snap" | "camera.clip";
payload: Record<string, unknown>;
@@ -266,54 +278,27 @@ describe("cli program (nodes media)", () => {
});
it("fails nodes camera snap on invalid facing", async () => {
mockNodeGateway();
const program = new Command();
program.exitOverride();
registerNodesCli(program);
runtime.error.mockClear();
await expect(
program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"], {
from: "user",
}),
).rejects.toThrow(/exit/i);
expect(runtime.error.mock.calls.some(([msg]) => /invalid facing/i.test(String(msg)))).toBe(
true,
await expectCameraSnapParseFailure(
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"],
/invalid facing/i,
);
});
it("fails nodes camera snap when --facing both and --device-id are combined", async () => {
mockNodeGateway();
const program = new Command();
program.exitOverride();
registerNodesCli(program);
runtime.error.mockClear();
await expect(
program.parseAsync(
[
"nodes",
"camera",
"snap",
"--node",
"ios-node",
"--facing",
"both",
"--device-id",
"cam-123",
],
{ from: "user" },
),
).rejects.toThrow(/exit/i);
expect(
runtime.error.mock.calls.some(([msg]) =>
/facing=both is not allowed when --device-id is set/i.test(String(msg)),
),
).toBe(true);
await expectCameraSnapParseFailure(
[
"nodes",
"camera",
"snap",
"--node",
"ios-node",
"--facing",
"both",
"--device-id",
"cam-123",
],
/facing=both is not allowed when --device-id is set/i,
);
});
describe("URL-based payloads", () => {

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
import { readPackageName, readPackageVersion } from "../../infra/package-json.js";
import { normalizePackageTagInput } from "../../infra/package-tag.js";
import { trimLogTail } from "../../infra/restart-sentinel.js";
import { parseSemver } from "../../infra/runtime-guard.js";
import { fetchNpmTagVersion } from "../../infra/update-check.js";
@@ -58,20 +59,7 @@ export const DEFAULT_PACKAGE_NAME = "openclaw";
const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]);
export function normalizeTag(value?: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("openclaw@")) {
return trimmed.slice("openclaw@".length);
}
if (trimmed.startsWith(`${DEFAULT_PACKAGE_NAME}@`)) {
return trimmed.slice(`${DEFAULT_PACKAGE_NAME}@`.length);
}
return trimmed;
return normalizePackageTagInput(value, ["openclaw", DEFAULT_PACKAGE_NAME]);
}
export function normalizeVersionTag(tag: string): string | null {