Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/seamless-add-go-rust-python.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,16 +288,18 @@ seamless add --local [email protected] # an auth server on this machine
seamless add --app <id> # 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

Expand Down
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 35 additions & 4 deletions src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});

Expand Down Expand Up @@ -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: "[email protected]", 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 () => {
Expand Down Expand Up @@ -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: "[email protected]" });

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: "[email protected]" });

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");
});
});
44 changes: 24 additions & 20 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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),
});
}

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -378,24 +377,26 @@ interface Install {
args: string[];
cwd: string;
display: string;
run: boolean;
}

function installCommands(
root: string,
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;
}

Expand All @@ -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") {
Expand All @@ -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" })));
Expand Down
8 changes: 5 additions & 3 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 92 additions & 2 deletions src/core/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
Loading
Loading