From 7cdcaaa021c86b05e16f69acd98904b9acbbf0c1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 12 Oct 2026 20:56:41 -0400 Subject: [PATCH] fix(add): name the local stack's Compose project after the repository seamless add --local wrote seamless/docker-compose.yml without a top-level name, so Compose named every repository's stack after the seamless/ folder and they shared containers, the database and the signing keys. The file now names its project -seamless, keeps an existing name on a re-run, and add prints how to stop the old seamless project when it replaces an unnamed stack. Closes #270 --- .changeset/add-compose-project-name.md | 7 +++ README.md | 2 +- src/commands/add.test.ts | 31 ++++++++++++ src/commands/add.ts | 12 ++++- src/commands/helpTopics.ts | 2 + src/generators/docker/authStack.test.ts | 64 ++++++++++++++++++++++++- src/generators/docker/authStack.ts | 49 ++++++++++++++++++- 7 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 .changeset/add-compose-project-name.md diff --git a/.changeset/add-compose-project-name.md b/.changeset/add-compose-project-name.md new file mode 100644 index 0000000..1ba88a1 --- /dev/null +++ b/.changeset/add-compose-project-name.md @@ -0,0 +1,7 @@ +--- +"seamless-cli": patch +--- + +`seamless add --local` now names the Compose project in `seamless/docker-compose.yml` after the repository (`name: -seamless`, lowercased and reduced to the characters Compose allows). Before, every repository's stack ran as project `seamless`, so two repositories on one machine shared containers, the `seamless_pgdata` database and the `seamless_auth-keys` signing keys. A re-run, with or without `--force`, keeps the name already in the file, including one you edited. + +Upgrading: a stack written by an earlier version keeps working until you re-run `seamless add --local --force`. The new stack starts empty under the new name, and `add` says so when it replaces a file without a name. Stop the old containers first with `docker compose -p seamless -f seamless/docker-compose.yml down`, since they hold port 5312. The old data stays in the `seamless_pgdata` and `seamless_auth-keys` volumes, which may hold another repository's data too; remove them with `docker volume rm` once no repository uses them. diff --git a/README.md b/README.md index e4a5e19..5189ca1 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ It finds a backend and a web app, at the project root or in `api/`, `server/`, ` The backend can be Express or Fastify, Go (net/http, Gin, chi or Echo), Rust (Axum), or Python (FastAPI or Django). The web app can be React, Angular, Vue or SvelteKit. -- connects an auth server. `--local` writes `seamless/docker-compose.yml` (the auth server and its Postgres) with its secrets in `seamless/.env`, which is gitignored. `--app` connects a managed application instead. +- connects an auth server. `--local` writes `seamless/docker-compose.yml` (the auth server and its Postgres) with its secrets in `seamless/.env`, which is gitignored. The compose file names its project after the repository (`-seamless`, kept on a re-run), so each repository gets its own database and signing keys. `--app` connects a managed application instead. - writes the backend's `.env` (auth server URL, service token, service token kid, and a cookie secret if you have none) and the web app's API URL in `.env.local` (Angular, which does not read `.env`, gets it in the printed code). It keeps every value you already have and every comment. - installs the adapter and the web binding (with `vue-router` or `@angular/router` when the app has no router yet) with your own tool: npm, pnpm, yarn or bun, `go get`, `cargo add`, `uv add` or `poetry add`. A plain pip project gets the `pip install` printed, since only you know which environment it belongs in. `--skip-install` prints every command instead. - prints the lines to add to your server and to your web app, file by file: the provider or plugin, the bundled sign-in screens as routes, and guards for signed-in and signed-out pages. Each comes in the project's own language, TypeScript or JavaScript to match it. It never edits your source files. diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts index 01d642e..072b6b3 100644 --- a/src/commands/add.test.ts +++ b/src/commands/add.test.ts @@ -134,6 +134,37 @@ describe("seamless add --local", () => { expect(logs.join("\n")).toContain("cd api && npm install @seamless-auth/express cookie-parser cors"); }); + it("gives each repository its own Compose project", async () => { + const other = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-other-")); + try { + fs.cpSync(root, other, { recursive: true }); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + await addSeamlessAuth({ dir: other, local: true, yes: true, email: "owner@x.test", install: false }); + const nameOf = (dir: string) => + /^name: (.+)$/m.exec(fs.readFileSync(path.join(dir, "seamless", "docker-compose.yml"), "utf-8"))?.[1]; + expect(nameOf(root)).toBe(`${path.basename(root).toLowerCase()}-seamless`); + expect(nameOf(other)).toBe(`${path.basename(other).toLowerCase()}-seamless`); + expect(nameOf(root)).not.toBe(nameOf(other)); + expect(logs.join("\n")).toContain(`Compose project ${nameOf(root)}`); + } finally { + fs.rmSync(other, { recursive: true, force: true }); + } + }); + + it("keeps the project name across --force, and says when it replaces an unnamed stack", async () => { + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + const composePath = path.join(root, "seamless", "docker-compose.yml"); + const first = fs.readFileSync(composePath, "utf-8").match(/^name: .+$/m)?.[0]; + await addSeamlessAuth({ dir: root, local: true, yes: true, force: true, email: "owner@x.test", install: false }); + expect(fs.readFileSync(composePath, "utf-8").match(/^name: .+$/m)?.[0]).toBe(first); + expect(logs.join("\n")).not.toContain("docker compose -p seamless"); + + fs.writeFileSync(composePath, fs.readFileSync(composePath, "utf-8").replace(/^name: .+\n/m, "")); + await addSeamlessAuth({ dir: root, local: true, yes: true, force: true, email: "owner@x.test", install: false }); + expect(fs.readFileSync(composePath, "utf-8").match(/^name: .+$/m)?.[0]).toBe(first); + expect(logs.join("\n")).toContain("docker compose -p seamless -f seamless/docker-compose.yml down"); + }); + it("warns when the backend .env is not gitignored", async () => { fs.rmSync(path.join(root, ".gitignore")); await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); diff --git a/src/commands/add.ts b/src/commands/add.ts index d533151..e39fbba 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -309,7 +309,17 @@ async function connectLocal( serveConsole: origins.serveConsole, ownerEmail, }); - console.log(kleur.green(`Wrote ${AUTH_STACK_DIR}/`) + kleur.dim(": docker-compose.yml, .env, .gitignore")); + console.log( + kleur.green(`Wrote ${AUTH_STACK_DIR}/`) + + kleur.dim(`: docker-compose.yml (Compose project ${stack.projectName}), .env, .gitignore`), + ); + if (stack.replacedUnnamed) { + console.log( + kleur.yellow( + ` The old stack ran as Compose project "seamless", shared with every other repository that used seamless add. Stop it with docker compose -p seamless -f ${AUTH_STACK_DIR}/docker-compose.yml down; its data stays in the seamless_pgdata and seamless_auth-keys volumes.`, + ), + ); + } return { authServerUrl: LOCAL_AUTH_SERVER_URL, diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index a8437da..0075d82 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -129,6 +129,8 @@ api/, server/, backend/, web/, client/, frontend/, apps/* or packages/*, then: • Run the auth server on this machine: seamless/docker-compose.yml with its secrets in seamless/.env (gitignored). Start it with docker compose -f seamless/docker-compose.yml up -d + • Its Compose project is named after the repository (-seamless), so + each repository gets its own database and signing keys. A re-run keeps it --app • Connect a managed application instead (needs seamless login). Issuing the diff --git a/src/generators/docker/authStack.test.ts b/src/generators/docker/authStack.test.ts index a9fe69a..00badae 100644 --- a/src/generators/docker/authStack.test.ts +++ b/src/generators/docker/authStack.test.ts @@ -9,7 +9,7 @@ vi.mock("../../core/fetch.js", () => ({ import { parseEnv } from "../../core/env.js"; import { SEAMLESS_AUTH_API_IMAGE } from "../../core/images.js"; -import { generateAuthStack } from "./authStack.js"; +import { composeProjectName, generateAuthStack } from "./authStack.js"; let root: string; beforeEach(() => { @@ -61,4 +61,66 @@ describe("generateAuthStack", () => { expect(env.SERVE_ADMIN_DASHBOARD).toBe("false"); expect(env.OWNER_EMAIL).toBeUndefined(); }); + + it("names the Compose project after the repository, not the seamless/ folder", async () => { + const stack = await generateAuthStack(root, { + apiOrigin: "http://localhost:3000", + webOrigins: ["http://localhost:5173"], + serveConsole: false, + }); + const compose = fs.readFileSync(path.join(root, "seamless", "docker-compose.yml"), "utf-8"); + expect(stack.projectName).toBe(composeProjectName(root)); + expect(compose).toMatch(new RegExp(`^name: ${stack.projectName}$`, "m")); + expect(stack.replacedUnnamed).toBe(false); + }); + + it("keeps the project name a re-run or a hand edit left", async () => { + const options = { apiOrigin: "http://localhost:3000", webOrigins: ["http://localhost:5173"], serveConsole: false }; + const first = await generateAuthStack(root, options); + const second = await generateAuthStack(root, options); + expect(second.projectName).toBe(first.projectName); + expect(second.replacedUnnamed).toBe(false); + + const composePath = path.join(root, "seamless", "docker-compose.yml"); + fs.writeFileSync(composePath, fs.readFileSync(composePath, "utf-8").replace(/^name: .*$/m, 'name: "my_stack"')); + const third = await generateAuthStack(root, options); + expect(third.projectName).toBe("my_stack"); + expect(fs.readFileSync(composePath, "utf-8")).toMatch(/^name: my_stack$/m); + }); + + it("reports replacing a stack written before the compose file named its project", async () => { + fs.mkdirSync(path.join(root, "seamless")); + fs.writeFileSync(path.join(root, "seamless", "docker-compose.yml"), "services:\n db:\n image: postgres\n"); + const stack = await generateAuthStack(root, { + apiOrigin: "http://localhost:3000", + webOrigins: ["http://localhost:5173"], + serveConsole: false, + }); + expect(stack.replacedUnnamed).toBe(true); + expect(stack.projectName).toBe(composeProjectName(root)); + }); +}); + +describe("composeProjectName", () => { + it("gives differently named repositories different projects", () => { + expect(composeProjectName("/work/shop")).toBe("shop-seamless"); + expect(composeProjectName("/work/blog")).toBe("blog-seamless"); + expect(composeProjectName("/work/shop/")).toBe("shop-seamless"); + }); + + it.each([ + ["/w/My Project", "my-project-seamless"], + ["/w/API_Server", "api_server-seamless"], + ["/w/.dotfiles", "dotfiles-seamless"], + ["/w/--weird--", "weird-seamless"], + ["/w/_private", "private-seamless"], + ["/w/Café Résumé", "cafe-resume-seamless"], + ["/w/日本語", "app-seamless"], + ["/w/app (copy) 2", "app-copy-2-seamless"], + ["/w/v2.0", "v2-0-seamless"], + ])("reduces %s to a valid Compose name", (dir, expected) => { + const name = composeProjectName(dir); + expect(name).toBe(expected); + expect(name).toMatch(/^[a-z0-9][a-z0-9_-]*$/); + }); }); diff --git a/src/generators/docker/authStack.ts b/src/generators/docker/authStack.ts index 111137d..2f1c9d3 100644 --- a/src/generators/docker/authStack.ts +++ b/src/generators/docker/authStack.ts @@ -21,15 +21,48 @@ 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". + */ +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]; +} + /** * 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 * 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 + * folder, which is "seamless" in every repository, and every repository on the + * machine would share one database and one signing key. */ 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 projectName = previousName ?? composeProjectName(root); fs.mkdirSync(dir, { recursive: true }); const example = parseEnvString(await fetchEnvExample()); @@ -60,7 +93,7 @@ ${envFile} fs.writeFileSync(path.join(dir, ".gitignore"), ".env\n"); fs.writeFileSync( - path.join(dir, "docker-compose.yml"), + composePath, `# A local Seamless Auth server for this project, written by seamless add. # # docker compose -f ${AUTH_STACK_DIR}/docker-compose.yml up -d @@ -68,6 +101,11 @@ ${envFile} # The auth server is published on 127.0.0.1 so it is reachable from this machine # only: it is configured for local development, where it returns one-time codes # to the adapter instead of sending them. Postgres is not published at all. +# +# The project name keeps this repository's database and signing keys apart from +# any other Seamless Auth stack on this machine. Keep it unique if you change it. +name: ${projectName} + services: db: image: ${POSTGRES_IMAGE} @@ -106,5 +144,12 @@ volumes: `, ); - return { dir, apiToken: shared.apiToken, kid: shared.kid }; + return { + dir, + apiToken: shared.apiToken, + kid: shared.kid, + projectName, + // A stack from before the compose file named its project ran as "seamless". + replacedUnnamed: replacing && previousName === undefined, + }; }