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-web-frameworks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-cli": minor
---

`seamless add` now wires Angular, Vue and SvelteKit web apps on the `@seamless-auth/angular`, `@seamless-auth/vue` and `@seamless-auth/svelte` bindings, alongside React. It installs the binding (and `vue-router` or `@angular/router` when the app has no router), writes `VITE_API_URL` for Vue and SvelteKit, and prints the code for each file: the provider or plugin, the bundled sign-in screens as routes, and guards for signed-in and signed-out pages. Angular, which does not read `.env`, gets the backend URL in the printed config. Nuxt and Svelte without SvelteKit are reported rather than wired.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,18 +288,18 @@ seamless add --local --email=you@example.com # an auth server on this machine
seamless add --app <id> # or a managed application (needs seamless login)
```

It finds a backend and a 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 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).
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, 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 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.
- 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` (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.

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.

Angular, Vue and Svelte web apps are tracked in [#248](https://github.com/fells-code/seamless-cli/issues/248).
A Svelte app without SvelteKit needs a navigator for its own router, so it is reported rather than wired; see the [Svelte bindings](https://github.com/fells-code/seamless-auth-react/tree/main/packages/svelte#without-sveltekit).

## Running your project

Expand Down
38 changes: 38 additions & 0 deletions src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,41 @@ describe("seamless add for a Go or Python backend", () => {
expect(out).toContain("add it to requirements.txt");
});
});

describe("seamless add for an Angular, Vue or SvelteKit web app", () => {
it("writes no .env for Angular, serves it on 4200, and prints the URL into its config", async () => {
write("web/package.json", { dependencies: { "@angular/core": "^22", "@angular/router": "^22" } });
write("web/src/app/app.config.ts", "");
await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" });

expect(fs.existsSync(path.join(root, "web", ".env.local"))).toBe(false);
expect(parseEnv(path.join(root, "api", ".env")).UI_ORIGINS).toBe("http://localhost:4200");
expect(vi.mocked(runCommand).mock.calls.at(-1)?.[1]).toEqual(["install", "@seamless-auth/angular"]);
const out = logs.join("\n");
expect(out).toContain("Web: Angular in web");
expect(out).toContain(`Provide Seamless Auth in your Angular app (${path.join("web", "src/app/app.config.ts")})`);
expect(out).toContain('provideSeamlessAuth({ apiHost: "http://localhost:4000" })');
expect(out).toContain(`Add the sign-in screens and guard your routes (${path.join("web", "src/app/app.routes.ts")})`);
});

it("installs vue-router alongside the Vue binding when the app has none", async () => {
write("web/package.json", { dependencies: { vue: "^3.5" }, devDependencies: { vite: "^7" } });
await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" });

expect(parseEnv(path.join(root, "web", ".env.local")).VITE_API_URL).toBe("http://localhost:4000/");
expect(vi.mocked(runCommand).mock.calls.at(-1)?.[1]).toEqual(["install", "@seamless-auth/vue", "vue-router"]);
expect(logs.join("\n")).toContain("Create a router with the sign-in screens, and install Seamless Auth");
});

it("wires SvelteKit through its own steps", async () => {
write("web/package.json", { devDependencies: { "@sveltejs/kit": "^3.0.0", svelte: "^5", vite: "^7" } });
write("web/tsconfig.json", "{}");
await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false });

const out = logs.join("\n");
expect(out).toContain("Web: SvelteKit in web");
expect(out).toContain(`Create the session (${path.join("web", "src/lib/auth.ts")})`);
expect(out).toContain("Render in the browser and share the session\n");
expect(out).toContain("cd web && npm install @seamless-auth/svelte");
});
});
33 changes: 22 additions & 11 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ import {
envLoadingHint,
frameworkName,
type PackageInstall,
WEB_PACKAGES,
webApiUrlVariable,
webSnippet,
} from "../core/snippets.js";
import {
webApiUrlVariable,
webFrameworkName,
webPackages,
webStepFile,
webSteps,
} from "../core/webSnippets.js";
import { requireInteractive } from "../core/tty.js";
import { AUTH_STACK_DIR, generateAuthStack } from "../generators/docker/authStack.js";
import { LOCAL_AUTH_ISSUER } from "../generators/docker/docker.js";
Expand Down Expand Up @@ -167,8 +171,8 @@ export async function addSeamlessAuth(opts: AddOptions) {
);
}

