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
7 changes: 7 additions & 0 deletions .changeset/add-compose-project-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"seamless-cli": patch
---

`seamless add --local` now names the Compose project in `seamless/docker-compose.yml` after the repository (`name: <repo>-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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<repo>-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.
Expand Down
31 changes: 31 additions & 0 deletions src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
12 changes: 11 additions & 1 deletion src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<repo>-seamless), so
each repository gets its own database and signing keys. A re-run keeps it

--app <id>
• Connect a managed application instead (needs seamless login). Issuing the
Expand Down
64 changes: 63 additions & 1 deletion src/generators/docker/authStack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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_-]*$/);
});
});
49 changes: 47 additions & 2 deletions src/generators/docker/authStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<root>/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());
Expand Down Expand Up @@ -60,14 +93,19 @@ ${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
#
# 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}
Expand Down Expand Up @@ -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,
};
}
Loading