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

`seamless add` fixes from writing its docs:

- The backend origin (`--api-url`) now defaults by ecosystem when the backend's `.env` has no `PORT`: the port in the entry file's listen call when there is one, otherwise 3000 for Node, 8000 for FastAPI and Django (the uvicorn and `runserver` defaults), and 8080 for Go and Rust (the adapters' own examples). It was 3000 for every backend, which pointed FastAPI and Django apps, and the auth server's `APP_ORIGINS`, at the wrong port.
- The "Found no backend" error, `seamless help add` and the README list the folders detection searches from the same list detection uses, so they now include `app/` (and `web/`, `client/` and `frontend/` in the error).
- Go, Axum and Django get working credentialed CORS for `UI_ORIGINS`, like Express, Fastify and FastAPI: net/http middleware for net/http and chi, a Gin middleware, Echo's CORS middleware, a tower-http `CorsLayer` (`cargo add tower-http --features cors` is now part of the install), and django-cors-headers (now installed with the Django adapter).
- FastAPI and Django install `python-dotenv`, and the printed code calls `load_dotenv()` before reading the environment. The hint no longer suggests `uvicorn --env-file .env`, which needed python-dotenv without installing it.
- React gets the bundled sign-in screens: `BrowserRouter` around `AuthProvider`, then `AuthRoutes` from `@seamless-auth/react/routes` with guards for signed-in and signed-out pages, in `src/App` when it exists. `react-router-dom` is installed when the app does not have it.
- Removed the em dashes from `seamless <command> --help` headers, the top-level help, the `verify` summary and the admin dashboard prompt.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,14 +311,16 @@ seamless add --local --email=you@example.com # an auth server on this machine
seamless add --app <id> # or a managed application (needs seamless login)
```

It finds a backend and a web app, at the project root or in `api/`, `server/`, `backend/`, `web/`, `client/`, `frontend/`, `apps/*` or `packages/*`, then:
It finds a backend and a web app, at the project root or `api/`, `server/`, `backend/`, `web/`, `client/`, `frontend/`, `app/`, `apps/*` or `packages/*`, then:

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.
- 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.
- installs the adapter and the web binding (with `react-router-dom`, `vue-router` or `@angular/router` when the app has no router yet, and for Python `python-dotenv`, plus `django-cors-headers` for Django) 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 adapter with credentialed CORS for your web origin, 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.

The backend's origin defaults to `http://localhost:<port>`: `PORT` from its `.env`, else the port in the entry file's listen call, else 3000 for Node, 8000 for FastAPI and Django, and 8080 for Go and Rust. Pass `--api-url` (and `--web-url`) when yours differ.

Then start the auth server (`docker compose -f seamless/docker-compose.yml up -d`), add the printed lines, and sign in. In development the one-time codes print in your backend's console. `seamless add --help` lists every option.

Expand Down
40 changes: 36 additions & 4 deletions src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ vi.mock("../prompts/appSelect.js", () => ({
import { confirm, select } from "@clack/prompts";

import { createPortalClient, ReauthRequiredError, type AuthClient } from "../core/authClient.js";
import { searchedDirsText } from "../core/detect.js";
import { parseEnv } from "../core/env.js";
import { runCommand } from "../core/exec.js";
import { issueServiceToken } from "../core/managedConnect.js";
Expand Down Expand Up @@ -113,12 +114,13 @@ describe("seamless add --local", () => {
expect(vi.mocked(runCommand).mock.calls.map(([cmd, args, cwd]) => [cmd, args.join(" "), path.relative(root, cwd)])).toEqual([
["npm", "install @seamless-auth/express cookie-parser cors", "api"],
["npm", "install --save-dev @types/cookie-parser @types/cors", "api"],
["npm", "install @seamless-auth/react", "web"],
["npm", "install @seamless-auth/react react-router-dom", "web"],
]);
const out = logs.join("\n");
expect(out).toContain("docker compose -f seamless/docker-compose.yml up -d");
expect(out).toContain('app.use("/auth", createSeamlessAuthServer(seamless));');
expect(out).toContain("<AuthProvider apiHost={import.meta.env.VITE_API_URL}>");
expect(out).toContain('<Route path="*" element={<SignIn />} />');
expect(out).not.toContain("does not look gitignored");
});

Expand All @@ -144,9 +146,12 @@ describe("seamless add --local", () => {
await expect(addSeamlessAuth({ dir: root, yes: true })).rejects.toThrow(/--local .* --app/);
});

it("explains when there is no backend to add to", async () => {
it("explains when there is no backend to add to, naming every folder it searched", async () => {
fs.rmSync(path.join(root, "api"), { recursive: true });
await expect(addSeamlessAuth({ dir: root, local: true, yes: true })).rejects.toThrow(/no backend to add/);
await expect(addSeamlessAuth({ dir: root, local: true, yes: true })).rejects.toThrow(
`at ${searchedDirsText()}.`,
);
});
});

Expand Down Expand Up @@ -301,8 +306,35 @@ describe("seamless add for a Go or Python backend", () => {

expect(vi.mocked(runCommand).mock.calls.map(([cmd]) => cmd)).toEqual(["npm"]);
const out = logs.join("\n");
expect(out).toContain('pip install "seamless-auth[fastapi]>=0.2"');
expect(out).toContain("add it to requirements.txt");
expect(out).toContain('pip install "seamless-auth[fastapi]>=0.2" python-dotenv');
expect(out).toContain("add them to requirements.txt");
expect(out).toContain("load_dotenv()");
});

it.each([
["FastAPI", "requirements.txt", "fastapi\n", "main.py", "8000"],
["Django", "requirements.txt", "django\n", "manage.py", "8000"],
["Go", "go.mod", "module example.com/app\n", "main.go", "8080"],
["Axum", "Cargo.toml", '[dependencies]\naxum = "0.8"\n', "src/main.rs", "8080"],
])("defaults the API URL for %s to its dev server's port", async (_name, manifest, content, entry, port) => {
fs.rmSync(path.join(root, "api"), { recursive: true });
write(manifest, content);
write(entry, "");
await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false });
expect(parseEnv(path.join(root, "seamless", ".env")).APP_ORIGINS).toBe(`http://localhost:${port}`);
expect(parseEnv(path.join(root, "web", ".env.local")).VITE_API_URL).toBe(`http://localhost:${port}/`);
});

it("takes the port from the entry's listen call, and PORT in .env over both", async () => {
fs.rmSync(path.join(root, "api"), { recursive: true });
write("go.mod", "module example.com/app\n");
write("main.go", 'log.Fatal(http.ListenAndServe(":9090", mux))');
await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false });
expect(parseEnv(path.join(root, "seamless", ".env")).APP_ORIGINS).toBe("http://localhost:9090");

write(".env", "PORT=7000\n");
await addSeamlessAuth({ dir: root, local: true, yes: true, force: true, email: "owner@x.test", install: false });
expect(parseEnv(path.join(root, "seamless", ".env")).APP_ORIGINS).toBe("http://localhost:7000");
});
});

Expand Down
8 changes: 5 additions & 3 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { CancelledError, orCancel } from "../core/cancel.js";
import { getPortalSession, normalizeInstanceUrl } from "../core/config.js";
import {
addPackagesCommand,
backendListenPort,
detectProject,
type DetectedBackend,
type DetectedProject,
type DetectedWeb,
searchedDirsText,
} from "../core/detect.js";
import { parseEnv } from "../core/env.js";
import { mergeEnvFile } from "../core/envFile.js";
Expand Down Expand Up @@ -124,7 +126,7 @@ export async function addSeamlessAuth(opts: AddOptions) {
const { backend, web } = project;
if (!backend) {
throw new Error(
"Found no backend to add Seamless Auth to. seamless add wires Express or Fastify (package.json), Go with net/http, Gin, chi or Echo (go.mod), Axum (Cargo.toml), and FastAPI or Django (pyproject.toml or requirements.txt), at the project root or in api/, server/, backend/, apps/* and packages/*. To start a new project instead, run seamless init.",
`Found no backend to add Seamless Auth to. seamless add wires Express or Fastify (package.json), Go with net/http, Gin, chi or Echo (go.mod), Axum (Cargo.toml), and FastAPI or Django (pyproject.toml or requirements.txt), at ${searchedDirsText()}. To start a new project instead, run seamless init.`,
);
}

Expand Down Expand Up @@ -357,7 +359,7 @@ function backendPort(root: string, backend: DetectedBackend): string {
const port = parseEnv(envPath).PORT;
if (port && /^\d+$/.test(port)) return port;
}
return "3000";
return String(backendListenPort(root, backend));
}

function defaultWebOrigin(web: DetectedWeb | undefined): string {
Expand Down Expand Up @@ -424,7 +426,7 @@ function printNextSteps(ctx: {
heading("Install the packages");
for (const install of ctx.installs) console.log(` ${install}`);
if (backend.packageManager === "pip") {
console.log(kleur.dim(" Install it into your project's environment, and add it to requirements.txt."));
console.log(kleur.dim(" Install them into your project's environment, and add them to requirements.txt."));
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export async function runCheck(args: string[] = []) {
checkStructure(root, config, report);

// Managed projects run the auth server remotely and ship no compose file, so the
// Docker/container checks don't apply — validate the remote instance instead.
// Docker/container checks don't apply. Validate the remote instance instead.
if (config?.services?.auth?.mode === "managed") {
await checkManaged(config, report);
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function errOutput(): string {
return errors.join("\n");
}

describe("runConfig — dispatch", () => {
describe("runConfig: dispatch", () => {
it("prints usage and exits 1 for an unknown subcommand", async () => {
vi.mocked(createAuthClient).mockResolvedValue(fakeClient(() => response(200, {})).client);

Expand Down
46 changes: 45 additions & 1 deletion src/commands/help.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// index.ts and help.ts import each other (help.ts reads VERSION from index.ts).
// Loading index.ts first here avoids a circular-import TDZ error that occurs
// when help.ts is the first module to pull index.ts in.
import "../index.js";
import { printCommandHelp, printHelp } from "./help.js";
import { DEFAULT_BACKEND_PORTS, searchedDirsText } from "../core/detect.js";
import { COMMAND_HELP } from "./helpTopics.js";

const here = path.dirname(fileURLToPath(import.meta.url));

describe("printHelp", () => {
let logSpy: ReturnType<typeof vi.spyOn>;

Expand Down Expand Up @@ -57,7 +63,7 @@ describe("printCommandHelp", () => {
expect(printCommandHelp(name)).toBe(true);

const [output] = logSpy.mock.calls[0] as [string];
expect(output).toContain(`seamless ${name} — seamless v`);
expect(output).toContain(`seamless ${name} (seamless v`);
expect(output).toContain("USAGE");
expect(output).toContain("DESCRIPTION");
expect(output).toContain("https://docs.seamlessauth.com");
Expand Down Expand Up @@ -90,3 +96,41 @@ describe("printCommandHelp", () => {
expect(logSpy).not.toHaveBeenCalled();
});
});

describe("add help", () => {
const body = () => COMMAND_HELP.find((c) => c.name === "add")!.sections[0].body;
const flat = (text: string) => text.replace(/\s+/g, " ");

it("names the folders detection searches and the ports it defaults to", () => {
expect(flat(body())).toContain(`at ${searchedDirsText()}, then:`);
for (const port of Object.values(DEFAULT_BACKEND_PORTS)) {
expect(body()).toContain(String(port));
}
});

it("keeps generated lines inside the help's width", () => {
for (const line of body().split("\n")) expect(line.length).toBeLessThanOrEqual(82);
});

it("matches the README's list of folders", () => {
const readme = fs.readFileSync(path.join(here, "../../README.md"), "utf-8");
const asMarkdown = searchedDirsText().replace(/([\w-]+\/\*?)/g, "`$1`");
expect(flat(readme)).toContain(asMarkdown);
});
});

describe("user-facing text", () => {
// The fells-code text standard: no em dashes.
it("has no em dash anywhere in src", () => {
const offenders: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (fs.readFileSync(full, "utf-8").includes("\u2014")) offenders.push(full);
}
};
walk(path.join(here, ".."));
expect(offenders).toEqual([]);
});
});
4 changes: 2 additions & 2 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function printHelp() {
console.log(`
seamless v${VERSION}

Seamless CLI — scaffold and manage full-stack authentication systems.
Seamless CLI: scaffold and manage full-stack authentication systems.

${DIVIDER}

Expand Down Expand Up @@ -121,7 +121,7 @@ export function printCommandHelp(name: string): boolean {
: "";

console.log(`
seamless ${command.name} — seamless v${VERSION}
seamless ${command.name} (seamless v${VERSION})

USAGE

Expand Down
32 changes: 26 additions & 6 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DEFAULT_BACKEND_PORTS, searchedDirsText } from "../core/detect.js";

export interface HelpSection {
heading: string;
body: string;
Expand Down Expand Up @@ -113,9 +115,9 @@ NON-INTERACTIVE
heading: "add [path]",
body: `Add Seamless Auth to an existing project

Finds a backend (Express, Fastify, Go net/http, Gin, chi, Echo, Axum, FastAPI or
Django) and a web app (React, Angular, Vue or SvelteKit), at the project root or in
api/, server/, backend/, web/, client/, frontend/, apps/* or packages/*, then:
${wrap(
`Finds a backend (Express, Fastify, Go net/http, Gin, chi, Echo, Axum, FastAPI or Django) and a web app (React, Angular, Vue or SvelteKit), at ${searchedDirsText()}, then:`,
)}
• Connects an auth server: a local one in Docker, written to seamless/, or a
managed application
• Writes the backend's .env (auth server URL, service token, service token
Expand All @@ -138,9 +140,11 @@ api/, server/, backend/, web/, client/, frontend/, apps/* or packages/*, then:
• With --local, the owner address: it becomes the admin when you register

--api-url=<url>, --web-url=<url>
• Your backend and web app origins, when they are not on the defaults
(http://localhost:<PORT from the backend .env, or 3000> and
http://localhost:5173, or 4200 for Angular and 3001 for Create React App)
• Your backend and web app origins, when they are not on the defaults.
${wrap(
`The backend: http://localhost:<port>, with PORT from the backend's .env, else the port in the entry file's listen call, else ${DEFAULT_BACKEND_PORTS.node} for Node, ${DEFAULT_BACKEND_PORTS.python} for FastAPI and Django (uvicorn and runserver), and ${DEFAULT_BACKEND_PORTS.go} for Go and Rust. The web app: http://localhost:5173, or 4200 for Angular and 3001 for Create React App`,
" ",
)}

--admin=<api|none>
• Whether your backend serves the admin dashboard at /console (default: api)
Expand Down Expand Up @@ -533,3 +537,19 @@ export const COMMANDS = COMMAND_HELP.map((c) => c.name);
export function findCommandHelp(name: string): CommandHelp | undefined {
return COMMAND_HELP.find((c) => c.name === name);
}

// Fills generated text to the width the hand-wrapped help uses.
function wrap(text: string, indent = "", width = 82): string {
const lines: string[] = [];
let line = "";
for (const word of text.split(" ")) {
if (line && indent.length + line.length + 1 + word.length > width) {
lines.push(indent + line);
line = word;
} else {
line = line ? `${line} ${word}` : word;
}
}
if (line) lines.push(indent + line);
return lines.join("\n");
}
4 changes: 2 additions & 2 deletions src/commands/org.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function logs(): string {
return logSpy.mock.calls.map((c) => String(c[0])).join("\n");
}

describe("runOrg — top-level routing", () => {
describe("runOrg: top-level routing", () => {
it("prints usage and exits 1 on an unknown subcommand", async () => {
await expect(runOrg(["bogus"])).rejects.toBeInstanceOf(ExitError);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown org subcommand: bogus"));
Expand Down Expand Up @@ -279,7 +279,7 @@ describe("runOrg update", () => {
});
});

describe("runOrg members — routing", () => {
describe("runOrg members: routing", () => {
it("prints usage and exits 1 on an unknown members subcommand", async () => {
await expect(runOrg(["members", "bogus"])).rejects.toBeInstanceOf(ExitError);
expect(errorSpy).toHaveBeenCalledWith(
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function errOutput(): string {
return errors.join("\n");
}

describe("runSessions — dispatch", () => {
describe("runSessions: dispatch", () => {
it("prints a yellow message and exits 1 on ReauthRequiredError", async () => {
vi.mocked(createAuthClient).mockRejectedValue(
new ReauthRequiredError("No active profile is configured."),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function logs(): string {
return logSpy.mock.calls.map((c) => String(c[0])).join("\n");
}

describe("runUsers — top-level routing", () => {
describe("runUsers: top-level routing", () => {
it("prints usage and exits 1 on an unknown subcommand", async () => {
await expect(runUsers(["bogus"])).rejects.toBeInstanceOf(ExitError);
expect(errorSpy).toHaveBeenCalledWith(
Expand Down
Loading
Loading