Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions bin/src/service-logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it } from "bun:test";
import { buildLogsCommand, parseLogsCliArgs, type LogsOptions } from "./service.ts";

const defaults: LogsOptions = { follow: true, lines: null, since: null };

describe("parseLogsCliArgs", () => {
it("defaults to follow + no limit + no since", () => {
const { options, errors } = parseLogsCliArgs([]);
expect(errors).toEqual([]);
expect(options).toEqual(defaults);
});

it("disables follow with --no-follow", () => {
const { options } = parseLogsCliArgs(["--no-follow"]);
expect(options.follow).toBe(false);
});

it("re-enables follow with --follow / -f after --no-follow", () => {
expect(parseLogsCliArgs(["--no-follow", "-f"]).options.follow).toBe(true);
expect(parseLogsCliArgs(["--no-follow", "--follow"]).options.follow).toBe(true);
});

it("parses -n N and --lines N", () => {
expect(parseLogsCliArgs(["-n", "100"]).options.lines).toBe(100);
expect(parseLogsCliArgs(["--lines", "5"]).options.lines).toBe(5);
});

it("flags non-numeric and negative -n values", () => {
expect(parseLogsCliArgs(["-n", "abc"]).errors.length).toBe(1);
expect(parseLogsCliArgs(["-n", "-1"]).errors.length).toBe(1);
});

it("captures --since with quoted relative timestamps", () => {
const { options } = parseLogsCliArgs(["--since", "1 hour ago"]);
expect(options.since).toBe("1 hour ago");
});

it("reports trailing flag with no value", () => {
expect(parseLogsCliArgs(["-n"]).errors[0]).toContain("requires a numeric value");
expect(parseLogsCliArgs(["--since"]).errors[0]).toContain("requires a value");
});

it("ignores unknown tokens (the dispatcher handles unknown subcommands)", () => {
const { options, errors } = parseLogsCliArgs(["--bogus", "--no-follow"]);
expect(errors).toEqual([]);
expect(options.follow).toBe(false);
});
});

describe("buildLogsCommand (linux/journalctl)", () => {
it("builds the default follow command", () => {
const cmd = buildLogsCommand("linux", "webmux-x", defaults, "");
expect(cmd).toEqual({
bin: "journalctl",
args: ["--user", "-u", "webmux-x", "--no-pager", "-f"],
});
});

it("drops -f when follow is false", () => {
const cmd = buildLogsCommand("linux", "webmux-x", { ...defaults, follow: false }, "");
expect(cmd.args).not.toContain("-f");
});

it("forwards -n N", () => {
const cmd = buildLogsCommand("linux", "webmux-x", { ...defaults, lines: 500 }, "");
expect(cmd.args).toContain("-n");
expect(cmd.args).toContain("500");
});

it("forwards --since to journalctl as-is", () => {
const cmd = buildLogsCommand("linux", "webmux-x", { ...defaults, since: "1 hour ago" }, "");
const idx = cmd.args.indexOf("--since");
expect(idx).toBeGreaterThan(-1);
expect(cmd.args[idx + 1]).toBe("1 hour ago");
});
});

