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/.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/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/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/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.`, + ]; +} 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: