Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/port-conflict-warnings.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/unique-compose-project.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 0 additions & 6 deletions scripts/cli-commands-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions scripts/quickstart-clean-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 0 additions & 12 deletions scripts/scaffold-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 74 additions & 9 deletions src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -16,6 +17,11 @@ vi.mock("child_process", () => ({
execSync: vi.fn(),
}));

vi.mock("../core/ports.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../core/ports.js")>()),
findPortConflicts: vi.fn(async () => []),
}));

const CONFIG = {
services: {
web: { path: "web" },
Expand Down Expand Up @@ -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());
});
Expand All @@ -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 })));
}

Expand All @@ -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("");
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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("");
Expand All @@ -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.");
});
});

Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down
48 changes: 42 additions & 6 deletions src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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",
Expand All @@ -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);
}
}

Expand Down
67 changes: 67 additions & 0 deletions src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof import("../core/ports.js")>()),
findPortConflicts: vi.fn(),
}));
vi.mock("../core/output.js", () => ({
printManagedSuccessOutput: vi.fn(),
printSuccessOutput: vi.fn(),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading