feat: centralize config paths and expose in snapshot

This commit is contained in:
Peter Steinberger
2026-01-01 09:22:37 +01:00
parent 20bc323963
commit f6956320f9
9 changed files with 78 additions and 44 deletions

View File

@@ -2,28 +2,17 @@ import Foundation
enum ClawdisConfigFile {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "config")
private static let configPathEnv = "CLAWDIS_CONFIG_PATH"
private static let stateDirEnv = "CLAWDIS_STATE_DIR"
static func url() -> URL {
if let override = self.envPath(self.configPathEnv) {
return URL(fileURLWithPath: override)
}
return self.stateDirURL()
.appendingPathComponent("clawdis.json")
ClawdisPaths.configURL
}
static func stateDirURL() -> URL {
if let override = self.envPath(self.stateDirEnv) {
return URL(fileURLWithPath: override, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis", isDirectory: true)
ClawdisPaths.stateDirURL
}
static func defaultWorkspaceURL() -> URL {
self.stateDirURL()
.appendingPathComponent("workspace", isDirectory: true)
ClawdisPaths.workspaceURL
}
static func loadDict() -> [String: Any] {
@@ -108,11 +97,4 @@ enum ClawdisConfigFile {
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
}
private static func envPath(_ key: String) -> String? {
guard let value = ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else {
return nil
}
return value
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
enum ClawdisEnv {
static func path(_ key: String) -> String? {
guard let value = ProcessInfo.processInfo.environment[key]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
else {
return nil
}
return value
}
}
enum ClawdisPaths {
private static let configPathEnv = "CLAWDIS_CONFIG_PATH"
private static let stateDirEnv = "CLAWDIS_STATE_DIR"
static var stateDirURL: URL {
if let override = ClawdisEnv.path(self.stateDirEnv) {
return URL(fileURLWithPath: override, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis", isDirectory: true)
}
static var configURL: URL {
if let override = ClawdisEnv.path(self.configPathEnv) {
return URL(fileURLWithPath: override)
}
return self.stateDirURL.appendingPathComponent("clawdis.json")
}
static var workspaceURL: URL {
self.stateDirURL.appendingPathComponent("workspace", isDirectory: true)
}
}

View File

@@ -129,22 +129,6 @@ enum DeviceModelCatalog {
if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) {
return bundle
}
if let resourceURL = Bundle.main.resourceURL {
if let enumerator = FileManager.default.enumerator(
at: resourceURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]) {
for case let url as URL in enumerator {
guard url.pathExtension == "bundle" else { continue }
if let bundle = Bundle(url: url),
self.bundleIfContainsDeviceModels(bundle) != nil {
return bundle
}
}
}
}
return nil
}

View File

@@ -228,6 +228,14 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed
}
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
return (configPath?.isEmpty == false ? configPath : nil,
stateDir?.isEmpty == false ? stateDir : nil)
}
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
let id = UUID()
let snapshot = self.lastSnapshot

View File

@@ -105,8 +105,9 @@ struct SettingsRootView: View {
}
private var nixManagedBanner: some View {
let configPath = ClawdisConfigFile.url().path
let stateDir = ClawdisConfigFile.stateDirURL().path
let snapshotPaths = GatewayConnection.shared.snapshotPaths()
let configPath = snapshotPaths.configPath ?? ClawdisPaths.configURL.path
let stateDir = snapshotPaths.stateDir ?? ClawdisPaths.stateDirURL.path
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {

View File

@@ -250,23 +250,31 @@ public struct Snapshot: Codable {
public let health: AnyCodable
public let stateversion: StateVersion
public let uptimems: Int
public let configpath: String?
public let statedir: String?
public init(
presence: [PresenceEntry],
health: AnyCodable,
stateversion: StateVersion,
uptimems: Int
uptimems: Int,
configpath: String?,
statedir: String?
) {
self.presence = presence
self.health = health
self.stateversion = stateversion
self.uptimems = uptimems
self.configpath = configpath
self.statedir = statedir
}
private enum CodingKeys: String, CodingKey {
case presence
case health
case stateversion = "stateVersion"
case uptimems = "uptimeMs"
case configpath = "configPath"
case statedir = "stateDir"
}
}

View File

@@ -37,6 +37,8 @@ export const SnapshotSchema = Type.Object(
health: HealthSnapshotSchema,
stateVersion: StateVersionSchema,
uptimeMs: Type.Integer({ minimum: 0 }),
configPath: Type.Optional(NonEmptyString),
stateDir: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);

View File

@@ -6,7 +6,12 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { agentCommand } from "../commands/agent.js";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
STATE_DIR_CLAWDIS,
readConfigFileSnapshot,
writeConfigFile,
} from "../config/config.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
@@ -190,6 +195,7 @@ vi.mock("../config/config.js", () => {
return {
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
isNixMode: false,
loadConfig: () => ({
agent: {
@@ -2046,9 +2052,12 @@ describe("gateway server", () => {
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true);
expect((res.payload as { type?: unknown } | undefined)?.type).toBe(
"hello-ok",
);
const payload = res.payload as
| { type?: unknown; snapshot?: { configPath?: string; stateDir?: string } }
| undefined;
expect(payload?.type).toBe("hello-ok");
expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDIS);
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDIS);
ws.close();
await server.close();
});

View File

@@ -46,6 +46,7 @@ import { getStatusSummary } from "../commands/status.js";
import {
type ClawdisConfig,
CONFIG_PATH_CLAWDIS,
STATE_DIR_CLAWDIS,
isNixMode,
loadConfig,
parseConfigJson5,
@@ -638,6 +639,8 @@ function buildSnapshot(): Snapshot {
health: emptyHealth,
stateVersion: { presence: presenceVersion, health: healthVersion },
uptimeMs,
configPath: CONFIG_PATH_CLAWDIS,
stateDir: STATE_DIR_CLAWDIS,
};
}