feat: Nix mode config, UX, onboarding, SwiftPM plist, docs
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -189,6 +189,7 @@ vi.mock("../config/config.js", () => {
|
||||
|
||||
return {
|
||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||
isNixMode: false,
|
||||
loadConfig: () => ({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
|
||||
@@ -46,6 +46,7 @@ import { getStatusSummary } from "../commands/status.js";
|
||||
import {
|
||||
type ClawdisConfig,
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
isNixMode,
|
||||
loadConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
@@ -1876,6 +1877,30 @@ export async function startGatewayServer(
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Load telegram token with priority: env var > tokenFile > botToken.
|
||||
* tokenFile supports secret managers (e.g., agenix).
|
||||
*/
|
||||
const loadTelegramToken = (cfg: ClawdisConfig): string => {
|
||||
if (process.env.TELEGRAM_BOT_TOKEN) {
|
||||
return process.env.TELEGRAM_BOT_TOKEN.trim();
|
||||
}
|
||||
if (cfg.telegram?.tokenFile) {
|
||||
const filePath = cfg.telegram.tokenFile;
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logTelegram.info(`telegram tokenFile not found: ${filePath}`);
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf-8").trim();
|
||||
} catch (err) {
|
||||
logTelegram.info(`failed to read telegram tokenFile: ${String(err)}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return cfg.telegram?.botToken?.trim() ?? "";
|
||||
};
|
||||
|
||||
const startTelegramProvider = async () => {
|
||||
if (telegramTask) return;
|
||||
const cfg = loadConfig();
|
||||
@@ -1888,8 +1913,7 @@ export async function startGatewayServer(
|
||||
logTelegram.info("skipping provider start (telegram.enabled=false)");
|
||||
return;
|
||||
}
|
||||
const telegramToken =
|
||||
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
|
||||
const telegramToken = loadTelegramToken(cfg);
|
||||
if (!telegramToken.trim()) {
|
||||
telegramRuntime = {
|
||||
...telegramRuntime,
|
||||
@@ -6090,6 +6114,9 @@ export async function startGatewayServer(
|
||||
});
|
||||
log.info(`listening on ws://${bindHost}:${port} (PID ${process.pid})`);
|
||||
log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
||||
if (isNixMode) {
|
||||
log.info("gateway: running in Nix mode (config managed externally)");
|
||||
}
|
||||
let tailscaleCleanup: (() => Promise<void>) | null = null;
|
||||
if (tailscaleMode !== "off") {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user