Skip to content

Commit d62612e

Browse files
hugocasaclaude
andauthored
feat: load global config env from ~/.config/webmux/.env (#295)
* feat: load global config env from ~/.config/webmux/.env at startup Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: add webmux service restart command Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: static-import webmux-paths in cli entry Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e725f3b commit d62612e

7 files changed

Lines changed: 124 additions & 3 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ Each issue is processed once while it stays in Todo + labeled. Remove the label
5252

5353
When the auto-create watcher picks up a `webmux_oneshot` issue, it posts a structured comment on the Linear issue (prefix `` **Webmux pickup — branch `<branch>`** ``) so external automation can track when the autonomous run starts. (Regular `webmux` pickups are user-driven and skip the comment.)
5454

55+
**Setup — `LINEAR_API_KEY`.** Everything above needs a [Linear API key](https://linear.app/settings/account/security) in the server's environment. webmux runs as a single machine-wide service started from your home directory, so put the key in `~/.config/webmux/.env` — a `KEY=value` file the server loads at startup regardless of which directory it runs from, so it survives `webmux update`:
56+
57+
```bash
58+
mkdir -p ~/.config/webmux
59+
echo 'LINEAR_API_KEY=lin_api_...' >> ~/.config/webmux/.env
60+
chmod 600 ~/.config/webmux/.env
61+
webmux service restart
62+
```
63+
64+
Your assigned issues appear in the dashboard once the service restarts.
65+
5566
## Quick Start
5667

5768
```bash
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { webmuxConfigDir, webmuxConfigEnvPath } from "../adapters/webmux-paths";
3+
4+
const originalHome = Bun.env.HOME;
5+
6+
afterEach(() => {
7+
if (originalHome === undefined) delete Bun.env.HOME;
8+
else Bun.env.HOME = originalHome;
9+
});
10+
11+
describe("webmuxConfigDir", () => {
12+
it("resolves the XDG config dir under $HOME", () => {
13+
Bun.env.HOME = "/home/alice";
14+
expect(webmuxConfigDir()).toBe("/home/alice/.config/webmux");
15+
});
16+
17+
it("falls back to /root when HOME is unset", () => {
18+
delete Bun.env.HOME;
19+
expect(webmuxConfigDir()).toBe("/root/.config/webmux");
20+
});
21+
});
22+
23+
describe("webmuxConfigEnvPath", () => {
24+
it("points at .env inside the config dir", () => {
25+
Bun.env.HOME = "/home/alice";
26+
expect(webmuxConfigEnvPath()).toBe("/home/alice/.config/webmux/.env");
27+
});
28+
});

backend/src/adapters/control-token.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { chmod, mkdir } from "node:fs/promises";
2-
import { dirname } from "node:path";
2+
import { dirname, join } from "node:path";
3+
import { webmuxConfigDir } from "./webmux-paths";
34

4-
const CONTROL_TOKEN_PATH = `${Bun.env.HOME ?? "/root"}/.config/webmux/control-token`;
5+
const CONTROL_TOKEN_PATH = join(webmuxConfigDir(), "control-token");
56

67
let cachedToken: string | null = null;
78

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { join } from "node:path";
2+
3+
/** webmux's XDG-style config directory (`~/.config/webmux`). Home to the
4+
* control token and the optional global env file. Distinct from the
5+
* `~/.webmux` runtime-state dir (projects registry, live-instance registry),
6+
* which holds transient state rather than user config. */
7+
export function webmuxConfigDir(): string {
8+
return join(Bun.env.HOME ?? "/root", ".config", "webmux");
9+
}
10+
11+
/** Optional global env file webmux reads at server startup for machine-wide
12+
* secrets (e.g. `LINEAR_API_KEY`). Loaded after the launch project's `.env`
13+
* so a project can still override a machine-wide default. */
14+
export function webmuxConfigEnvPath(): string {
15+
return join(webmuxConfigDir(), ".env");
16+
}

bin/src/service.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseInstalledServiceConfig,
77
readEnvVarsFromUnit,
88
readPortFromUnit,
9+
restartCommands,
910
resolveConfirmDecision,
1011
resolveEnvVars,
1112
shouldPersistProject,
@@ -59,6 +60,29 @@ describe("resolveConfirmDecision", () => {
5960
});
6061
});
6162

63+
describe("restartCommands", () => {
64+
const base: ServiceConfig = {
65+
platform: "linux",
66+
serviceName: "webmux",
67+
webmuxPath: "/usr/bin/webmux",
68+
port: 5111,
69+
envVars: {},
70+
};
71+
72+
it("restarts the user unit on linux", () => {
73+
expect(restartCommands(base)).toEqual([
74+
["systemctl", ["--user", "restart", "webmux"]],
75+
]);
76+
});
77+
78+
it("kickstarts the labeled agent on darwin", () => {
79+
const [[bin, args]] = restartCommands({ ...base, platform: "darwin" });
80+
expect(bin).toBe("launchctl");
81+
expect(args.slice(0, 2)).toEqual(["kickstart", "-k"]);
82+
expect(args[2]).toMatch(/^gui\/\d+\/com\.webmux\.webmux$/);
83+
});
84+
});
85+
6286
describe("shouldPersistProject", () => {
6387
it("persists a webmux repo that isn't registered yet", () => {
6488
expect(shouldPersistProject("/some/repo", true, ["/other/repo"])).toBe(true);

bin/src/service.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,14 @@ function uninstallCommands(config: ServiceConfig): Command[] {
339339
];
340340
}
341341

342+
export function restartCommands(config: ServiceConfig): Command[] {
343+
if (config.platform === "linux") {
344+
return [["systemctl", ["--user", "restart", config.serviceName]]];
345+
}
346+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
347+
return [["launchctl", ["kickstart", "-k", `gui/${uid}/com.webmux.${config.serviceName}`]]];
348+
}
349+
342350
// ── Check if service exists ─────────────────────────────────────────────────
343351

344352
function isInstalled(config: ServiceConfig): boolean {
@@ -650,6 +658,22 @@ async function uninstall(config: ServiceConfig): Promise<void> {
650658
p.log.success("Service uninstalled.");
651659
}
652660

661+
function restart(config: ServiceConfig): void {
662+
if (!isInstalled(config)) {
663+
p.log.error("Service is not installed.");
664+
return;
665+
}
666+
667+
for (const cmd of restartCommands(config)) {
668+
const result = runCommand(cmd);
669+
if (!result.success) {
670+
p.log.error(`Command failed: ${formatCommand(cmd)}\n${result.stderr.toString()}`);
671+
return;
672+
}
673+
}
674+
p.log.success("Service restarted.");
675+
}
676+
653677
function status(config: ServiceConfig): void {
654678
if (!isInstalled(config)) {
655679
p.log.error("Service is not installed.");
@@ -702,6 +726,7 @@ add more projects from the dashboard or with \`webmux project add\`.
702726
Usage:
703727
webmux service install Install, enable, and start the service
704728
webmux service uninstall Stop, disable, and remove the service
729+
webmux service restart Restart the service (e.g. to reload config env)
705730
webmux service status Show service status
706731
webmux service logs Tail service logs
707732
@@ -721,6 +746,10 @@ Options:
721746
722747
When any env var is set, the unit file is written with mode 0600 so
723748
secrets are readable only by the installing user.
749+
750+
Alternatively, put machine-wide secrets (e.g. LINEAR_API_KEY) in
751+
~/.config/webmux/.env — the server loads it at startup regardless of which
752+
directory it runs from, so they survive updates without re-baking the unit.
724753
`);
725754
}
726755

