Archived
feat: unify Docker entrypoint
This commit is contained in:
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, p
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
||||
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("parses PI WEB config responses", () => {
|
||||
@@ -31,6 +31,33 @@ describe("API parsers", () => {
|
||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
});
|
||||
|
||||
it("parses Docker PI WEB installation metadata", () => {
|
||||
const response = {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", dockerMode: "dev" } },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { restart: "pi-web-docker restart", status: "pi-web-docker status" },
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const parsed = parsePiWebStatusResponse(response);
|
||||
|
||||
expect(parsed.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
|
||||
expect(parsed.components.sessiond.installation).toEqual({ kind: "docker", dockerMode: "dev" });
|
||||
expect(parsed.commands).toEqual({ restart: "pi-web-docker restart", status: "pi-web-docker status" });
|
||||
expect(() => parsePiWebStatusResponse({
|
||||
...response,
|
||||
components: {
|
||||
...response.components,
|
||||
web: { ...response.components.web, installation: { kind: "docker", dockerMode: "hidden" } },
|
||||
},
|
||||
})).toThrow("Invalid PI WEB Docker mode");
|
||||
});
|
||||
|
||||
it("parses PI WEB plugin status responses", () => {
|
||||
expect(parsePiWebPluginsResponse({
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||
|
||||
@@ -645,15 +645,18 @@ function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo |
|
||||
if (value === undefined) return undefined;
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "kind");
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "docker" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
|
||||
const scope = record["scope"];
|
||||
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid PI WEB installation scope");
|
||||
const dockerMode = record["dockerMode"];
|
||||
if (dockerMode !== undefined && dockerMode !== "runtime" && dockerMode !== "dev") throw new Error("Invalid PI WEB Docker mode");
|
||||
return {
|
||||
kind,
|
||||
...optionalField("path", optionalString(record, "path")),
|
||||
...optionalField("source", optionalString(record, "source")),
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
...optionalField("npmRoot", optionalString(record, "npmRoot")),
|
||||
...(dockerMode === undefined ? {} : { dockerMode }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -223,6 +223,10 @@ function installationLabel(installation: PiWebInstallationInfo | undefined): str
|
||||
return `global npm package${npmRoot}${path}`;
|
||||
}
|
||||
if (installation.kind === "local") return installation.path === undefined ? "local checkout" : `local checkout · ${installation.path}`;
|
||||
if (installation.kind === "docker") {
|
||||
const mode = installation.dockerMode === "dev" ? "Docker development runtime" : "Docker runtime";
|
||||
return installation.path === undefined ? mode : `${mode} · ${installation.path}`;
|
||||
}
|
||||
return installation.path === undefined ? "installation unknown" : `installation unknown · ${installation.path}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
FileTreeResponse,
|
||||
MachineKind,
|
||||
PiWebComponentStatus,
|
||||
PiWebDockerMode,
|
||||
PiWebInstallationInfo,
|
||||
PiWebInstallationKind,
|
||||
PiWebReleaseStatus,
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const dockerEntrypoint = join(repoRoot, "docker", "pi-web-docker");
|
||||
|
||||
let tempDir = "";
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
interface FakeDocker {
|
||||
binDir: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Docker command assets", () => {
|
||||
it("keeps shell entrypoints syntactically valid", async () => {
|
||||
await Promise.all([
|
||||
execUtf8("sh", ["-n", dockerEntrypoint], process.env),
|
||||
execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env),
|
||||
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env),
|
||||
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env),
|
||||
]);
|
||||
});
|
||||
|
||||
it("packages the canonical Docker command and internal support assets", async () => {
|
||||
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dockerignore] = await Promise.all([
|
||||
readRepoFile("docker/Dockerfile"),
|
||||
readRepoFile("docker/Dockerfile.dev"),
|
||||
readRepoFile("docker/compose.yml"),
|
||||
readRepoFile("docker/compose.dev.yml"),
|
||||
readRepoFile("docker/install.sh"),
|
||||
readRepoFile("docker/internal/dev/compose"),
|
||||
readRepoFile("docker/.dockerignore"),
|
||||
]);
|
||||
|
||||
expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker");
|
||||
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
|
||||
expect(dockerfile).toContain("COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base");
|
||||
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker");
|
||||
expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec");
|
||||
expect(dockerignore).toContain("!pi-web-docker");
|
||||
expect(dockerignore).toContain("!internal/bin/hostexec");
|
||||
expect(installer).toContain("write_asset pi-web-docker 0755");
|
||||
expect(installer).toContain("write_asset internal/host-profile.sh 0644");
|
||||
expect(installer).toContain("compose_cmd --project-name \"$compose_project_name\"");
|
||||
expect(installer).toContain("PI_WEB_DOCKER_INSTALL_DIR=$install_dir");
|
||||
expect(installer).toContain("PI_WEB_DOCKER_REF=$asset_ref");
|
||||
expect(installer).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
|
||||
expect(devWrapper).toContain("$repo_root/docker/internal/host-profile.sh");
|
||||
expect(devWrapper).toContain("--project-name \"$compose_project_name\"");
|
||||
expect(devWrapper).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT=$repo_root");
|
||||
expect(devWrapper).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_RUNTIME: \"1\"");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_MODE: runtime");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_INSTALL_DIR: ${PI_WEB_DOCKER_INSTALL_DIR:?set by docker/install.sh}");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_IMAGE:-pi-web:local}");
|
||||
expect(runtimeCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web}");
|
||||
expect(devCompose).toContain("PI_WEB_DOCKER_MODE: dev");
|
||||
expect(devCompose).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}");
|
||||
expect(devCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}");
|
||||
expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}");
|
||||
});
|
||||
|
||||
it("runs status through Docker Compose in the foreground", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
const result = await runDockerCommand(["status"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
expect(result.stdout).toContain("fake docker compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose version");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
|
||||
it("runs production host lifecycle commands through the generated runtime env", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
const env = runtimeHostEnv(fakeDocker, installDir);
|
||||
|
||||
await runDockerCommand(["start"], env);
|
||||
await runDockerCommand(["stop"], env);
|
||||
await runDockerCommand(["restart-sessiond"], env);
|
||||
await runDockerCommand(["logs", "web"], env);
|
||||
await runDockerCommand(["shell", "sessiond"], env);
|
||||
await runDockerCommand(["cli", "config", "show"], env);
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml down");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml logs -f web");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec sessiond bash");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec web pi-web config show");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
|
||||
it("ignores ambient Compose project names for runtime lifecycle commands", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
await runDockerCommand(["status"], {
|
||||
...runtimeHostEnv(fakeDocker, installDir),
|
||||
COMPOSE_PROJECT_NAME: "ambient-project",
|
||||
});
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
|
||||
expect(log).not.toContain("--project-name ambient-project");
|
||||
});
|
||||
|
||||
it("runs development commands through generated env while preserving host user ids", async () => {
|
||||
const devRoot = await createDevRepoFixture();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeUname(fakeDocker.binDir, "Darwin");
|
||||
await installFakeId(fakeDocker.binDir, 1234, 2345);
|
||||
const home = join(tempDir, "home");
|
||||
const runtimeDataDir = join(tempDir, "runtime-data");
|
||||
const runtimeEnvFile = join(tempDir, "runtime.env");
|
||||
const socketPath = join(home, ".docker", "run", "docker.sock");
|
||||
await writeFile(runtimeEnvFile, [
|
||||
"PI_WEB_UID=0",
|
||||
"PI_WEB_GID=0",
|
||||
`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}`,
|
||||
"PI_WEB_BIND_ADDR=0.0.0.0",
|
||||
"COMPOSE_PROJECT_NAME=runtime-project",
|
||||
"",
|
||||
].join("\n"));
|
||||
|
||||
await withUnixSocket(socketPath, async () => {
|
||||
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, { PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile }));
|
||||
});
|
||||
|
||||
const generatedEnvPath = join(devRoot, ".pi-web", "docker-compose-dev.generated.env");
|
||||
const generatedEnv = await readFile(generatedEnvPath, "utf8");
|
||||
expect(generatedEnv).toContain("PI_WEB_UID=1234\n");
|
||||
expect(generatedEnv).toContain("PI_WEB_GID=2345\n");
|
||||
expect(generatedEnv).toContain("DOCKER_GID=0\n");
|
||||
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
|
||||
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}\n`);
|
||||
expect(generatedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
|
||||
expect(generatedEnv).toContain("PI_WEB_DEV_API_BIND_ADDR=0.0.0.0\n");
|
||||
expect(generatedEnv).not.toContain("COMPOSE_PROJECT_NAME=runtime-project");
|
||||
|
||||
await withUnixSocket(socketPath, async () => {
|
||||
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, {
|
||||
COMPOSE_PROJECT_NAME: "ambient-dev-project",
|
||||
PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile,
|
||||
}));
|
||||
});
|
||||
const regeneratedEnv = await readFile(generatedEnvPath, "utf8");
|
||||
expect(regeneratedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
|
||||
expect(regeneratedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
|
||||
expect(regeneratedEnv).not.toContain("COMPOSE_PROJECT_NAME=ambient-dev-project");
|
||||
|
||||
const localConfig = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.local.env"), "utf8");
|
||||
expect(localConfig).toContain("docker/pi-web-docker --dev creates this file once");
|
||||
expect(localConfig).toContain("PI_WEB_UID and PI_WEB_GID default to the current host user");
|
||||
const override = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.host.generated.yml"), "utf8");
|
||||
expect(override).toContain(socketPath);
|
||||
expect(override).toContain(devRoot);
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain(`compose --project-name pi-web-dev --env-file ${generatedEnvPath} -f ${devRoot}/docker/compose.dev.yml -f ${devRoot}/.pi-web/docker-compose-dev.host.generated.yml ps`);
|
||||
});
|
||||
|
||||
it("rejects development commands as root unless explicitly allowed", async () => {
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeId(fakeDocker.binDir, 0, 0);
|
||||
|
||||
const result = await runDockerCommandAllowFailure(["--dev", "status"], {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
});
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("refusing to run Docker development mode as root");
|
||||
});
|
||||
|
||||
it("passes the root override through to the development compose helper", async () => {
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeId(fakeDocker.binDir, 0, 0);
|
||||
const helperLog = join(tempDir, "dev-helper.log");
|
||||
const devRoot = await createDevRepoFixtureWithFakeHelper(helperLog);
|
||||
|
||||
await runDockerCommand(["--dev", "--allow-root", "status"], {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
|
||||
});
|
||||
|
||||
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
|
||||
});
|
||||
|
||||
it("starts development detached helpers as the generated dev user", async () => {
|
||||
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeId(fakeDocker.binDir, 1234, 2345);
|
||||
|
||||
await runDockerCommand(["--dev", "restart-sessiond"], devRuntimeEnv(fakeDocker, devRoot));
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain(`--env-file ${devRoot}/.pi-web/docker-compose-dev.generated.env`);
|
||||
expect(log).toContain("--group-add 3456");
|
||||
expect(log).toContain("--user 1234:2345");
|
||||
expect(log).toContain("PI_WEB_DOCKER_MODE=dev");
|
||||
expect(log).toContain("PI_WEB_DOCKER_ALLOW_ROOT=0");
|
||||
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
|
||||
expect(log).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`);
|
||||
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-dev-test");
|
||||
expect(log).toContain("pi-web.docker-helper.mode=dev");
|
||||
expect(log).toContain("pi-web:test pi-web-docker --dev __run-detached restart-sessiond");
|
||||
expect(log).not.toContain("--user 0:0");
|
||||
});
|
||||
|
||||
it("rejects inside-container commands whose explicit mode does not match the container mode", async () => {
|
||||
const result = await runDockerCommandAllowFailure(["restart-sessiond"], {
|
||||
...cleanProcessEnv(),
|
||||
PI_WEB_DOCKER_RUNTIME: "1",
|
||||
PI_WEB_DOCKER_MODE: "dev",
|
||||
});
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("this PI WEB Docker container is in dev mode");
|
||||
});
|
||||
|
||||
it("routes production install to the bootstrap installer", async () => {
|
||||
const result = await runDockerCommand(["install", "--help"], cleanProcessEnv());
|
||||
|
||||
expect(result.stdout).toContain("Usage: docker/install.sh [options]");
|
||||
});
|
||||
|
||||
it("starts restart-sessiond in a detached Docker helper", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
const result = await runDockerCommand(["restart-sessiond"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
expect(result.stdout).toContain("Started detached PI WEB Docker helper");
|
||||
expect(result.stdout).toContain("Follow progress with: docker logs -f pi-web-docker-restart-sessiond-");
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("container inspect");
|
||||
expect(log).toContain("run -d");
|
||||
expect(log).toContain("--env-file");
|
||||
expect(log).toContain(`${installDir}/.env`);
|
||||
expect(log).toContain("--volumes-from");
|
||||
expect(log).toContain("--group-add 3456");
|
||||
expect(log).toContain("--user 1234:2345");
|
||||
expect(log).toContain(`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`);
|
||||
expect(log).toContain(`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`);
|
||||
expect(log).toContain("PI_WEB_PORT=12345");
|
||||
expect(log).toContain("PI_WEB_DOCKER_EXTRA_HOST_PATHS=/srv/pi-web-extra /opt/pi-web-extra");
|
||||
expect(log).toContain("PI_WEB_EXTRA_ZYPPER_PACKAGES=git-lfs jq");
|
||||
expect(log).not.toContain('PI_WEB_EXTRA_ZYPPER_PACKAGES="git-lfs jq"');
|
||||
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
|
||||
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-test");
|
||||
expect(log).toContain("pi-web.docker-helper.mode=runtime");
|
||||
expect(log).toContain("pi-web.docker-helper.root=");
|
||||
expect(log).toContain("pi-web.docker-helper.project=pi-web-test");
|
||||
expect(log).toContain("pi-web:test pi-web-docker __run-detached restart-sessiond");
|
||||
expect(log).not.toContain("--user 0:0");
|
||||
expect(log).not.toContain("compose -f compose.yml -f compose.override.yml restart sessiond");
|
||||
});
|
||||
|
||||
it("executes the detached restart-sessiond action through Compose", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
await runDockerCommand(["__run-detached", "restart-sessiond"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
|
||||
it("executes the detached runtime update action through Compose without nesting helpers", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
await runDockerCommand(["__run-detached", "update"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml build --pull --no-cache");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d --force-recreate --remove-orphans");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
});
|
||||
|
||||
async function readRepoFile(relativePath: string): Promise<string> {
|
||||
return await readFile(join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function runDockerCommand(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
|
||||
return execUtf8("sh", [dockerEntrypoint, ...args], env);
|
||||
}
|
||||
|
||||
function runDockerCommandAllowFailure(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
|
||||
return execUtf8AllowFailure("sh", [dockerEntrypoint, ...args], env);
|
||||
}
|
||||
|
||||
function execUtf8(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
reject(error instanceof Error ? error : new Error("Process failed"));
|
||||
return;
|
||||
}
|
||||
resolvePromise({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function execUtf8AllowFailure(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
|
||||
return new Promise((resolvePromise) => {
|
||||
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
|
||||
const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0;
|
||||
resolvePromise({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createRuntimeInstall(): Promise<string> {
|
||||
const installDir = join(tempDir, "runtime");
|
||||
await mkdir(installDir, { recursive: true });
|
||||
await writeFile(join(installDir, ".env"), [
|
||||
"PI_WEB_UID=1234",
|
||||
"PI_WEB_GID=2345",
|
||||
"DOCKER_GID=3456",
|
||||
`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`,
|
||||
`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`,
|
||||
"PI_WEB_DOCKER_EXTRA_HOST_PATHS=\"/srv/pi-web-extra /opt/pi-web-extra\"",
|
||||
"PI_WEB_BIND_ADDR=127.0.0.1",
|
||||
"PI_WEB_PORT=12345",
|
||||
"PI_WEB_EXTRA_ZYPPER_PACKAGES=\"git-lfs jq\"",
|
||||
"PI_WEB_IMAGE=pi-web:test",
|
||||
"COMPOSE_PROJECT_NAME=pi-web-test",
|
||||
"",
|
||||
].join("\n"), "utf8");
|
||||
await writeFile(join(installDir, "compose.yml"), "name: pi-web\nservices: {}\n", "utf8");
|
||||
await writeFile(join(installDir, "compose.override.yml"), "services: {}\n", "utf8");
|
||||
return installDir;
|
||||
}
|
||||
|
||||
async function createDevRepoFixture(): Promise<string> {
|
||||
const devRoot = join(tempDir, "dev-repo");
|
||||
await mkdir(join(devRoot, "docker", "internal", "dev"), { recursive: true });
|
||||
await copyFile(join(repoRoot, "docker", "internal", "dev", "compose"), join(devRoot, "docker", "internal", "dev", "compose"));
|
||||
await chmod(join(devRoot, "docker", "internal", "dev", "compose"), 0o755);
|
||||
await copyFile(join(repoRoot, "docker", "internal", "host-profile.sh"), join(devRoot, "docker", "internal", "host-profile.sh"));
|
||||
await writeFile(join(devRoot, "docker", "compose.dev.yml"), "name: pi-web-dev\nservices: {}\n", "utf8");
|
||||
return devRoot;
|
||||
}
|
||||
|
||||
async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<string> {
|
||||
const devRoot = join(tempDir, "dev-repo-fake-helper");
|
||||
const helperPath = join(devRoot, "docker", "internal", "dev", "compose");
|
||||
await mkdir(dirname(helperPath), { recursive: true });
|
||||
await writeFile(helperPath, `#!/usr/bin/env sh
|
||||
set -eu
|
||||
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >${shellSingleQuote(logPath)}
|
||||
`, "utf8");
|
||||
await chmod(helperPath, 0o755);
|
||||
return devRoot;
|
||||
}
|
||||
|
||||
async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise<string> {
|
||||
const devRoot = join(tempDir, "dev-runtime");
|
||||
await mkdir(join(devRoot, ".pi-web"), { recursive: true });
|
||||
await writeFile(join(devRoot, ".pi-web", "docker-compose-dev.generated.env"), [
|
||||
`PI_WEB_UID=${String(ids.uid)}`,
|
||||
`PI_WEB_GID=${String(ids.gid)}`,
|
||||
`DOCKER_GID=${String(ids.dockerGid)}`,
|
||||
`PI_WEB_DOCKER_DATA_DIR=${join(tempDir, "dev-data")}`,
|
||||
`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`,
|
||||
"PI_WEB_DEV_API_BIND_ADDR=127.0.0.1",
|
||||
"PI_WEB_DEV_BIND_ADDR=127.0.0.1",
|
||||
"PI_WEB_DEV_API_PORT=8504",
|
||||
"PI_WEB_DEV_PORT=8505",
|
||||
"PI_WEB_DEV_IMAGE=pi-web:test",
|
||||
"COMPOSE_PROJECT_NAME=pi-web-dev-test",
|
||||
"",
|
||||
].join("\n"), "utf8");
|
||||
return devRoot;
|
||||
}
|
||||
|
||||
async function installFakeDocker(): Promise<FakeDocker> {
|
||||
const binDir = join(tempDir, "bin");
|
||||
const logPath = join(tempDir, "docker.log");
|
||||
const dockerPath = join(binDir, "docker");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeFile(dockerPath, `#!/usr/bin/env sh
|
||||
set -eu
|
||||
: "\${FAKE_DOCKER_LOG:?}"
|
||||
printf '%s\n' "$*" >>"$FAKE_DOCKER_LOG"
|
||||
case "\${1:-}" in
|
||||
--version)
|
||||
printf 'Docker version 99.0.0, fake\n'
|
||||
exit 0
|
||||
;;
|
||||
context)
|
||||
case "\${2:-}" in
|
||||
show)
|
||||
printf 'default\n'
|
||||
exit 0
|
||||
;;
|
||||
inspect)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
info)
|
||||
if [ "\${2:-}" = --format ]; then
|
||||
printf 'Docker Desktop\n'
|
||||
else
|
||||
printf 'Fake Docker info\n'
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
compose)
|
||||
if [ "\${2:-}" = version ]; then
|
||||
exit 0
|
||||
fi
|
||||
printf 'fake docker'
|
||||
for arg in "$@"; do
|
||||
printf ' %s' "$arg"
|
||||
done
|
||||
printf '\n'
|
||||
exit 0
|
||||
;;
|
||||
container)
|
||||
if [ "\${2:-}" = inspect ]; then
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = --format ]; then
|
||||
printf 'pi-web:test\n'
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
printf '{}\n'
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
ps|rm)
|
||||
exit 0
|
||||
;;
|
||||
run)
|
||||
printf 'fake-helper-container-id\n'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
printf 'unexpected fake docker args: %s\n' "$*" >&2
|
||||
exit 9
|
||||
`, "utf8");
|
||||
await chmod(dockerPath, 0o755);
|
||||
return { binDir, logPath };
|
||||
}
|
||||
|
||||
async function installFakeUname(binDir: string, osName: string): Promise<void> {
|
||||
const unamePath = join(binDir, "uname");
|
||||
await writeFile(unamePath, `#!/usr/bin/env sh
|
||||
set -eu
|
||||
printf '%s\n' ${shellSingleQuote(osName)}
|
||||
`, "utf8");
|
||||
await chmod(unamePath, 0o755);
|
||||
}
|
||||
|
||||
async function installFakeId(binDir: string, uid: number, gid: number): Promise<void> {
|
||||
const idPath = join(binDir, "id");
|
||||
await writeFile(idPath, `#!/usr/bin/env sh
|
||||
set -eu
|
||||
case "\${1:-}" in
|
||||
-u) printf '%s\n' ${String(uid)} ;;
|
||||
-g) printf '%s\n' ${String(gid)} ;;
|
||||
*) printf '%s\n' ${String(uid)} ;;
|
||||
esac
|
||||
`, "utf8");
|
||||
await chmod(idPath, 0o755);
|
||||
}
|
||||
|
||||
async function withUnixSocket<T>(socketPath: string, callback: () => Promise<T>): Promise<T> {
|
||||
await mkdir(dirname(socketPath), { recursive: true });
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, resolvePromise);
|
||||
});
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
await new Promise<void>((resolvePromise) => {
|
||||
server.close(() => {
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
await rm(socketPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cleanProcessEnv(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key.startsWith("PI_WEB_")) {
|
||||
Reflect.deleteProperty(env, key);
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function runtimeEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...runtimeHostEnv(fakeDocker, installDir),
|
||||
PI_WEB_DOCKER_RUNTIME: "1",
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeHostEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
FAKE_DOCKER_LOG: fakeDocker.logPath,
|
||||
PI_WEB_DOCKER_RUNTIME: "0",
|
||||
PI_WEB_DOCKER_MODE: "runtime",
|
||||
PI_WEB_DOCKER_INSTALL_DIR: installDir,
|
||||
};
|
||||
}
|
||||
|
||||
function devHostEnv(fakeDocker: FakeDocker, devRoot: string, home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...cleanProcessEnv(),
|
||||
...extra,
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
HOME: home,
|
||||
DOCKER_HOST: "",
|
||||
FAKE_DOCKER_LOG: fakeDocker.logPath,
|
||||
PI_WEB_DOCKER_RUNTIME: "0",
|
||||
PI_WEB_DOCKER_MODE: "dev",
|
||||
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function devRuntimeEnv(fakeDocker: FakeDocker, devRoot: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
FAKE_DOCKER_LOG: fakeDocker.logPath,
|
||||
PI_WEB_DOCKER_RUNTIME: "1",
|
||||
PI_WEB_DOCKER_MODE: "dev",
|
||||
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
|
||||
PI_WEB_DOCKER_CONTAINER_ID: "current-container",
|
||||
};
|
||||
}
|
||||
|
||||
function shellSingleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -8,10 +8,20 @@ import type { PiWebComponentStatus } from "../shared/apiTypes.js";
|
||||
|
||||
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||
const originalHome = process.env["HOME"];
|
||||
const originalPath = process.env["PATH"];
|
||||
const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
|
||||
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
|
||||
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
|
||||
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
|
||||
restoreEnv("HOME", originalHome);
|
||||
restoreEnv("PATH", originalPath);
|
||||
restoreEnv("PI_WEB_DOCKER_RUNTIME", originalDockerRuntime);
|
||||
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
|
||||
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
|
||||
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -42,6 +52,7 @@ describe("PI WEB status", () => {
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
@@ -63,9 +74,13 @@ describe("PI WEB status", () => {
|
||||
it("suggests native systemd commands for local development services", async () => {
|
||||
if (process.platform !== "linux") return;
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
const home = await tempHome();
|
||||
const binDir = await tempHome();
|
||||
try {
|
||||
process.env["HOME"] = home;
|
||||
await installExecutable(binDir, "systemctl");
|
||||
process.env["PATH"] = `${binDir}:${process.env["PATH"] ?? ""}`;
|
||||
await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]);
|
||||
const daemon = daemonWithComponent(staleLocalSessiond());
|
||||
|
||||
@@ -76,12 +91,72 @@ describe("PI WEB status", () => {
|
||||
expect(status.commands.restartSessiond).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
|
||||
expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
await Promise.all([
|
||||
rm(home, { recursive: true, force: true }),
|
||||
rm(binDir, { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("suggests Docker commands when running inside the Docker runtime", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
|
||||
process.env["PI_WEB_DOCKER_INSTALL_DIR"] = "/srv/pi-web-docker";
|
||||
process.env["PATH"] = "";
|
||||
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } });
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
|
||||
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
|
||||
expect(status.commands).toEqual({
|
||||
update: "pi-web-docker update",
|
||||
restart: "pi-web-docker restart",
|
||||
restartWeb: "pi-web-docker restart-web",
|
||||
restartSessiond: "pi-web-docker restart-sessiond",
|
||||
status: "pi-web-docker status",
|
||||
});
|
||||
expect(JSON.stringify(status)).not.toContain("npm install -g");
|
||||
expect(JSON.stringify(status)).not.toContain("pi-web restart");
|
||||
});
|
||||
|
||||
it("suggests explicit Docker development commands when running inside the Docker dev runtime", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
process.env["PI_WEB_DOCKER_MODE"] = "dev";
|
||||
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
|
||||
process.env["PATH"] = "";
|
||||
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" } });
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
|
||||
expect(status.commands).toEqual({
|
||||
update: "pi-web-docker --dev update",
|
||||
restart: "pi-web-docker --dev restart",
|
||||
restartWeb: "pi-web-docker --dev restart-web",
|
||||
restartSessiond: "pi-web-docker --dev restart-sessiond",
|
||||
status: "pi-web-docker --dev status",
|
||||
});
|
||||
});
|
||||
|
||||
it("infers explicit Docker development commands from the generated dev root when mode is omitted", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
|
||||
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
|
||||
process.env["PATH"] = "";
|
||||
const daemon = daemonWithComponent(staleLocalSessiond());
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
|
||||
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" });
|
||||
expect(status.commands.update).toBe("pi-web-docker --dev update");
|
||||
expect(status.commands.status).toBe("pi-web-docker --dev status");
|
||||
});
|
||||
|
||||
it("omits local restart commands when no native service command is known", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
const home = await tempHome();
|
||||
try {
|
||||
process.env["HOME"] = home;
|
||||
@@ -131,6 +206,19 @@ async function installSystemdServiceFiles(home: string, names: string[]): Promis
|
||||
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
|
||||
}
|
||||
|
||||
async function installExecutable(dir: string, name: string): Promise<void> {
|
||||
const path = join(dir, name);
|
||||
await writeFile(path, "#!/usr/bin/env sh\nexit 0\n");
|
||||
await chmod(path, 0o755);
|
||||
}
|
||||
|
||||
function disableDockerRuntimeEnv(): void {
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "0";
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_INSTALL_DIR");
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_DEV_REPO_ROOT");
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key);
|
||||
else process.env[key] = value;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
|
||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
|
||||
@@ -188,6 +189,8 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
|
||||
}
|
||||
|
||||
async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
|
||||
const docker = detectDockerInstallation();
|
||||
if (docker !== undefined) return docker;
|
||||
const root = packageRootPath();
|
||||
const realRoot = await realPathOrSelf(root);
|
||||
const piPackage = await detectPiPackageInstallation(realRoot, root);
|
||||
@@ -197,6 +200,46 @@ async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
|
||||
return { kind: "local", path: root };
|
||||
}
|
||||
|
||||
function detectDockerInstallation(): PiWebInstallationInfo | undefined {
|
||||
if (!isTruthyEnv("PI_WEB_DOCKER_RUNTIME")) return undefined;
|
||||
const dockerMode = dockerModeFromEnv(process.env["PI_WEB_DOCKER_MODE"]) ?? inferredDockerModeFromRoots();
|
||||
const path = dockerRootPathFromEnv(dockerMode);
|
||||
return {
|
||||
kind: "docker",
|
||||
...(path === undefined ? {} : { path }),
|
||||
...(dockerMode === undefined ? {} : { dockerMode }),
|
||||
};
|
||||
}
|
||||
|
||||
function dockerModeFromEnv(value: string | undefined): PiWebInstallationInfo["dockerMode"] | undefined {
|
||||
return value === "runtime" || value === "dev" ? value : undefined;
|
||||
}
|
||||
|
||||
function inferredDockerModeFromRoots(): PiWebInstallationInfo["dockerMode"] | undefined {
|
||||
if (firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT") !== undefined) return "dev";
|
||||
if (firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR") !== undefined) return "runtime";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function dockerRootPathFromEnv(mode: PiWebInstallationInfo["dockerMode"] | undefined): string | undefined {
|
||||
if (mode === "dev") return firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", "PI_WEB_DOCKER_INSTALL_DIR");
|
||||
if (mode === "runtime") return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
|
||||
return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
|
||||
}
|
||||
|
||||
function firstNonEmptyEnv(...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined && value !== "") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTruthyEnv(key: string): boolean {
|
||||
const value = process.env[key];
|
||||
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
||||
}
|
||||
|
||||
async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
|
||||
try {
|
||||
const agentDir = getAgentDir();
|
||||
@@ -377,6 +420,8 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
|
||||
|
||||
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
|
||||
const installation = preferredInstallation(components);
|
||||
if (installation?.kind === "docker") return dockerCommands(installation);
|
||||
|
||||
const [serviceCommands, cliCommands] = await Promise.all([
|
||||
nativeServiceCommands(),
|
||||
piWebCliCommands(installation),
|
||||
@@ -399,10 +444,21 @@ async function commandsFor(components: PiWebStatusResponse["components"]): Promi
|
||||
function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined {
|
||||
const web = components.web.installation;
|
||||
const sessiond = components.sessiond.installation;
|
||||
if (web?.kind === "docker" || sessiond?.kind === "docker") return web?.kind === "docker" ? web : sessiond;
|
||||
if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond;
|
||||
return web ?? sessiond;
|
||||
}
|
||||
|
||||
function dockerCommands(installation: PiWebInstallationInfo): PiWebStatusResponse["commands"] {
|
||||
return {
|
||||
update: piWebDockerCommand(installation.dockerMode, "update"),
|
||||
restart: piWebDockerCommand(installation.dockerMode, "restart"),
|
||||
restartWeb: piWebDockerCommand(installation.dockerMode, "restart-web"),
|
||||
restartSessiond: piWebDockerCommand(installation.dockerMode, "restart-sessiond"),
|
||||
status: piWebDockerCommand(installation.dockerMode, "status"),
|
||||
};
|
||||
}
|
||||
|
||||
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
|
||||
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
|
||||
return { restart: "pi-web restart", status: "pi-web status" };
|
||||
|
||||
@@ -434,7 +434,8 @@ export interface TerminalCommandRunFilter {
|
||||
|
||||
export type PiWebServiceComponent = "web" | "sessiond";
|
||||
export type PiWebStatusSeverity = "info" | "warning" | "error";
|
||||
export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "unknown";
|
||||
export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "docker" | "unknown";
|
||||
export type PiWebDockerMode = "runtime" | "dev";
|
||||
|
||||
export interface PiWebInstallationInfo {
|
||||
kind: PiWebInstallationKind;
|
||||
@@ -442,6 +443,7 @@ export interface PiWebInstallationInfo {
|
||||
source?: string;
|
||||
scope?: "user" | "project";
|
||||
npmRoot?: string;
|
||||
dockerMode?: PiWebDockerMode;
|
||||
}
|
||||
|
||||
export interface PiWebComponentStatus {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebVersionResponse } from "./piWebStatusParsing.js";
|
||||
|
||||
describe("PI WEB shared status parsing", () => {
|
||||
it("parses Docker installation metadata", () => {
|
||||
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
|
||||
kind: "docker",
|
||||
path: "/srv/pi-web-docker",
|
||||
dockerMode: "runtime",
|
||||
});
|
||||
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" })).toEqual({
|
||||
kind: "docker",
|
||||
path: "/workspace/pi-web",
|
||||
dockerMode: "dev",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores invalid optional Docker modes without rejecting component status", () => {
|
||||
expect(parsePiWebComponentStatus({
|
||||
component: "web",
|
||||
label: "Web/UI",
|
||||
runtimeVersion: "1.0.0",
|
||||
stale: false,
|
||||
available: true,
|
||||
installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "hidden" },
|
||||
})?.installation).toEqual({ kind: "docker", path: "/workspace/pi-web" });
|
||||
});
|
||||
|
||||
it("parses version responses that include Docker runtime and development components", () => {
|
||||
const parsed = parsePiWebVersionResponse({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true, installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true, installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed?.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
|
||||
expect(parsed?.components.sessiond.installation).toEqual({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" });
|
||||
});
|
||||
});
|
||||
@@ -69,13 +69,15 @@ export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInf
|
||||
const source = value["source"];
|
||||
const scope = value["scope"];
|
||||
const npmRoot = value["npmRoot"];
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
|
||||
const dockerMode = value["dockerMode"];
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "docker" && kind !== "unknown") return undefined;
|
||||
return {
|
||||
kind,
|
||||
...(typeof path === "string" ? { path } : {}),
|
||||
...(typeof source === "string" ? { source } : {}),
|
||||
...(scope === "user" || scope === "project" ? { scope } : {}),
|
||||
...(typeof npmRoot === "string" ? { npmRoot } : {}),
|
||||
...(dockerMode === "runtime" || dockerMode === "dev" ? { dockerMode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user