fix: load global .env fallback

This commit is contained in:
Peter Steinberger
2026-01-05 00:41:36 +01:00
parent aa45f512f4
commit 77b19643e2
6 changed files with 107 additions and 4 deletions

View File

@@ -14,6 +14,7 @@
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).

View File

@@ -214,6 +214,8 @@ Minimal `~/.clawdbot/clawdbot.json`:
}
```
Env vars: loaded from `.env` in the current working directory, plus a global fallback at `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`) without overriding existing values.
### WhatsApp
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).

View File

@@ -2,7 +2,6 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import { getReplyFromConfig } from "./auto-reply/reply.js";
import { applyTemplate } from "./auto-reply/templating.js";
import { createDefaultDeps } from "./cli/deps.js";
@@ -17,6 +16,7 @@ import {
saveSessionStore,
} from "./config/sessions.js";
import { ensureBinary } from "./infra/binaries.js";
import { loadDotEnv } from "./infra/dotenv.js";
import { normalizeEnv } from "./infra/env.js";
import { isMainModule } from "./infra/is-main.js";
import { ensureClawdbotCliOnPath } from "./infra/path-env.js";
@@ -32,7 +32,7 @@ import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { monitorWebProvider } from "./provider-web.js";
import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";
dotenv.config({ quiet: true });
loadDotEnv({ quiet: true });
normalizeEnv();
ensureClawdbotCliOnPath();

80
src/infra/dotenv.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadDotEnv } from "./dotenv.js";
async function writeEnvFile(filePath: string, contents: string) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents, "utf8");
}
describe("loadDotEnv", () => {
it("loads ~/.clawdbot/.env as fallback without overriding CWD .env", async () => {
const prevEnv = { ...process.env };
const prevCwd = process.cwd();
const base = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-dotenv-test-"),
);
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");
process.env.CLAWDBOT_STATE_DIR = stateDir;
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\nBAR=1\n");
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
process.chdir(cwdDir);
delete process.env.FOO;
delete process.env.BAR;
loadDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-cwd");
expect(process.env.BAR).toBe("1");
process.chdir(prevCwd);
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(prevEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("does not override an already-set env var from the shell", async () => {
const prevEnv = { ...process.env };
const prevCwd = process.cwd();
const base = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-dotenv-test-"),
);
const cwdDir = path.join(base, "cwd");
const stateDir = path.join(base, "state");
process.env.CLAWDBOT_STATE_DIR = stateDir;
process.env.FOO = "from-shell";
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n");
process.chdir(cwdDir);
loadDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-shell");
process.chdir(prevCwd);
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(prevEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
});

20
src/infra/dotenv.ts Normal file
View File

@@ -0,0 +1,20 @@
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
import { resolveConfigDir } from "../utils.js";
export function loadDotEnv(opts?: { quiet?: boolean }) {
const quiet = opts?.quiet ?? true;
// Load from process CWD first (dotenv default).
dotenv.config({ quiet });
// Then load global fallback: ~/.clawdbot/.env (or CLAWDBOT_STATE_DIR/.env),
// without overriding any env vars already present.
const globalEnvPath = path.join(resolveConfigDir(process.env), ".env");
if (!fs.existsSync(globalEnvPath)) return;
dotenv.config({ quiet, path: globalEnvPath, override: false });
}

View File

@@ -34,8 +34,8 @@ async function main() {
await patchBunLongForProtobuf();
const { default: dotenv } = await import("dotenv");
dotenv.config({ quiet: true });
const { loadDotEnv } = await import("../infra/dotenv.js");
loadDotEnv({ quiet: true });
const { ensureClawdbotCliOnPath } = await import("../infra/path-env.js");
ensureClawdbotCliOnPath();