@@ -732,7 +761,7 @@ export default async function service(args: string[]): Promise<void> {
732761
return;
733762
}
734763

735-
if (!["install", "uninstall", "status", "logs"].includes(action)) {
764+
if (!["install", "uninstall", "restart", "status", "logs"].includes(action)) {
736765
p.log.error(`Unknown action: ${action}`);
737766
usage();
738767
return;
@@ -824,6 +853,9 @@ export default async function service(args: string[]): Promise<void> {
824853
case "uninstall":
825854
await uninstall(config);
826855
break;
856+
case "restart":
857+
restart(config);
858+
break;
827859
case "status":
828860
status(config);
829861
break;

bin/src/webmux.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
55
import { fileURLToPath } from "node:url";
66
import type { Subprocess } from "bun";
77
import pkg from "../../package.json";
8+
import { webmuxConfigEnvPath } from "../../backend/src/adapters/webmux-paths";
89

910
// ── Helpers ──────────────────────────────────────────────────────────────────
1011

@@ -350,6 +351,14 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
350351
for (const key of await loadEnvFile(resolve(process.cwd(), ".env.local"))) projectEnvKeys.add(key);
351352
for (const key of await loadEnvFile(resolve(process.cwd(), ".env"))) projectEnvKeys.add(key);
352353

354+
// webmux's own global config env (`~/.config/webmux/.env`): machine-wide
355+
// secrets like LINEAR_API_KEY the single service reads regardless of which
356+
// directory it runs from. Loaded last so an already-set value wins — the unit
357+
// and the launch project's `.env` both take precedence. Its keys join
358+
// projectEnvKeys so they're stripped from the tmux global environment like any
359+
// other secret webmux loads, rather than leaking into every session and pane.
360+
for (const key of await loadEnvFile(webmuxConfigEnvPath())) projectEnvKeys.add(key);
361+
353362
// When the user didn't pin a port, point CLI commands at the live server for
354363
// this project rather than the 5111 default. `webmux serve` walks to a free
355364
// port when 5111 is taken, so the running instance is often elsewhere (e.g.

0 commit comments

Comments
 (0)