if (web) {
const variable = webApiUrlVariable(web);
const variable = web && webApiUrlVariable(web);
if (web && variable) {
const envFile = path.join(web.dir, ".env.local");
const result = mergeEnvFile(path.join(root, envFile), {
defaults: { [variable]: `${apiOrigin}/` },
Expand Down Expand Up @@ -196,6 +200,7 @@ export async function addSeamlessAuth(opts: AddOptions) {
mode,
backend,
web,
apiOrigin,
webOrigin,
serveConsole,
installs: installs.filter((i) => opts.install === false || !i.run).map((i) => i.display),
Expand All @@ -217,13 +222,13 @@ function reportDetection(project: DetectedProject) {
}
if (web) {
console.log(
` Web: React in ${web.dir}` + kleur.dim(web.entry ? ` (${web.entry})` : ""),
` Web: ${webFrameworkName(web.framework)} in ${web.dir}` + kleur.dim(web.entry ? ` (${web.entry})` : ""),
);
}
if (unsupported.length > 0) {
console.log(
kleur.dim(
` Also found ${unsupported.join(", ")}, which seamless add does not wire yet (fells-code/seamless-cli#248).`,
` Also found ${unsupported.join(", ")}, which seamless add does not wire yet.`,
),
);
}
Expand Down Expand Up @@ -356,7 +361,9 @@ function backendPort(root: string, backend: DetectedBackend): string {
}

function defaultWebOrigin(web: DetectedWeb | undefined): string {
return web?.bundler === "react-scripts" ? "http://localhost:3001" : "http://localhost:5173";
if (web?.bundler === "react-scripts") return "http://localhost:3001";
if (web?.framework === "angular") return "http://localhost:4200";
return "http://localhost:5173";
}

// A rough answer is enough here: it only decides whether to warn.
Expand Down Expand Up @@ -394,7 +401,7 @@ function installCommands(
});
const out = backendInstalls(backend).map((i) => located(backend.dir, i));
if (web) {
const { command, args } = addPackagesCommand(web.packageManager, WEB_PACKAGES);
const { command, args } = addPackagesCommand(web.packageManager, webPackages(web));
out.push(located(web.dir, { command, args, display: `${command} ${args.join(" ")}`, run: true }));
}
return out;
Expand All @@ -404,6 +411,7 @@ function printNextSteps(ctx: {
mode: "local" | "managed";
backend: DetectedBackend;
web: DetectedWeb | undefined;
apiOrigin: string;
webOrigin: string;
serveConsole: boolean;
installs: string[];
Expand Down Expand Up @@ -437,8 +445,11 @@ function printNextSteps(ctx: {
console.log(indent(backendSnippet(backend, { webOrigin: ctx.webOrigin, serveConsole: ctx.serveConsole && mode === "local" })));

if (web) {
heading("Wrap your React app" + (web.entry ? ` (${path.join(web.dir, web.entry)})` : ""));
console.log(indent(webSnippet(web)));
for (const webStep of webSteps(web, { apiOrigin: ctx.apiOrigin })) {
const file = webStepFile(web, webStep);
heading(webStep.heading + (file ? ` (${file})` : ""));
console.log(indent(webStep.code));
}
}

heading("Sign in");
Expand Down
8 changes: 4 additions & 4 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,13 @@ NON-INTERACTIVE
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 React web app (at the project root, or in api/, server/, backend/,
web/, client/, frontend/, apps/* or packages/*), then:
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:
• 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
URL in .env.local (Angular gets it in the printed code)
• 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
Expand All @@ -140,7 +140,7 @@ web/, client/, frontend/, apps/* or packages/*), then:
--api-url=<url>, --web-url=<url>
• Your backend and web app origins, when they are not on the defaults
(http://localhost:<PORT from the backend .env, or 3000> and
http://localhost:5173)
http://localhost:5173, or 4200 for Angular and 3001 for Create React App)

--admin=<api|none>
• Whether your backend serves the admin dashboard at /console (default: api)
Expand Down
64 changes: 64 additions & 0 deletions src/core/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,67 @@ describe("detectProject, Go, Rust and Python", () => {
expect(detectProject(root).backend).toBeUndefined();
});
});

describe("detectProject, Angular, Vue and SvelteKit", () => {
it("finds a standalone Angular app and its config", () => {
write("web/package.json", { dependencies: { "@angular/core": "^22", "@angular/router": "^22" } });
write("web/src/app/app.config.ts", "");
expect(detectProject(root).web).toMatchObject({
framework: "angular",
bundler: "angular",
dir: "web",
typescript: true,
entry: "src/app/app.config.ts",
missingPeers: [],
});
});

it("falls back to an NgModule app, and notes a missing router", () => {
write("client/package.json", { dependencies: { "@angular/core": "^20" } });
write("client/src/app/app.module.ts", "");
expect(detectProject(root).web).toMatchObject({
entry: "src/app/app.module.ts",
missingPeers: ["@angular/router"],
});
});

it("finds a Vue app and the file its router is created in", () => {
write("frontend/package.json", { dependencies: { vue: "^3.5", "vue-router": "^4" }, devDependencies: { vite: "^7" } });
write("frontend/src/main.ts", "");
write("frontend/src/router/index.ts", "");
expect(detectProject(root).web).toMatchObject({
framework: "vue",
bundler: "vite",
entry: "src/main.ts",
router: "src/router/index.ts",
missingPeers: [],
});
});

it("notes a Vue app without vue-router, and leaves Nuxt alone", () => {
write("web/package.json", { dependencies: { vue: "^3.5" } });
expect(detectProject(root).web).toMatchObject({ bundler: "other", missingPeers: ["vue-router"] });
write("web/package.json", { dependencies: { vue: "^3.5", nuxt: "^4" } });
expect(detectProject(root)).toEqual({ unsupported: ["Nuxt"] });
});

it("finds SvelteKit, with the lib alias of its major version", () => {
write("web/package.json", { devDependencies: { "@sveltejs/kit": "^3.0.0", svelte: "^5" } });
write("web/src/routes/+layout.svelte", "");
expect(detectProject(root).web).toMatchObject({
framework: "sveltekit",
bundler: "vite",
entry: "src/routes/+layout.svelte",
libAlias: "#lib",
});
write("web/package.json", { devDependencies: { "@sveltejs/kit": "^2.26.0", svelte: "^5" } });
expect(detectProject(root).web?.libAlias).toBe("$lib");
write("web/package.json", { devDependencies: { "@sveltejs/kit": "latest", svelte: "^5" } });
expect(detectProject(root).web?.libAlias).toBe("$lib");
});

it("reports Svelte without SvelteKit, which needs a router of its own", () => {
write("web/package.json", { devDependencies: { svelte: "^5" } });
expect(detectProject(root)).toEqual({ unsupported: ["Svelte without SvelteKit"] });
});
});
84 changes: 67 additions & 17 deletions src/core/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ export interface DetectedBackend {
packageManager: BackendPackageManager;
}

export type WebFramework = "react" | "angular" | "vue" | "sveltekit";

export interface DetectedWeb {
framework: "react";
bundler: "vite" | "react-scripts" | "other";
framework: WebFramework;
bundler: "vite" | "react-scripts" | "angular" | "other";
dir: string;
typescript: boolean;
entry?: string;
// Vue: the file the router is created in, 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";
// Peer dependencies of the binding the app does not have yet.
missingPeers: string[];
packageManager: JsPackageManager;
}

Expand Down Expand Up @@ -168,8 +176,6 @@ export function detectProject(root: string): DetectedProject {

for (const dir of candidateDirs(root)) {
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);
Expand All @@ -193,24 +199,68 @@ export function detectProject(root: string): DetectedProject {
}

if ("next" in deps) note("Next.js");
if ("vue" in deps) note("Vue");
if ("svelte" in deps) note("Svelte");
if ("@angular/core" in deps) note("Angular");
if ("nuxt" in deps) note("Nuxt");
if ("svelte" in deps && !("@sveltejs/kit" in deps)) note("Svelte without SvelteKit");

if (!result.web && "react" in deps && !("next" in deps) && !("react-native" in deps)) {
result.web = {
framework: "react",
bundler: "vite" in deps ? "vite" : "react-scripts" in deps ? "react-scripts" : "other",
dir: rel,
typescript,
entry: findEntry(dir, pkg, WEB_ENTRIES),
packageManager: detectJsPackageManager(dir, root),
};
}
if (!result.web) result.web = detectWeb(dir, rel, root, pkg, typescript);
}
return result;
}

function detectWeb(
dir: string,
rel: string,
root: string,
pkg: PackageJson,
typescript: boolean,
): DetectedWeb | undefined {
const deps = dependencies(pkg);
const base = { dir: rel, typescript, packageManager: detectJsPackageManager(dir, root) };
const missing = (...names: string[]) => names.filter((name) => !(name in deps));

if ("@angular/core" in deps) {
return {
...base,
framework: "angular",
bundler: "angular",
typescript: true,
entry: firstExisting(dir, ["src/app/app.config.ts", "src/app/app.module.ts", "src/main.ts"]),
missingPeers: missing("@angular/router"),
};
}
if ("@sveltejs/kit" in deps) {
const kit = deps["@sveltejs/kit"].match(/\d+/)?.[0];
return {
...base,
framework: "sveltekit",
bundler: "vite",
entry: firstExisting(dir, ["src/routes/+layout.svelte"]),
libAlias: kit !== undefined && Number(kit) >= 3 ? "#lib" : "$lib",
missingPeers: [],
};
}
if ("vue" in deps && !("nuxt" in deps)) {
return {
...base,
framework: "vue",
bundler: "vite" in deps ? "vite" : "other",
entry: firstExisting(dir, ["src/main.ts", "src/main.js"]),
router: firstExisting(dir, ["src/router/index.ts", "src/router/index.js", "src/router.ts", "src/router.js"]),
missingPeers: missing("vue-router"),
};
}
if ("react" in deps && !("next" in deps) && !("react-native" in deps)) {
return {
...base,
framework: "react",
bundler: "vite" in deps ? "vite" : "react-scripts" in deps ? "react-scripts" : "other",
entry: findEntry(dir, pkg, WEB_ENTRIES),
missingPeers: [],
};
}
return undefined;
}

function readText(file: string): string | undefined {
try {
return fs.readFileSync(file, "utf-8");
Expand Down
Loading
Loading