refactor(core): extract shared dedup helpers
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { sanitizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
@@ -8,9 +7,11 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
handleCronCliError,
|
||||
parseAt,
|
||||
parseCronStaggerMs,
|
||||
parseDurationMs,
|
||||
printCronJson,
|
||||
printCronList,
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
@@ -24,10 +25,9 @@ export function registerCronStatusCommand(cron: Command) {
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.status", opts, {});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
printCronJson(res);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -46,14 +46,13 @@ export function registerCronListCommand(cron: Command) {
|
||||
includeDisabled: Boolean(opts.all),
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
printCronJson(res);
|
||||
return;
|
||||
}
|
||||
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
|
||||
printCronList(jobs, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -273,11 +272,10 @@ export function registerCronAddCommand(cron: Command) {
|
||||
};
|
||||
|
||||
const res = await callGatewayFromCli("cron.add", opts, params);
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
printCronJson(res);
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { warnIfCronSchedulerDisabled } from "./shared.js";
|
||||
import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js";
|
||||
|
||||
function registerCronToggleCommand(params: {
|
||||
cron: Command;
|
||||
@@ -21,11 +20,10 @@ function registerCronToggleCommand(params: {
|
||||
id,
|
||||
patch: { enabled: params.enabled },
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
printCronJson(res);
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -43,10 +41,9 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.remove", opts, { id });
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
printCronJson(res);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -79,10 +76,9 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
id,
|
||||
limit,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
printCronJson(res);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -102,12 +98,11 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
id,
|
||||
mode: opts.due ? "due" : "force",
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
||||
printCronJson(res);
|
||||
const result = res as { ok?: boolean; ran?: boolean } | undefined;
|
||||
defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||
import { resolveCronStaggerMs } from "../../cron/stagger.js";
|
||||
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
@@ -11,6 +12,15 @@ import { callGatewayFromCli } from "../gateway-rpc.js";
|
||||
export const getCronChannelOptions = () =>
|
||||
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
|
||||
|
||||
export function printCronJson(value: unknown) {
|
||||
defaultRuntime.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export function handleCronCliError(err: unknown) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||
try {
|
||||
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
||||
|
||||
@@ -60,6 +60,8 @@ describe("memory cli", () => {
|
||||
return JSON.parse(String(log.mock.calls[0]?.[0] ?? "null")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive";
|
||||
|
||||
function expectCliSync(sync: ReturnType<typeof vi.fn>) {
|
||||
expect(sync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
||||
@@ -85,6 +87,25 @@ describe("memory cli", () => {
|
||||
getMemorySearchManager.mockResolvedValueOnce({ manager });
|
||||
}
|
||||
|
||||
function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType<typeof vi.fn>) {
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||
resolvedConfig: {},
|
||||
diagnostics: [inactiveMemorySecretDiagnostic] as string[],
|
||||
});
|
||||
mockManager({
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => makeMemoryStatus({ workspaceDir: undefined }),
|
||||
close,
|
||||
});
|
||||
}
|
||||
|
||||
function hasLoggedInactiveSecretDiagnostic(spy: ReturnType<typeof vi.spyOn>) {
|
||||
return spy.mock.calls.some(
|
||||
(call: unknown[]) =>
|
||||
typeof call[0] === "string" && call[0].includes(inactiveMemorySecretDiagnostic),
|
||||
);
|
||||
}
|
||||
|
||||
async function runMemoryCli(args: string[]) {
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
@@ -191,26 +212,12 @@ describe("memory cli", () => {
|
||||
|
||||
it("logs gateway secret diagnostics for non-json status output", async () => {
|
||||
const close = vi.fn(async () => {});
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||
resolvedConfig: {},
|
||||
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
|
||||
});
|
||||
mockManager({
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => makeMemoryStatus({ workspaceDir: undefined }),
|
||||
close,
|
||||
});
|
||||
setupMemoryStatusWithInactiveSecretDiagnostics(close);
|
||||
|
||||
const log = spyRuntimeLogs();
|
||||
await runMemoryCli(["status"]);
|
||||
|
||||
expect(
|
||||
log.mock.calls.some(
|
||||
(call) =>
|
||||
typeof call[0] === "string" &&
|
||||
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true);
|
||||
});
|
||||
|
||||
it("prints vector error when unavailable", async () => {
|
||||
@@ -410,15 +417,7 @@ describe("memory cli", () => {
|
||||
|
||||
it("routes gateway secret diagnostics to stderr for json status output", async () => {
|
||||
const close = vi.fn(async () => {});
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||
resolvedConfig: {},
|
||||
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
|
||||
});
|
||||
mockManager({
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => makeMemoryStatus({ workspaceDir: undefined }),
|
||||
close,
|
||||
});
|
||||
setupMemoryStatusWithInactiveSecretDiagnostics(close);
|
||||
|
||||
const log = spyRuntimeLogs();
|
||||
const error = spyRuntimeErrors();
|
||||
@@ -426,13 +425,7 @@ describe("memory cli", () => {
|
||||
|
||||
const payload = firstLoggedJson(log);
|
||||
expect(Array.isArray(payload)).toBe(true);
|
||||
expect(
|
||||
error.mock.calls.some(
|
||||
(call) =>
|
||||
typeof call[0] === "string" &&
|
||||
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("logs default message when memory manager is missing", async () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type ExecSecurity,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
normalizeExecAsk,
|
||||
normalizeExecSecurity,
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../../infra/exec-approvals.js";
|
||||
import { buildNodeShellCommand } from "../../infra/node-shell.js";
|
||||
@@ -43,22 +45,6 @@ type ExecDefaults = {
|
||||
safeBins?: string[];
|
||||
};
|
||||
|
||||
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeExecAsk(value?: string | null): ExecAsk | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
|
||||
return normalized as ExecAsk;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExecDefaults(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
agentId: string | undefined,
|
||||
|
||||
@@ -72,6 +72,32 @@ function createTailscaleRemoteRefConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultSecretProvider() {
|
||||
return {
|
||||
providers: {
|
||||
default: { source: "env" as const },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createLocalGatewayConfigWithAuth(auth: Record<string, unknown>) {
|
||||
return {
|
||||
secrets: createDefaultSecretProvider(),
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createLocalGatewayPasswordRefAuth(secretId: string) {
|
||||
return {
|
||||
mode: "password",
|
||||
password: { source: "env", provider: "default", id: secretId },
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerQrCli", () => {
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
@@ -88,6 +114,23 @@ describe("registerQrCli", () => {
|
||||
await expect(runQr(args)).rejects.toThrow("exit");
|
||||
}
|
||||
|
||||
function parseLastLoggedQrJson() {
|
||||
return JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
||||
setupCode?: string;
|
||||
gatewayUrl?: string;
|
||||
auth?: string;
|
||||
urlSource?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function mockTailscaleStatusLookup() {
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
||||
@@ -157,21 +200,11 @@ describe("registerQrCli", () => {
|
||||
});
|
||||
|
||||
it("skips local password SecretRef resolution when --token override is provided", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
||||
},
|
||||
},
|
||||
});
|
||||
loadConfig.mockReturnValue(
|
||||
createLocalGatewayConfigWithAuth(
|
||||
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
|
||||
),
|
||||
);
|
||||
|
||||
await runQr(["--setup-code-only", "--token", "override-token"]);
|
||||
|
||||
@@ -184,21 +217,11 @@ describe("registerQrCli", () => {
|
||||
|
||||
it("resolves local gateway auth password SecretRefs before setup code generation", async () => {
|
||||
vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret");
|
||||
loadConfig.mockReturnValue({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: { source: "env", provider: "default", id: "QR_LOCAL_GATEWAY_PASSWORD" },
|
||||
},
|
||||
},
|
||||
});
|
||||
loadConfig.mockReturnValue(
|
||||
createLocalGatewayConfigWithAuth(
|
||||
createLocalGatewayPasswordRefAuth("QR_LOCAL_GATEWAY_PASSWORD"),
|
||||
),
|
||||
);
|
||||
|
||||
await runQr(["--setup-code-only"]);
|
||||
|
||||
@@ -212,21 +235,11 @@ describe("registerQrCli", () => {
|
||||
|
||||
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env");
|
||||
loadConfig.mockReturnValue({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
||||
},
|
||||
},
|
||||
});
|
||||
loadConfig.mockReturnValue(
|
||||
createLocalGatewayConfigWithAuth(
|
||||
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
|
||||
),
|
||||
);
|
||||
|
||||
await runQr(["--setup-code-only"]);
|
||||
|
||||
@@ -239,22 +252,13 @@ describe("registerQrCli", () => {
|
||||
});
|
||||
|
||||
it("does not resolve local password SecretRef when auth mode is token", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "token-123",
|
||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
||||
},
|
||||
},
|
||||
});
|
||||
loadConfig.mockReturnValue(
|
||||
createLocalGatewayConfigWithAuth({
|
||||
mode: "token",
|
||||
token: "token-123",
|
||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
||||
}),
|
||||
);
|
||||
|
||||
await runQr(["--setup-code-only"]);
|
||||
|
||||
@@ -268,20 +272,11 @@ describe("registerQrCli", () => {
|
||||
|
||||
it("resolves local password SecretRef when auth mode is inferred", async () => {
|
||||
vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password");
|
||||
loadConfig.mockReturnValue({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: {
|
||||
password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" },
|
||||
},
|
||||
},
|
||||
});
|
||||
loadConfig.mockReturnValue(
|
||||
createLocalGatewayConfigWithAuth({
|
||||
password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" },
|
||||
}),
|
||||
);
|
||||
|
||||
await runQr(["--setup-code-only"]);
|
||||
|
||||
@@ -390,20 +385,11 @@ describe("registerQrCli", () => {
|
||||
{ name: "when tailscale is configured", withTailscale: true },
|
||||
])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => {
|
||||
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale }));
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
||||
stderr: "",
|
||||
});
|
||||
mockTailscaleStatusLookup();
|
||||
|
||||
await runQr(["--json", "--remote"]);
|
||||
|
||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
||||
setupCode?: string;
|
||||
gatewayUrl?: string;
|
||||
auth?: string;
|
||||
urlSource?: string;
|
||||
};
|
||||
const payload = parseLastLoggedQrJson();
|
||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||
expect(payload.auth).toBe("token");
|
||||
expect(payload.urlSource).toBe("gateway.remote.url");
|
||||
@@ -416,20 +402,11 @@ describe("registerQrCli", () => {
|
||||
resolvedConfig: createRemoteQrConfig(),
|
||||
diagnostics: ["gateway.remote.password inactive"] as string[],
|
||||
});
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
||||
stderr: "",
|
||||
});
|
||||
mockTailscaleStatusLookup();
|
||||
|
||||
await runQr(["--json", "--remote"]);
|
||||
|
||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
||||
setupCode?: string;
|
||||
gatewayUrl?: string;
|
||||
auth?: string;
|
||||
urlSource?: string;
|
||||
};
|
||||
const payload = parseLastLoggedQrJson();
|
||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||
expect(
|
||||
runtime.error.mock.calls.some((call) =>
|
||||
|
||||
Reference in New Issue
Block a user