From 828a6a54f9684e3d62eb90ef60b1d740c4be4463 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 10 Nov 2026 16:20:33 -0500 Subject: [PATCH 1/2] fix(init): give each scaffolded stack its own Compose project A second project scaffolded under a directory name used before shared the first one's Compose project, so docker compose up ran its old images and reused its database. The compose file now names the project after the directory plus a random suffix, keeps a name an earlier run wrote, and pins no container names. Closes #365 --- .changeset/unique-compose-project.md | 5 +++ scripts/cli-commands-e2e.sh | 6 --- scripts/quickstart-clean-run.sh | 6 --- scripts/scaffold-smoke.sh | 12 ------ src/core/composeName.ts | 39 ++++++++++++++++++ src/generators/docker/authStack.ts | 29 ++++---------- src/generators/docker/docker.test.ts | 59 +++++++++++++++++++++++++--- src/generators/docker/docker.ts | 26 +++++++----- 8 files changed, 121 insertions(+), 61 deletions(-) create mode 100644 .changeset/unique-compose-project.md create mode 100644 src/core/composeName.ts diff --git a/.changeset/unique-compose-project.md b/.changeset/unique-compose-project.md new file mode 100644 index 0000000..40b2f46 --- /dev/null +++ b/.changeset/unique-compose-project.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +`seamless init` names each local stack's Compose project after its directory plus a random suffix, and no longer pins container names. A second project scaffolded under a name used before (such as the quickstart's `my-app`) used to share the first one's Compose project, so `docker compose up` ran the old project's built images, which crashed against the new starters (`does not provide an export named 'requirePermission'`), and reused its database. Two scaffolded projects can now also run side by side. Existing projects keep their compose file. diff --git a/scripts/cli-commands-e2e.sh b/scripts/cli-commands-e2e.sh index 6563a6c..27885b1 100755 --- a/scripts/cli-commands-e2e.sh +++ b/scripts/cli-commands-e2e.sh @@ -64,12 +64,6 @@ cleanup() { } trap cleanup EXIT -for name in seamless-db seamless-auth; do - if docker ps -a --format '{{.Names}}' | grep -qx "$name"; then - fail "a container named '$name' already exists. The generated compose pins that name, so this cannot run beside it." - fi -done - echo "==> Scaffolding into $APP_DIR" cd "$WORKDIR" # Retried for the same reason as scaffold-smoke.sh: init downloads from GitHub, and a diff --git a/scripts/quickstart-clean-run.sh b/scripts/quickstart-clean-run.sh index cff7419..0a6c98f 100755 --- a/scripts/quickstart-clean-run.sh +++ b/scripts/quickstart-clean-run.sh @@ -49,12 +49,6 @@ cleanup() { } trap cleanup EXIT -for name in seamless-db seamless-auth api web; do - if docker ps -a --format '{{.Names}}' | grep -qx "$name"; then - fail "a container named '$name' already exists. The generated compose pins that name." - fi -done - PHASES=() phase_start=0 start_phase() { diff --git a/scripts/scaffold-smoke.sh b/scripts/scaffold-smoke.sh index cb4a5d0..98b8b74 100755 --- a/scripts/scaffold-smoke.sh +++ b/scripts/scaffold-smoke.sh @@ -48,18 +48,6 @@ cleanup() { } trap cleanup EXIT -# The generated compose pins container_name, so a leftover from another stack wedges -# this with a raw Docker conflict. Say what is wrong instead, and never remove -# containers this script did not create: on a developer's machine they are their work. -for name in seamless-db seamless-auth; do - if docker ps -a --format '{{.Names}}' | grep -qx "$name"; then - echo "FAIL: a container named '$name' already exists." >&2 - echo " The generated compose pins that name, so this cannot run beside it." >&2 - echo " Stop it first, or run this on a machine without a Seamless stack up." >&2 - exit 1 - fi -done - echo "==> Scaffolding into $APP_DIR" cd "$WORKDIR" # `init` downloads the template registry, the templates archive, and the auth diff --git a/src/core/composeName.ts b/src/core/composeName.ts new file mode 100644 index 0000000..b30b9e1 --- /dev/null +++ b/src/core/composeName.ts @@ -0,0 +1,39 @@ +import fs from "fs"; +import path from "path"; +import { randomBytes } from "crypto"; + +const COMPOSE_NAME_LINE = /^name:\s*["']?([a-z0-9][a-z0-9_-]*)["']?\s*$/m; + +/** + * A directory's name reduced to what Compose accepts as a project name: lowercase + * letters, digits, dashes and underscores, starting with a letter or digit. + */ +export function composeSlug(root: string): string { + const base = path + .basename(path.resolve(root)) + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^[^a-z0-9]+|[-_]+$/g, ""); + return base || "app"; +} + +/** + * The project name for a scaffolded stack. The suffix is random because the + * directory name alone repeats: everyone who follows the quickstart twice has two + * "my-app" projects, and Compose would hand the second one the first one's built + * images and its database volume. + */ +export function uniqueComposeProjectName(root: string): string { + return `${composeSlug(root)}-${randomBytes(3).toString("hex")}`; +} + +// The name an earlier run wrote, so a re-run (or a hand edit) keeps its volumes. +export function existingComposeProjectName( + composePath: string, +): string | undefined { + if (!fs.existsSync(composePath)) return undefined; + return COMPOSE_NAME_LINE.exec(fs.readFileSync(composePath, "utf-8"))?.[1]; +} diff --git a/src/generators/docker/authStack.ts b/src/generators/docker/authStack.ts index a4b9fb9..0415642 100644 --- a/src/generators/docker/authStack.ts +++ b/src/generators/docker/authStack.ts @@ -4,6 +4,10 @@ import path from "path"; import { fetchEnvExample } from "../../core/fetch.js"; import { formatValue, parseEnvString, writePrivateFile } from "../../core/env.js"; import { ensureGitignore } from "../../core/gitignore.js"; +import { + composeSlug, + existingComposeProjectName, +} from "../../core/composeName.js"; import { POSTGRES_IMAGE, SEAMLESS_AUTH_API_IMAGE } from "../../core/images.js"; import { buildAuthEnv } from "./docker.js"; @@ -22,36 +26,19 @@ export interface AuthStackOptions { ownerEmail?: string; } -const COMPOSE_NAME_LINE = /^name:\s*["']?([a-z0-9][a-z0-9_-]*)["']?\s*$/m; - /** * The Compose project name for a repository's stack: the repository directory's - * name, reduced to what Compose accepts (lowercase letters, digits, dashes and - * underscores, starting with a letter or digit), plus "-seamless". + * name, reduced to what Compose accepts, plus "-seamless". */ export function composeProjectName(root: string): string { - const base = path - .basename(path.resolve(root)) - .normalize("NFKD") - .replace(/\p{M}/gu, "") - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/-{2,}/g, "-") - .replace(/^[^a-z0-9]+|[-_]+$/g, ""); - return `${base || "app"}-seamless`; -} - -// The name an earlier run wrote, so a re-run (or a hand edit) keeps its volumes. -function existingProjectName(compose: string): string | undefined { - if (!fs.existsSync(compose)) return undefined; - return COMPOSE_NAME_LINE.exec(fs.readFileSync(compose, "utf-8"))?.[1]; + return `${composeSlug(root)}-seamless`; } /** * Writes a local auth server and its Postgres into `/seamless/`: a compose * file, the server's environment (secrets included) in seamless/.env, and a * .gitignore that keeps that file out of the repository. Unlike the `init` stack it - * names no containers and publishes no Postgres port, because an existing project + * publishes no Postgres port, because an existing project * may run its own database, or another Seamless stack, on the same machine. * * The compose file names its project. Without that, Compose would name it after the @@ -62,7 +49,7 @@ export async function generateAuthStack(root: string, options: AuthStackOptions) const dir = path.join(root, AUTH_STACK_DIR); const composePath = path.join(dir, "docker-compose.yml"); const replacing = fs.existsSync(composePath); - const previousName = existingProjectName(composePath); + const previousName = existingComposeProjectName(composePath); const projectName = previousName ?? composeProjectName(root); fs.mkdirSync(dir, { recursive: true }); diff --git a/src/generators/docker/docker.test.ts b/src/generators/docker/docker.test.ts index 3afdff8..cc0e501 100644 --- a/src/generators/docker/docker.test.ts +++ b/src/generators/docker/docker.test.ts @@ -297,14 +297,13 @@ describe("generateDockerCompose", () => { ); expect(compose).toContain(`image: ${POSTGRES_IMAGE}`); - expect(compose).toContain("container_name: seamless-db"); expect(compose).toContain("build:\n context: ./auth"); expect(compose).toContain("env_file:\n - ./auth/.env"); expect(compose).toContain("DB_HOST: db"); expect(compose).toContain("ISSUER: http://auth:5312"); expect(compose).not.toContain("existing-token"); expect(compose).toContain("JWKS_KID: existing-kid"); - expect(compose).toContain("container_name: web"); + expect(compose).toContain("\n web:\n build: ./web"); expect(compose).toContain(`image: ${SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE}`); expect(compose).not.toContain("build: ./admin"); expect(compose).toContain("volumes:\n pgdata:"); @@ -379,7 +378,7 @@ describe("generateDockerCompose", () => { path.join(tmpDir, "docker-compose.yml"), "utf-8", ); - expect(compose).not.toContain("container_name: admin"); + expect(compose).not.toContain("\n admin:"); expect(compose).toContain("UI_ORIGINS: http://localhost:5173\n"); }); @@ -395,7 +394,7 @@ describe("generateDockerCompose", () => { path.join(tmpDir, "docker-compose.yml"), "utf-8", ); - expect(compose).not.toContain("container_name: admin"); + expect(compose).not.toContain("\n admin:"); }); it("builds a source-mode admin service and keeps 5174 in the api CORS origins", async () => { @@ -410,8 +409,7 @@ describe("generateDockerCompose", () => { path.join(tmpDir, "docker-compose.yml"), "utf-8", ); - expect(compose).toContain("container_name: admin"); - expect(compose).toContain("build: ./admin"); + expect(compose).toContain("\n admin:\n build: ./admin"); expect(compose).not.toContain("AUTH_MODE"); expect(compose).toContain("- ./admin:/app"); expect(compose).toContain('- "127.0.0.1:5174:8080"'); @@ -522,6 +520,55 @@ describe("generateDockerCompose", () => { }); }); +// Two scaffolds under one directory name (everyone who follows the quickstart +// twice has two "my-app" projects) used to be one Compose project: the second +// reused the first one's built images, which then crashed against the new +// templates, and its database volume. +describe("generateDockerCompose project name", () => { + function scaffold(dir: string): string { + fs.mkdirSync(dir, { recursive: true }); + writeAuthEnvFixture(dir, "SEAMLESS_JWKS_ACTIVE_KID"); + return dir; + } + + async function composeIn(dir: string): Promise { + await generateDockerCompose(dir, { authMode: "local", adminMode: "api" }); + return fs.readFileSync(path.join(dir, "docker-compose.yml"), "utf-8"); + } + + it("names the project after the directory plus a random suffix", async () => { + const compose = await composeIn(scaffold(path.join(tmpDir, "My App"))); + + expect(compose).toMatch(/^name: my-app-[0-9a-f]{6}$/m); + }); + + it("gives two scaffolds with the same directory name different projects", async () => { + const first = await composeIn(scaffold(path.join(tmpDir, "one", "my-app"))); + const second = await composeIn(scaffold(path.join(tmpDir, "two", "my-app"))); + + const name = (compose: string) => /^name: (\S+)$/m.exec(compose)?.[1]; + expect(name(first)).toMatch(/^my-app-/); + expect(name(second)).toMatch(/^my-app-/); + expect(name(first)).not.toBe(name(second)); + }); + + it("keeps the name an earlier run wrote, so a re-run keeps its volumes", async () => { + const dir = scaffold(path.join(tmpDir, "rerun")); + const first = await composeIn(dir); + const second = await composeIn(dir); + + expect(/^name: (\S+)$/m.exec(second)?.[1]).toBe( + /^name: (\S+)$/m.exec(first)?.[1], + ); + }); + + it("names no containers, so two projects can run side by side", async () => { + const compose = await composeIn(scaffold(path.join(tmpDir, "names"))); + + expect(compose).not.toContain("container_name"); + }); +}); + describe("generateDockerCompose output shape", () => { async function render( authMode: "local" | "docker", diff --git a/src/generators/docker/docker.ts b/src/generators/docker/docker.ts index 53aead5..87389fa 100644 --- a/src/generators/docker/docker.ts +++ b/src/generators/docker/docker.ts @@ -4,6 +4,10 @@ import { fetchEnvExample } from "../../core/fetch.js"; import { parseEnv, parseEnvString, writePrivateFile } from "../../core/env.js"; import { generateLocalJwksKid } from "../../core/jwksKid.js"; import { generateSecret } from "../../core/secrets.js"; +import { + existingComposeProjectName, + uniqueComposeProjectName, +} from "../../core/composeName.js"; import { buildOAuthAuthEnv, withLoginMethod, @@ -35,10 +39,13 @@ export async function generateDockerCompose( kid?: string; }, ) { - const { compose, shared } = await buildCompose(options, root); + const composePath = path.join(root, "docker-compose.yml"); + const projectName = + existingComposeProjectName(composePath) ?? uniqueComposeProjectName(root); + const { compose, shared } = await buildCompose(options, root, projectName); fs.writeFileSync( - path.join(root, "docker-compose.yml"), + composePath, compose.trim() + "\n", ); @@ -85,6 +92,7 @@ async function buildCompose( kid?: string; }, root: string, + projectName: string, ) { const { authMode, adminMode, oauth, ownerEmail, fullStack, kid } = options; @@ -121,6 +129,12 @@ async function buildCompose( # only. The auth server is configured to return OTP codes in the response for # local login, which would otherwise be an authentication bypass for anyone on # the same network. Drop the 127.0.0.1 prefix only if you know you want that. +# +# The project name keeps this stack's images, database and signing keys apart from +# any other Seamless project on this machine, including an earlier one scaffolded +# under the same directory name. Keep it unique if you change it. +name: ${projectName} + services: ${services.join("\n\n")} @@ -135,7 +149,6 @@ function dbService() { return ` db: image: ${POSTGRES_IMAGE} - container_name: seamless-db ports: - "127.0.0.1:5432:5432" environment: @@ -174,7 +187,6 @@ async function authService( return { service: ` auth: - container_name: seamless-auth build: context: ./auth dockerfile: Dockerfile.dev @@ -229,7 +241,6 @@ function apiUiOrigins(adminMode: AdminMode): string { function apiService(shared: any, adminMode: AdminMode) { return ` api: - container_name: api build: ./api ports: - "127.0.0.1:3000:3000" @@ -255,7 +266,6 @@ function apiService(shared: any, adminMode: AdminMode) { function webService() { return ` web: - container_name: web build: ./web ports: - "127.0.0.1:5173:8080" @@ -283,7 +293,6 @@ function webService() { function fullStackWebService(shared: any) { return ` web: - container_name: web build: ./web ports: - "127.0.0.1:5173:8080" @@ -340,7 +349,6 @@ async function authServiceDocker( service: ` auth: image: ${SEAMLESS_AUTH_API_IMAGE} - container_name: seamless-auth ports: - "127.0.0.1:5312:5312" env_file: @@ -366,7 +374,6 @@ function adminService(mode: "image" | "source") { if (mode === "source") { return ` admin: - container_name: admin build: ./admin ports: - "127.0.0.1:5174:8080" @@ -383,7 +390,6 @@ function adminService(mode: "image" | "source") { return ` admin: image: ${SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE} - container_name: admin ports: - "127.0.0.1:5174:8080" environment: From a12074dc97befa35a85025568e3d83da541182a9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 10 Nov 2026 16:20:46 -0500 Subject: [PATCH 2/2] fix(init,check): report stack ports another program holds The stack publishes on 127.0.0.1 only, so a program on [::1]:5173 took http://localhost:5173 in the browser while every check passed. init now warns about each taken port, and check fails on one held on [::1]. check also lists running services from the project's compose file instead of matching container names across the machine. Part of #366 --- .changeset/port-conflict-warnings.md | 5 ++ src/commands/check.test.ts | 83 +++++++++++++++++-- src/commands/check.ts | 48 +++++++++-- src/commands/init.test.ts | 67 +++++++++++++++ src/commands/init.ts | 25 ++++++ src/core/ports.test.ts | 118 +++++++++++++++++++++++++++ src/core/ports.ts | 95 +++++++++++++++++++++ 7 files changed, 426 insertions(+), 15 deletions(-) create mode 100644 .changeset/port-conflict-warnings.md create mode 100644 src/core/ports.test.ts create mode 100644 src/core/ports.ts diff --git a/.changeset/port-conflict-warnings.md b/.changeset/port-conflict-warnings.md new file mode 100644 index 0000000..33e7f08 --- /dev/null +++ b/.changeset/port-conflict-warnings.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +`seamless init` warns when another program already holds a port the local stack publishes (5173, 3000, 5312, 5432, and 5174 with a separate console), and `seamless check` fails on one. The stack publishes on 127.0.0.1 only, so a Vite dev server on `[::1]:5173` used to take `http://localhost:5173` in the browser while every check passed. Each warning says what the conflict breaks and how to find the program. `check` also asks the project's own compose file which services run, rather than matching container names across every running container. diff --git a/src/commands/check.test.ts b/src/commands/check.test.ts index 5691d11..31ca25c 100644 --- a/src/commands/check.test.ts +++ b/src/commands/check.test.ts @@ -3,6 +3,7 @@ import fs from "fs"; import { execSync } from "child_process"; import { runCheck } from "./check.js"; +import { findPortConflicts } from "../core/ports.js"; vi.mock("fs", () => { const fns = { @@ -16,6 +17,11 @@ vi.mock("child_process", () => ({ execSync: vi.fn(), })); +vi.mock("../core/ports.js", async (importOriginal) => ({ + ...(await importOriginal()), + findPortConflicts: vi.fn(async () => []), +})); + const CONFIG = { services: { web: { path: "web" }, @@ -45,6 +51,8 @@ beforeEach(() => { vi.mocked(fs.existsSync).mockReset(); vi.mocked(fs.readFileSync).mockReset(); vi.mocked(execSync).mockReset(); + vi.mocked(findPortConflicts).mockReset(); + vi.mocked(findPortConflicts).mockResolvedValue([]); // Default global fetch to something the test overrides per-case. vi.stubGlobal("fetch", vi.fn()); }); @@ -67,7 +75,7 @@ function errOutput(): string { function allPassing(): void { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG) as never); - vi.mocked(execSync).mockReturnValue("seamless-api\nseamless-auth" as never); + vi.mocked(execSync).mockReturnValue("db\nauth\napi\nweb\n" as never); vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 }))); } @@ -88,9 +96,9 @@ describe("runCheck", () => { // config present, web/api dirs present, compose file present. vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); - // docker --version and docker ps both succeed; containers include "api". + // docker --version and compose ps both succeed; the running services include "api". vi.mocked(execSync).mockImplementation((cmd: string) => { - if (String(cmd).includes("docker ps")) { + if (String(cmd).includes("compose ps")) { return Buffer.from("web\napi\nauth\n"); } return Buffer.from(""); @@ -146,12 +154,12 @@ describe("runCheck", () => { return String(p).endsWith("seamless.config.json"); }); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); - // docker --version throws (not installed); docker ps returns names without "api". + // docker --version throws (not installed); compose ps lists no "api" service. vi.mocked(execSync).mockImplementation((cmd: string) => { if (String(cmd).includes("docker --version")) { throw new Error("no docker"); } - if (String(cmd).includes("docker ps")) { + if (String(cmd).includes("compose ps")) { return Buffer.from("web\nauth\n"); } return Buffer.from(""); @@ -267,11 +275,11 @@ describe("runCheck", () => { expect(output()).not.toContain("Console"); }); - it("reports a container check failure when docker ps throws", async () => { + it("reports a container check failure when compose ps throws", async () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG)); vi.mocked(execSync).mockImplementation((cmd: string) => { - if (String(cmd).includes("docker ps")) { + if (String(cmd).includes("compose ps")) { throw new Error("daemon down"); } return Buffer.from(""); @@ -281,6 +289,63 @@ describe("runCheck", () => { await runCheck(); expect(output()).toContain("Failed to check containers"); + expect(output()).toContain("Is Docker running?"); + }); +}); + +describe("runCheck containers and ports", () => { + it("asks this project's compose file which services run, not every container", async () => { + allPassing(); + // Another project's container named api must not count for this one. + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (String(cmd).includes("compose ps")) return Buffer.from("db\nauth\n"); + return Buffer.from(""); + }); + + await runCheck(); + + const call = vi + .mocked(execSync) + .mock.calls.find(([cmd]) => String(cmd).includes("compose ps")); + expect(call?.[0]).toBe("docker compose ps --services --status running"); + expect(call?.[1]).toMatchObject({ cwd: process.cwd() }); + expect(output()).toContain("API container not running"); + }); + + it("checks only [::1] while the stack runs, for the ports this project publishes", async () => { + allPassing(); + + await runCheck(); + + expect(findPortConflicts).toHaveBeenCalledWith( + [ + { port: 5173, service: "web" }, + { port: 3000, service: "api" }, + { port: 5312, service: "auth" }, + { port: 5432, service: "db" }, + { port: 5174, service: "admin" }, + ], + { includeIpv4: false }, + ); + expect(output()).toContain("No other program holds the stack's ports"); + }); + + // The quickstart failure this guards: a Vite dev server on [::1]:5173 answered + // http://localhost:5173 while every health check passed. + it("fails when another program holds a stack port on [::1], and says how to find it", async () => { + allPassing(); + vi.mocked(findPortConflicts).mockResolvedValue([ + { port: 5173, service: "web", hosts: ["::1"] }, + ]); + + await expect(runCheck(["--strict"])).rejects.toThrow("process.exit(1)"); + + const out = output(); + expect(out).toContain( + "✖ Port 5173 (web) is held on [::1] by another program. Browsers try [::1] first, so http://localhost:5173 opens that program instead of this project's web.", + ); + expect(out).toContain("lsof -nP -iTCP:5173 -sTCP:LISTEN"); + expect(errOutput()).toContain("1 check failed."); }); }); @@ -355,7 +420,7 @@ describe("runCheck on a full-stack project", () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(FULL_STACK)); vi.mocked(execSync).mockImplementation((cmd: string) => - Buffer.from(String(cmd).includes("docker ps") ? "web\nseamless-auth\n" : ""), + Buffer.from(String(cmd).includes("compose ps") ? "web\nseamless-auth\n" : ""), ); vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as Response); @@ -374,7 +439,7 @@ describe("runCheck on a full-stack project", () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(FULL_STACK)); vi.mocked(execSync).mockImplementation((cmd: string) => - Buffer.from(String(cmd).includes("docker ps") ? "seamless-auth\n" : ""), + Buffer.from(String(cmd).includes("compose ps") ? "seamless-auth\n" : ""), ); vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as Response); diff --git a/src/commands/check.ts b/src/commands/check.ts index d7c4722..ad92075 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -2,6 +2,12 @@ import fs from "fs"; import path from "path"; import { execSync } from "child_process"; +import { + describePortConflict, + findPortConflicts, + stackPorts, +} from "../core/ports.js"; + /** * Records results so `--strict` can exit on a failure. * @@ -78,7 +84,8 @@ export async function runCheck(args: string[] = []) { } else { checkDocker(report); checkCompose(root, config, report); - checkContainers(config, report); + checkContainers(root, config, report); + await checkPorts(config, report); await checkHealth(config, report); } @@ -144,14 +151,18 @@ function isFullStackProject(config: any): boolean { return config?.services?.web?.kind === "fullstack" && !config?.services?.api; } -function checkContainers(config: any, report: Report) { +function checkContainers(root: string, config: any, report: Report) { // The container that owns /auth: the api service, or the web app when it is - // its own backend. + // its own backend. Asked of this project's compose file, since the generated + // stack names no containers and another project's "api" would otherwise count. const backend = isFullStackProject(config) ? "web" : "api"; try { - const output = execSync("docker ps --format '{{.Names}}'").toString(); + const output = execSync("docker compose ps --services --status running", { + cwd: root, + stdio: ["ignore", "pipe", "ignore"], + }).toString(); - if (!output.includes(backend)) { + if (!output.split(/\r?\n/).includes(backend)) { report.fail( `${backend === "web" ? "Web" : "API"} container not running`, "Run: docker compose up\n", @@ -160,7 +171,32 @@ function checkContainers(config: any, report: Report) { report.ok("Containers running"); } } catch { - report.fail("Failed to check containers"); + // `docker --version` above passes without a daemon, so this is where a + // stopped Docker shows up. + report.fail( + "Failed to check containers", + "Is Docker running? Start it, then run: docker compose up\n", + ); + } +} + +// The health checks below would pass against another program holding a port on +// [::1], where a browser's localhost goes first, so look for one explicitly. +async function checkPorts(config: any, report: Report) { + const conflicts = await findPortConflicts( + stackPorts({ + fullStack: isFullStackProject(config), + adminMode: config?.services?.admin?.mode, + }), + { includeIpv4: false }, + ); + if (conflicts.length === 0) { + report.ok("No other program holds the stack's ports"); + return; + } + for (const conflict of conflicts) { + const [what, remedy] = describePortConflict(conflict); + report.fail(what, remedy); } } diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index da1225c..27c146f 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -39,6 +39,7 @@ import { ensureGitignore, SECRET_IGNORES } from "../core/gitignore.js"; import { fetchActiveJwksKid, resolveLocalJwksKid } from "../core/jwksKid.js"; import { CancelledError } from "../core/cancel.js"; +import { findPortConflicts } from "../core/ports.js"; import { runCLI } from "./init.js"; // Defensive: templates.ts transitively imports ../index.js (which runs main() at @@ -94,6 +95,11 @@ vi.mock("../generators/config/config.js", () => ({ vi.mock("../generators/agents/agents.js", () => ({ generateAgentsFiles: vi.fn(), })); +// Real probes would find whatever this machine happens to run on 5173 or 5432. +vi.mock("../core/ports.js", async (importOriginal) => ({ + ...(await importOriginal()), + findPortConflicts: vi.fn(), +})); vi.mock("../core/output.js", () => ({ printManagedSuccessOutput: vi.fn(), printSuccessOutput: vi.fn(), @@ -256,6 +262,7 @@ beforeEach(() => { vi.mocked(chooseScaffoldTarget).mockResolvedValue("managed"); vi.mocked(getApplicationDatabase).mockResolvedValue(null); vi.mocked(confirmLocalFallback).mockResolvedValue(undefined); + vi.mocked(findPortConflicts).mockResolvedValue([]); }); afterEach(() => { @@ -535,6 +542,66 @@ describe("scaffoldLocal", () => { expect(printSuccessOutput).toHaveBeenCalled(); }); + // A Vite dev server on [::1]:5173 took http://localhost:5173 from the scaffolded + // web app, and nothing said so. + it("warns after scaffolding about every stack port another program holds", async () => { + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "api", + includeAdmin: true, + ownerEmail: "dev@example.com", + } as never); + vi.mocked(generateDockerCompose).mockResolvedValue({ + apiToken: "docker-token", + kid: "docker-kid", + } as never); + vi.mocked(findPortConflicts).mockResolvedValue([ + { port: 5173, service: "web", hosts: ["::1"] }, + { port: 5432, service: "db", hosts: ["127.0.0.1"] }, + ]); + + await runCLI(undefined, []); + + expect(findPortConflicts).toHaveBeenCalledWith( + [ + { port: 5173, service: "web" }, + { port: 3000, service: "api" }, + { port: 5312, service: "auth" }, + { port: 5432, service: "db" }, + ], + { includeIpv4: true }, + ); + const out = logs.join("\n"); + expect(out).toContain("Before docker compose up:"); + expect(out).toContain("http://localhost:5173 opens that program"); + expect(out).toContain("Port 5432 (db) is already in use"); + expect(vi.mocked(printSuccessOutput).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(findPortConflicts).mock.invocationCallOrder[0], + ); + }); + + it("prints no port warning when every port is free", async () => { + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "api", + includeAdmin: true, + ownerEmail: "dev@example.com", + } as never); + vi.mocked(generateDockerCompose).mockResolvedValue({ + apiToken: "docker-token", + kid: "docker-kid", + } as never); + + await runCLI(undefined, []); + + expect(findPortConflicts).toHaveBeenCalled(); + expect(logs.join("\n")).not.toContain("Before docker compose up"); + }); + it("puts the app's service token in api/.env and git-ignores every .env", async () => { vi.mocked(runProjectSetupPrompts).mockResolvedValue({ webTemplateId: "web-basic", diff --git a/src/commands/init.ts b/src/commands/init.ts index 75ad66f..352565c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -22,6 +22,11 @@ import { printSuccessOutput, } from "../core/output.js"; import { generateSeamlessConfig } from "../generators/config/config.js"; +import { + describePortConflict, + findPortConflicts, + stackPorts, +} from "../core/ports.js"; import { generateAgentsFiles, type AgentsTemplate, @@ -682,6 +687,26 @@ async function scaffoldLocal( }); printOAuthNextSteps(oauthProviders); + + await warnOnPortConflicts(fullStack, answers.adminMode); +} + +// Nothing is running yet, so anything on a port the stack publishes belongs to +// another program. Warned rather than refused: the user can stop it before +// docker compose up, and the scaffold itself is fine. +async function warnOnPortConflicts(fullStack: boolean, adminMode: string) { + const conflicts = await findPortConflicts( + stackPorts({ fullStack, adminMode }), + { includeIpv4: true }, + ); + if (conflicts.length === 0) return; + + console.log(kleur.yellow("Before docker compose up:\n")); + for (const conflict of conflicts) { + const [what, remedy] = describePortConflict(conflict); + console.log(kleur.yellow(` • ${what}`)); + console.log(kleur.yellow(` ${remedy}\n`)); + } } // Existing-project integration: wire the managed credentials into an already diff --git a/src/core/ports.test.ts b/src/core/ports.test.ts new file mode 100644 index 0000000..fb043d8 --- /dev/null +++ b/src/core/ports.test.ts @@ -0,0 +1,118 @@ +import net from "net"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + describePortConflict, + findPortConflicts, + isListening, + stackPorts, +} from "./ports.js"; + +const servers: net.Server[] = []; + +function listen(host: string): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + servers.push(server); + server.once("error", reject); + server.listen(0, host, () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((s) => new Promise((resolve) => s.close(resolve))), + ); +}); + +describe("stackPorts", () => { + it("lists web, api, auth and db for the default stack", () => { + expect(stackPorts({ adminMode: "api" })).toEqual([ + { port: 5173, service: "web" }, + { port: 3000, service: "api" }, + { port: 5312, service: "auth" }, + { port: 5432, service: "db" }, + ]); + }); + + it("drops the api for a full-stack web app and adds the admin container's port", () => { + expect(stackPorts({ fullStack: true, adminMode: "image" })).toEqual([ + { port: 5173, service: "web" }, + { port: 5312, service: "auth" }, + { port: 5432, service: "db" }, + { port: 5174, service: "admin" }, + ]); + }); +}); + +describe("isListening", () => { + it("is true for a port something listens on and false once it stops", async () => { + const port = await listen("127.0.0.1"); + expect(await isListening("127.0.0.1", port)).toBe(true); + + await new Promise((resolve) => servers.pop()!.close(resolve)); + expect(await isListening("127.0.0.1", port)).toBe(false); + }); +}); + +describe("findPortConflicts", () => { + it("checks both loopbacks before the stack runs and reports where each port is taken", async () => { + const probe = vi.fn( + async (host: string, port: number) => + (port === 5173 && host === "::1") || (port === 5432 && host === "127.0.0.1"), + ); + + const conflicts = await findPortConflicts(stackPorts({ adminMode: "api" }), { + includeIpv4: true, + probe, + }); + + expect(conflicts).toEqual([ + { port: 5173, service: "web", hosts: ["::1"] }, + { port: 5432, service: "db", hosts: ["127.0.0.1"] }, + ]); + expect(probe).toHaveBeenCalledTimes(8); + }); + + // The running stack holds 127.0.0.1 itself, so only [::1] can show a stranger. + it("checks only [::1] while the stack runs", async () => { + const probe = vi.fn(async () => true); + + await findPortConflicts([{ port: 5173, service: "web" }], { + includeIpv4: false, + probe, + }); + + expect(probe.mock.calls).toEqual([["::1", 5173]]); + }); +}); + +describe("describePortConflict", () => { + it("says a browser on localhost reaches the other program when it holds [::1]", () => { + const [what, remedy] = describePortConflict({ + port: 5173, + service: "web", + hosts: ["::1"], + }); + expect(what).toBe( + "Port 5173 (web) is held on [::1] by another program. Browsers try [::1] first, so http://localhost:5173 opens that program instead of this project's web.", + ); + expect(remedy).toContain("lsof -nP -iTCP:5173 -sTCP:LISTEN"); + }); + + it("says docker compose up cannot publish a port taken on 127.0.0.1", () => { + const [what] = describePortConflict({ + port: 3000, + service: "api", + hosts: ["127.0.0.1"], + }); + expect(what).toContain("docker compose up cannot publish it"); + expect(what).toContain("docker compose down in that project"); + }); + + it("describes a database port without a browser", () => { + const [what] = describePortConflict({ port: 5432, service: "db", hosts: ["::1"] }); + expect(what).toContain("connects to localhost:5432"); + expect(what).not.toContain("Browsers"); + }); +}); diff --git a/src/core/ports.ts b/src/core/ports.ts new file mode 100644 index 0000000..6f6a29d --- /dev/null +++ b/src/core/ports.ts @@ -0,0 +1,95 @@ +import net from "net"; + +import type { AdminMode } from "../generators/docker/docker.js"; + +export interface StackPort { + port: number; + service: string; +} + +// The generated compose file publishes every port on 127.0.0.1 only. +export const IPV4_LOOPBACK = "127.0.0.1"; +export const IPV6_LOOPBACK = "::1"; + +export type Loopback = typeof IPV4_LOOPBACK | typeof IPV6_LOOPBACK; + +export interface PortConflict extends StackPort { + hosts: Loopback[]; +} + +/** The host ports a scaffolded local stack publishes. */ +export function stackPorts(options: { + fullStack?: boolean; + adminMode?: AdminMode | string; +}): StackPort[] { + const ports: StackPort[] = [{ port: 5173, service: "web" }]; + if (!options.fullStack) ports.push({ port: 3000, service: "api" }); + ports.push({ port: 5312, service: "auth" }, { port: 5432, service: "db" }); + if (options.adminMode === "image" || options.adminMode === "source") { + ports.push({ port: 5174, service: "admin" }); + } + return ports; +} + +/** Whether something accepts a TCP connection on host:port. */ +export function isListening( + host: string, + port: number, + timeoutMs = 500, +): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host, port }); + const done = (listening: boolean) => { + socket.destroy(); + resolve(listening); + }; + socket.setTimeout(timeoutMs, () => done(false)); + socket.once("connect", () => done(true)); + // Refused, or no IPv6 loopback on this machine: either way nothing is there. + socket.once("error", () => done(false)); + }); +} + +/** + * Finds the stack's ports that some other program already holds. + * + * [::1] is always checked, because the stack never publishes there: a listener on + * it is another program, and browsers try ::1 first for localhost, so it silently + * wins (a Vite dev server on its default 5173 is the usual case). 127.0.0.1 is + * checked only before the stack runs, when anything on it would make + * `docker compose up` fail to publish the port. + */ +export async function findPortConflicts( + ports: StackPort[], + options: { includeIpv4: boolean; probe?: typeof isListening }, +): Promise { + const probe = options.probe ?? isListening; + const hosts: Loopback[] = options.includeIpv4 + ? [IPV4_LOOPBACK, IPV6_LOOPBACK] + : [IPV6_LOOPBACK]; + + const results = await Promise.all( + ports.map(async (entry) => { + const taken: Loopback[] = []; + for (const host of hosts) { + if (await probe(host, entry.port)) taken.push(host); + } + return { ...entry, hosts: taken }; + }), + ); + return results.filter((entry) => entry.hosts.length > 0); +} + +/** One line saying what the conflict breaks, then how to find the program. */ +export function describePortConflict(conflict: PortConflict): string[] { + const { port, service } = conflict; + const what = conflict.hosts.includes(IPV6_LOOPBACK) + ? service === "db" + ? `Port ${port} (${service}) is held on [::1] by another program, so an app on this machine that connects to localhost:${port} reaches that program instead of this stack's database.` + : `Port ${port} (${service}) is held on [::1] by another program. Browsers try [::1] first, so http://localhost:${port} opens that program instead of this project's ${service}.` + : `Port ${port} (${service}) is already in use, so docker compose up cannot publish it. If it is another Seamless project, run docker compose down in that project.`; + return [ + what, + `Stop that program first. On macOS or Linux, lsof -nP -iTCP:${port} -sTCP:LISTEN names it.`, + ]; +}