diff --git a/.changeset/seamless-add-go-rust-python.md b/.changeset/seamless-add-go-rust-python.md new file mode 100644 index 0000000..3b8f275 --- /dev/null +++ b/.changeset/seamless-add-go-rust-python.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +`seamless add` now wires Go (net/http, Gin, chi, Echo), Rust (Axum) and Python (FastAPI, Django) backends on the seamless-auth-go, seamless-auth crate and seamless-auth PyPI adapters. It reads go.mod, Cargo.toml, pyproject.toml or requirements.txt, installs with `go get`, `cargo add`, `uv add` or `poetry add` (printing the `pip install` for a plain pip project), says how the language loads `.env`, and prints the code for that framework, including the development-only code logging and the admin console mount. diff --git a/README.md b/README.md index 51ac2c8..1cc01b6 100644 --- a/README.md +++ b/README.md @@ -288,16 +288,18 @@ 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 an Express or Fastify backend and a React web app, at the project root or in `api/`, `server/`, `backend/`, `web/`, `client/`, `frontend/`, `apps/*` or `packages/*`, then: +It finds a backend and a React web app, at the project root or in `api/`, `server/`, `backend/`, `web/`, `client/`, `frontend/`, `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). - 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, signing key id, and a cookie secret if you have none) and the web app's API URL in `.env.local`. It keeps every value you already have and every comment. -- installs the adapter and the React SDK with your own package manager (`--skip-install` prints the commands instead). -- prints the lines to add to your server and to your React root, in TypeScript or JavaScript to match your project. It never edits your source files. +- installs the adapter and the React SDK 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 React root, in your backend's language (TypeScript or JavaScript to match a Node project). It never edits your source files. 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. -Go, Rust, Python, Angular, Vue and Svelte projects are tracked in [#248](https://github.com/fells-code/seamless-cli/issues/248). +Angular, Vue and Svelte web apps are tracked in [#248](https://github.com/fells-code/seamless-cli/issues/248). ## Running your project diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index c931b02..fdefd25 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.2% + + coverage: 99.3% @@ -17,7 +17,7 @@ coverage coverage - 99.2% - 99.2% + 99.3% + 99.3% diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts index 3126bd8..7467ad1 100644 --- a/src/commands/add.test.ts +++ b/src/commands/add.test.ts @@ -146,7 +146,7 @@ describe("seamless add --local", () => { it("explains when there is no backend to add to", async () => { fs.rmSync(path.join(root, "api"), { recursive: true }); - await expect(addSeamlessAuth({ dir: root, local: true, yes: true })).rejects.toThrow(/no Express or Fastify backend/); + await expect(addSeamlessAuth({ dir: root, local: true, yes: true })).rejects.toThrow(/no backend to add/); }); }); @@ -236,10 +236,9 @@ describe("seamless add, edge cases", () => { }); it("says which stacks it does not wire yet", async () => { - write("worker/go.mod", "module x"); - write("go.mod", "module x"); + write("server/requirements.txt", "flask==3\n"); await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); - expect(logs.join("\n")).toContain("Also found Go, which seamless add does not wire yet"); + expect(logs.join("\n")).toContain("Also found Python without FastAPI or Django, which seamless add does not wire yet"); }); it("wires a Create React App web app on its own port and variable", async () => { @@ -274,3 +273,35 @@ describe("seamless add, edge cases", () => { expect(fs.existsSync(path.join(root, "seamless", "docker-compose.yml"))).toBe(true); }); }); + +describe("seamless add for a Go or Python backend", () => { + it("runs go get and prints Go", async () => { + fs.rmSync(path.join(root, "api"), { recursive: true }); + write("server/go.mod", "module example.com/app\nrequire github.com/gin-gonic/gin v1.12.0\n"); + write("server/main.go", "package main"); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" }); + + expect(vi.mocked(runCommand).mock.calls[0]).toEqual([ + "go", + ["get", "github.com/fells-code/seamless-auth-go@latest"], + path.join(root, "server"), + ]); + expect(parseEnv(path.join(root, "server", ".env")).AUTH_SERVER_URL).toBe("http://localhost:5312"); + const out = logs.join("\n"); + expect(out).toContain("Backend: Gin in server"); + expect(out).toContain("Add Seamless Auth to your Gin app (server/main.go)"); + expect(out).toContain("godotenv"); + }); + + it("prints a pip install rather than guessing the environment", async () => { + fs.rmSync(path.join(root, "api"), { recursive: true }); + write("requirements.txt", "fastapi\n"); + write("main.py", ""); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" }); + + 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"); + }); +}); diff --git a/src/commands/add.ts b/src/commands/add.ts index 7099018..ef46b96 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -26,8 +26,11 @@ import { import { listApplications } from "../core/portal.js"; import { generateSecret } from "../core/secrets.js"; import { - backendPackages, + backendInstalls, backendSnippet, + envLoadingHint, + frameworkName, + type PackageInstall, WEB_PACKAGES, webApiUrlVariable, webSnippet, @@ -117,7 +120,7 @@ export async function addSeamlessAuth(opts: AddOptions) { const { backend, web } = project; if (!backend) { throw new Error( - "Found no Express or Fastify backend to add Seamless Auth to. seamless add looks for one in package.json at the project root and 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 the project root or in api/, server/, backend/, apps/* and packages/*. To start a new project instead, run seamless init.", ); } @@ -179,7 +182,7 @@ export async function addSeamlessAuth(opts: AddOptions) { const installs = installCommands(root, backend, web); if (opts.install !== false) { - for (const install of installs) { + for (const install of installs.filter((i) => i.run)) { console.log(kleur.dim(`\n$ ${install.display}`)); try { await runCommand(install.command, install.args, install.cwd); @@ -195,7 +198,7 @@ export async function addSeamlessAuth(opts: AddOptions) { web, webOrigin, serveConsole, - installs: opts.install === false ? installs.map((i) => i.display) : [], + installs: installs.filter((i) => opts.install === false || !i.run).map((i) => i.display), }); } @@ -226,10 +229,6 @@ function reportDetection(project: DetectedProject) { } } -function frameworkName(framework: DetectedBackend["framework"]) { - return framework === "express" ? "Express" : "Fastify"; -} - async function resolveMode(opts: AddOptions): Promise<"local" | "managed"> { if (opts.local) return "local"; if (opts.appId) return "managed"; @@ -378,6 +377,7 @@ interface Install { args: string[]; cwd: string; display: string; + run: boolean; } function installCommands( @@ -385,17 +385,18 @@ function installCommands( backend: DetectedBackend, web: DetectedWeb | undefined, ): Install[] { - const out: Install[] = []; - const add = (dir: string, manager: DetectedBackend["packageManager"], packages: string[], dev = false) => { - if (packages.length === 0) return; - const { command, args } = addPackagesCommand(manager, packages, dev); - const prefix = dir === "." ? "" : `cd ${dir} && `; - out.push({ command, args, cwd: path.join(root, dir), display: `${prefix}${command} ${args.join(" ")}` }); - }; - const packages = backendPackages(backend); - add(backend.dir, backend.packageManager, packages.deps); - add(backend.dir, backend.packageManager, packages.dev, true); - if (web) add(web.dir, web.packageManager, WEB_PACKAGES); + const located = (dir: string, i: PackageInstall): Install => ({ + command: i.command, + args: i.args, + run: i.run, + cwd: path.join(root, dir), + display: `${dir === "." ? "" : `cd ${dir} && `}${i.display}`, + }); + const out = backendInstalls(backend).map((i) => located(backend.dir, i)); + if (web) { + const { command, args } = addPackagesCommand(web.packageManager, WEB_PACKAGES); + out.push(located(web.dir, { command, args, display: `${command} ${args.join(" ")}`, run: true })); + } return out; } @@ -414,6 +415,9 @@ function printNextSteps(ctx: { if (ctx.installs.length > 0) { 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.")); + } } if (mode === "local") { @@ -427,7 +431,7 @@ function printNextSteps(ctx: { ); console.log( kleur.dim( - ` After you create the app and before your routes. It reads the values written to ${path.join(backend.dir, ".env")}:\n if your app does not load .env yet, start it with node --env-file=.env (Node 20.6+) or import "dotenv/config" first.\n`, + ` After you create the app and before your routes. Merge the imports with your own. It reads the\n values written to ${path.join(backend.dir, ".env")}: ${envLoadingHint(backend)}\n`, ), ); console.log(indent(backendSnippet(backend, { webOrigin: ctx.webOrigin, serveConsole: ctx.serveConsole && mode === "local" }))); diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index b21f4c0..f0ea1fa 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -113,14 +113,16 @@ NON-INTERACTIVE heading: "add [path]", body: `Add Seamless Auth to an existing project -Finds an Express or Fastify backend and a React web app (at the project root, or in -api/, server/, backend/, web/, client/, frontend/, apps/* or packages/*), then: +Finds a backend (Express, Fastify, Go net/http, Gin, chi, Echo, Axum, FastAPI or +Django) and a React web app (at the project root, or in api/, server/, backend/, +web/, client/, frontend/, apps/* or packages/*), 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, signing key id, a cookie secret), keeping every value you already have, and the web app's API URL in .env.local - • Installs the adapter and SDK with the project's own package manager + • Installs the adapter and SDK with the project's own tool (npm, pnpm, yarn, + bun, go get, cargo add, uv or poetry; a pip install is printed instead) • Prints the lines of code to add. It never edits your source files --local diff --git a/src/core/detect.test.ts b/src/core/detect.test.ts index adc8429..d942301 100644 --- a/src/core/detect.test.ts +++ b/src/core/detect.test.ts @@ -60,14 +60,14 @@ describe("detectProject", () => { }); it("reports stacks it does not wire yet", () => { - write("go.mod", "module x"); + write("Cargo.toml", '[dependencies]\nactix-web = "4"\n'); write("web/package.json", { dependencies: { next: "^15", react: "^19" } }); const project = detectProject(root); expect(project.backend).toBeUndefined(); expect(project.web).toBeUndefined(); - expect(project.unsupported).toEqual(["Go", "Next.js"]); + expect(project.unsupported).toEqual(["Rust without Axum", "Next.js"]); }); it("reads a Create React App build as react-scripts", () => { @@ -98,3 +98,93 @@ describe("addPackagesCommand", () => { expect(addPackagesCommand("bun", ["a"])).toEqual({ command: "bun", args: ["add", "a"] }); }); }); + +describe("detectProject, Go, Rust and Python", () => { + it.each([ + ["github.com/gin-gonic/gin v1.12.0", "gin"], + ["github.com/go-chi/chi/v5 v5.2.0", "chi"], + ["github.com/labstack/echo/v4 v4.13.0", "echo"], + ["github.com/jackc/pgx/v5 v5.7.0", "nethttp"], + ] as const)("reads %s from go.mod as %s", (requirement, framework) => { + write("go.mod", `module example.com/app\n\ngo 1.26\n\nrequire ${requirement}\n`); + write("cmd/server/main.go", "package main"); + expect(detectProject(root).backend).toEqual({ + framework, + ecosystem: "go", + dir: ".", + typescript: false, + entry: path.join("cmd", "server", "main.go"), + packageManager: "go", + }); + }); + + it("finds a Go entry in any cmd/ folder, or none", () => { + write("api/go.mod", "module x"); + write("api/cmd/worker/main.go", "package main"); + expect(detectProject(root).backend?.entry).toBe(path.join("cmd", "worker", "main.go")); + fs.rmSync(path.join(root, "api", "cmd"), { recursive: true }); + expect(detectProject(root).backend?.entry).toBeUndefined(); + }); + + it("reads Axum from Cargo.toml", () => { + write("server/Cargo.toml", '[dependencies]\naxum = "0.8"\ntokio = { version = "1" }\n'); + write("server/src/main.rs", "fn main() {}"); + expect(detectProject(root).backend).toMatchObject({ + framework: "axum", + ecosystem: "rust", + dir: "server", + entry: "src/main.rs", + packageManager: "cargo", + }); + }); + + it("reads FastAPI from pyproject.toml, with uv when it has a uv.lock", () => { + write("pyproject.toml", '[project]\ndependencies = ["fastapi>=0.115", "uvicorn"]\n'); + write("uv.lock", ""); + write("app/main.py", ""); + expect(detectProject(root).backend).toMatchObject({ + framework: "fastapi", + ecosystem: "python", + entry: "app/main.py", + packageManager: "uv", + }); + fs.rmSync(path.join(root, "uv.lock")); + expect(detectProject(root).backend?.packageManager).toBe("pip"); + }); + + it("reads Django from requirements.txt and points at its settings module", () => { + write("backend/requirements.txt", "Django==5.2\npsycopg[binary]\n"); + write( + "backend/manage.py", + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")\n', + ); + write("backend/config/settings.py", ""); + expect(detectProject(root).backend).toMatchObject({ + framework: "django", + dir: "backend", + entry: path.join("config", "settings.py"), + packageManager: "pip", + }); + }); + + it("falls back to manage.py when the settings module is not there, and reads poetry", () => { + write("pyproject.toml", '[tool.poetry.dependencies]\ndjango = "^5"\n'); + write("manage.py", "no settings module named here\n"); + expect(detectProject(root).backend).toMatchObject({ entry: "manage.py", packageManager: "poetry" }); + fs.rmSync(path.join(root, "manage.py")); + expect(detectProject(root).backend?.entry).toBeUndefined(); + }); + + it("reports Python and Rust stacks it has no adapter for", () => { + write("pyproject.toml", '[project]\ndependencies = ["flask"]\n'); + write("server/Cargo.toml", '[dependencies]\nactix-web = "4"\n'); + expect(detectProject(root)).toEqual({ + unsupported: ["Python without FastAPI or Django", "Rust without Axum"], + }); + }); + + it("does not take a package that only mentions a framework's name", () => { + write("requirements.txt", "fastapi-users==13\n"); + expect(detectProject(root).backend).toBeUndefined(); + }); +}); diff --git a/src/core/detect.ts b/src/core/detect.ts index ed5c2b3..0a144b4 100644 --- a/src/core/detect.ts +++ b/src/core/detect.ts @@ -1,17 +1,31 @@ import fs from "fs"; import path from "path"; -export type BackendFramework = "express" | "fastify"; +export type BackendFramework = + | "express" + | "fastify" + | "nethttp" + | "gin" + | "chi" + | "echo" + | "axum" + | "fastapi" + | "django"; +export type Ecosystem = "node" | "go" | "rust" | "python"; export type JsPackageManager = "npm" | "pnpm" | "yarn" | "bun"; +export type PythonPackageManager = "uv" | "poetry" | "pip"; +export type BackendPackageManager = JsPackageManager | PythonPackageManager | "go" | "cargo"; export interface DetectedBackend { framework: BackendFramework; + ecosystem: Ecosystem; dir: string; + // Node only: whether to print TypeScript. typescript: boolean; // The file the app is most likely started from, when one can be read off - // package.json or found at a conventional path. + // the project's manifest or found at a conventional path. entry?: string; - packageManager: JsPackageManager; + packageManager: BackendPackageManager; } export interface DetectedWeb { @@ -153,26 +167,24 @@ export function detectProject(root: string): DetectedProject { }; for (const dir of candidateDirs(root)) { - if (fs.existsSync(path.join(dir, "go.mod"))) note("Go"); - if (fs.existsSync(path.join(dir, "Cargo.toml"))) note("Rust"); - if ( - fs.existsSync(path.join(dir, "pyproject.toml")) || - fs.existsSync(path.join(dir, "requirements.txt")) - ) { - note("Python"); - } + const rel = path.relative(root, dir) || "."; if (fs.existsSync(path.join(dir, "angular.json"))) note("Angular"); + // Every folder is read, so a stack that is not wired yet is reported even + // after a backend has been found; the first backend found is the one used. + const native = detectNativeBackend(dir, rel, note); + if (native && !result.backend) result.backend = native; + const pkg = readPackageJson(dir); if (!pkg) continue; const deps = dependencies(pkg); const typescript = fs.existsSync(path.join(dir, "tsconfig.json")) || "typescript" in deps; - const rel = path.relative(root, dir) || "."; if (!result.backend && ("express" in deps || "fastify" in deps)) { result.backend = { framework: "express" in deps ? "express" : "fastify", + ecosystem: "node", dir: rel, typescript, entry: findEntry(dir, pkg, BACKEND_ENTRIES), @@ -199,6 +211,102 @@ export function detectProject(root: string): DetectedProject { return result; } +function readText(file: string): string | undefined { + try { + return fs.readFileSync(file, "utf-8"); + } catch { + return undefined; + } +} + +function firstExisting(dir: string, candidates: string[]): string | undefined { + return candidates.find((candidate) => fs.existsSync(path.join(dir, candidate))); +} + +// Go, Rust and Python backends, read from go.mod, Cargo.toml, pyproject.toml or +// requirements.txt. A manifest for a framework seamless add has no adapter for is +// reported rather than guessed at. +function detectNativeBackend( + dir: string, + rel: string, + note: (name: string) => void, +): DetectedBackend | undefined { + const goMod = readText(path.join(dir, "go.mod")); + if (goMod !== undefined) { + const framework: BackendFramework = /github\.com\/gin-gonic\/gin\b/.test(goMod) + ? "gin" + : /github\.com\/go-chi\/chi\b/.test(goMod) + ? "chi" + : /github\.com\/labstack\/echo\b/.test(goMod) + ? "echo" + : "nethttp"; + let entry = firstExisting(dir, ["main.go", "server.go", "cmd/server/main.go", "cmd/api/main.go"]); + if (!entry) { + try { + const cmd = fs.readdirSync(path.join(dir, "cmd")).find((name) => + fs.existsSync(path.join(dir, "cmd", name, "main.go")), + ); + if (cmd) entry = path.join("cmd", cmd, "main.go"); + } catch { + // no cmd/ directory + } + } + return { framework, ecosystem: "go", dir: rel, typescript: false, entry, packageManager: "go" }; + } + + const cargo = readText(path.join(dir, "Cargo.toml")); + if (cargo !== undefined) { + if (!/^\s*axum\s*=/m.test(cargo)) { + note("Rust without Axum"); + return undefined; + } + return { + framework: "axum", + ecosystem: "rust", + dir: rel, + typescript: false, + entry: firstExisting(dir, ["src/main.rs"]), + packageManager: "cargo", + }; + } + + const pyproject = readText(path.join(dir, "pyproject.toml")); + const requirements = readText(path.join(dir, "requirements.txt")); + if (pyproject === undefined && requirements === undefined) return undefined; + const manifest = `${pyproject ?? ""}\n${requirements ?? ""}`.toLowerCase(); + const declares = (name: string) => new RegExp(`(^|["'\\s])${name}(\\[|[<>=~!;"'\\s]|$)`, "m").test(manifest); + const framework: BackendFramework | undefined = declares("django") + ? "django" + : declares("fastapi") + ? "fastapi" + : undefined; + if (!framework) { + note("Python without FastAPI or Django"); + return undefined; + } + const packageManager: PythonPackageManager = fs.existsSync(path.join(dir, "uv.lock")) + ? "uv" + : fs.existsSync(path.join(dir, "poetry.lock")) || /\[tool\.poetry[\].]/.test(pyproject ?? "") + ? "poetry" + : "pip"; + const entry = + framework === "django" + ? djangoSettings(dir) + : firstExisting(dir, ["main.py", "app/main.py", "app.py", "src/main.py", "api/main.py"]); + return { framework, ecosystem: "python", dir: rel, typescript: false, entry, packageManager }; +} + +// The settings file manage.py points at, which is where the configuration goes. +function djangoSettings(dir: string): string | undefined { + const manage = readText(path.join(dir, "manage.py")); + const module = manage?.match(/DJANGO_SETTINGS_MODULE["']\s*,\s*["']([\w.]+)["']/)?.[1]; + if (module) { + const file = `${module.replace(/\./g, "/")}.py`; + if (fs.existsSync(path.join(dir, file))) return file; + } + return manage === undefined ? undefined : "manage.py"; +} + /** The command that adds packages with a project's own package manager. */ export function addPackagesCommand( manager: JsPackageManager, diff --git a/src/core/nativeSnippets.ts b/src/core/nativeSnippets.ts new file mode 100644 index 0000000..5757ebf --- /dev/null +++ b/src/core/nativeSnippets.ts @@ -0,0 +1,253 @@ +import type { DetectedBackend } from "./detect.js"; + +// The code `seamless add` prints for Go, Rust and Python backends, on the +// seamless-auth-go, seamless-auth (crates.io) and seamless-auth (PyPI) adapters. +// Each follows that adapter's README and the api starter built on it, with the +// development-only logging of one-time codes the starters use: the auth server +// does not send messages in development, so without it nobody sees a code. + +export interface SnippetOptions { + webOrigin: string; + serveConsole: boolean; +} + +const DEV_NOTE = + "Development only: log one-time codes and magic links instead of sending them,\n// so you can sign in without a mail or SMS provider. Replace before deploying."; + +function goOptions(): string { + return `opts := seamlessauth.Options{ + AuthServerURL: os.Getenv("AUTH_SERVER_URL"), + AuthServerIssuer: os.Getenv("AUTH_SERVER_ISSUER"), + CookieSecret: os.Getenv("COOKIE_SIGNING_KEY"), + ServiceSecret: os.Getenv("API_SERVICE_TOKEN"), + JWKSKid: os.Getenv("JWKS_KID"), +} +// ${DEV_NOTE} +if os.Getenv("APP_ENV") != "production" { + opts.Deliver = func(_ context.Context, d seamlessauth.Delivery) error { + log.Printf("Dev OTP to=%s code=%s link=%s", d.To, d.Token, d.MagicLinkURL) + return nil + } +} +auth, err := seamlessauth.New(opts) +if err != nil { + log.Fatal(err) +}`; +} + +const GO_ME = `func(w http.ResponseWriter, r *http.Request) { + user, _ := seamlessauth.UserFromContext(r.Context()) + json.NewEncoder(w).Encode(map[string]string{"id": user.ID}) +}`; + +function goSnippet(framework: DetectedBackend["framework"], opts: SnippetOptions): string { + const imports = [ + '"context"', + '"encoding/json"', + '"log"', + '"net/http"', + '"os"', + "", + '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"'); + 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 consoleLine = (mount: string) => + opts.serveConsole + ? `\n// The admin dashboard at /console.\nif os.Getenv("SERVE_ADMIN_CONSOLE") == "true" {\n\t${mount}\n}` + : ""; + + switch (framework) { + case "gin": + return `${header} +${cors} +r.Any("/auth/*path", gin.WrapH(http.StripPrefix("/auth", auth.Handler())))${consoleLine( + 'r.Any("/console/*path", gin.WrapH(http.StripPrefix("/console", auth.ConsoleHandler())))', + )} + +// Protect a route. RequireAuth answers 401 without a session. +r.GET("/api/me", gin.WrapH(auth.RequireAuth(http.HandlerFunc(${GO_ME}))))`; + case "chi": + return `${header} +${cors} +r.Mount("/auth", http.StripPrefix("/auth", auth.Handler()))${consoleLine( + 'r.Mount("/console", http.StripPrefix("/console", auth.ConsoleHandler()))', + )} + +// Protect a route. RequireAuth answers 401 without a session. +r.With(auth.RequireAuth).Get("/api/me", ${GO_ME})`; + case "echo": + return `${header} +${cors} +e.Any("/auth/*", echo.WrapHandler(http.StripPrefix("/auth", auth.Handler())))${consoleLine( + 'e.Any("/console/*", echo.WrapHandler(http.StripPrefix("/console", auth.ConsoleHandler())))', + )} + +// Protect a route. RequireAuth answers 401 without a session. +e.GET("/api/me", echo.WrapHandler(auth.RequireAuth(http.HandlerFunc(${GO_ME}))))`; + default: + return `${header} +${cors} +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})))`; + } +} + +function rustSnippet(opts: SnippetOptions): string { + return `use std::collections::HashMap; +use std::net::SocketAddr; + +use axum::{Json, routing::get}; +use seamless_auth::{Adapter, Delivery, User}; + +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")) + .cookie_secret(env("COOKIE_SIGNING_KEY")) + .service_secret(env("API_SERVICE_TOKEN")) + .jwks_kid(env("JWKS_KID")); +if let Ok(issuer) = std::env::var("AUTH_SERVER_ISSUER") { + builder = builder.auth_server_issuer(issuer); +} +// ${DEV_NOTE} +if std::env::var("APP_ENV").as_deref() != Ok("production") { + builder = builder.deliver(|d: Delivery| async move { + println!("Dev OTP to={} code={}", d.to, d.token.unwrap_or_default()); + Ok::<_, std::convert::Infallible>(()) + }); +} +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. +// Added to the router you already have: +let app = app + // Protect a route. require_auth answers 401 without a session. + .route( + "/api/me", + get(|user: User| async move { Json(HashMap::from([("id", user.id)])) }) + .route_layer(auth.require_auth()), + ) + .nest("/auth", auth.router());${ + opts.serveConsole + ? ` +// The admin dashboard at /console (merged, not nested: it serves /console/ too). +let app = if std::env::var("SERVE_ADMIN_CONSOLE").as_deref() == Ok("true") { + app.merge(auth.console_router()) +} else { + app +};` + : "" + } + +// Serve with connect info, so the adapter can forward the client address. +axum::serve(listener, app.into_make_service_with_connect_info::()).await?;`; +} + +function fastapiSnippet(opts: SnippetOptions): string { + return `import os + +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" : ""} + + +# ${DEV_NOTE.replace("\n//", "\n#")} +def dev_deliver(d: Delivery) -> None: + print(f"Dev OTP to={d.to} code={d.token} link={d.magic_link_url}", flush=True) + + +auth = Adapter( + auth_server_url=os.environ["AUTH_SERVER_URL"], + auth_server_issuer=os.environ.get("AUTH_SERVER_ISSUER") or None, + cookie_secret=os.environ["COOKIE_SIGNING_KEY"], + service_secret=os.environ["API_SERVICE_TOKEN"], + jwks_kid=os.environ["JWKS_KID"], + deliver=None if os.environ.get("APP_ENV") == "production" else dev_deliver, +) +current_user = RequireUser(auth) + +# Your web app calls /auth on this server with cookies, so it needs credentialed CORS. +app.add_middleware( + CORSMiddleware, + allow_origins=os.environ.get("UI_ORIGINS", "${opts.webOrigin}").split(","), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(auth_router(auth))${ + opts.serveConsole + ? ` +if os.environ.get("SERVE_ADMIN_CONSOLE") == "true": + app.include_router(console_router(auth))` + : "" + } + + +# Protect a route. RequireUser answers 401 without a session. +@app.get("/api/me") +def me(user: User = Depends(current_user)) -> dict[str, str]: + return {"id": user.id}`; +} + +function djangoSnippet(opts: SnippetOptions): string { + return `# settings.py +import os + + +# ${DEV_NOTE.replace("\n//", "\n#")} +def _dev_deliver(d): + print(f"Dev OTP to={d.to} code={d.token} link={d.magic_link_url}", flush=True) + + +SEAMLESS_AUTH = { + "auth_server_url": os.environ["AUTH_SERVER_URL"], + "auth_server_issuer": os.environ.get("AUTH_SERVER_ISSUER") or None, + "cookie_secret": os.environ["COOKIE_SIGNING_KEY"], + "service_secret": os.environ["API_SERVICE_TOKEN"], + "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). + +# urls.py +from django.urls import include, path +${opts.serveConsole ? "from seamless_auth.django import console_urlpatterns\n" : ""} +from . import views # where me() below lives + +urlpatterns += [ + path("auth/", include("seamless_auth.django")),${ + opts.serveConsole ? '\n path("console/", include(console_urlpatterns)),' : "" + } + path("api/me", views.me), +] + +# views.py: protect a view. require_auth answers 401 without a session. +from django.http import JsonResponse +from seamless_auth.django import require_auth, user_of + + +@require_auth +def me(request): + return JsonResponse({"id": user_of(request).id})`; +} + +/** The code for a Go, Rust or Python backend. */ +export function nativeBackendSnippet(backend: DetectedBackend, opts: SnippetOptions): string { + switch (backend.ecosystem) { + case "go": + return goSnippet(backend.framework, opts); + case "rust": + return rustSnippet(opts); + default: + return backend.framework === "django" ? djangoSnippet(opts) : fastapiSnippet(opts); + } +} diff --git a/src/core/snippets.test.ts b/src/core/snippets.test.ts index 2a7e2e7..e924e12 100644 --- a/src/core/snippets.test.ts +++ b/src/core/snippets.test.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from "vitest"; import type { DetectedBackend, DetectedWeb } from "./detect.js"; -import { backendPackages, backendSnippet, webApiUrlVariable, webSnippet } from "./snippets.js"; +import { + backendInstalls, + backendPackages, + backendSnippet, + envLoadingHint, + frameworkName, + webApiUrlVariable, + webSnippet, +} from "./snippets.js"; const backend = (framework: "express" | "fastify", typescript: boolean): DetectedBackend => ({ framework, + ecosystem: "node", typescript, dir: "api", packageManager: "npm", @@ -69,3 +78,96 @@ describe("backendPackages", () => { }); }); }); + +describe("Go, Rust and Python backends", () => { + const native = ( + framework: DetectedBackend["framework"], + ecosystem: DetectedBackend["ecosystem"], + packageManager: DetectedBackend["packageManager"] = "go", + ): DetectedBackend => ({ framework, ecosystem, dir: ".", typescript: false, packageManager }); + const on = { webOrigin: "http://localhost:5173", serveConsole: true }; + const off = { webOrigin: "http://localhost:5173", serveConsole: false }; + + it.each([ + ["nethttp", 'mux.Handle("/auth/", http.StripPrefix("/auth", auth.Handler()))', 'mux.Handle("/console/"'], + ["gin", 'r.Any("/auth/*path", gin.WrapH(http.StripPrefix("/auth", auth.Handler())))', 'r.Any("/console/*path"'], + ["chi", 'r.Mount("/auth", http.StripPrefix("/auth", auth.Handler()))', 'r.Mount("/console"'], + ["echo", 'e.Any("/auth/*", echo.WrapHandler(http.StripPrefix("/auth", auth.Handler())))', 'e.Any("/console/*"'], + ] as const)("mounts the Go adapter for %s", (framework, mount, consoleMount) => { + const code = backendSnippet(native(framework, "go"), on); + expect(code).toContain('seamlessauth "github.com/fells-code/seamless-auth-go"'); + expect(code).toContain(mount); + expect(code).toContain(consoleMount); + expect(code).toContain("opts.Deliver = func"); + expect(backendSnippet(native(framework, "go"), off)).not.toContain("ConsoleHandler"); + }); + + it("imports the Go router packages a framework needs", () => { + expect(backendSnippet(native("gin", "go"), on)).toContain('"github.com/gin-gonic/gin"'); + expect(backendSnippet(native("echo", "go"), on)).toContain('"github.com/labstack/echo/v4"'); + expect(backendSnippet(native("nethttp", "go"), on)).not.toContain("gin-gonic"); + }); + + it("extends the existing Axum router, and serves with connect info", () => { + const code = backendSnippet(native("axum", "rust", "cargo"), on); + expect(code).toContain("let app = app"); + expect(code).not.toContain("Router::new()"); + expect(code).toContain('.nest("/auth", auth.router())'); + expect(code).toContain("app.merge(auth.console_router())"); + expect(code).toContain("into_make_service_with_connect_info::()"); + expect(backendSnippet(native("axum", "rust", "cargo"), off)).not.toContain("console_router"); + }); + + it("wires FastAPI and Django through their bindings", () => { + const fastapi = backendSnippet(native("fastapi", "python", "uv"), on); + expect(fastapi).toContain("app.include_router(auth_router(auth))"); + expect(fastapi).toContain("app.include_router(console_router(auth))"); + expect(fastapi).toContain("def me(user: User = Depends(current_user))"); + expect(backendSnippet(native("fastapi", "python", "uv"), off)).not.toContain("console_router"); + + const django = backendSnippet(native("django", "python", "pip"), on); + expect(django).toContain("SEAMLESS_AUTH = {"); + expect(django).toContain('path("auth/", include("seamless_auth.django"))'); + expect(django).toContain('path("console/", include(console_urlpatterns))'); + expect(django).toContain("from . import views"); + expect(django).toContain("@require_auth"); + expect(backendSnippet(native("django", "python", "pip"), off)).not.toContain("console_urlpatterns"); + }); + + it("installs with each ecosystem's own tool", () => { + 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("fastapi", "python", "uv"))[0]).toMatchObject({ + args: ["add", "seamless-auth[fastapi]>=0.2"], + display: 'uv add "seamless-auth[fastapi]>=0.2"', + run: true, + }); + expect(backendInstalls(native("django", "python", "poetry"))[0].display).toBe('poetry add "seamless-auth[django]>=0.2"'); + expect(backendInstalls(native("django", "python", "pip"))[0]).toMatchObject({ + display: 'pip install "seamless-auth[django]>=0.2"', + run: false, + }); + expect(backendInstalls(backend("express", true)).map((i) => i.display)).toEqual([ + "npm install @seamless-auth/express cookie-parser cors", + "npm install --save-dev @types/cookie-parser @types/cors", + ]); + expect(backendInstalls(backend("fastify", false)).map((i) => i.display)).toEqual([ + "npm install @seamless-auth/fastify @fastify/cors", + ]); + }); + + it("says how each ecosystem loads .env", () => { + 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("django", "python", "pip"))).toContain("settings.py"); + }); + + 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 63745b9..2fd3ebd 100644 --- a/src/core/snippets.ts +++ b/src/core/snippets.ts @@ -1,4 +1,5 @@ -import type { DetectedBackend, DetectedWeb } from "./detect.js"; +import { addPackagesCommand, type DetectedBackend, type DetectedWeb } from "./detect.js"; +import { nativeBackendSnippet } from "./nativeSnippets.js"; // What `seamless add` prints for the adopter to paste. It never edits their files: // these follow the reference apps the conformance suite runs (verify/adapter-app, @@ -44,6 +45,7 @@ export function backendSnippet( backend: DetectedBackend, opts: { webOrigin: string; serveConsole: boolean }, ): string { + if (backend.ecosystem !== "node") return nativeBackendSnippet(backend, opts); const ts = backend.typescript; if (backend.framework === "express") { const typing = ts @@ -141,6 +143,77 @@ export function webSnippet(web: DetectedWeb): string { const { isAuthenticated, user, logout } = useAuth();`; } +const FRAMEWORK_NAMES: Record = { + express: "Express", + fastify: "Fastify", + nethttp: "Go (net/http)", + gin: "Gin", + chi: "chi", + echo: "Echo", + axum: "Axum", + fastapi: "FastAPI", + django: "Django", +}; + +export function frameworkName(framework: DetectedBackend["framework"]): string { + return FRAMEWORK_NAMES[framework]; +} + +/** How the backend gets the values written to its .env, when it does not already. */ +export function envLoadingHint(backend: DetectedBackend): string { + switch (backend.ecosystem) { + case "node": + return 'if your app does not load .env yet, start it with node --env-file=.env (Node 20.6+) or import "dotenv/config" first.'; + case "go": + return "Go does not read .env on its own: export the values, or load them with github.com/joho/godotenv at startup."; + case "rust": + 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."; + } +} + +export interface PackageInstall { + command: string; + args: string[]; + // How it is shown, quoted for a shell. + display: string; + // False when seamless add cannot know it would install into the right place + // (pip, with no project tool to say which environment), so it only prints it. + run: boolean; +} + +function install(command: string, args: string[], run = true): PackageInstall { + const display = [command, ...args.map((a) => (/[[\]\s]/.test(a) ? `"${a}"` : a))].join(" "); + return { command, args, display, run }; +} + +/** The backend's own install commands, for its ecosystem's tool. */ +export function backendInstalls(backend: DetectedBackend): PackageInstall[] { + switch (backend.ecosystem) { + case "node": { + const { deps, dev } = backendPackages(backend); + const manager = backend.packageManager as Parameters[0]; + return [ + ...(deps.length ? [addPackagesCommand(manager, deps)] : []), + ...(dev.length ? [addPackagesCommand(manager, dev, true)] : []), + ].map(({ command, args }) => install(command, args)); + } + case "go": + return [install("go", ["get", "github.com/fells-code/seamless-auth-go@latest"])]; + case "rust": + return [install("cargo", ["add", "seamless-auth"])]; + 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)]; + } + } +} + /** What to install in each half, with dev dependencies listed apart. */ export function backendPackages(backend: DetectedBackend): { deps: string[]; dev: string[] } { if (backend.framework === "express") {