diff --git a/.changeset/add-rough-edges.md b/.changeset/add-rough-edges.md new file mode 100644 index 0000000..5cfc47e --- /dev/null +++ b/.changeset/add-rough-edges.md @@ -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 --help` headers, the top-level help, the `verify` summary and the admin dashboard prompt. diff --git a/README.md b/README.md index e4a5e19..6035d97 100644 --- a/README.md +++ b/README.md @@ -311,14 +311,16 @@ seamless add --local --email=you@example.com # an auth server on this machine seamless add --app # 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` 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. diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts index 01d642e..751abac 100644 --- a/src/commands/add.test.ts +++ b/src/commands/add.test.ts @@ -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"; @@ -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(""); + expect(out).toContain('} />'); expect(out).not.toContain("does not look gitignored"); }); @@ -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()}.`, + ); }); }); @@ -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"); }); }); diff --git a/src/commands/add.ts b/src/commands/add.ts index d533151..6b848bb 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -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"; @@ -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.`, ); } @@ -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 { @@ -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.")); } } diff --git a/src/commands/check.ts b/src/commands/check.ts index 4518efe..d7c4722 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -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 { diff --git a/src/commands/config.test.ts b/src/commands/config.test.ts index 8918d0e..a164c81 100644 --- a/src/commands/config.test.ts +++ b/src/commands/config.test.ts @@ -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); diff --git a/src/commands/help.test.ts b/src/commands/help.test.ts index 2fa13ee..3debc3c 100644 --- a/src/commands/help.test.ts +++ b/src/commands/help.test.ts @@ -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; @@ -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"); @@ -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([]); + }); +}); diff --git a/src/commands/help.ts b/src/commands/help.ts index 48a9a42..a8e1818 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -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} @@ -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 diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index a8437da..90cb591 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -1,3 +1,5 @@ +import { DEFAULT_BACKEND_PORTS, searchedDirsText } from "../core/detect.js"; + export interface HelpSection { heading: string; body: string; @@ -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 @@ -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=, --web-url= - • Your backend and web app origins, when they are not on the defaults - (http://localhost: 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:, 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= • Whether your backend serves the admin dashboard at /console (default: api) @@ -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"); +} diff --git a/src/commands/org.test.ts b/src/commands/org.test.ts index 647abef..553d157 100644 --- a/src/commands/org.test.ts +++ b/src/commands/org.test.ts @@ -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")); @@ -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( diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index a72de87..33eff50 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -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."), diff --git a/src/commands/users.test.ts b/src/commands/users.test.ts index f0d9a39..53e8bce 100644 --- a/src/commands/users.test.ts +++ b/src/commands/users.test.ts @@ -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( diff --git a/src/commands/verify.test.ts b/src/commands/verify.test.ts index faadd75..0a03246 100644 --- a/src/commands/verify.test.ts +++ b/src/commands/verify.test.ts @@ -125,7 +125,7 @@ afterEach(() => { process.env = { ...SAVED_ENV }; }); -describe("runVerify — published (default) mode", () => { +describe("runVerify: published (default) mode", () => { it("cleans vendor, builds the base stack, and runs API + web layers", async () => { await runVerify([]); @@ -315,7 +315,7 @@ describe("runVerify with an Angular app", () => { }); }); -describe("runVerify — flag parsing", () => { +describe("runVerify: flag parsing", () => { it("--dev adds a development-server pass after each production pass", async () => { await runVerify(["--dev"]); @@ -429,7 +429,7 @@ describe("runVerify — flag parsing", () => { }); }); -describe("runVerify — --adapter-url (an external reference app)", () => { +describe("runVerify: --adapter-url (an external reference app)", () => { it("starts only the API and runs the conformance project against the URL", async () => { await runVerify(["--adapter-url=http://localhost:8080/"]); @@ -472,7 +472,7 @@ describe("runVerify — --adapter-url (an external reference app)", () => { }); }); -describe("runVerify — local mode", () => { +describe("runVerify: local mode", () => { it("packs local server and react SDKs before starting the stack", async () => { await runVerify(["--local"]); @@ -632,7 +632,7 @@ describe("runVerify — local mode", () => { }); }); -describe("runVerify — SEAMLESS_REACT_DIR override", () => { +describe("runVerify: SEAMLESS_REACT_DIR override", () => { it("uses the single override template and its manifest flows", async () => { process.env.SEAMLESS_REACT_DIR = "/fake/override-template"; @@ -681,7 +681,7 @@ describe("runVerify — SEAMLESS_REACT_DIR override", () => { }); }); -describe("runVerify — setup failures (thrown before the run)", () => { +describe("runVerify: setup failures (thrown before the run)", () => { it("aborts when Docker is not installed", async () => { vi.mocked(execSync).mockImplementation(() => { throw new Error("not found"); @@ -717,7 +717,7 @@ describe("runVerify — setup failures (thrown before the run)", () => { }); }); -describe("runVerify — conformance failures (caught, exits 1)", () => { +describe("runVerify: conformance failures (caught, exits 1)", () => { it("marks the run failed when a Playwright layer fails", async () => { vi.mocked(runCommand).mockImplementation((cmd: string, args: string[]) => { if (cmd === "npm" && args[0] === "test") return Promise.reject(new Error("tests failed")); @@ -789,7 +789,7 @@ describe("runVerify — conformance failures (caught, exits 1)", () => { }); }); -describe("runVerify — duration formatting in the summary", () => { +describe("runVerify: duration formatting in the summary", () => { it("renders sub-minute layer durations as seconds", async () => { vi.spyOn(Date, "now") .mockReturnValueOnce(0) // startedAt diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 29fbd55..e3decda 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -621,7 +621,7 @@ function printSummary( const sdk = opts.local ? "local (built from source)" : "released (npm)"; console.log(`\n${rule}`); - console.log(kleur.bold(" Seamless Verify — summary")); + console.log(kleur.bold(" Seamless Verify: summary")); console.log(kleur.dim(` SDK: ${sdk} · ${formatDuration(elapsedMs)} total`)); if (packages.length > 0) { @@ -654,18 +654,18 @@ function printSummary( const failedCount = results.filter((r) => !r.ok).length; console.log( kleur.red( - ` ✖ Conformance failed — ${failedCount} of ${results.length} layer(s) failed.`, + ` ✖ Conformance failed: ${failedCount} of ${results.length} layer(s) failed.`, ), ); } else { - console.log(kleur.green(` ✔ Conformance passed — ${results.length} layer(s).`)); + console.log(kleur.green(` ✔ Conformance passed: ${results.length} layer(s).`)); } console.log(`${rule}\n`); } export async function runVerify(args: string[] = []): Promise { const opts = parseArgs(args); - console.log(kleur.bold("\nSeamless Verify — auth conformance")); + console.log(kleur.bold("\nSeamless Verify: auth conformance")); console.log(kleur.dim(`SDK: ${opts.local ? "local (built from source)" : "released (npm)"}\n`)); ensureDocker(); @@ -847,7 +847,7 @@ export async function runVerify(args: string[] = []): Promise { failed = true; setupError = err as Error; console.log(kleur.red(`\n✖ Conformance failed: ${setupError.message}\n`)); - // A container that exits on startup surfaces only as "docker failed" — dump the + // A container that exits on startup surfaces only as "docker failed", so dump the // recent stack logs before teardown so the real cause is visible (in CI too). console.log(kleur.dim("→ Recent container logs:\n")); await compose( diff --git a/src/core/detect.test.ts b/src/core/detect.test.ts index 5ac79da..ec56a8f 100644 --- a/src/core/detect.test.ts +++ b/src/core/detect.test.ts @@ -3,7 +3,15 @@ import os from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { addPackagesCommand, detectJsPackageManager, detectProject } from "./detect.js"; +import { + addPackagesCommand, + backendListenPort, + DEFAULT_BACKEND_PORTS, + type DetectedBackend, + detectJsPackageManager, + detectProject, + searchedDirsText, +} from "./detect.js"; let root: string; @@ -38,9 +46,18 @@ describe("detectProject", () => { packageManager: "npm", }); expect(project.web).toMatchObject({ framework: "react", bundler: "vite", dir: "web", entry: "src/main.tsx" }); + expect(project.web?.router).toBeUndefined(); + expect(project.web?.missingPeers).toEqual(["react-router-dom"]); expect(project.unsupported).toEqual([]); }); + it("finds a React app's App component, and its router when it has one", () => { + write("package.json", { dependencies: { react: "^19", "react-router-dom": "^7" }, devDependencies: { vite: "^7" } }); + write("src/main.jsx", ""); + write("src/App.jsx", ""); + expect(detectProject(root).web).toMatchObject({ entry: "src/main.jsx", router: "src/App.jsx", missingPeers: [] }); + }); + it("finds Fastify in a workspace package and the root lock file's package manager", () => { write("package.json", { private: true, workspaces: ["apps/*"] }); write("pnpm-lock.yaml", ""); @@ -252,3 +269,57 @@ describe("detectProject, Angular, Vue and SvelteKit", () => { expect(detectProject(root)).toEqual({ unsupported: ["Svelte without SvelteKit"] }); }); }); + +describe("searchedDirsText", () => { + it("names every folder detection looks in", () => { + expect(searchedDirsText()).toBe( + "the project root or api/, server/, backend/, web/, client/, frontend/, app/, apps/* or packages/*", + ); + }); + + it("matches where detection actually looks", () => { + for (const dir of ["api", "server", "backend", "web", "client", "frontend", "app", "apps/one", "packages/two"]) { + fs.rmSync(root, { recursive: true, force: true }); + fs.mkdirSync(root); + write(`${dir}/package.json`, { dependencies: { express: "^5" } }); + expect(detectProject(root).backend?.dir).toBe(dir); + expect(searchedDirsText()).toContain(dir.includes("/") ? `${dir.split("/")[0]}/*` : `${dir}/`); + } + }); +}); + +describe("backendListenPort", () => { + const backend = (ecosystem: DetectedBackend["ecosystem"], entry?: string): DetectedBackend => ({ + framework: "express", + ecosystem, + dir: ".", + typescript: false, + entry, + packageManager: "npm", + }); + + it("defaults by ecosystem", () => { + expect(DEFAULT_BACKEND_PORTS).toEqual({ node: 3000, python: 8000, go: 8080, rust: 8080 }); + for (const ecosystem of ["node", "python", "go", "rust"] as const) { + expect(backendListenPort(root, backend(ecosystem))).toBe(DEFAULT_BACKEND_PORTS[ecosystem]); + } + }); + + it.each([ + ["node", "app.listen(4000, () => {});", 4000], + ["node", "app.listen({ port: 4100, host: '0.0.0.0' });", 4100], + ["node", "app.listen(port);", 3000], + ["go", 'log.Fatal(http.ListenAndServe(":8081", mux))', 8081], + ["go", 'r.Run(":9000")', 9000], + ["go", 'e.Logger.Fatal(e.Start(":1323"))', 1323], + ["go", 'srv := &http.Server{Addr: "localhost:7070"}', 7070], + ["go", "r.Run()", 8080], + ["rust", 'let listener = TcpListener::bind("0.0.0.0:3000").await?;', 3000], + ["rust", "let addr = SocketAddr::from(([127, 0, 0, 1], 4001));", 4001], + ["python", 'uvicorn.run(app, host="0.0.0.0", port=8001)', 8001], + ["python", "app = FastAPI()", 8000], + ] as const)("reads a %s listen call: %s", (ecosystem, source, port) => { + write("entry", source); + expect(backendListenPort(root, backend(ecosystem, "entry"))).toBe(port); + }); +}); diff --git a/src/core/detect.ts b/src/core/detect.ts index dda0f5d..4f03f5d 100644 --- a/src/core/detect.ts +++ b/src/core/detect.ts @@ -36,7 +36,8 @@ export interface DetectedWeb { dir: string; typescript: boolean; entry?: string; - // Vue: the file the router is created in, when it is not the entry. + // Vue: the file the router is created in. React: the component that renders + // the routes (src/App). Only when it is not the entry. router?: string; // SvelteKit: how the app imports from src/lib. SvelteKit 3 renamed $lib to #lib. libAlias?: "$lib" | "#lib"; @@ -66,6 +67,19 @@ const CANDIDATE_DIRS = [ ]; const WORKSPACE_PARENTS = ["apps", "packages"]; +/** + * Where detection looks, in words: "the project root or api/, server/, ..., apps/* + * or packages/*". The error, the help and the README quote it, so they cannot + * drift from the list above. + */ +export function searchedDirsText(): string { + const dirs = [ + ...CANDIDATE_DIRS.filter((d) => d !== ".").map((d) => `${d}/`), + ...WORKSPACE_PARENTS.map((d) => `${d}/*`), + ]; + return `the project root or ${dirs.slice(0, -1).join(", ")} or ${dirs.at(-1)}`; +} + interface PackageJson { main?: string; scripts?: Record; @@ -250,12 +264,16 @@ function detectWeb( }; } if ("react" in deps && !("next" in deps) && !("react-native" in deps)) { + const entry = findEntry(dir, pkg, WEB_ENTRIES); + const app = firstExisting(dir, ["src/App.tsx", "src/App.jsx", "src/App.js"]); return { ...base, framework: "react", bundler: "vite" in deps ? "vite" : "react-scripts" in deps ? "react-scripts" : "other", - entry: findEntry(dir, pkg, WEB_ENTRIES), - missingPeers: [], + entry, + router: app !== entry ? app : undefined, + // The bundled sign-in screens (@seamless-auth/react/routes) render with it. + missingPeers: missing("react-router-dom"), }; } return undefined; @@ -357,6 +375,38 @@ function djangoSettings(dir: string): string | undefined { return manage === undefined ? undefined : "manage.py"; } +/** + * The port a backend's dev server listens on when nothing says otherwise: Node + * servers by convention, uvicorn's and Django runserver's default, and the Go and + * Rust adapters' own examples (Gin's Run() also defaults to it). + */ +export const DEFAULT_BACKEND_PORTS: Record = { + node: 3000, + go: 8080, + rust: 8080, + python: 8000, +}; + +// A port written into the entry file's listen call, such as app.listen(4000), +// http.ListenAndServe(":8081", mux), TcpListener::bind("0.0.0.0:3000") or +// uvicorn.run(app, port=8001). +const LISTEN_PORT: Record = { + node: [/\.listen\(\s*(\d{2,5})\s*[,)]/, /\.listen\(\s*\{[^}]*\bport:\s*(\d{2,5})/], + go: [/(?:ListenAndServe(?:TLS)?|\.Run|\.Start)\(\s*"[^"]*:(\d{2,5})"/, /\bAddr:\s*"[^"]*:(\d{2,5})"/], + rust: [/\bbind\(\s*"[^"]*:(\d{2,5})"/, /SocketAddr::from\(\(\s*\[[^\]]*\]\s*,\s*(\d{2,5})\s*\)\)/], + python: [/uvicorn\.run\([^)]*\bport\s*=\s*(\d{2,5})/], +}; + +/** The port the backend listens on, read from its entry file, or its ecosystem's default. */ +export function backendListenPort(root: string, backend: DetectedBackend): number { + const source = backend.entry ? readText(path.join(root, backend.dir, backend.entry)) : undefined; + for (const pattern of source === undefined ? [] : LISTEN_PORT[backend.ecosystem]) { + const port = source!.match(pattern)?.[1]; + if (port) return Number(port); + } + return DEFAULT_BACKEND_PORTS[backend.ecosystem]; +} + /** The command that adds packages with a project's own package manager. */ export function addPackagesCommand( manager: JsPackageManager, diff --git a/src/core/interactiveLogin.test.ts b/src/core/interactiveLogin.test.ts index fc19894..e1c0863 100644 --- a/src/core/interactiveLogin.test.ts +++ b/src/core/interactiveLogin.test.ts @@ -41,7 +41,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("promptLogin — the code prompt", () => { +describe("promptLogin: the code prompt", () => { it.each([ ["a six-letter code", "ABCDEF"], ["an alphanumeric code", "A1B2C3"], diff --git a/src/core/nativeSnippets.ts b/src/core/nativeSnippets.ts index 5757ebf..72c864a 100644 --- a/src/core/nativeSnippets.ts +++ b/src/core/nativeSnippets.ts @@ -40,21 +40,77 @@ const GO_ME = `func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"id": user.ID}) }`; +const CORS_COMMENT = "Your web app calls /auth on this server with cookies, so it needs credentialed\n// CORS for the origins in UI_ORIGINS."; + +// The origins in UI_ORIGINS, falling back to the web origin seamless add found. +function goOrigins(webOrigin: string): string { + return `var uiOrigins []string +for _, o := range strings.Split(cmp.Or(os.Getenv("UI_ORIGINS"), "${webOrigin}"), ",") { + uiOrigins = append(uiOrigins, strings.TrimSpace(o)) +}`; +} + +// Credentialed CORS as net/http middleware, for net/http and chi. An origin not +// in the list gets no CORS headers, and the browser blocks the response. +// Same-origin requests (the console's own) need none. +const GO_CORS_MIDDLEWARE = `withCORS := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); slices.Contains(uiOrigins, origin) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + h.Add("Vary", "Origin") + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + h.Set("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE") + h.Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers")) + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) +}`; + +// The same for Gin, as the Gin starter writes it. +const GIN_CORS = `// Ahead of every route: Gin applies middleware only to routes added after it. +r.Use(func(c *gin.Context) { + if origin := c.GetHeader("Origin"); slices.Contains(uiOrigins, origin) { + h := c.Writer.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + h.Add("Vary", "Origin") + if c.Request.Method == http.MethodOptions && c.GetHeader("Access-Control-Request-Method") != "" { + h.Set("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE") + h.Set("Access-Control-Allow-Headers", c.GetHeader("Access-Control-Request-Headers")) + c.AbortWithStatus(http.StatusNoContent) + return + } + } + c.Next() +})`; + +const ECHO_CORS = `e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: uiOrigins, + AllowCredentials: true, +}))`; + function goSnippet(framework: DetectedBackend["framework"], opts: SnippetOptions): string { const imports = [ + '"cmp"', '"context"', '"encoding/json"', '"log"', '"net/http"', '"os"', + ...(framework === "echo" ? [] : ['"slices"']), + '"strings"', "", 'seamlessauth "github.com/fells-code/seamless-auth-go"', ]; if (framework === "gin") imports.push('"github.com/gin-gonic/gin"'); - if (framework === "echo") imports.push('"github.com/labstack/echo/v4"'); + if (framework === "echo") imports.push('"github.com/labstack/echo/v4"', '"github.com/labstack/echo/v4/middleware"'); const header = `import (\n${imports.map((i) => (i ? `\t${i}` : "")).join("\n")}\n)\n\n${goOptions()}\n`; - const cors = `// Your web app (${opts.webOrigin}) calls /auth on this server with cookies, so it -// needs credentialed CORS for the origins in UI_ORIGINS.`; + const cors = (use: string) => `// ${CORS_COMMENT}\n${goOrigins(opts.webOrigin)}\n${use}`; const consoleLine = (mount: string) => opts.serveConsole ? `\n// The admin dashboard at /console.\nif os.Getenv("SERVE_ADMIN_CONSOLE") == "true" {\n\t${mount}\n}` @@ -63,7 +119,8 @@ function goSnippet(framework: DetectedBackend["framework"], opts: SnippetOptions switch (framework) { case "gin": return `${header} -${cors} +${cors(GIN_CORS)} + r.Any("/auth/*path", gin.WrapH(http.StripPrefix("/auth", auth.Handler())))${consoleLine( 'r.Any("/console/*path", gin.WrapH(http.StripPrefix("/console", auth.ConsoleHandler())))', )} @@ -72,7 +129,8 @@ r.Any("/auth/*path", gin.WrapH(http.StripPrefix("/auth", auth.Handler())))${cons r.GET("/api/me", gin.WrapH(auth.RequireAuth(http.HandlerFunc(${GO_ME}))))`; case "chi": return `${header} -${cors} +${cors(`${GO_CORS_MIDDLEWARE}\n// Ahead of every route: chi refuses middleware added after one.\nr.Use(withCORS)`)} + r.Mount("/auth", http.StripPrefix("/auth", auth.Handler()))${consoleLine( 'r.Mount("/console", http.StripPrefix("/console", auth.ConsoleHandler()))', )} @@ -81,7 +139,8 @@ r.Mount("/auth", http.StripPrefix("/auth", auth.Handler()))${consoleLine( r.With(auth.RequireAuth).Get("/api/me", ${GO_ME})`; case "echo": return `${header} -${cors} +${cors(ECHO_CORS)} + e.Any("/auth/*", echo.WrapHandler(http.StripPrefix("/auth", auth.Handler())))${consoleLine( 'e.Any("/console/*", echo.WrapHandler(http.StripPrefix("/console", auth.ConsoleHandler())))', )} @@ -90,13 +149,17 @@ e.Any("/auth/*", echo.WrapHandler(http.StripPrefix("/auth", auth.Handler())))${c e.GET("/api/me", echo.WrapHandler(auth.RequireAuth(http.HandlerFunc(${GO_ME}))))`; default: return `${header} -${cors} +${cors(GO_CORS_MIDDLEWARE)} + mux.Handle("/auth/", http.StripPrefix("/auth", auth.Handler()))${consoleLine( 'mux.Handle("/console/", http.StripPrefix("/console", auth.ConsoleHandler()))', )} // Protect a route. RequireAuth answers 401 without a session. -mux.Handle("GET /api/me", auth.RequireAuth(http.HandlerFunc(${GO_ME})))`; +mux.Handle("GET /api/me", auth.RequireAuth(http.HandlerFunc(${GO_ME}))) + +// Serve the mux through the CORS middleware, where you start the server: +// http.ListenAndServe(addr, withCORS(mux))`; } } @@ -104,8 +167,10 @@ function rustSnippet(opts: SnippetOptions): string { return `use std::collections::HashMap; use std::net::SocketAddr; +use axum::http::{HeaderValue, Method}; use axum::{Json, routing::get}; use seamless_auth::{Adapter, Delivery, User}; +use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; let env = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set")); let mut builder = Adapter::builder(env("AUTH_SERVER_URL")) @@ -124,8 +189,18 @@ if std::env::var("APP_ENV").as_deref() != Ok("production") { } let auth = builder.build().expect("Seamless Auth configuration"); -// Your web app (${opts.webOrigin}) calls /auth on this server with cookies, so it -// needs credentialed CORS (tower-http's CorsLayer) for the origins in UI_ORIGINS. +// ${CORS_COMMENT} +let ui_origins: Vec = std::env::var("UI_ORIGINS") + .unwrap_or_else(|_| "${opts.webOrigin}".into()) + .split(',') + .filter_map(|origin| origin.trim().parse().ok()) + .collect(); +let cors = CorsLayer::new() + .allow_origin(AllowOrigin::list(ui_origins)) + .allow_credentials(true) + .allow_methods([Method::GET, Method::HEAD, Method::PUT, Method::PATCH, Method::POST, Method::DELETE]) + .allow_headers(AllowHeaders::mirror_request()); + // Added to the router you already have: let app = app // Protect a route. require_auth answers 401 without a session. @@ -134,7 +209,10 @@ let app = app get(|user: User| async move { Json(HashMap::from([("id", user.id)])) }) .route_layer(auth.require_auth()), ) - .nest("/auth", auth.router());${ + .nest("/auth", auth.router()) + // CORS covers the routes added so far, and leaves out the console below, + // which is same-origin. + .layer(cors);${ opts.serveConsole ? ` // The admin dashboard at /console (merged, not nested: it serves /console/ too). @@ -153,11 +231,16 @@ axum::serve(listener, app.into_make_service_with_connect_info::()).a function fastapiSnippet(opts: SnippetOptions): string { return `import os +from dotenv import load_dotenv from fastapi import Depends from fastapi.middleware.cors import CORSMiddleware from seamless_auth import Adapter, Delivery, User from seamless_auth.fastapi import RequireUser, auth_router${opts.serveConsole ? ", console_router" : ""} +# Reads .env into the environment. A missing file is fine: in production the +# variables come from the environment. +load_dotenv() + # ${DEV_NOTE.replace("\n//", "\n#")} def dev_deliver(d: Delivery) -> None: @@ -174,7 +257,7 @@ auth = Adapter( ) current_user = RequireUser(auth) -# Your web app calls /auth on this server with cookies, so it needs credentialed CORS. +# ${CORS_COMMENT.replace("\n//", "\n#")} app.add_middleware( CORSMiddleware, allow_origins=os.environ.get("UI_ORIGINS", "${opts.webOrigin}").split(","), @@ -201,6 +284,12 @@ function djangoSnippet(opts: SnippetOptions): string { return `# settings.py import os +from dotenv import load_dotenv + +# Reads .env into the environment. A missing file is fine: in production the +# variables come from the environment. +load_dotenv() + # ${DEV_NOTE.replace("\n//", "\n#")} def _dev_deliver(d): @@ -215,8 +304,13 @@ SEAMLESS_AUTH = { "jwks_kid": os.environ["JWKS_KID"], "deliver": None if os.environ.get("APP_ENV") == "production" else _dev_deliver, } -# Your web app (${opts.webOrigin}) calls /auth with cookies, so it needs -# credentialed CORS for the origins in UI_ORIGINS (django-cors-headers). + +# ${CORS_COMMENT.replace("\n//", "\n#")} +# django-cors-headers answers it; its middleware goes first. +INSTALLED_APPS += ["corsheaders"] +MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware") +CORS_ALLOWED_ORIGINS = [o.strip() for o in os.environ.get("UI_ORIGINS", "${opts.webOrigin}").split(",")] +CORS_ALLOW_CREDENTIALS = True # urls.py from django.urls import include, path diff --git a/src/core/redact.test.ts b/src/core/redact.test.ts index 60cc445..062eb68 100644 --- a/src/core/redact.test.ts +++ b/src/core/redact.test.ts @@ -12,7 +12,7 @@ describe("redactToken", () => { }); }); -describe("scrubTokens — by key", () => { +describe("scrubTokens: by key", () => { it("masks token-like fields recursively", () => { expect( scrubTokens({ @@ -66,7 +66,7 @@ describe("scrubTokens — by key", () => { }); }); -describe("scrubTokens — by shape", () => { +describe("scrubTokens: by shape", () => { // Key matching cannot help here: the secret has no key of its own, it is quoted // inside a message the server wrote. it("masks a JWT embedded in a string value", () => { diff --git a/src/core/snippets.test.ts b/src/core/snippets.test.ts index a8db34e..714b11f 100644 --- a/src/core/snippets.test.ts +++ b/src/core/snippets.test.ts @@ -119,15 +119,20 @@ describe("Go, Rust and Python backends", () => { expect(backendInstalls(native("gin", "go"))).toEqual([ { command: "go", args: ["get", "github.com/fells-code/seamless-auth-go@latest"], display: "go get github.com/fells-code/seamless-auth-go@latest", run: true }, ]); - expect(backendInstalls(native("axum", "rust", "cargo"))[0].display).toBe("cargo add seamless-auth"); + expect(backendInstalls(native("axum", "rust", "cargo")).map((i) => i.display)).toEqual([ + "cargo add seamless-auth", + "cargo add tower-http --features cors", + ]); expect(backendInstalls(native("fastapi", "python", "uv"))[0]).toMatchObject({ - args: ["add", "seamless-auth[fastapi]>=0.2"], - display: 'uv add "seamless-auth[fastapi]>=0.2"', + args: ["add", "seamless-auth[fastapi]>=0.2", "python-dotenv"], + display: 'uv add "seamless-auth[fastapi]>=0.2" python-dotenv', run: true, }); - expect(backendInstalls(native("django", "python", "poetry"))[0].display).toBe('poetry add "seamless-auth[django]>=0.2"'); + expect(backendInstalls(native("django", "python", "poetry"))[0].display).toBe( + 'poetry add "seamless-auth[django]>=0.2" python-dotenv django-cors-headers', + ); expect(backendInstalls(native("django", "python", "pip"))[0]).toMatchObject({ - display: 'pip install "seamless-auth[django]>=0.2"', + display: 'pip install "seamless-auth[django]>=0.2" python-dotenv django-cors-headers', run: false, }); expect(backendInstalls(backend("express", true)).map((i) => i.display)).toEqual([ @@ -143,10 +148,58 @@ describe("Go, Rust and Python backends", () => { expect(envLoadingHint(backend("express", true))).toContain("--env-file=.env"); expect(envLoadingHint(native("gin", "go"))).toContain("godotenv"); expect(envLoadingHint(native("axum", "rust", "cargo"))).toContain("dotenvy"); - expect(envLoadingHint(native("fastapi", "python", "uv"))).toContain("--env-file"); + expect(envLoadingHint(native("fastapi", "python", "uv"))).toContain("python-dotenv"); + expect(envLoadingHint(native("fastapi", "python", "uv"))).not.toContain("--env-file"); expect(envLoadingHint(native("django", "python", "pip"))).toContain("settings.py"); }); + it("loads the .env with python-dotenv in the Python code", () => { + for (const framework of ["fastapi", "django"] as const) { + const code = backendSnippet(native(framework, "python", "uv"), on); + expect(code).toContain("from dotenv import load_dotenv"); + expect(code.indexOf("load_dotenv()")).toBeLessThan(code.indexOf('os.environ["AUTH_SERVER_URL"]')); + } + }); + + it.each(["nethttp", "chi", "gin", "echo"] as const)("gives Go (%s) credentialed CORS for UI_ORIGINS", (framework) => { + const code = backendSnippet(native(framework, "go"), on); + expect(code).toContain('cmp.Or(os.Getenv("UI_ORIGINS"), "http://localhost:5173")'); + // CORS goes ahead of the routes it covers. + expect(code.indexOf("uiOrigins")).toBeLessThan(code.indexOf("auth.Handler()")); + if (framework === "echo") { + expect(code).toContain("e.Use(middleware.CORSWithConfig(middleware.CORSConfig{"); + expect(code).toContain("AllowCredentials: true,"); + expect(code).toContain('"github.com/labstack/echo/v4/middleware"'); + expect(code).not.toContain('"slices"'); + } else { + expect(code).toContain('h.Set("Access-Control-Allow-Credentials", "true")'); + expect(code).toContain('"slices"'); + } + }); + + it("wires each Go router's CORS its own way", () => { + expect(backendSnippet(native("nethttp", "go"), on)).toContain("http.ListenAndServe(addr, withCORS(mux))"); + expect(backendSnippet(native("chi", "go"), on)).toContain("r.Use(withCORS)"); + expect(backendSnippet(native("gin", "go"), on)).toContain("r.Use(func(c *gin.Context) {"); + expect(backendSnippet(native("gin", "go"), on)).toContain("c.AbortWithStatus(http.StatusNoContent)"); + }); + + it("gives Axum a tower-http CorsLayer that leaves the console out", () => { + const code = backendSnippet(native("axum", "rust", "cargo"), on); + expect(code).toContain("use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};"); + expect(code).toContain(".allow_origin(AllowOrigin::list(ui_origins))"); + expect(code).toContain(".allow_credentials(true)"); + expect(code.indexOf(".layer(cors)")).toBeLessThan(code.indexOf("auth.console_router()")); + }); + + it("gives Django credentialed CORS with django-cors-headers", () => { + const code = backendSnippet(native("django", "python", "pip"), on); + expect(code).toContain('INSTALLED_APPS += ["corsheaders"]'); + expect(code).toContain('MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware")'); + expect(code).toContain('os.environ.get("UI_ORIGINS", "http://localhost:5173")'); + expect(code).toContain("CORS_ALLOW_CREDENTIALS = True"); + }); + it("names each framework", () => { expect(frameworkName("nethttp")).toBe("Go (net/http)"); expect(frameworkName("django")).toBe("Django"); diff --git a/src/core/snippets.ts b/src/core/snippets.ts index ae4ca0f..6d15d64 100644 --- a/src/core/snippets.ts +++ b/src/core/snippets.ts @@ -148,8 +148,8 @@ export function envLoadingHint(backend: DetectedBackend): string { return "Rust does not read .env on its own: call dotenvy::dotenv() at startup (the dotenvy crate)."; default: return backend.framework === "django" - ? "load it at the top of settings.py with python-dotenv (from dotenv import load_dotenv; load_dotenv())." - : "load it at startup with python-dotenv (load_dotenv()), or run uvicorn with --env-file .env."; + ? "the code below loads it at the top of settings.py with python-dotenv, which the install step adds." + : "the code below loads it with python-dotenv (load_dotenv()), which the install step adds."; } } @@ -182,12 +182,16 @@ export function backendInstalls(backend: DetectedBackend): PackageInstall[] { case "go": return [install("go", ["get", "github.com/fells-code/seamless-auth-go@latest"])]; case "rust": - return [install("cargo", ["add", "seamless-auth"])]; + return [install("cargo", ["add", "seamless-auth"]), install("cargo", ["add", "tower-http", "--features", "cors"])]; default: { - const pkg = `seamless-auth[${backend.framework === "django" ? "django" : "fastapi"}]>=0.2`; - if (backend.packageManager === "uv") return [install("uv", ["add", pkg])]; - if (backend.packageManager === "poetry") return [install("poetry", ["add", pkg])]; - return [install("pip", ["install", pkg], false)]; + // python-dotenv loads the .env; django-cors-headers answers CORS for Django. + const pkgs = + backend.framework === "django" + ? ["seamless-auth[django]>=0.2", "python-dotenv", "django-cors-headers"] + : ["seamless-auth[fastapi]>=0.2", "python-dotenv"]; + if (backend.packageManager === "uv") return [install("uv", ["add", ...pkgs])]; + if (backend.packageManager === "poetry") return [install("poetry", ["add", ...pkgs])]; + return [install("pip", ["install", ...pkgs], false)]; } } } diff --git a/src/core/webSnippets.test.ts b/src/core/webSnippets.test.ts index c895fd0..00c793b 100644 --- a/src/core/webSnippets.test.ts +++ b/src/core/webSnippets.test.ts @@ -30,11 +30,28 @@ describe("React", () => { expect(code(web({ bundler: "react-scripts" }))).toContain("process.env.REACT_APP_API_URL"); }); - it("is one step at the app's entry", () => { - const steps = webSteps(web({ entry: "src/main.tsx" }), api); - expect(steps.map((s) => s.heading)).toEqual(["Wrap your React app"]); + it("wraps the app at its entry, then mounts the sign-in screens where the routes are", () => { + const steps = webSteps(web({ entry: "src/main.tsx", router: "src/App.tsx" }), api); + expect(steps.map((s) => s.heading)).toEqual(["Wrap your React app", "Add the sign-in screens and guard your routes"]); + expect(steps.map((s) => s.file)).toEqual(["src/main.tsx", "src/App.tsx"]); expect(webStepFile(web({}), steps[0])).toBe(path.join("web", "src/main.tsx")); + expect(steps[0].code).toContain(""); + expect(steps[1].code).toContain('import { AuthRoutes } from "@seamless-auth/react/routes";'); + expect(steps[1].code).toContain('} />'); + expect(steps[1].code).toContain(''); + expect(steps[1].code).toContain("{ children }: { children: ReactNode }"); + expect(webSteps(web({ entry: "src/main.tsx" }), api)[1].file).toBe("src/main.tsx"); + }); + + it("prints plain JSX for a JavaScript app", () => { + const routes = webSteps(web({ typescript: false }), api)[1].code; + expect(routes).not.toContain("ReactNode"); + expect(routes).toContain("function RequireAuth({ children }) {"); + }); + + it("installs react-router-dom with the binding when the app has none", () => { expect(webPackages(web({}))).toEqual(["@seamless-auth/react"]); + expect(webPackages(web({ missingPeers: ["react-router-dom"] }))).toEqual(["@seamless-auth/react", "react-router-dom"]); }); }); diff --git a/src/core/webSnippets.ts b/src/core/webSnippets.ts index 6e98c82..fd74ca9 100644 --- a/src/core/webSnippets.ts +++ b/src/core/webSnippets.ts @@ -44,19 +44,51 @@ const ENTRY_SCREENS_COMMENT = function reactSteps(web: DetectedWeb): WebStep[] { const variable = webApiUrlVariable(web); const read = web.bundler === "react-scripts" ? `process.env.${variable}` : `import.meta.env.${variable}`; + const ts = web.typescript; return [ { heading: "Wrap your React app", file: web.entry, - code: `import { AuthProvider, useAuth } from "@seamless-auth/react"; + code: `import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "@seamless-auth/react"; + +// Wrap your app once, at the root, inside your router (keep yours if the app +// already has one). AuthProvider talks to your backend's /auth routes. + + + + +`, + }, + { + heading: "Add the sign-in screens and guard your routes", + file: web.router ?? web.entry, + code: `${ts ? 'import type { ReactNode } from "react";\n' : ""}import { Navigate, Route, Routes } from "react-router-dom"; +import { useAuth } from "@seamless-auth/react"; +import { AuthRoutes } from "@seamless-auth/react/routes"; + +// Pages that need a signed-in user send everyone else to the sign-in screens. +function RequireAuth({ children }${ts ? ": { children: ReactNode }" : ""}) { + const { isAuthenticated, loading } = useAuth(); + if (loading && !isAuthenticated) return null; + return isAuthenticated ? <>{children} : ; +} + +// The sign-in screens are for signed-out visitors only. +function SignIn() { + const { isAuthenticated, loading } = useAuth(); + if (loading) return null; + return isAuthenticated ? : ; +} -// Wrap your app once, at the root. It talks to your backend's /auth routes. - - - + + {/* Your routes, wrapped in RequireAuth where they need a signed-in user: */} + } /> + {/* Last: every other path is a sign-in screen (/login, /passkey-login, ...). */} + } /> + -// Anywhere below it: -const { isAuthenticated, user, logout } = useAuth();`, +// In a component: const { user, logout } = useAuth();`, }, ]; } diff --git a/src/prompts/projectSetup.ts b/src/prompts/projectSetup.ts index bdbb3d0..e4279f1 100644 --- a/src/prompts/projectSetup.ts +++ b/src/prompts/projectSetup.ts @@ -361,11 +361,11 @@ async function resolveAdminMode( }, { value: "image", - label: "Separate container — official Docker image", + label: "Separate container: the official Docker image", }, { value: "source", - label: "Separate container — clone repo for modification", + label: "Separate container: clone the repo to modify it", }, { value: "none",