refactor: dedupe cli cron trimmed readers

This commit is contained in:
Peter Steinberger
2026-04-08 00:17:07 +01:00
parent ca8685d5f2
commit df91db906f
27 changed files with 131 additions and 81 deletions

View File

@@ -36,6 +36,7 @@ import {
} from "../plugins/memory-embedding-providers.js";
import { writeRuntimeJson, defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { normalizeStringifiedOptionalString } from "../shared/string-coerce.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { canonicalizeSpeechProviderId, listSpeechProviders } from "../tts/provider-registry.js";
@@ -1237,7 +1238,7 @@ export function registerCapabilityCli(program: Command) {
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
const target = String(opts.model).trim();
const target = normalizeStringifiedOptionalString(opts.model) ?? "";
const catalog = await loadModelCatalog({ config: loadConfig() });
const entry =
catalog.find((candidate) => `${candidate.provider}/${candidate.id}` === target) ??

View File

@@ -36,6 +36,7 @@ import {
discoverConfigSecretTargets,
resolveConfigSecretTargetByPath,
} from "../secrets/target-registry.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
@@ -370,7 +371,7 @@ function pruneInactiveGatewayAuthCredentials(params: {
return [];
}
const auth = authRaw as Record<string, unknown>;
const mode = typeof auth.mode === "string" ? auth.mode.trim() : "";
const mode = normalizeOptionalString(auth.mode) ?? "";
const removedPaths: string[] = [];
const remove = (key: "token" | "password") => {

View File

@@ -1,5 +1,9 @@
import fs from "node:fs";
import JSON5 from "json5";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
export type ConfigSetOptions = {
strictJson?: boolean;
@@ -38,8 +42,7 @@ export type ConfigSetBatchEntry = {
export function hasBatchMode(opts: ConfigSetOptions): boolean {
return Boolean(
(opts.batchJson && opts.batchJson.trim().length > 0) ||
(opts.batchFile && opts.batchFile.trim().length > 0),
normalizeOptionalString(opts.batchJson) || normalizeOptionalString(opts.batchFile),
);
}
@@ -87,7 +90,7 @@ function parseBatchEntries(raw: string, sourceLabel: string): ConfigSetBatchEntr
throw new Error(`${sourceLabel}[${index}] must be an object.`);
}
const typed = entry as Record<string, unknown>;
const path = typeof typed.path === "string" ? typed.path.trim() : "";
const path = normalizeOptionalString(typed.path) ?? "";
if (!path) {
throw new Error(`${sourceLabel}[${index}].path is required.`);
}
@@ -111,8 +114,10 @@ function parseBatchEntries(raw: string, sourceLabel: string): ConfigSetBatchEntr
}
export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[] | null {
const hasInline = Boolean(opts.batchJson && opts.batchJson.trim().length > 0);
const hasFile = Boolean(opts.batchFile && opts.batchFile.trim().length > 0);
const batchJson = normalizeOptionalString(opts.batchJson);
const batchFile = normalizeOptionalString(opts.batchFile);
const hasInline = Boolean(batchJson);
const hasFile = Boolean(batchFile);
if (!hasInline && !hasFile) {
return null;
}
@@ -120,9 +125,9 @@ export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[]
throw new Error("Use either --batch-json or --batch-file, not both.");
}
if (hasInline) {
return parseBatchEntries(opts.batchJson as string, "--batch-json");
return parseBatchEntries(batchJson as string, "--batch-json");
}
const pathname = (opts.batchFile as string).trim();
const pathname = normalizeStringifiedOptionalString(opts.batchFile) ?? "";
if (!pathname) {
throw new Error("--batch-file must not be empty.");
}

View File

@@ -9,7 +9,11 @@ import {
} from "../infra/device-pairing.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { defaultRuntime } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -208,7 +212,7 @@ function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
}
function formatPendingRoles(request: PendingDevice): string {
const role = typeof request.role === "string" ? request.role.trim() : "";
const role = normalizeOptionalString(request.role) ?? "";
if (role) {
return role;
}
@@ -234,8 +238,8 @@ function formatPendingScopes(request: PendingDevice): string {
function resolveRequiredDeviceRole(
opts: DevicesRpcOpts,
): { deviceId: string; role: string } | null {
const deviceId = String(opts.device ?? "").trim();
const role = String(opts.role ?? "").trim();
const deviceId = normalizeStringifiedOptionalString(opts.device) ?? "";
const role = normalizeStringifiedOptionalString(opts.role) ?? "";
if (deviceId && role) {
return { deviceId, role };
}
@@ -355,7 +359,7 @@ export function registerDevicesCli(program: Command) {
const rejectedRequestIds: string[] = [];
const paired = Array.isArray(list.paired) ? list.paired : [];
for (const device of paired) {
const deviceId = typeof device.deviceId === "string" ? device.deviceId.trim() : "";
const deviceId = normalizeOptionalString(device.deviceId) ?? "";
if (!deviceId) {
continue;
}
@@ -365,7 +369,7 @@ export function registerDevicesCli(program: Command) {
if (opts.pending) {
const pending = Array.isArray(list.pending) ? list.pending : [];
for (const req of pending) {
const requestId = typeof req.requestId === "string" ? req.requestId.trim() : "";
const requestId = normalizeOptionalString(req.requestId) ?? "";
if (!requestId) {
continue;
}

View File

@@ -7,6 +7,10 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { danger } from "../globals.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { defaultRuntime } from "../runtime.js";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
import { formatDocsLink } from "../terminal/links.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
@@ -22,7 +26,7 @@ function parseLimit(value: unknown): number | null {
if (typeof value !== "string") {
return null;
}
const raw = value.trim();
const raw = normalizeOptionalString(value) ?? "";
if (!raw) {
return null;
}
@@ -36,7 +40,7 @@ function parseLimit(value: unknown): number | null {
function buildRows(entries: Array<{ id: string; name?: string | undefined }>) {
return entries.map((entry) => ({
ID: entry.id,
Name: entry.name?.trim() ?? "",
Name: normalizeOptionalString(entry.name) ?? "",
}));
}
@@ -274,7 +278,7 @@ export function registerDirectoryCli(program: Command) {
if (!fn) {
throw new Error(`Channel ${channelId} does not support group members listing`);
}
const groupId = String(opts.groupId ?? "").trim();
const groupId = normalizeStringifiedOptionalString(opts.groupId) ?? "";
if (!groupId) {
throw new Error("Missing --group-id");
}

View File

@@ -10,6 +10,7 @@ import { serveOpenClawChannelMcp } from "../mcp/channel-server.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeStringifiedOptionalString } from "../shared/string-coerce.js";
function fail(message: string): never {
defaultRuntime.error(message);
@@ -85,7 +86,7 @@ export function registerMcpCli(program: Command) {
warnSecretCliFlag("--password");
}
const claudeChannelMode = normalizeLowercaseStringOrEmpty(
String(opts.claudeChannelMode ?? "auto").trim(),
normalizeStringifiedOptionalString(opts.claudeChannelMode) ?? "auto",
);
if (
claudeChannelMode !== "auto" &&

View File

@@ -1,3 +1,5 @@
import { normalizeStringifiedOptionalString } from "../../shared/string-coerce.js";
export { parseNodeList, parsePairingList } from "../../shared/node-list-parse.js";
export function formatPermissions(raw: unknown) {
@@ -5,7 +7,7 @@ export function formatPermissions(raw: unknown) {
return null;
}
const entries = Object.entries(raw as Record<string, unknown>)
.map(([key, value]) => [String(key).trim(), value === true] as const)
.map(([key, value]) => [normalizeStringifiedOptionalString(key) ?? "", value === true] as const)
.filter(([key]) => key.length > 0)
.toSorted((a, b) => a[0].localeCompare(b[0]));
if (entries.length === 0) {

View File

@@ -1,6 +1,9 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { getTerminalTableWidth, renderTable } from "../../terminal/table.js";
import { shortenHomePath } from "../../utils.js";
import {
@@ -23,7 +26,7 @@ import {
import type { NodesRpcOpts } from "./types.js";
const parseFacing = (value: string): CameraFacing => {
const v = normalizeLowercaseStringOrEmpty(String(value ?? "").trim());
const v = normalizeLowercaseStringOrEmpty(normalizeOptionalString(value) ?? "");
if (v === "front" || v === "back") {
return v;
}
@@ -112,9 +115,11 @@ export function registerNodesCameraCommands(nodes: Command) {
.option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("camera snap", async () => {
const node = await resolveNode(opts, String(opts.node ?? ""));
const node = await resolveNode(opts, normalizeOptionalString(opts.node) ?? "");
const nodeId = node.nodeId;
const facingOpt = normalizeLowercaseStringOrEmpty(String(opts.facing ?? "both").trim());
const facingOpt = normalizeLowercaseStringOrEmpty(
normalizeOptionalString(opts.facing) ?? "both",
);
const facings: CameraFacing[] =
facingOpt === "both"
? ["front", "back"]
@@ -129,7 +134,7 @@ export function registerNodesCameraCommands(nodes: Command) {
const maxWidth = opts.maxWidth ? Number.parseInt(String(opts.maxWidth), 10) : undefined;
const quality = opts.quality ? Number.parseFloat(String(opts.quality)) : undefined;
const delayMs = opts.delayMs ? Number.parseInt(String(opts.delayMs), 10) : undefined;
const deviceId = opts.deviceId ? String(opts.deviceId).trim() : undefined;
const deviceId = normalizeOptionalString(opts.deviceId);
if (deviceId && facings.length > 1) {
throw new Error("facing=both is not allowed when --device-id is set");
}
@@ -206,7 +211,7 @@ export function registerNodesCameraCommands(nodes: Command) {
.option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 90000)", "90000")
.action(async (opts: NodesRpcOpts & { audio?: boolean }) => {
await runNodesCommand("camera clip", async () => {
const node = await resolveNode(opts, String(opts.node ?? ""));
const node = await resolveNode(opts, normalizeOptionalString(opts.node) ?? "");
const nodeId = node.nodeId;
const facing = parseFacing(String(opts.facing ?? "front"));
const durationMs = parseDurationMs(String(opts.duration ?? "3000"));
@@ -214,7 +219,7 @@ export function registerNodesCameraCommands(nodes: Command) {
const timeoutMs = opts.invokeTimeout
? Number.parseInt(String(opts.invokeTimeout), 10)
: undefined;
const deviceId = opts.deviceId ? String(opts.deviceId).trim() : undefined;
const deviceId = normalizeOptionalString(opts.deviceId);
const invokeParams = buildNodeInvokeParams({
nodeId,

View File

@@ -1,7 +1,10 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { shortenHomePath } from "../../utils.js";
import { writeBase64ToFile } from "../nodes-camera.js";
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js";
@@ -12,7 +15,7 @@ import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } f
import type { NodesRpcOpts } from "./types.js";
async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record<string, unknown>) {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const timeoutMs = parseTimeoutMs(opts.invokeTimeout);
return await callGatewayCli(
"node.invoke",
@@ -42,7 +45,9 @@ export function registerNodesCanvasCommands(nodes: Command) {
.option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas snapshot", async () => {
const formatOpt = normalizeLowercaseStringOrEmpty(String(opts.format ?? "jpg").trim());
const formatOpt = normalizeLowercaseStringOrEmpty(
normalizeOptionalString(opts.format) ?? "jpg",
);
const formatForParams =
formatOpt === "jpg" ? "jpeg" : formatOpt === "jpeg" ? "jpeg" : "png";
if (formatForParams !== "png" && formatForParams !== "jpeg") {

View File

@@ -1,7 +1,10 @@
import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -20,8 +23,8 @@ export function registerNodesInvokeCommands(nodes: Command) {
.option("--idempotency-key <key>", "Idempotency key (optional)")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("invoke", async () => {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const command = String(opts.command ?? "").trim();
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const command = normalizeOptionalString(opts.command) ?? "";
if (!nodeId || !command) {
const { error } = getNodesTheme();
defaultRuntime.error(error("--node and --command required"));

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -19,9 +20,9 @@ export function registerNodesNotifyCommand(nodes: Command) {
.option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 15000)", "15000")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("notify", async () => {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const title = String(opts.title ?? "").trim();
const body = String(opts.body ?? "").trim();
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const title = normalizeOptionalString(opts.title) ?? "";
const body = normalizeOptionalString(opts.body) ?? "";
if (!title && !body) {
throw new Error("missing --title or --body");
}

View File

@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { getTerminalTableWidth } from "../../terminal/table.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { parsePairingList } from "./format.js";
@@ -78,8 +79,8 @@ export function registerNodesPairingCommands(nodes: Command) {
.requiredOption("--name <displayName>", "New display name")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("rename", async () => {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const name = String(opts.name ?? "").trim();
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const name = normalizeOptionalString(opts.name) ?? "";
if (!nodeId || !name) {
defaultRuntime.error("--node and --name required");
defaultRuntime.exit(1);

View File

@@ -37,9 +37,9 @@ export function registerNodesPushCommand(nodes: Command) {
.option("--environment <sandbox|production>", "Override APNs environment")
.action(async (opts: NodesPushOpts) => {
await runNodesCommand("push", async () => {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const title = String(opts.title ?? "").trim() || "OpenClaw";
const body = String(opts.body ?? "").trim() || `Push test for node ${nodeId}`;
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const title = normalizeOptionalString(opts.title) || "OpenClaw";
const body = normalizeOptionalString(opts.body) || `Push test for node ${nodeId}`;
const environment = normalizeEnvironment(opts.environment);
if (opts.environment && !environment) {
throw new Error("invalid --environment (use sandbox|production)");

View File

@@ -92,8 +92,7 @@ function parseSinceMs(raw: unknown, label: string): number | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
const value =
typeof raw === "string" ? raw.trim() : typeof raw === "number" ? String(raw).trim() : null;
const value = normalizeOptionalString(raw) ?? (typeof raw === "number" ? String(raw) : null);
if (value === null) {
defaultRuntime.error(`${label}: invalid duration value`);
defaultRuntime.exit(1);

View File

@@ -9,7 +9,10 @@ import {
type PairingChannel,
} from "../pairing/pairing-store.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
import { formatDocsLink } from "../terminal/links.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
@@ -17,14 +20,7 @@ import { formatCliCommand } from "./command-format.js";
/** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
const value = normalizeLowercaseStringOrEmpty(
(typeof raw === "string"
? raw
: typeof raw === "number" || typeof raw === "boolean"
? String(raw)
: ""
).trim(),
);
const value = normalizeLowercaseStringOrEmpty(normalizeStringifiedOptionalString(raw) ?? "");
if (!value) {
throw new Error("Channel required");
}
@@ -75,7 +71,7 @@ export function registerPairingCli(program: Command) {
);
}
const channel = parseChannel(channelRaw, channels);
const accountId = String(opts.account ?? "").trim();
const accountId = normalizeStringifiedOptionalString(opts.account) ?? "";
const requests = accountId
? await listChannelPairingRequests(channel, process.env, accountId)
: await listChannelPairingRequests(channel);
@@ -144,7 +140,7 @@ export function registerPairingCli(program: Command) {
);
}
const channel = parseChannel(channelRaw, channels);
const accountId = String(opts.account ?? "").trim();
const accountId = normalizeStringifiedOptionalString(opts.account) ?? "";
const approved = accountId
? await approveChannelPairingCode({
channel,

View File

@@ -1,4 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
export type BytesParseOptions = {
defaultUnit?: "b" | "kb" | "mb" | "gb" | "tb";
@@ -17,7 +20,7 @@ const UNIT_MULTIPLIERS: Record<string, number> = {
};
export function parseByteSize(raw: string, opts?: BytesParseOptions): number {
const trimmed = normalizeLowercaseStringOrEmpty(String(raw ?? "").trim());
const trimmed = normalizeLowercaseStringOrEmpty(normalizeOptionalString(raw) ?? "");
if (!trimmed) {
throw new Error("invalid byte size (empty)");
}

View File

@@ -1,4 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
export type DurationMsParseOptions = {
defaultUnit?: "ms" | "s" | "m" | "h" | "d";
@@ -13,7 +16,7 @@ const DURATION_MULTIPLIERS: Record<string, number> = {
};
export function parseDurationMs(raw: string, opts?: DurationMsParseOptions): number {
const trimmed = normalizeLowercaseStringOrEmpty(String(raw ?? "").trim());
const trimmed = normalizeLowercaseStringOrEmpty(normalizeOptionalString(raw) ?? "");
if (!trimmed) {
throw new Error("invalid duration (empty)");
}

View File

@@ -121,15 +121,15 @@ export function registerQrCli(program: Command) {
throw new Error("Use either --token or --password, not both.");
}
const token = typeof opts.token === "string" ? opts.token.trim() : "";
const password = typeof opts.password === "string" ? opts.password.trim() : "";
const token = trimToUndefined(opts.token) ?? "";
const password = trimToUndefined(opts.password) ?? "";
const wantsRemote = opts.remote === true;
const loadedRaw = loadConfig();
if (wantsRemote && !opts.url && !opts.publicUrl) {
const tailscaleMode = loadedRaw.gateway?.tailscale?.mode ?? "off";
const remoteUrl = loadedRaw.gateway?.remote?.url;
const hasRemoteUrl = typeof remoteUrl === "string" && remoteUrl.trim().length > 0;
const hasRemoteUrl = Boolean(trimToUndefined(remoteUrl));
const hasTailscaleServe = tailscaleMode === "serve" || tailscaleMode === "funnel";
if (!hasRemoteUrl && !hasTailscaleServe) {
throw new Error(
@@ -170,12 +170,8 @@ export function registerQrCli(program: Command) {
cfg.gateway.auth.token = undefined;
}
if (wantsRemote && !token && !password) {
const remoteToken =
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
const remotePassword =
typeof cfg.gateway?.remote?.password === "string"
? cfg.gateway.remote.password.trim()
: "";
const remoteToken = trimToUndefined(cfg.gateway?.remote?.token) ?? "";
const remotePassword = trimToUndefined(cfg.gateway?.remote?.password) ?? "";
if (remoteToken) {
cfg.gateway.auth.mode = "token";
cfg.gateway.auth.token = remoteToken;

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import type { GatewayRpcOpts } from "./gateway-rpc.js";
@@ -10,7 +11,7 @@ type SystemEventOpts = GatewayRpcOpts & { text?: string; mode?: string; json?: b
type SystemGatewayOpts = GatewayRpcOpts & { json?: boolean };
const normalizeWakeMode = (raw: unknown) => {
const mode = typeof raw === "string" ? raw.trim() : "";
const mode = normalizeOptionalString(raw) ?? "";
if (!mode) {
return "next-heartbeat" as const;
}
@@ -59,7 +60,7 @@ export function registerSystemCli(program: Command) {
await runSystemGatewayCommand(
opts,
async () => {
const text = typeof opts.text === "string" ? opts.text.trim() : "";
const text = normalizeOptionalString(opts.text) ?? "";
if (!text) {
throw new Error("--text is required");
}

View File

@@ -107,7 +107,7 @@ export function registerWebhooksCli(program: Command) {
function parseGmailSetupOptions(raw: Record<string, unknown>): GmailSetupOptions {
const accountRaw = raw.account;
const account = typeof accountRaw === "string" ? accountRaw.trim() : "";
const account = normalizeOptionalString(accountRaw) ?? "";
if (!account) {
throw new Error("--account is required");
}

View File

@@ -1,9 +1,11 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
export function normalizeCronJobIdentityFields(raw: Record<string, unknown>): {
mutated: boolean;
legacyJobIdIssue: boolean;
} {
const rawId = typeof raw.id === "string" ? raw.id.trim() : "";
const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : "";
const rawId = normalizeOptionalString(raw.id) ?? "";
const legacyJobId = normalizeOptionalString(raw.jobId) ?? "";
const hadJobIdKey = "jobId" in raw;
const normalizedId = rawId || legacyJobId;
const idChanged = Boolean(normalizedId && raw.id !== normalizedId);

View File

@@ -5,6 +5,7 @@ import type { CronConfig } from "../config/types.cron.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
import type { CronDeliveryStatus, CronRunStatus, CronRunTelemetry } from "./types.js";
@@ -93,7 +94,10 @@ export function resolveCronRunLogPruneOptions(cfg?: CronConfig["runLog"]): {
let maxBytes = DEFAULT_CRON_RUN_LOG_MAX_BYTES;
if (cfg?.maxBytes !== undefined) {
try {
maxBytes = parseByteSize(String(cfg.maxBytes).trim(), { defaultUnit: "b" });
const configuredMaxBytes = normalizeStringifiedOptionalString(cfg.maxBytes);
if (configuredMaxBytes) {
maxBytes = parseByteSize(configuredMaxBytes, { defaultUnit: "b" });
}
} catch {
maxBytes = DEFAULT_CRON_RUN_LOG_MAX_BYTES;
}

View File

@@ -1,4 +1,5 @@
import { Cron } from "croner";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import type { CronSchedule } from "./types.js";
@@ -6,7 +7,7 @@ const CRON_EVAL_CACHE_MAX = 512;
const cronEvalCache = new Map<string, Cron>();
function resolveCronTimezone(tz?: string) {
const trimmed = typeof tz === "string" ? tz.trim() : "";
const trimmed = normalizeOptionalString(tz) ?? "";
if (trimmed) {
return trimmed;
}

View File

@@ -764,19 +764,19 @@ function mergeCronDelivery(
};
if (patchFd) {
if ("channel" in patchFd) {
const channel = typeof patchFd.channel === "string" ? patchFd.channel.trim() : "";
const channel = normalizeOptionalString(patchFd.channel) ?? "";
nextFd.channel = channel ? channel : undefined;
}
if ("to" in patchFd) {
const to = typeof patchFd.to === "string" ? patchFd.to.trim() : "";
const to = normalizeOptionalString(patchFd.to) ?? "";
nextFd.to = to ? to : undefined;
}
if ("accountId" in patchFd) {
const accountId = typeof patchFd.accountId === "string" ? patchFd.accountId.trim() : "";
const accountId = normalizeOptionalString(patchFd.accountId) ?? "";
nextFd.accountId = accountId ? accountId : undefined;
}
if ("mode" in patchFd) {
const mode = typeof patchFd.mode === "string" ? patchFd.mode.trim() : "";
const mode = normalizeOptionalString(patchFd.mode) ?? "";
nextFd.mode = mode === "announce" || mode === "webhook" ? mode : undefined;
}
}
@@ -818,11 +818,11 @@ function mergeCronFailureAlert(
next.cooldownMs = cooldownMs >= 0 ? Math.floor(cooldownMs) : undefined;
}
if ("mode" in patch) {
const mode = typeof patch.mode === "string" ? patch.mode.trim() : "";
const mode = normalizeOptionalString(patch.mode) ?? "";
next.mode = mode === "announce" || mode === "webhook" ? mode : undefined;
}
if ("accountId" in patch) {
const accountId = typeof patch.accountId === "string" ? patch.accountId.trim() : "";
const accountId = normalizeOptionalString(patch.accountId) ?? "";
next.accountId = accountId ? accountId : undefined;
}

View File

@@ -1,3 +1,4 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import type { CronSchedule } from "./types.js";
@@ -29,7 +30,7 @@ export function validateScheduleTimestamp(
return { ok: true };
}
const atRaw = typeof schedule.at === "string" ? schedule.at.trim() : "";
const atRaw = normalizeOptionalString(schedule.at) ?? "";
const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null;
if (atMs === null || !Number.isFinite(atMs)) {

View File

@@ -35,6 +35,7 @@ export {
normalizeNullableString,
normalizeOptionalLowercaseString,
normalizeOptionalString,
normalizeStringifiedOptionalString,
readStringValue,
} from "../shared/string-coerce.js";
export {

View File

@@ -14,6 +14,16 @@ export function normalizeOptionalString(value: unknown): string | undefined {
return normalizeNullableString(value) ?? undefined;
}
export function normalizeStringifiedOptionalString(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOptionalString(value);
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return normalizeOptionalString(String(value));
}
return undefined;
}
export function normalizeOptionalLowercaseString(value: unknown): string | undefined {
return normalizeOptionalString(value)?.toLowerCase();
}