feat: unify Docker entrypoint

This commit is contained in:
Pi Web Agent
2026-06-29 11:27:47 +00:00
parent 4a1136eac2
commit bd28c93cfe
35 changed files with 2316 additions and 118 deletions
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import {
PI_WEB_DOCKER_USER_COMMANDS,
parsePiWebDockerArgs,
piWebDockerCommand,
piWebDockerCommandPrefix,
planPiWebDockerDevHostCommand,
planPiWebDockerRuntimeHostCommand,
validatePiWebDockerDevRootSafety,
} from "./piWebDockerCommandPlan.js";
describe("pi-web-docker command planning", () => {
it("plans runtime commands by default", () => {
expect(parsePiWebDockerArgs(["status"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "status", allowRoot: false, args: [] },
});
});
it("emits runtime commands by default and development commands explicitly", () => {
expect(parsePiWebDockerArgs(["--dev", "restart-sessiond"])).toEqual({
ok: true,
plan: { mode: "dev", command: "restart-sessiond", allowRoot: false, args: [] },
});
expect(piWebDockerCommandPrefix(undefined)).toBe("pi-web-docker");
expect(piWebDockerCommandPrefix("runtime")).toBe("pi-web-docker");
expect(piWebDockerCommandPrefix("dev")).toBe("pi-web-docker --dev");
expect(piWebDockerCommand(undefined, "status")).toBe("pi-web-docker status");
expect(piWebDockerCommand("runtime", "update")).toBe("pi-web-docker update");
expect(piWebDockerCommand("dev", "status")).toBe("pi-web-docker --dev status");
});
it("keeps production install out of development mode", () => {
expect(parsePiWebDockerArgs(["install", "--install-dir", "/srv/pi-web-docker"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "install", allowRoot: false, args: ["--install-dir", "/srv/pi-web-docker"] },
});
expect(parsePiWebDockerArgs(["--dev", "install"])).toEqual({
ok: false,
errors: ["install is only available in runtime mode"],
});
});
it("validates logs and shell targets", () => {
expect(parsePiWebDockerArgs(["--dev", "logs", "data-init"])).toEqual({
ok: true,
plan: { mode: "dev", command: "logs", allowRoot: false, args: ["data-init"], target: "data-init" },
});
expect(parsePiWebDockerArgs(["logs", "data-init"])).toEqual({
ok: false,
errors: ["logs data-init is only available in development mode"],
});
expect(parsePiWebDockerArgs(["shell"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "shell", allowRoot: false, args: [], target: "web" },
});
expect(parsePiWebDockerArgs(["shell", "data-init"])).toEqual({
ok: false,
errors: ["Invalid shell target: data-init"],
});
});
it("treats cli as the pi-web escape hatch", () => {
expect(parsePiWebDockerArgs(["cli", "config", "show"])).toEqual({
ok: true,
plan: { mode: "runtime", command: "cli", allowRoot: false, args: ["config", "show"] },
});
expect(parsePiWebDockerArgs(["cli"])).toEqual({ ok: false, errors: ["cli requires pi-web arguments"] });
});
it("keeps the canonical user command surface parseable", () => {
const sampleArgs = new Map<string, string[]>([
["install", ["--asset-ref", "release"]],
["logs", ["web"]],
["shell", ["sessiond"]],
["cli", ["config", "show"]],
]);
for (const command of PI_WEB_DOCKER_USER_COMMANDS) {
const parsed = parsePiWebDockerArgs([command, ...(sampleArgs.get(command) ?? [])]);
expect(parsed).toMatchObject({ ok: true });
}
});
it("rejects unknown options and unexpected positional arguments", () => {
expect(parsePiWebDockerArgs(["--prod", "status"])).toEqual({ ok: false, errors: ["Unknown global option: --prod"] });
expect(parsePiWebDockerArgs(["status", "web"])).toEqual({ ok: false, errors: ["status does not accept positional arguments"] });
expect(parsePiWebDockerArgs(["restart-sessiond", "web"])).toEqual({ ok: false, errors: ["restart-sessiond does not accept positional arguments"] });
expect(parsePiWebDockerArgs([])).toEqual({ ok: false, errors: ["Missing command"] });
});
it("parses root override as an explicit global option", () => {
expect(parsePiWebDockerArgs(["--dev", "--allow-root", "status"])).toEqual({
ok: true,
plan: { mode: "dev", command: "status", allowRoot: true, args: [] },
});
});
it("plans production host commands through installer or Compose actions", () => {
expect(runtimeHostPlan(["install", "--asset-ref", "release"])).toEqual({
kind: "installer",
action: "install",
args: ["--asset-ref", "release"],
useRuntimeRootAsInstallDir: false,
});
expect(runtimeHostPlan(["update"])).toEqual({ kind: "installer", action: "update", args: [], useRuntimeRootAsInstallDir: true });
expect(runtimeHostPlan(["start"])).toEqual({ kind: "compose", args: ["up", "-d"] });
expect(runtimeHostPlan(["stop"])).toEqual({ kind: "compose", args: ["down"] });
expect(runtimeHostPlan(["restart"])).toEqual({ kind: "compose", args: ["restart", "web", "sessiond"] });
expect(runtimeHostPlan(["status"])).toEqual({ kind: "compose", args: ["ps"] });
expect(runtimeHostPlan(["logs", "web"])).toEqual({ kind: "compose", args: ["logs", "-f", "web"] });
expect(runtimeHostPlan(["shell"])).toEqual({ kind: "compose", args: ["exec", "web", "bash"] });
expect(runtimeHostPlan(["cli", "config", "show"])).toEqual({ kind: "compose", args: ["exec", "web", "pi-web", "config", "show"] });
});
it("plans development host commands through the generated dev Compose environment", () => {
expect(devHostPlan(["--dev", "start"])).toEqual({ kind: "compose", args: ["up", "-d", "--build"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "status"])).toEqual({ kind: "compose", args: ["ps"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "logs", "data-init"])).toEqual({ kind: "compose", args: ["logs", "-f", "data-init"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "shell"])).toEqual({ kind: "compose", args: ["exec", "web", "bash"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "cli", "config", "show"])).toEqual({ kind: "compose", args: ["exec", "web", "pi-web", "config", "show"], usesGeneratedEnv: true });
expect(devHostPlan(["--dev", "update"])).toEqual({
kind: "composeSequence",
usesGeneratedEnv: true,
steps: [
{ args: ["build", "--pull"] },
{ args: ["up", "-d", "--force-recreate", "--remove-orphans"] },
],
});
});
it("keeps development root safety explicit in command planning", () => {
const parsed = parsePiWebDockerArgs(["--dev", "status"]);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
expect(validatePiWebDockerDevRootSafety(parsed.plan, 0)).toBe("refusing to run Docker development mode as root; retry with --allow-root if this is intentional");
expect(validatePiWebDockerDevRootSafety({ ...parsed.plan, allowRoot: true }, 0)).toBeUndefined();
expect(validatePiWebDockerDevRootSafety(parsed.plan, 500)).toBeUndefined();
});
it("does not apply production host planning to development mode", () => {
const parsed = parsePiWebDockerArgs(["--dev", "status"]);
expect(parsed.ok).toBe(true);
if (parsed.ok) expect(planPiWebDockerRuntimeHostCommand(parsed.plan)).toBeUndefined();
});
it("does not apply development host planning to production mode", () => {
const parsed = parsePiWebDockerArgs(["status"]);
expect(parsed.ok).toBe(true);
if (parsed.ok) expect(planPiWebDockerDevHostCommand(parsed.plan)).toBeUndefined();
});
});
function runtimeHostPlan(argv: string[]) {
const parsed = parsePiWebDockerArgs(argv);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
return planPiWebDockerRuntimeHostCommand(parsed.plan);
}
function devHostPlan(argv: string[]) {
const parsed = parsePiWebDockerArgs(argv);
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
return planPiWebDockerDevHostCommand(parsed.plan);
}
+274
View File
@@ -0,0 +1,274 @@
export type PiWebDockerMode = "runtime" | "dev";
export type PiWebDockerCommand =
| "install"
| "start"
| "stop"
| "restart"
| "restart-web"
| "restart-sessiond"
| "update"
| "status"
| "logs"
| "shell"
| "doctor"
| "cli"
| "help";
export type PiWebDockerLogsTarget = "web" | "sessiond" | "data-init";
export type PiWebDockerShellTarget = "web" | "sessiond";
export interface PiWebDockerCommandPlan {
mode: PiWebDockerMode;
command: PiWebDockerCommand;
allowRoot: boolean;
args: string[];
target?: PiWebDockerLogsTarget | PiWebDockerShellTarget;
}
export type PiWebDockerParseResult =
| { ok: true; plan: PiWebDockerCommandPlan }
| { ok: false; errors: string[] };
export const PI_WEB_DOCKER_USER_COMMANDS = [
"install",
"start",
"stop",
"restart",
"restart-web",
"restart-sessiond",
"update",
"status",
"logs",
"shell",
"doctor",
"cli",
] as const satisfies readonly Exclude<PiWebDockerCommand, "help">[];
export type PiWebDockerRuntimeHostPlan =
| { kind: "installer"; action: "install" | "update"; args: string[]; useRuntimeRootAsInstallDir: boolean }
| { kind: "compose"; args: string[] }
| { kind: "diagnostics" }
| { kind: "usage" };
export interface PiWebDockerComposeStep {
args: string[];
}
export type PiWebDockerDevHostPlan =
| { kind: "compose"; args: string[]; usesGeneratedEnv: true }
| { kind: "composeSequence"; steps: PiWebDockerComposeStep[]; usesGeneratedEnv: true }
| { kind: "diagnostics"; usesGeneratedEnv: true }
| { kind: "usage" };
const noArgumentCommands = new Set<PiWebDockerCommand>([
"start",
"stop",
"restart",
"restart-web",
"restart-sessiond",
"update",
"status",
"doctor",
"help",
]);
const commands: ReadonlySet<string> = new Set([...PI_WEB_DOCKER_USER_COMMANDS, "help"]);
const logsTargets: ReadonlySet<string> = new Set(["web", "sessiond", "data-init"]);
const shellTargets: ReadonlySet<string> = new Set(["web", "sessiond"]);
export function piWebDockerCommandPrefix(mode: PiWebDockerMode | undefined): string {
return mode === "dev" ? "pi-web-docker --dev" : "pi-web-docker";
}
export function piWebDockerCommand(mode: PiWebDockerMode | undefined, command: Exclude<PiWebDockerCommand, "help">): string {
return `${piWebDockerCommandPrefix(mode)} ${command}`;
}
export function planPiWebDockerRuntimeHostCommand(plan: PiWebDockerCommandPlan): PiWebDockerRuntimeHostPlan | undefined {
if (plan.mode !== "runtime") return undefined;
switch (plan.command) {
case "install":
return { kind: "installer", action: "install", args: [...plan.args], useRuntimeRootAsInstallDir: false };
case "update":
return { kind: "installer", action: "update", args: [], useRuntimeRootAsInstallDir: true };
case "start":
return composeHostPlan("up", "-d");
case "stop":
return composeHostPlan("down");
case "restart":
return composeHostPlan("restart", "web", "sessiond");
case "restart-web":
return composeHostPlan("restart", "web");
case "restart-sessiond":
return composeHostPlan("restart", "sessiond");
case "status":
return composeHostPlan("ps");
case "logs":
return plan.target === undefined ? composeHostPlan("logs", "-f") : composeHostPlan("logs", "-f", plan.target);
case "shell":
return composeHostPlan("exec", plan.target ?? "web", "bash");
case "cli":
return { kind: "compose", args: ["exec", "web", "pi-web", ...plan.args] };
case "doctor":
return { kind: "diagnostics" };
case "help":
return { kind: "usage" };
}
}
export function planPiWebDockerDevHostCommand(plan: PiWebDockerCommandPlan): PiWebDockerDevHostPlan | undefined {
if (plan.mode !== "dev") return undefined;
switch (plan.command) {
case "install":
return undefined;
case "start":
return devComposeHostPlan("up", "-d", "--build");
case "stop":
return devComposeHostPlan("down");
case "restart":
return devComposeHostPlan("restart", "web", "sessiond");
case "restart-web":
return devComposeHostPlan("restart", "web");
case "restart-sessiond":
return devComposeHostPlan("restart", "sessiond");
case "update":
return {
kind: "composeSequence",
usesGeneratedEnv: true,
steps: [
{ args: ["build", "--pull"] },
{ args: ["up", "-d", "--force-recreate", "--remove-orphans"] },
],
};
case "status":
return devComposeHostPlan("ps");
case "logs":
return plan.target === undefined ? devComposeHostPlan("logs", "-f") : devComposeHostPlan("logs", "-f", plan.target);
case "shell":
return devComposeHostPlan("exec", plan.target ?? "web", "bash");
case "cli":
return { kind: "compose", args: ["exec", "web", "pi-web", ...plan.args], usesGeneratedEnv: true };
case "doctor":
return { kind: "diagnostics", usesGeneratedEnv: true };
case "help":
return { kind: "usage" };
}
}
export function validatePiWebDockerDevRootSafety(plan: PiWebDockerCommandPlan, uid: number): string | undefined {
if (plan.mode !== "dev" || plan.allowRoot || plan.command === "help" || uid !== 0) return undefined;
return "refusing to run Docker development mode as root; retry with --allow-root if this is intentional";
}
export function parsePiWebDockerArgs(argv: readonly string[]): PiWebDockerParseResult {
let mode: PiWebDockerMode = "runtime";
let allowRoot = false;
let index = 0;
for (; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === undefined) break;
if (arg === "--") {
index += 1;
break;
}
if (arg === "--dev") {
mode = "dev";
continue;
}
if (arg === "--allow-root") {
allowRoot = true;
continue;
}
if (arg === "--help" || arg === "-h") {
return { ok: true, plan: { mode, command: "help", allowRoot, args: [] } };
}
if (arg.startsWith("-")) {
return { ok: false, errors: [`Unknown global option: ${arg}`] };
}
break;
}
const commandValue = argv[index];
if (commandValue === undefined) return { ok: false, errors: ["Missing command"] };
if (!isPiWebDockerCommand(commandValue)) return { ok: false, errors: [`Unknown command: ${commandValue}`] };
const args = argv.slice(index + 1);
const plan: PiWebDockerCommandPlan = { mode, command: commandValue, allowRoot, args };
return validatePlan(withDefaultTarget(plan));
}
function composeHostPlan(...args: string[]): PiWebDockerRuntimeHostPlan {
return { kind: "compose", args };
}
function devComposeHostPlan(...args: string[]): PiWebDockerDevHostPlan {
return { kind: "compose", args, usesGeneratedEnv: true };
}
function withDefaultTarget(plan: PiWebDockerCommandPlan): PiWebDockerCommandPlan {
if (plan.command === "shell" && plan.target === undefined && plan.args.length === 0) return { ...plan, target: "web" };
return plan;
}
function validatePlan(plan: PiWebDockerCommandPlan): PiWebDockerParseResult {
const errors: string[] = [];
if (plan.command === "install" && plan.mode === "dev") {
errors.push("install is only available in runtime mode");
}
if (noArgumentCommands.has(plan.command) && plan.args.length > 0) {
errors.push(`${plan.command} does not accept positional arguments`);
}
if (plan.command === "logs") {
validateOptionalTarget(plan.args, isLogsTarget, "logs", errors);
if (plan.args[0] === "data-init" && plan.mode !== "dev") errors.push("logs data-init is only available in development mode");
}
if (plan.command === "shell") {
validateOptionalTarget(plan.args, isShellTarget, "shell", errors);
}
if (plan.command === "cli" && plan.args.length === 0) {
errors.push("cli requires pi-web arguments");
}
if (errors.length > 0) return { ok: false, errors };
const target = targetFrom(plan.command, plan.args);
return { ok: true, plan: target === undefined ? plan : { ...plan, target } };
}
function validateOptionalTarget(args: readonly string[], isAllowed: (value: string) => boolean, command: string, errors: string[]): void {
if (args.length > 1) {
errors.push(`${command} accepts at most one target`);
return;
}
const [target] = args;
if (target !== undefined && !isAllowed(target)) errors.push(`Invalid ${command} target: ${target}`);
}
function targetFrom(command: PiWebDockerCommand, args: readonly string[]): PiWebDockerCommandPlan["target"] | undefined {
const [target] = args;
if (target === undefined) return command === "shell" ? "web" : undefined;
if (command === "logs" && isLogsTarget(target)) return target;
if (command === "shell" && isShellTarget(target)) return target;
return undefined;
}
function isPiWebDockerCommand(value: string): value is PiWebDockerCommand {
return commands.has(value);
}
function isLogsTarget(value: string): value is PiWebDockerLogsTarget {
return logsTargets.has(value);
}
function isShellTarget(value: string): value is PiWebDockerShellTarget {
return shellTargets.has(value);
}
+44
View File
@@ -0,0 +1,44 @@
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { PI_WEB_DOCKER_USER_COMMANDS } from "./piWebDockerCommandPlan.js";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const dockerOneLine = "curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh";
describe("pi-web-docker documentation", () => {
it("documents the Docker one-line install in the Docker guide, root README, and install page", async () => {
const [dockerReadme, rootReadme, installPage] = await Promise.all([
readRepoFile("docker/README.md"),
readRepoFile("README.md"),
readRepoFile("docs/install.html"),
]);
expect(dockerReadme).toContain(dockerOneLine);
expect(dockerReadme).toContain("does not require Node.js or npm on the host");
expect(rootReadme).toContain(dockerOneLine);
expect(installPage).toContain(dockerOneLine);
});
it("keeps the Docker command matrix aligned with the canonical user command surface", async () => {
const [dockerReadme, dockerEntrypoint] = await Promise.all([
readRepoFile("docker/README.md"),
readRepoFile("docker/pi-web-docker"),
]);
for (const command of PI_WEB_DOCKER_USER_COMMANDS) {
expect(dockerReadme).toContain(`| \`${command}\` |`);
expect(dockerEntrypoint).toContain(command);
}
expect(dockerReadme).toContain("`pi-web-docker --dev status`");
expect(dockerReadme).toContain("`./docker/pi-web-docker --dev start`");
expect(dockerReadme).not.toContain("pi-web-docker-control");
expect(dockerReadme).not.toContain("docker/scripts/docker-compose-dev");
});
});
async function readRepoFile(relativePath: string): Promise<string> {
return await readFile(join(repoRoot, relativePath), "utf8");
}