refactor: dedupe infra lowercase helpers

This commit is contained in:
Peter Steinberger
2026-04-07 17:40:36 +01:00
parent 1a3f141215
commit 6058eacaec
23 changed files with 83 additions and 47 deletions

View File

@@ -8,6 +8,7 @@ import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import JSZip from "jszip";
import * as tar from "tar";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {
resolveArchiveOutputPath,
stripArchivePath,
@@ -76,7 +77,7 @@ const OPEN_WRITE_CREATE_FLAGS =
const TAR_SUFFIXES = [".tgz", ".tar.gz", ".tar"];
export function resolveArchiveKind(filePath: string): ArchiveKind | null {
const lower = filePath.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(filePath);
if (lower.endsWith(".zip")) {
return "zip";
}

View File

@@ -2,7 +2,10 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { isAtLeast, parseSemver } from "./runtime-guard.js";
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
import { createTempDownloadTarget } from "./temp-download.js";
@@ -415,7 +418,7 @@ export function parseClawHubPluginSpec(raw: string): {
baseUrl?: string;
} | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("clawhub:")) {
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("clawhub:")) {
return null;
}
const spec = trimmed.slice("clawhub:".length).trim();

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { expandHomePrefix } from "./home-dir.js";
const GLOB_REGEX_CACHE_LIMIT = 512;
@@ -7,7 +8,7 @@ const globRegexCache = new Map<string, RegExp>();
function normalizeMatchTarget(value: string): string {
if (process.platform === "win32") {
const stripped = value.replace(/^\\\\[?.]\\/, "");
return stripped.replace(/\\/g, "/").toLowerCase();
return normalizeLowercaseStringOrEmpty(stripped.replace(/\\/g, "/"));
}
return value.replace(/\\\\/g, "/");
}

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
readStringValue,
@@ -198,7 +199,7 @@ export function resolveExecApprovalsSocketPath(): string {
function normalizeAllowlistPattern(value: string | undefined): string | null {
const trimmed = normalizeOptionalString(value) ?? "";
return trimmed ? trimmed.toLowerCase() : null;
return trimmed ? normalizeLowercaseStringOrEmpty(trimmed) : null;
}
function mergeLegacyAgent(

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { expandHomePrefix } from "./home-dir.js";
export function isDriveLessWindowsRootedPath(value: string): boolean {
@@ -50,7 +51,7 @@ function resolveWindowsExecutableExtensions(
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => ext.toLowerCase()),
.map((ext) => normalizeLowercaseStringOrEmpty(ext)),
];
}
@@ -64,7 +65,7 @@ function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => ext.toLowerCase())
.map((ext) => normalizeLowercaseStringOrEmpty(ext))
.filter(Boolean),
);
}
@@ -76,7 +77,7 @@ export function isExecutableFile(filePath: string): boolean {
return false;
}
if (process.platform === "win32") {
const ext = path.extname(filePath).toLowerCase();
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
if (!ext) {
return true;
}

View File

@@ -1,5 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
function normalizeProcArg(arg: string): string {
return arg.replaceAll("\\", "/").toLowerCase();
return normalizeLowercaseStringOrEmpty(arg.replaceAll("\\", "/"));
}
export function parseProcCmdline(raw: string): string[] {

View File

@@ -53,7 +53,7 @@ export function buildExecEventPrompt(opts?: { deliverToUser?: boolean }): string
);
}
const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase();
const HEARTBEAT_OK_PREFIX = normalizeLowercaseStringOrEmpty(HEARTBEAT_TOKEN);
// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events.
function isHeartbeatAckEvent(evt: string): boolean {
@@ -61,7 +61,7 @@ function isHeartbeatAckEvent(evt: string): boolean {
if (!trimmed) {
return false;
}
const lower = trimmed.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(trimmed);
if (!lower.startsWith(HEARTBEAT_OK_PREFIX)) {
return false;
}
@@ -85,7 +85,7 @@ function isHeartbeatNoiseEvent(evt: string): boolean {
}
export function isExecCompletionEvent(evt: string): boolean {
return evt.toLowerCase().includes("exec finished");
return normalizeLowercaseStringOrEmpty(evt).includes("exec finished");
}
// Returns true when a system event should be treated as real cron reminder content.

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([
"accept",
"accept-encoding",
@@ -23,7 +25,7 @@ export function retainSafeHeadersForCrossOriginRedirect(
const incoming = new Headers(headers);
const safeHeaders: Record<string, string> = {};
for (const [key, value] of incoming.entries()) {
if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(key.toLowerCase())) {
if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(normalizeLowercaseStringOrEmpty(key))) {
safeHeaders[key] = value;
}
}

View File

@@ -1,7 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
export function buildNodeShellCommand(command: string, platform?: string | null) {
const normalized = String(platform ?? "")
.trim()
.toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(String(platform ?? "").trim());
if (normalized.startsWith("win")) {
return ["cmd.exe", "/d", "/s", "/c", command];
}

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
const EXACT_SEMVER_VERSION_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -126,7 +128,7 @@ export function isPrereleaseResolutionAllowed(params: {
if (params.spec.selectorKind === "exact-version") {
return params.spec.selectorIsPrerelease;
}
return params.spec.selector?.toLowerCase() !== "latest";
return normalizeLowercaseStringOrEmpty(params.spec.selector) !== "latest";
}
export function formatPrereleaseResolutionError(params: {
@@ -134,7 +136,8 @@ export function formatPrereleaseResolutionError(params: {
resolvedVersion: string;
}): string {
const selectorHint =
params.spec.selectorKind === "none" || params.spec.selector?.toLowerCase() === "latest"
params.spec.selectorKind === "none" ||
normalizeLowercaseStringOrEmpty(params.spec.selector) === "latest"
? `Use "${params.spec.name}@beta" (or another prerelease tag) or an exact prerelease version to opt in explicitly.`
: `Use an explicit prerelease tag or exact prerelease version if you want prerelease installs.`;
return `Resolved ${params.spec.raw} to prerelease version ${params.resolvedVersion}, but prereleases are only installed when explicitly requested. ${selectorHint}`;

View File

@@ -1,8 +1,11 @@
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
function resolveExplicitConversationTargetId(target: string): string | undefined {
for (const prefix of ["channel:", "conversation:", "group:", "room:", "dm:"]) {
if (target.toLowerCase().startsWith(prefix)) {
if (normalizeLowercaseStringOrEmpty(target).startsWith(prefix)) {
return normalizeOptionalString(target.slice(prefix.length));
}
}

View File

@@ -9,6 +9,7 @@ import {
} from "../../config/sessions/inbound.runtime.js";
import { buildAgentSessionKey, type RoutePeer } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import type { ResolvedMessagingTarget } from "./target-resolver.js";
export type OutboundSessionRoute = {
@@ -39,8 +40,8 @@ function resolveOutboundChannelPlugin(channel: ChannelId) {
function stripProviderPrefix(raw: string, channel: string): string {
const trimmed = raw.trim();
const lower = trimmed.toLowerCase();
const prefix = `${channel.toLowerCase()}:`;
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const prefix = `${normalizeLowercaseStringOrEmpty(channel)}:`;
if (lower.startsWith(prefix)) {
return trimmed.slice(prefix.length).trim();
}

View File

@@ -31,7 +31,9 @@ function parseListenerAddress(address: string): { host: string; port: number } |
const bracketMatch = normalized.match(/^\[([^\]]+)\]:(\d+)$/);
if (bracketMatch) {
const port = Number.parseInt(bracketMatch[2], 10);
return Number.isFinite(port) ? { host: bracketMatch[1].toLowerCase(), port } : null;
return Number.isFinite(port)
? { host: normalizeLowercaseStringOrEmpty(bracketMatch[1]), port }
: null;
}
const lastColon = normalized.lastIndexOf(":");
if (lastColon <= 0 || lastColon >= normalized.length - 1) {

View File

@@ -1,4 +1,5 @@
import { runCommandWithTimeout } from "../process/exec.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { isErrno } from "./errors.js";
import { buildPortHints } from "./ports-format.js";
import { resolveLsofCommand } from "./ports-lsof.js";
@@ -207,7 +208,7 @@ function parseNetstatListeners(output: string, port: number): PortListener[] {
if (!line) {
continue;
}
if (!line.toLowerCase().includes("listen")) {
if (!normalizeLowercaseStringOrEmpty(line).includes("listen")) {
continue;
}
if (!line.includes(portToken)) {
@@ -239,7 +240,7 @@ async function resolveWindowsImageName(pid: number): Promise<string | undefined>
}
for (const rawLine of res.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.toLowerCase().startsWith("image name:")) {
if (!normalizeLowercaseStringOrEmpty(line).startsWith("image name:")) {
continue;
}
const value = line.slice("image name:".length).trim();
@@ -263,7 +264,7 @@ async function resolveWindowsCommandLine(pid: number): Promise<string | undefine
}
for (const rawLine of res.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.toLowerCase().startsWith("commandline=")) {
if (!normalizeLowercaseStringOrEmpty(line).startsWith("commandline=")) {
continue;
}
const value = line.slice("commandline=".length).trim();

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type {
@@ -55,7 +56,7 @@ export async function fetchGeminiUsage(
let hasFlash = false;
for (const [model, frac] of Object.entries(quotas)) {
const lower = model.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(model);
if (lower.includes("pro")) {
hasPro = true;
if (frac < proMin) {

View File

@@ -1,6 +1,9 @@
import { URL } from "node:url";
import type { GatewayConfig } from "../config/types.gateway.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import {
loadOrCreateDeviceIdentity,
signDevicePayload,
@@ -70,7 +73,9 @@ function normalizeTimeoutMs(value: string | number | undefined): number {
}
function readAllowHttp(value: string | undefined): boolean {
const normalized = normalizeOptionalString(value)?.toLowerCase();
const normalized = normalizeOptionalString(value)
? normalizeLowercaseStringOrEmpty(value)
: undefined;
return normalized === "1" || normalized === "true" || normalized === "yes";
}

View File

@@ -144,10 +144,7 @@ function isValidNodeId(value: string): boolean {
}
function normalizeApnsToken(value: string): string {
return value
.trim()
.replace(/[<>\s]/g, "")
.toLowerCase();
return normalizeLowercaseStringOrEmpty(value.trim().replace(/[<>\s]/g, ""));
}
function normalizeRelayHandle(value: string): string {
@@ -187,10 +184,7 @@ function normalizeTokenDebugSuffix(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value
.trim()
.toLowerCase()
.replace(/[^0-9a-z]/g, "");
const normalized = normalizeLowercaseStringOrEmpty(value.trim()).replace(/[^0-9a-z]/g, "");
return normalized.length > 0 ? normalized.slice(-8) : undefined;
}
@@ -266,7 +260,9 @@ function normalizeDistribution(value: unknown): "official" | null {
if (typeof value !== "string") {
return null;
}
const normalized = normalizeOptionalString(value)?.toLowerCase();
const normalized = normalizeOptionalString(value)
? normalizeLowercaseStringOrEmpty(value)
: undefined;
return normalized === "official" ? "official" : null;
}

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
export const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
export const POWERSHELL_INLINE_COMMAND_FLAGS = new Set([
"-c",
@@ -20,7 +22,7 @@ export function resolveInlineCommandMatch(
if (!token) {
continue;
}
const lower = token.toLowerCase();
const lower = normalizeLowercaseStringOrEmpty(token);
if (lower === "--") {
break;
}

View File

@@ -4,7 +4,10 @@ import { promptYesNo } from "../cli/prompt.js";
import { danger, info, logVerbose, shouldLogVerbose, warn } from "../globals.js";
import { runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { ensureBinary } from "./binaries.js";
@@ -272,7 +275,7 @@ function isPermissionDeniedError(err: unknown): boolean {
if (code.toUpperCase() === "EACCES") {
return true;
}
const combined = `${stdout}\n${stderr}\n${message}`.toLowerCase();
const combined = normalizeLowercaseStringOrEmpty(`${stdout}\n${stderr}\n${message}`);
return (
combined.includes("permission denied") ||
combined.includes("access denied") ||

View File

@@ -1,5 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
export function normalizeFingerprint(input: string): string {
const trimmed = input.trim();
const withoutPrefix = trimmed.replace(/^sha-?256\s*:?\s*/i, "");
return withoutPrefix.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
return normalizeLowercaseStringOrEmpty(withoutPrefix.replace(/[^a-fA-F0-9]/g, ""));
}

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { pathExists } from "../utils.js";
import { readPackageVersion } from "./package-json.js";
import { applyPathPrepend } from "./path-prepend.js";
@@ -39,7 +40,7 @@ function normalizePackageTarget(value: string): string {
}
export function isMainPackageTarget(value: string): boolean {
return normalizePackageTarget(value).toLowerCase() === "main";
return normalizeLowercaseStringOrEmpty(normalizePackageTarget(value)) === "main";
}
export function isExplicitPackageInstallSpec(value: string): boolean {
@@ -205,7 +206,10 @@ function inferNpmPrefixFromPackageRoot(pkgRoot?: string | null): string | null {
if (path.basename(parentDir) === "lib") {
return path.dirname(parentDir);
}
if (process.platform === "win32" && path.basename(parentDir).toLowerCase() === "npm") {
if (
process.platform === "win32" &&
normalizeLowercaseStringOrEmpty(path.basename(parentDir)) === "npm"
) {
return parentDir;
}
return null;

View File

@@ -5,6 +5,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { loadConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { VERSION } from "../version.js";
import { writeJsonAtomic } from "./json-files.js";
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
@@ -236,7 +237,7 @@ async function runAutoUpdateCommand(params: {
const baseArgs = ["update", "--yes", "--channel", params.channel, "--json"];
const execPath = process.execPath?.trim();
const argv1 = process.argv[1]?.trim();
const lowerExecBase = execPath ? path.basename(execPath).toLowerCase() : "";
const lowerExecBase = execPath ? normalizeLowercaseStringOrEmpty(path.basename(execPath)) : "";
const runtimeIsNodeOrBun =
lowerExecBase === "node" ||
lowerExecBase === "node.exe" ||

View File

@@ -1,5 +1,6 @@
import { spawnSync } from "node:child_process";
import { parseCmdScriptCommandLine } from "../daemon/cmd-argv.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
const DEFAULT_TIMEOUT_MS = 5_000;
@@ -95,13 +96,13 @@ function extractWindowsCommandLine(raw: string): string | null {
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
if (!line.toLowerCase().startsWith("commandline=")) {
if (!normalizeLowercaseStringOrEmpty(line).startsWith("commandline=")) {
continue;
}
const value = line.slice("commandline=".length).trim();
return value || null;
}
return lines.find((line) => line.toLowerCase() !== "commandline") ?? null;
return lines.find((line) => normalizeLowercaseStringOrEmpty(line) !== "commandline") ?? null;
}
export function readWindowsProcessArgsSync(