feat: Nix mode config, UX, onboarding, SwiftPM plist, docs

This commit is contained in:
Josh Palmer
2025-12-20 21:32:06 +01:00
parent 26fa9dea97
commit b7363f7c18
16 changed files with 513 additions and 133 deletions

View File

@@ -16,6 +16,37 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
}
}
/**
* Helper to test env var overrides. Saves/restores env vars and resets modules.
*/
async function withEnvOverride<T>(
overrides: Record<string, string | undefined>,
fn: () => Promise<T>,
): Promise<T> {
const saved: Record<string, string | undefined> = {};
for (const key of Object.keys(overrides)) {
saved[key] = process.env[key];
if (overrides[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = overrides[key];
}
}
vi.resetModules();
try {
return await fn();
} finally {
for (const key of Object.keys(saved)) {
if (saved[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = saved[key];
}
}
vi.resetModules();
}
}
describe("config identity defaults", () => {
let previousHome: string | undefined;
@@ -174,3 +205,152 @@ describe("config identity defaults", () => {
});
});
});
describe("Nix integration (U3, U5, U9)", () => {
describe("U3: isNixMode env var detection", () => {
it("isNixMode is false when CLAWDIS_NIX_MODE is not set", async () => {
await withEnvOverride({ CLAWDIS_NIX_MODE: undefined }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(false);
});
});
it("isNixMode is false when CLAWDIS_NIX_MODE is empty", async () => {
await withEnvOverride({ CLAWDIS_NIX_MODE: "" }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(false);
});
});
it("isNixMode is false when CLAWDIS_NIX_MODE is not '1'", async () => {
await withEnvOverride({ CLAWDIS_NIX_MODE: "true" }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(false);
});
});
it("isNixMode is true when CLAWDIS_NIX_MODE=1", async () => {
await withEnvOverride({ CLAWDIS_NIX_MODE: "1" }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(true);
});
});
});
describe("U5: CONFIG_PATH and STATE_DIR env var overrides", () => {
it("STATE_DIR_CLAWDIS defaults to ~/.clawdis when env not set", async () => {
await withEnvOverride({ CLAWDIS_STATE_DIR: undefined }, async () => {
const { STATE_DIR_CLAWDIS } = await import("./config.js");
expect(STATE_DIR_CLAWDIS).toMatch(/\.clawdis$/);
});
});
it("STATE_DIR_CLAWDIS respects CLAWDIS_STATE_DIR override", async () => {
await withEnvOverride(
{ CLAWDIS_STATE_DIR: "/custom/state/dir" },
async () => {
const { STATE_DIR_CLAWDIS } = await import("./config.js");
expect(STATE_DIR_CLAWDIS).toBe("/custom/state/dir");
},
);
});
it("CONFIG_PATH_CLAWDIS defaults to ~/.clawdis/clawdis.json when env not set", async () => {
await withEnvOverride(
{ CLAWDIS_CONFIG_PATH: undefined, CLAWDIS_STATE_DIR: undefined },
async () => {
const { CONFIG_PATH_CLAWDIS } = await import("./config.js");
expect(CONFIG_PATH_CLAWDIS).toMatch(/\.clawdis\/clawdis\.json$/);
},
);
});
it("CONFIG_PATH_CLAWDIS respects CLAWDIS_CONFIG_PATH override", async () => {
await withEnvOverride(
{ CLAWDIS_CONFIG_PATH: "/nix/store/abc/clawdis.json" },
async () => {
const { CONFIG_PATH_CLAWDIS } = await import("./config.js");
expect(CONFIG_PATH_CLAWDIS).toBe("/nix/store/abc/clawdis.json");
},
);
});
it("CONFIG_PATH_CLAWDIS uses STATE_DIR_CLAWDIS when only state dir is overridden", async () => {
await withEnvOverride(
{
CLAWDIS_CONFIG_PATH: undefined,
CLAWDIS_STATE_DIR: "/custom/state",
},
async () => {
const { CONFIG_PATH_CLAWDIS } = await import("./config.js");
expect(CONFIG_PATH_CLAWDIS).toBe("/custom/state/clawdis.json");
},
);
});
});
describe("U9: telegram.tokenFile schema validation", () => {
it("accepts config with only botToken", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify({
telegram: { botToken: "123:ABC" },
}),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.telegram?.botToken).toBe("123:ABC");
expect(cfg.telegram?.tokenFile).toBeUndefined();
});
});
it("accepts config with only tokenFile", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify({
telegram: { tokenFile: "/run/agenix/telegram-token" },
}),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
expect(cfg.telegram?.botToken).toBeUndefined();
});
});
it("accepts config with both botToken and tokenFile", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdis.json"),
JSON.stringify({
telegram: {
botToken: "fallback:token",
tokenFile: "/run/agenix/telegram-token",
},
}),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.telegram?.botToken).toBe("fallback:token");
expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
});
});
});
});

View File

@@ -7,6 +7,16 @@ import { z } from "zod";
import { parseDurationMs } from "../cli/parse-duration.js";
/**
* Nix mode detection: When CLAWDIS_NIX_MODE=1, the gateway is running under Nix.
* In this mode:
* - No auto-install flows should be attempted
* - Missing dependencies should produce actionable Nix-specific error messages
* - Config is managed externally (read-only from Nix perspective)
*/
export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1";
export type ReplyMode = "text" | "command";
export type SessionScope = "per-sender" | "global";
export type SessionConfig = {
@@ -131,6 +141,8 @@ export type TelegramConfig = {
/** If false, do not start the Telegram provider. Default: true. */
enabled?: boolean;
botToken?: string;
/** Path to file containing bot token (for secret managers like agenix) */
tokenFile?: string;
requireMention?: boolean;
allowFrom?: Array<string | number>;
mediaMaxMb?: number;
@@ -395,12 +407,22 @@ export type ClawdisConfig = {
skills?: Record<string, SkillConfig>;
};
// New branding path (preferred)
export const CONFIG_PATH_CLAWDIS = path.join(
os.homedir(),
".clawdis",
"clawdis.json",
);
/**
* State directory for mutable data (sessions, logs, caches).
* Can be overridden via CLAWDIS_STATE_DIR environment variable.
* Default: ~/.clawdis
*/
export const STATE_DIR_CLAWDIS =
process.env.CLAWDIS_STATE_DIR ?? path.join(os.homedir(), ".clawdis");
/**
* Config file path (JSON5).
* Can be overridden via CLAWDIS_CONFIG_PATH environment variable.
* Default: ~/.clawdis/clawdis.json (or $CLAWDIS_STATE_DIR/clawdis.json)
*/
export const CONFIG_PATH_CLAWDIS =
process.env.CLAWDIS_CONFIG_PATH ??
path.join(STATE_DIR_CLAWDIS, "clawdis.json");
const ModelApiSchema = z.union([
z.literal("openai-completions"),
@@ -731,6 +753,7 @@ const ClawdisSchema = z.object({
.object({
enabled: z.boolean().optional(),
botToken: z.string().optional(),
tokenFile: z.string().optional(),
requireMention: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
mediaMaxMb: z.number().positive().optional(),