describe("buildLogsCommand (darwin/launchd)", () => {
const logPath = "/Users/x/Library/Logs/webmux-x.log";

it("uses cat for no-follow + no-limit (cheaper than tail -n +1)", () => {
const cmd = buildLogsCommand("darwin", "webmux-x", { ...defaults, follow: false }, logPath);
expect(cmd).toEqual({ bin: "cat", args: [logPath] });
});

it("uses tail -f for follow mode", () => {
const cmd = buildLogsCommand("darwin", "webmux-x", defaults, logPath);
expect(cmd.bin).toBe("tail");
expect(cmd.args).toContain("-f");
expect(cmd.args[cmd.args.length - 1]).toBe(logPath);
});

it("uses tail -n N for line-limit + follow", () => {
const cmd = buildLogsCommand("darwin", "webmux-x", { ...defaults, lines: 200 }, logPath);
expect(cmd.bin).toBe("tail");
expect(cmd.args.slice(0, 3)).toEqual(["-n", "200", "-f"]);
});

it("uses tail -n N without -f when --no-follow", () => {
const cmd = buildLogsCommand(
"darwin",
"webmux-x",
{ ...defaults, follow: false, lines: 200 },
logPath,
);
expect(cmd.bin).toBe("tail");
expect(cmd.args).not.toContain("-f");
expect(cmd.args.slice(0, 2)).toEqual(["-n", "200"]);
});

it("warns when --since is set (tail has no time filter)", () => {
const cmd = buildLogsCommand(
"darwin",
"webmux-x",
{ ...defaults, since: "1 hour ago" },
logPath,
);
expect(cmd.warning).toBeDefined();
expect(cmd.warning).toContain("macOS");
// The filter is dropped silently from the argv — only the warning conveys it.
expect(cmd.args).not.toContain("--since");
});
});
151 changes: 130 additions & 21 deletions bin/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,29 +374,124 @@ function status(config: ServiceConfig): void {
}
}

function logs(config: ServiceConfig): void {
export interface LogsOptions {
/** When true, follow the log (default). When false, print the full history
* then exit — useful for piping into less/grep/save-to-file. */
follow: boolean;
/** Limit output to the last N lines. Maps to `journalctl -n` on Linux and
* `tail -n` on macOS. Undefined → no limit (journalctl prints everything;
* on macOS `cat` / `tail -f` cover the no-limit cases). */
lines: number | null;
/** Time-bounded filter (e.g. "1 hour ago", "2026-05-21 14:00"). Linux only
* — macOS `tail` has no equivalent and a warning is logged when set. */
since: string | null;
}

export interface LogsCommand {
bin: string;
args: string[];
/** Set when an option was silently dropped (e.g. --since on macOS). The
* caller surfaces this to the user before spawning so dropped filters
* don't look like a working command. */
warning?: string;
}

/** Pure builder for the spawn command. Kept separate from the spawn call so
* flag → argv mapping is unit-testable without hitting the filesystem or
* spawning journalctl. */
export function buildLogsCommand(
platform: Platform,
serviceName: string,
options: LogsOptions,
logPath: string,
): LogsCommand {
if (platform === "linux") {
const args = ["--user", "-u", serviceName, "--no-pager"];
if (options.follow) args.push("-f");
if (options.lines !== null) args.push("-n", String(options.lines));
if (options.since !== null) args.push("--since", options.since);
return { bin: "journalctl", args };
}
// macOS: tail-based. tail can do `-n` + optional `-f`, but has no time
// filter — surface that limitation rather than dropping --since silently.
let warning: string | undefined;
if (options.since !== null) {
warning = `--since is not supported on macOS (launchd logs to a flat file). Ignoring "${options.since}".`;
}
// No follow + no lines → just cat the file (cheaper than `tail -n +1`).
if (!options.follow && options.lines === null) {
return { bin: "cat", args: [logPath], warning };
}
const args: string[] = [];
if (options.lines !== null) args.push("-n", String(options.lines));
if (options.follow) args.push("-f");
args.push(logPath);
return { bin: "tail", args, warning };
}

export interface LogsCliParseResult {
options: LogsOptions;
errors: string[];
}

/** Parse the logs subcommand's flags out of the post-action args slice.
* Lenient about ordering and ignores unknown tokens (the dispatcher above
* already validated the action). */
export function parseLogsCliArgs(args: string[]): LogsCliParseResult {
const options: LogsOptions = { follow: true, lines: null, since: null };
const errors: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--no-follow") {
options.follow = false;
} else if (arg === "-f" || arg === "--follow") {
// Explicit follow — same as the default, accepted for symmetry.
options.follow = true;
} else if (arg === "-n" || arg === "--lines") {
const raw = args[i + 1];
if (raw === undefined) {
errors.push(`${arg} requires a numeric value`);
break;
}
i++;
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed < 0) {
errors.push(`${arg} expects a non-negative integer (got: ${raw})`);
continue;
}
options.lines = parsed;
} else if (arg === "--since") {
const raw = args[i + 1];
if (raw === undefined) {
errors.push("--since requires a value (e.g. \"1 hour ago\")");
break;
}
i++;
options.since = raw;
}
}
return { options, errors };
}

