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"; import { sanitizedGitEnv } from "./git/gitEnv.js"; 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", () => { // The Docker control shell scripts intentionally support Linux and macOS hosts. // Keep Windows CI on static/syntax coverage instead of executing POSIX host-path and socket flows there. const dockerCommandIt = it.skipIf(process.platform === "win32"); 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("bash", ["-n", join(repoRoot, "docker", "internal", "dev", "sync-node-modules")], 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, dependencySync, 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/internal/dev/sync-node-modules"), readRepoFile("docker/.dockerignore"), ]); const customImageHooksIndex = devDockerfile.indexOf("for script in /tmp/pi-web-custom-image.d/*.sh"); const dependencyGenerationIndex = devDockerfile.indexOf("/opt/pi-web-dev-dependencies/generation"); 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(dockerfile).toContain("--include=peer"); expect(dockerfile).toContain('peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"'); expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@"); 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(devDockerfile).toContain("COPY --chmod=0755 docker/internal/dev/sync-node-modules /usr/local/sbin/pi-web-dev-sync-node-modules"); expect(devDockerfile).toContain("/opt/pi-web-dev-dependencies/node_modules"); // Hooks can mutate the dependency seed, so its cache generation must be finalized afterward. expect(customImageHooksIndex).toBeGreaterThanOrEqual(0); expect(dependencyGenerationIndex).toBeGreaterThan(customImageHooksIndex); expect(dependencySync).toContain(".pi-web-dev-dependency-generation"); 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}"); expect(devCompose).toContain("/usr/local/sbin/pi-web-dev-sync-node-modules"); expect(devCompose.match(/volumes: \*pi-web-dev-volumes/g)).toHaveLength(3); }); dockerCommandIt("fetches remote installer assets without clobbering the write target", async () => { const installDir = join(tempDir, "remote-runtime"); const fakeDocker = await installFakeDocker(); await installFakeCurl(fakeDocker.binDir); await installFakeUname(fakeDocker.binDir, "Darwin"); const home = join(tempDir, "home"); const socketPath = join(home, ".docker", "run", "docker.sock"); await withUnixSocket(socketPath, async () => { await execUtf8("sh", [ join(repoRoot, "docker", "install.sh"), "--install-dir", installDir, "--data-dir", join(installDir, "data"), "--asset-ref", "test-assets", "--skip-compose", ], { ...cleanProcessEnv(), PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`, HOME: home, FAKE_DOCKER_LOG: fakeDocker.logPath, PI_WEB_DOCKER_ASSET_BASE: "https://assets.example.test/docker", }); }); expect(await readFile(join(installDir, "Dockerfile"), "utf8")).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker"); expect(await readFile(join(installDir, "pi-web-docker"), "utf8")).toContain("Usage: pi-web-docker"); const env = await readFile(join(installDir, ".env"), "utf8"); expect(env).toContain(`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`); expect(env).toContain("PI_WEB_DOCKER_REF=test-assets"); }); dockerCommandIt("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"); }); dockerCommandIt("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"); }); dockerCommandIt("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"); }); dockerCommandIt("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`); }); dockerCommandIt("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"); }); dockerCommandIt("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"); }); dockerCommandIt("refuses development updates when the checkout has uncommitted files", async () => { const helperLog = join(tempDir, "dev-helper.log"); const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); const fakeDocker = await installFakeDocker(); await installFakeId(fakeDocker.binDir, 1234, 2345); await writeFile(join(devRoot, "staged.txt"), "changed\n", "utf8"); await execUtf8("git", ["-C", devRoot, "add", "staged.txt"], cleanProcessEnv()); await writeFile(join(devRoot, "modified.txt"), "changed\n", "utf8"); await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8"); const result = await runDockerCommandAllowFailure( ["--dev", "update"], devHostEnv(fakeDocker, devRoot, join(tempDir, "home")), ); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("refusing to update the Docker development stack because the checkout has uncommitted changes"); expect(result.stderr).toContain("staged.txt"); expect(result.stderr).toContain("modified.txt"); expect(result.stderr).toContain("?? untracked.txt"); expect(result.stderr).toContain("commit, stash, or remove these changes"); await expect(readFile(helperLog, "utf8")).rejects.toThrow(); }); dockerCommandIt("refuses dirty development updates before scheduling a detached helper", async () => { const helperLog = join(tempDir, "dev-helper.log"); const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); const fakeDocker = await installFakeDocker(); await installFakeId(fakeDocker.binDir, 1234, 2345); await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8"); const result = await runDockerCommandAllowFailure(["--dev", "update"], devRuntimeEnv(fakeDocker, devRoot)); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("checkout has uncommitted changes"); expect(result.stdout).not.toContain("Started detached PI WEB Docker helper"); await expect(readFile(fakeDocker.logPath, "utf8")).rejects.toThrow(); await expect(readFile(helperLog, "utf8")).rejects.toThrow(); }); dockerCommandIt("refuses development updates while a Git operation is in progress", async () => { const helperLog = join(tempDir, "dev-helper.log"); const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); const fakeDocker = await installFakeDocker(); await installFakeId(fakeDocker.binDir, 1234, 2345); const head = (await execUtf8("git", ["-C", devRoot, "rev-parse", "HEAD"], cleanProcessEnv())).stdout.trim(); await writeFile(join(devRoot, ".git", "MERGE_HEAD"), `${head}\n`, "utf8"); const result = await runDockerCommandAllowFailure( ["--dev", "update"], devHostEnv(fakeDocker, devRoot, join(tempDir, "home")), ); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("while a Git merge is in progress"); expect(result.stderr).toContain("resolve or abort the Git merge"); await expect(readFile(helperLog, "utf8")).rejects.toThrow(); }); dockerCommandIt("allows clean development updates and dirty development starts", async () => { const helperLog = join(tempDir, "dev-helper.log"); const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); const fakeDocker = await installFakeDocker(); await installFakeId(fakeDocker.binDir, 1234, 2345); const env = devHostEnv(fakeDocker, devRoot, join(tempDir, "home")); await runDockerCommand(["--dev", "update"], env); await writeFile(join(devRoot, "in-progress-work.txt"), "dirty by design\n", "utf8"); await runDockerCommand(["--dev", "start"], env); expect(await readFile(helperLog, "utf8")).toBe([ "allow=0 args=build --pull", "allow=0 args=up -d --force-recreate --remove-orphans", "allow=0 args=up -d --build", "", ].join("\n")); }); dockerCommandIt("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"); }); dockerCommandIt("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"); }); dockerCommandIt("routes production install to the bootstrap installer", async () => { const result = await runDockerCommand(["install", "--help"], cleanProcessEnv()); expect(result.stdout).toContain("Usage: docker/install.sh [options]"); }); dockerCommandIt("explains source checkout runtime-mode mistakes", async () => { const fakeDocker = await installFakeDocker(); const result = await runDockerCommandAllowFailure(["start"], { ...cleanProcessEnv(), PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`, HOME: "/home/pi-web-test", }); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("runtime install assets were not found"); expect(result.stderr).toContain("running this checkout's Docker command in runtime mode"); expect(result.stderr).toContain("./docker/pi-web-docker --dev start"); expect(result.stderr).toContain("/home/pi-web-test/.local/share/pi-web-docker/pi-web-docker start"); expect(result.stderr).toContain("PI_WEB_DOCKER_INSTALL_DIR"); }); dockerCommandIt("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("Streaming detached PI WEB Docker helper logs inline."); expect(result.stdout).toContain("Reconnect with: docker logs -f pi-web-docker-restart-sessiond-"); expect(result.stdout).toContain("fake helper log"); 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"); }); dockerCommandIt("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"); }); dockerCommandIt("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 { return await readFile(join(repoRoot, relativePath), "utf8"); } function runDockerCommand(args: string[], env: NodeJS.ProcessEnv): Promise { return execUtf8("sh", [dockerEntrypoint, ...args], env); } function runDockerCommandAllowFailure(args: string[], env: NodeJS.ProcessEnv): Promise { return execUtf8AllowFailure("sh", [dockerEntrypoint, ...args], env); } function execUtf8(file: string, args: string[], env: NodeJS.ProcessEnv): Promise { 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 { 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 { 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 { 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 { 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 createCleanDevGitRepoWithFakeHelper(logPath: string): Promise { const devRoot = await createDevRepoFixtureWithFakeHelper(logPath); await Promise.all([ writeFile(join(devRoot, "staged.txt"), "clean\n", "utf8"), writeFile(join(devRoot, "modified.txt"), "clean\n", "utf8"), ]); const env = cleanProcessEnv(); await execUtf8("git", ["init", "--quiet", devRoot], env); await execUtf8("git", ["-C", devRoot, "add", "."], env); await execUtf8("git", [ "-C", devRoot, "-c", "user.name=PI WEB Test", "-c", "user.email=pi-web-test@example.invalid", "-c", "core.hooksPath=/dev/null", "commit", "--quiet", "--no-gpg-sign", "-m", "test fixture", ], env); return devRoot; } async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise { 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 { 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 ;; logs) printf 'fake helper log\n' exit 0 ;; inspect) for arg in "$@"; do case "$arg" in *State.ExitCode*) printf '0\n' exit 0 ;; esac done printf '{}\n' exit 0 ;; esac printf 'unexpected fake docker args: %s\n' "$*" >&2 exit 9 `, "utf8"); await chmod(dockerPath, 0o755); return { binDir, logPath }; } async function installFakeCurl(binDir: string): Promise { const curlPath = join(binDir, "curl"); await writeFile(curlPath, `#!/usr/bin/env sh set -eu asset_root=${shellSingleQuote(join(repoRoot, "docker"))} output= url= while [ "$#" -gt 0 ]; do case "$1" in -o) shift output=\${1:-} ;; -*) ;; *) url=$1 ;; esac if [ "$#" -gt 0 ]; then shift fi done [ -n "$url" ] || { printf '%s\n' "fake curl missing URL" >&2; exit 2; } [ -n "$output" ] || { printf '%s\n' "fake curl missing -o output" >&2; exit 2; } case "$url" in */docker/*) rel=\${url##*/docker/} ;; *) printf 'unexpected fake curl url: %s\n' "$url" >&2; exit 2 ;; esac src=$asset_root/$rel [ -f "$src" ] || { printf 'missing fake curl asset: %s\n' "$src" >&2; exit 2; } mkdir -p "$(dirname "$output")" cp "$src" "$output" `, "utf8"); await chmod(curlPath, 0o755); } async function installFakeUname(binDir: string, osName: string): Promise { 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 { 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(socketPath: string, callback: () => Promise): Promise { await mkdir(dirname(socketPath), { recursive: true }); const server = createServer(); await new Promise((resolvePromise, reject) => { server.once("error", reject); server.listen(socketPath, resolvePromise); }); try { return await callback(); } finally { await new Promise((resolvePromise) => { server.close(() => { resolvePromise(); }); }); await rm(socketPath, { force: true }); } } function cleanProcessEnv(): NodeJS.ProcessEnv { const env = sanitizedGitEnv(process.env); for (const key of Object.keys(env)) { if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key === "XDG_DATA_HOME" || 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("'", "'\\''")}'`; }