refactor: centralize daemon path resolution

This commit is contained in:
Peter Steinberger
2026-01-15 23:09:08 +00:00
parent 4a99b9b651
commit db9be87d94
9 changed files with 130 additions and 56 deletions

View File

@@ -4,6 +4,7 @@
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.

View File

@@ -5,6 +5,7 @@ import {
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
resolveGatewayLaunchAgentLabel,
resolveGatewayProfileSuffix,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "./constants.js";
@@ -153,6 +154,25 @@ describe("resolveGatewayWindowsTaskName", () => {
});
});
describe("resolveGatewayProfileSuffix", () => {
it("returns empty string when no profile is set", () => {
expect(resolveGatewayProfileSuffix()).toBe("");
});
it("returns empty string for default profiles", () => {
expect(resolveGatewayProfileSuffix("default")).toBe("");
expect(resolveGatewayProfileSuffix(" Default ")).toBe("");
});
it("returns a hyphenated suffix for custom profiles", () => {
expect(resolveGatewayProfileSuffix("dev")).toBe("-dev");
});
it("trims whitespace from profiles", () => {
expect(resolveGatewayProfileSuffix(" staging ")).toBe("-staging");
});
});
describe("formatGatewayServiceDescription", () => {
it("returns default description when no profile/version", () => {
expect(formatGatewayServiceDescription()).toBe("Clawdbot Gateway");

View File

@@ -8,34 +8,35 @@ export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gatewa
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [];
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = [];
export function resolveGatewayLaunchAgentLabel(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_LAUNCH_AGENT_LABEL;
}
return `com.clawdbot.${trimmed}`;
}
function normalizeGatewayProfile(profile?: string): string | null {
export function normalizeGatewayProfile(profile?: string): string | null {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") return null;
return trimmed;
}
export function resolveGatewaySystemdServiceName(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_SYSTEMD_SERVICE_NAME;
export function resolveGatewayProfileSuffix(profile?: string): string {
const normalized = normalizeGatewayProfile(profile);
return normalized ? `-${normalized}` : "";
}
export function resolveGatewayLaunchAgentLabel(profile?: string): string {
const normalized = normalizeGatewayProfile(profile);
if (!normalized) {
return GATEWAY_LAUNCH_AGENT_LABEL;
}
return `clawdbot-gateway-${trimmed}`;
return `com.clawdbot.${normalized}`;
}
export function resolveGatewaySystemdServiceName(profile?: string): string {
const suffix = resolveGatewayProfileSuffix(profile);
if (!suffix) return GATEWAY_SYSTEMD_SERVICE_NAME;
return `clawdbot-gateway${suffix}`;
}
export function resolveGatewayWindowsTaskName(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_WINDOWS_TASK_NAME;
}
return `Clawdbot Gateway (${trimmed})`;
const normalized = normalizeGatewayProfile(profile);
if (!normalized) return GATEWAY_WINDOWS_TASK_NAME;
return `Clawdbot Gateway (${normalized})`;
}
export function formatGatewayServiceDescription(params?: {

View File

@@ -16,6 +16,7 @@ import {
} from "./launchd-plist.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
const execFileAsync = promisify(execFile);
@@ -29,11 +30,6 @@ function resolveLaunchAgentLabel(args?: { env?: Record<string, string | undefine
if (envLabel) return envLabel;
return resolveGatewayLaunchAgentLabel(args?.env?.CLAWDBOT_PROFILE);
}
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME");
return home;
}
function resolveLaunchAgentPlistPathForLabel(
env: Record<string, string | undefined>,
@@ -53,12 +49,7 @@ export function resolveGatewayLogPaths(env: Record<string, string | undefined>):
stdoutPath: string;
stderrPath: string;
} {
const home = resolveHomeDir(env);
const stateOverride = env.CLAWDBOT_STATE_DIR?.trim();
const profile = env.CLAWDBOT_PROFILE?.trim();
const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
const defaultStateDir = path.join(home, `.clawdbot${suffix}`);
const stateDir = stateOverride ? resolveUserPathWithHome(stateOverride, home) : defaultStateDir;
const stateDir = resolveGatewayStateDir(env);
const logDir = path.join(stateDir, "logs");
return {
logDir,
@@ -67,16 +58,6 @@ export function resolveGatewayLogPaths(env: Record<string, string | undefined>):
};
}
function resolveUserPathWithHome(input: string, home: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
const expanded = trimmed.replace(/^~(?=$|[\\/])/, home);
return path.resolve(expanded);
}
return path.resolve(trimmed);
}
export async function readLaunchAgentProgramArguments(
env: Record<string, string | undefined>,
): Promise<{

37
src/daemon/paths.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveGatewayStateDir } from "./paths.js";
describe("resolveGatewayStateDir", () => {
it("uses the default state dir when no overrides are set", () => {
const env = { HOME: "/Users/test" };
expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot"));
});
it("appends the profile suffix when set", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "rescue" };
expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot-rescue"));
});
it("treats default profiles as the base state dir", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" };
expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot"));
});
it("uses CLAWDBOT_STATE_DIR when provided", () => {
const env = { HOME: "/Users/test", CLAWDBOT_STATE_DIR: "/var/lib/clawdbot" };
expect(resolveGatewayStateDir(env)).toBe(path.resolve("/var/lib/clawdbot"));
});
it("expands ~ in CLAWDBOT_STATE_DIR", () => {
const env = { HOME: "/Users/test", CLAWDBOT_STATE_DIR: "~/clawdbot-state" };
expect(resolveGatewayStateDir(env)).toBe(path.resolve("/Users/test/clawdbot-state"));
});
it("preserves Windows absolute paths without HOME", () => {
const env = { CLAWDBOT_STATE_DIR: "C:\\State\\clawdbot" };
expect(resolveGatewayStateDir(env)).toBe("C:\\State\\clawdbot");
});
});

37
src/daemon/paths.ts Normal file
View File

@@ -0,0 +1,37 @@
import path from "node:path";
import { resolveGatewayProfileSuffix } from "./constants.js";
const windowsAbsolutePath = /^[a-zA-Z]:[\\/]/;
const windowsUncPath = /^\\\\/;
export function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME");
return home;
}
export function resolveUserPathWithHome(input: string, home?: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
if (!home) throw new Error("Missing HOME");
const expanded = trimmed.replace(/^~(?=$|[\\/])/, home);
return path.resolve(expanded);
}
if (windowsAbsolutePath.test(trimmed) || windowsUncPath.test(trimmed)) {
return trimmed;
}
return path.resolve(trimmed);
}
export function resolveGatewayStateDir(env: Record<string, string | undefined>): string {
const override = env.CLAWDBOT_STATE_DIR?.trim();
if (override) {
const home = override.startsWith("~") ? resolveHomeDir(env) : undefined;
return resolveUserPathWithHome(override, home);
}
const home = resolveHomeDir(env);
const suffix = resolveGatewayProfileSuffix(env.CLAWDBOT_PROFILE);
return path.join(home, `.clawdbot${suffix}`);
}