function logs(config: ServiceConfig, options: LogsOptions): void {
if (!isInstalled(config)) {
p.log.error("Service is not installed.");
return;
}

let proc: ReturnType<typeof Bun.spawn>;
if (config.platform === "linux") {
proc = Bun.spawn(
["journalctl", "--user", "-u", config.serviceName, "-f", "--no-pager"],
{ stdout: "inherit", stderr: "inherit" },
);
} else {
const logPath = join(homedir(), "Library", "Logs", `webmux-${config.serviceName}.log`);
if (!existsSync(logPath)) {
p.log.error(`Log file not found: ${logPath}`);
return;
}
proc = Bun.spawn(["tail", "-f", logPath], {
stdout: "inherit",
stderr: "inherit",
});
const logPath = config.platform === "darwin"
? join(homedir(), "Library", "Logs", `webmux-${config.serviceName}.log`)
: "";

if (config.platform === "darwin" && !existsSync(logPath)) {
p.log.error(`Log file not found: ${logPath}`);
return;
}

const cmd = buildLogsCommand(config.platform, config.serviceName, options, logPath);
if (cmd.warning) p.log.warn(cmd.warning);

const proc = Bun.spawn([cmd.bin, ...cmd.args], { stdout: "inherit", stderr: "inherit" });
process.on("SIGINT", () => proc.kill());
proc.exited.then((code) => process.exit(code));
}
Expand All @@ -411,13 +506,21 @@ Usage:
webmux service install Install, enable, and start the service
webmux service uninstall Stop, disable, and remove the service
webmux service status Show service status
webmux service logs Tail service logs
webmux service logs Show service logs (follows by default)

Options:
Install options:
--port N Pin the service to a specific port. When omitted,
a free port is picked automatically by scanning
other webmux instances and installed services
— second-project installs no longer collide on 5111.

Logs options:
--no-follow Print the available history and exit instead of
following. Useful for piping into less / grep.
-n, --lines N Limit output to the last N lines.
--since <time> Linux only — pass-through to journalctl --since
(e.g. "1 hour ago", "2026-05-21 14:00"). On
macOS this is logged and ignored.
`);
}

Expand Down Expand Up @@ -496,8 +599,14 @@ export default async function service(args: string[]): Promise<void> {
case "status":
status(config);
break;
case "logs":
logs(config);
case "logs": {
const parsed = parseLogsCliArgs(args.slice(1));
if (parsed.errors.length > 0) {
for (const err of parsed.errors) p.log.error(err);
return;
}
logs(config, parsed.options);
break;
}
}
}
3 changes: 2 additions & 1 deletion site/src/lib/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,12 @@ export const rootCommands: DocCommand[] = [
{
title: "service",
usage:
"webmux service install [--port <number>]\nwebmux service uninstall\nwebmux service status\nwebmux service logs",
"webmux service install [--port <number>]\nwebmux service uninstall\nwebmux service status\nwebmux service logs [--no-follow] [-n <lines>] [--since <time>]",
description: "Manage webmux as a user-level service on Linux or macOS.",
details: [
"Uses systemctl --user on Linux and launchctl on macOS.",
"install writes a service file that runs webmux serve --port ... from the git root.",
"logs follows the journal by default. Pass --no-follow to print history and exit, -n N to limit, or --since \"1 hour ago\" to time-bound (Linux only — macOS tail has no time filter).",
"Not supported on other platforms.",
],
},
Expand Down