View File

@@ -58,6 +58,15 @@ describe("resolveTaskScriptPath", () => {
);
});
it("prefers CLAWDBOT_STATE_DIR over profile-derived defaults", () => {
const env = {
USERPROFILE: "C:\\Users\\test",
CLAWDBOT_PROFILE: "rescue",
CLAWDBOT_STATE_DIR: "C:\\State\\clawdbot",
};
expect(resolveTaskScriptPath(env)).toBe(path.join("C:\\State\\clawdbot", "gateway.cmd"));
});
it("handles case-insensitive 'Default' profile", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "Default" };
expect(resolveTaskScriptPath(env)).toBe(

View File

@@ -5,6 +5,7 @@ import { promisify } from "node:util";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js";
import { resolveGatewayStateDir } from "./paths.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -15,17 +16,9 @@ const formatLine = (label: string, value: string) => {
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.USERPROFILE?.trim() || env.HOME?.trim();
if (!home) throw new Error("Missing HOME");
return home;
}
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
const home = resolveHomeDir(env);
const profile = env.CLAWDBOT_PROFILE?.trim();
const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
return path.join(home, `.clawdbot${suffix}`, "gateway.cmd");
const stateDir = resolveGatewayStateDir(env);
return path.join(stateDir, "gateway.cmd");
}
function quoteCmdArg(value: string): string {

View File

@@ -10,6 +10,7 @@ import {
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
import { resolveHomeDir } from "./paths.js";
import {
enableSystemdUserLinger,
readSystemdUserLingerStatus,
@@ -28,12 +29,6 @@ const formatLine = (label: string, value: string) => {
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) throw new Error("Missing HOME");
return home;
}
function resolveSystemdUnitPathForName(
env: Record<string, string | undefined>,
name: string,