diff --git a/.changeset/seamless-add.md b/.changeset/seamless-add.md new file mode 100644 index 0000000..053bdee --- /dev/null +++ b/.changeset/seamless-add.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +Add `seamless add`, which adds Seamless Auth to an existing Express or Fastify backend and React web app. It connects a local auth server (written to `seamless/`) or a managed application, writes the backend's `.env` and the web app's API URL while keeping existing values, installs the adapter and SDK with the project's package manager, and prints the code to add for TypeScript or JavaScript. It never edits source files. Commands now run without a shell outside Windows, which also stops Node 24's DEP0190 warning. diff --git a/README.md b/README.md index cb4eab0..51ac2c8 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,26 @@ No manual wiring is required. --- +## Adding Seamless Auth to an existing project + +`seamless init` starts a new project. For an app you already have, run `seamless add` in it: + +```bash +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: + +- 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. + +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). + ## Running your project ### Option 1: Docker diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts new file mode 100644 index 0000000..3126bd8 --- /dev/null +++ b/src/commands/add.test.ts @@ -0,0 +1,276 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../core/fetch.js", () => ({ + fetchEnvExample: async () => "LOGIN_METHODS=passkey\n", +})); +vi.mock("../core/exec.js", () => ({ runCommand: vi.fn(async () => undefined) })); +vi.mock("../index.js", () => ({ VERSION: "0.0.0-test" })); +vi.mock("@clack/prompts", async (importOriginal) => ({ + ...(await importOriginal()), + select: vi.fn(), + confirm: vi.fn(), +})); +vi.mock("../core/authClient.js", async (importOriginal) => ({ + ...(await importOriginal()), + createPortalClient: vi.fn(), +})); +vi.mock("../core/portal.js", async (importOriginal) => ({ + ...(await importOriginal()), + listApplications: vi.fn(), +})); +vi.mock("../core/managedConnect.js", async (importOriginal) => ({ + ...(await importOriginal()), + issueServiceToken: vi.fn(async () => "managed-service-token"), + resolveJwksKid: vi.fn(async () => "paidkey1"), +})); +vi.mock("../prompts/appSelect.js", () => ({ + selectApplication: vi.fn(async (apps: { id: string }[], id?: string) => apps.find((a) => a.id === id) ?? apps[0]), +})); + +import { confirm, select } from "@clack/prompts"; + +import { createPortalClient, ReauthRequiredError, type AuthClient } from "../core/authClient.js"; +import { parseEnv } from "../core/env.js"; +import { runCommand } from "../core/exec.js"; +import { issueServiceToken } from "../core/managedConnect.js"; +import { listApplications, type PortalApp } from "../core/portal.js"; +import { addSeamlessAuth, parseAddArgs } from "./add.js"; + +let root: string; +let logs: string[]; + +function write(rel: string, content: string | object) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, typeof content === "string" ? content : JSON.stringify(content)); +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-add-")); + logs = []; + vi.spyOn(console, "log").mockImplementation((...args) => { + logs.push(args.join(" ")); + }); + vi.mocked(runCommand).mockClear(); + write(".gitignore", "node_modules\n.env\n"); + write("api/package.json", { scripts: { dev: "tsx src/index.ts" }, dependencies: { express: "^5" } }); + write("api/tsconfig.json", "{}"); + write("api/src/index.ts", ""); + write("api/.env", "# mine\nPORT=4000\nDATABASE_URL=postgres://me@db/app\n"); + write("web/package.json", { dependencies: { react: "^19" }, devDependencies: { vite: "^7" } }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("parseAddArgs", () => { + it("reads the path and flags", () => { + expect(parseAddArgs(["./app", "--local", "--email=me@x.test", "--skip-install", "--admin=none", "-y"])).toMatchObject({ + dir: "./app", + local: true, + email: "me@x.test", + install: false, + console: false, + yes: true, + }); + expect(parseAddArgs(["--app", "app_1"]).appId).toBe("app_1"); + }); + + it("refuses what it does not know", () => { + expect(() => parseAddArgs(["--admin=image"])).toThrow(/--admin/); + expect(() => parseAddArgs(["--email=nope"])).toThrow(/--email/); + expect(() => parseAddArgs(["--bogus"])).toThrow(/Unknown option/); + }); +}); + +describe("seamless add --local", () => { + it("writes the stack and both env files, installs, and keeps the project's own values", async () => { + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" }); + + expect(fs.existsSync(path.join(root, "seamless", "docker-compose.yml"))).toBe(true); + const stackEnv = parseEnv(path.join(root, "seamless", ".env")); + expect(stackEnv.APP_ORIGINS).toBe("http://localhost:4000"); + + const apiEnv = fs.readFileSync(path.join(root, "api", ".env"), "utf-8"); + expect(apiEnv.startsWith("# mine\nPORT=4000\nDATABASE_URL=postgres://me@db/app\n")).toBe(true); + const values = parseEnv(path.join(root, "api", ".env")); + expect(values).toMatchObject({ + AUTH_SERVER_URL: "http://localhost:5312", + AUTH_SERVER_ISSUER: "http://auth:5312", + API_SERVICE_TOKEN: stackEnv.API_SERVICE_TOKEN, + JWKS_KID: "dev-main", + UI_ORIGINS: "http://localhost:5173", + SERVE_ADMIN_CONSOLE: "true", + }); + expect(values.COOKIE_SIGNING_KEY).toMatch(/^[0-9a-f]{64}$/); + expect(parseEnv(path.join(root, "web", ".env.local")).VITE_API_URL).toBe("http://localhost:4000/"); + + 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"], + ]); + 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).not.toContain("does not look gitignored"); + }); + + it("keeps a re-run's cookie secret and refuses to replace the stack without --force", async () => { + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + const first = parseEnv(path.join(root, "api", ".env")).COOKIE_SIGNING_KEY; + + await expect(addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false })).rejects.toThrow(/--force/); + + await addSeamlessAuth({ dir: root, local: true, yes: true, force: true, email: "owner@x.test", install: false }); + expect(parseEnv(path.join(root, "api", ".env")).COOKIE_SIGNING_KEY).toBe(first); + expect(runCommand).not.toHaveBeenCalled(); + expect(logs.join("\n")).toContain("cd api && npm install @seamless-auth/express cookie-parser cors"); + }); + + it("warns when the backend .env is not gitignored", async () => { + fs.rmSync(path.join(root, ".gitignore")); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + expect(logs.join("\n")).toContain("does not look gitignored"); + }); + + it("asks which auth server under --yes rather than guessing", async () => { + await expect(addSeamlessAuth({ dir: root, yes: true })).rejects.toThrow(/--local .* --app/); + }); + + 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/); + }); +}); + +describe("seamless add --app", () => { + const app: PortalApp = { + id: "app_1", + name: "Acme", + instanceUrl: "https://acme.auth.example/", + ownerEmails: [], + hasServiceToken: true, + }; + + it("connects a managed application and writes no local stack", async () => { + vi.mocked(createPortalClient).mockResolvedValue({} as AuthClient); + vi.mocked(listApplications).mockResolvedValue([app]); + + await addSeamlessAuth({ dir: root, appId: "app_1", yes: true, force: true, install: false }); + + expect(fs.existsSync(path.join(root, "seamless"))).toBe(false); + expect(parseEnv(path.join(root, "api", ".env"))).toMatchObject({ + AUTH_SERVER_URL: "https://acme.auth.example", + AUTH_SERVER_ISSUER: "https://acme.auth.example", + API_SERVICE_TOKEN: "managed-service-token", + JWKS_KID: "paidkey1", + SERVE_ADMIN_CONSOLE: "false", + }); + expect(vi.mocked(issueServiceToken).mock.calls[0][2]).toMatchObject({ yes: true, force: true }); + const out = logs.join("\n"); + expect(out).toContain("Connected to Acme"); + expect(out).not.toContain("docker compose -f seamless"); + expect(out).not.toContain("managed-service-token"); + }); + + it("asks for seamless login without a portal session", async () => { + vi.mocked(createPortalClient).mockRejectedValue(new ReauthRequiredError("expired")); + await expect(addSeamlessAuth({ dir: root, appId: "app_1", yes: true })).rejects.toThrow(/seamless login/); + }); + + it("passes other connection failures through", async () => { + vi.mocked(createPortalClient).mockRejectedValue(new Error("offline")); + await expect(addSeamlessAuth({ dir: root, appId: "app_1", yes: true })).rejects.toThrow("offline"); + }); + + it("explains an account with no application to connect", async () => { + vi.mocked(createPortalClient).mockResolvedValue({} as AuthClient); + vi.mocked(listApplications).mockResolvedValue([{ ...app, instanceUrl: undefined }]); + await expect(addSeamlessAuth({ dir: root, appId: "app_1", yes: true })).rejects.toThrow(/no application with an auth server/); + }); +}); + +describe("seamless add, interactively", () => { + it("asks which auth server to use", async () => { + vi.mocked(select).mockResolvedValueOnce("local"); + await addSeamlessAuth({ dir: root, email: "owner@x.test", install: false }); + expect(select).toHaveBeenCalled(); + expect(fs.existsSync(path.join(root, "seamless", "docker-compose.yml"))).toBe(true); + }); + + it("asks before replacing a stack, and changes nothing when declined", async () => { + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + const before = fs.readFileSync(path.join(root, "seamless", ".env"), "utf-8"); + vi.mocked(confirm).mockResolvedValueOnce(false); + + await expect(addSeamlessAuth({ dir: root, local: true, email: "owner@x.test", install: false })).rejects.toThrow(/Nothing was changed/); + + expect(fs.readFileSync(path.join(root, "seamless", ".env"), "utf-8")).toBe(before); + }); + + it("replaces the stack when confirmed", async () => { + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + const before = parseEnv(path.join(root, "seamless", ".env")).API_SERVICE_TOKEN; + vi.mocked(confirm).mockResolvedValueOnce(true); + + await addSeamlessAuth({ dir: root, local: true, email: "owner@x.test", install: false }); + + const after = parseEnv(path.join(root, "seamless", ".env")).API_SERVICE_TOKEN; + expect(after).not.toBe(before); + expect(parseEnv(path.join(root, "api", ".env")).API_SERVICE_TOKEN).toBe(after); + }); +}); + +describe("seamless add, edge cases", () => { + it("prints the install command when running it fails", async () => { + vi.mocked(runCommand).mockRejectedValueOnce(new Error("npm failed")); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test" }); + expect(logs.join("\n")).toContain("Could not run that. Run it yourself: cd api && npm install @seamless-auth/express cookie-parser cors"); + }); + + it("says which stacks it does not wire yet", async () => { + write("worker/go.mod", "module x"); + write("go.mod", "module x"); + 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"); + }); + + it("wires a Create React App web app on its own port and variable", async () => { + write("web/package.json", { dependencies: { react: "^19", "react-scripts": "5" } }); + write("api/.env", "PORT=not-a-port\n"); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false, console: false }); + expect(parseEnv(path.join(root, "web", ".env.local")).REACT_APP_API_URL).toBe("http://localhost:3000/"); + expect(parseEnv(path.join(root, "api", ".env")).UI_ORIGINS).toBe("http://localhost:3001"); + expect(parseEnv(path.join(root, "seamless", ".env")).SERVE_ADMIN_DASHBOARD).toBe("false"); + expect(logs.join("\n")).toContain("process.env.REACT_APP_API_URL"); + }); + + it("works with a backend that has no web app, entry or .env", async () => { + fs.rmSync(path.join(root, "web"), { recursive: true }); + fs.rmSync(path.join(root, "api", "src"), { recursive: true }); + fs.rmSync(path.join(root, "api", ".env")); + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false, apiUrl: "http://localhost:8000/", webUrl: "https://app.example/" }); + const values = parseEnv(path.join(root, "api", ".env")); + expect(values.UI_ORIGINS).toBe("https://app.example"); + expect(parseEnv(path.join(root, "seamless", ".env")).APP_ORIGINS).toBe("http://localhost:8000"); + expect(logs.join("\n")).not.toContain("Wrap your React app"); + }); + + it("refuses --local and --app together, and a path that is not there", async () => { + await expect(addSeamlessAuth({ dir: root, local: true, appId: "app_1" })).rejects.toThrow(/not both/); + await expect(addSeamlessAuth({ dir: path.join(root, "missing"), local: true })).rejects.toThrow(/does not exist/); + }); + + it("runs through runAdd with parsed arguments", async () => { + const { runAdd } = await import("./add.js"); + await runAdd([root, "--local", "--yes", "--email=owner@x.test", "--skip-install"]); + expect(fs.existsSync(path.join(root, "seamless", "docker-compose.yml"))).toBe(true); + }); +}); diff --git a/src/commands/add.ts b/src/commands/add.ts new file mode 100644 index 0000000..7099018 --- /dev/null +++ b/src/commands/add.ts @@ -0,0 +1,459 @@ +import fs from "fs"; +import path from "path"; + +import { confirm, select } from "@clack/prompts"; +import kleur from "kleur"; + +import { createPortalClient, ReauthRequiredError } from "../core/authClient.js"; +import { CancelledError, orCancel } from "../core/cancel.js"; +import { getPortalSession, normalizeInstanceUrl } from "../core/config.js"; +import { + addPackagesCommand, + detectProject, + type DetectedBackend, + type DetectedProject, + type DetectedWeb, +} from "../core/detect.js"; +import { parseEnv } from "../core/env.js"; +import { mergeEnvFile } from "../core/envFile.js"; +import { runCommand } from "../core/exec.js"; +import { + connectable, + issueServiceToken, + requireInstanceUrl, + resolveJwksKid, +} from "../core/managedConnect.js"; +import { listApplications } from "../core/portal.js"; +import { generateSecret } from "../core/secrets.js"; +import { + backendPackages, + backendSnippet, + WEB_PACKAGES, + webApiUrlVariable, + webSnippet, +} from "../core/snippets.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"; +import { selectApplication } from "../prompts/appSelect.js"; +import { resolveOwnerEmail } from "../prompts/projectSetup.js"; +import { extractFlag } from "../core/args.js"; + +const LOCAL_AUTH_SERVER_URL = "http://localhost:5312"; + +export interface AddOptions { + dir?: string; + local?: boolean; + appId?: string; + email?: string; + apiUrl?: string; + webUrl?: string; + console?: boolean; + install?: boolean; + yes?: boolean; + force?: boolean; +} + +export function parseAddArgs(args: string[]): AddOptions { + let rest = args; + const take = (name: string) => { + const { value, rest: remaining } = extractFlag(rest, name); + rest = remaining; + return value; + }; + const appId = take("app"); + const email = take("email"); + const apiUrl = take("api-url"); + const webUrl = take("web-url"); + const admin = take("admin"); + if (admin !== undefined && admin !== "api" && admin !== "none") { + throw new Error(`--admin must be api or none for seamless add (got "${admin}").`); + } + if (email !== undefined && !email.includes("@")) { + throw new Error(`--email needs an email address (got "${email}").`); + } + const has = (flag: string) => rest.includes(flag); + const positional = rest.filter((arg) => !arg.startsWith("-")); + const unknown = rest.filter( + (arg) => + arg.startsWith("-") && + !["--local", "--yes", "-y", "--force", "--skip-install"].includes(arg), + ); + if (unknown.length > 0) { + throw new Error(`Unknown option for seamless add: ${unknown.join(", ")}`); + } + return { + dir: positional[0], + local: has("--local"), + appId, + email, + apiUrl, + webUrl, + console: admin !== "none", + install: !has("--skip-install"), + yes: has("--yes") || has("-y"), + force: has("--force"), + }; +} + +export async function runAdd(args: string[]) { + await addSeamlessAuth(parseAddArgs(args)); +} + +/** + * Adds Seamless Auth to an existing project: finds its backend and web app, + * connects an auth server (a local one in seamless/, or a managed application), + * writes the backend's environment, installs the packages, and prints the code to + * add. It never edits the project's source. + */ +export async function addSeamlessAuth(opts: AddOptions) { + const root = path.resolve(opts.dir ?? process.cwd()); + if (!fs.existsSync(root)) { + throw new Error(`${root} does not exist.`); + } + + const project = detectProject(root); + reportDetection(project); + 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.", + ); + } + + if (opts.local && opts.appId) { + throw new Error("Pass --local or --app, not both."); + } + const mode = await resolveMode(opts); + + const apiOrigin = trimSlash(opts.apiUrl ?? `http://localhost:${backendPort(root, backend)}`); + const webOrigin = trimSlash(opts.webUrl ?? defaultWebOrigin(web)); + const serveConsole = opts.console !== false; + + const connection = + mode === "local" + ? await connectLocal(root, opts, { apiOrigin, webOrigin, serveConsole }) + : await connectManaged(opts); + + const backendDir = path.join(root, backend.dir); + const backendEnv = mergeEnvFile(path.join(backendDir, ".env"), { + set: { + AUTH_SERVER_URL: connection.authServerUrl, + AUTH_SERVER_ISSUER: connection.authServerIssuer, + API_SERVICE_TOKEN: connection.serviceToken, + JWKS_KID: connection.jwksKid, + }, + defaults: { + COOKIE_SIGNING_KEY: generateSecret(32), + UI_ORIGINS: webOrigin, + SERVE_ADMIN_CONSOLE: serveConsole && mode === "local" ? "true" : "false", + }, + }); + console.log( + kleur.green(`\nWrote ${path.join(backend.dir, ".env")}`) + + kleur.dim(`: ${backendEnv.written.join(", ") || "nothing new"}`), + ); + if (backendEnv.kept.length > 0) { + console.log(kleur.dim(` Kept your existing ${backendEnv.kept.join(", ")}.`)); + } + if (!isGitIgnored(root, backendDir, ".env")) { + console.log( + kleur.yellow( + ` ${path.join(backend.dir, ".env")} holds secrets and does not look gitignored. Add .env to your .gitignore.`, + ), + ); + } + + if (web) { + const variable = webApiUrlVariable(web); + const envFile = path.join(web.dir, ".env.local"); + const result = mergeEnvFile(path.join(root, envFile), { + defaults: { [variable]: `${apiOrigin}/` }, + }); + console.log( + result.written.length > 0 + ? kleur.green(`Wrote ${envFile}`) + kleur.dim(`: ${variable}`) + : kleur.dim(`Kept your existing ${variable} in ${envFile}.`), + ); + } + + const installs = installCommands(root, backend, web); + if (opts.install !== false) { + for (const install of installs) { + console.log(kleur.dim(`\n$ ${install.display}`)); + try { + await runCommand(install.command, install.args, install.cwd); + } catch { + console.log(kleur.yellow(`Could not run that. Run it yourself: ${install.display}`)); + } + } + } + + printNextSteps({ + mode, + backend, + web, + webOrigin, + serveConsole, + installs: opts.install === false ? installs.map((i) => i.display) : [], + }); +} + +function trimSlash(url: string) { + return url.replace(/\/+$/, ""); +} + +function reportDetection(project: DetectedProject) { + const { backend, web, unsupported } = project; + console.log(kleur.bold("\nseamless add\n")); + if (backend) { + console.log( + ` Backend: ${frameworkName(backend.framework)} in ${backend.dir}` + + kleur.dim(backend.entry ? ` (${backend.entry})` : ""), + ); + } + if (web) { + console.log( + ` Web: React 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).`, + ), + ); + } +} + +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"; + if (opts.yes) { + throw new Error( + "--yes needs to know which auth server to use: pass --local for one on this machine, or --app for a managed application.", + ); + } + requireInteractive( + "Which auth server should this project use?", + "Pass --local or --app .", + ); + return orCancel( + await select({ + message: "Which auth server should this project use?", + options: [ + { + value: "local" as const, + label: "A local one, in Docker", + hint: `written to ${AUTH_STACK_DIR}/`, + }, + { + value: "managed" as const, + label: "A managed application", + hint: "needs seamless login", + }, + ], + }), + ); +} + +interface Connection { + authServerUrl: string; + authServerIssuer: string; + serviceToken: string; + jwksKid: string; +} + +async function connectLocal( + root: string, + opts: AddOptions, + origins: { apiOrigin: string; webOrigin: string; serveConsole: boolean }, +): Promise { + const compose = path.join(root, AUTH_STACK_DIR, "docker-compose.yml"); + if (fs.existsSync(compose) && !opts.force) { + // A new stack mints a new service token, which the running one rejects. + if (opts.yes) { + throw new Error( + `${AUTH_STACK_DIR}/ already has an auth server. Re-run with --force to replace it, which issues new secrets.`, + ); + } + requireInteractive( + `${AUTH_STACK_DIR}/ already has an auth server. Replace it?`, + "Pass --force to replace it.", + ); + const replace = orCancel( + await confirm({ + message: `${AUTH_STACK_DIR}/ already has an auth server. Replace it with new secrets?`, + initialValue: false, + }), + ); + if (!replace) throw new CancelledError("Cancelled. Nothing was changed."); + } + + const ownerEmail = await resolveOwnerEmail( + opts.email, + getPortalSession()?.email, + Boolean(opts.yes), + ); + const stack = await generateAuthStack(root, { + apiOrigin: origins.apiOrigin, + webOrigins: [origins.webOrigin], + serveConsole: origins.serveConsole, + ownerEmail, + }); + console.log(kleur.green(`Wrote ${AUTH_STACK_DIR}/`) + kleur.dim(": docker-compose.yml, .env, .gitignore")); + + return { + authServerUrl: LOCAL_AUTH_SERVER_URL, + // The server signs as the compose service name; this app reaches it on localhost. + authServerIssuer: LOCAL_AUTH_ISSUER, + serviceToken: stack.apiToken, + jwksKid: stack.kid, + }; +} + +async function connectManaged(opts: AddOptions): Promise { + let client; + try { + client = await createPortalClient(); + } catch (err) { + if (err instanceof ReauthRequiredError) { + throw new Error( + "Connecting a managed application needs a portal session. Run seamless login, or pass --local for an auth server on this machine.", + ); + } + throw err; + } + const apps = connectable(await listApplications(client)); + if (apps.length === 0) { + throw new Error( + "Your account has no application with an auth server yet. Run seamless apps list to check on provisioning, or pass --local.", + ); + } + const app = await selectApplication(apps, opts.appId); + const instanceUrl = normalizeInstanceUrl(requireInstanceUrl(app)); + const serviceToken = await issueServiceToken(client, app, opts); + const jwksKid = await resolveJwksKid(instanceUrl); + console.log(kleur.green(`Connected to ${app.name}`) + kleur.dim(` (${instanceUrl})`)); + return { + authServerUrl: instanceUrl, + authServerIssuer: instanceUrl, + serviceToken, + jwksKid, + }; +} + +function backendPort(root: string, backend: DetectedBackend): string { + const envPath = path.join(root, backend.dir, ".env"); + if (fs.existsSync(envPath)) { + const port = parseEnv(envPath).PORT; + if (port && /^\d+$/.test(port)) return port; + } + return "3000"; +} + +function defaultWebOrigin(web: DetectedWeb | undefined): string { + return web?.bundler === "react-scripts" ? "http://localhost:3001" : "http://localhost:5173"; +} + +// A rough answer is enough here: it only decides whether to warn. +function isGitIgnored(root: string, dir: string, file: string): boolean { + for (const base of new Set([dir, root])) { + const ignore = path.join(base, ".gitignore"); + if (!fs.existsSync(ignore)) continue; + const lines = fs.readFileSync(ignore, "utf-8").split("\n").map((l) => l.trim()); + if (lines.some((l) => l === file || l === `/${file}` || l === `${file}*` || l === "*.env" || l === `${file}.*`)) { + return true; + } + } + return false; +} + +interface Install { + command: string; + args: string[]; + cwd: string; + display: string; +} + +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); + return out; +} + +function printNextSteps(ctx: { + mode: "local" | "managed"; + backend: DetectedBackend; + web: DetectedWeb | undefined; + webOrigin: string; + serveConsole: boolean; + installs: string[]; +}) { + const { mode, backend, web } = ctx; + let step = 1; + const heading = (text: string) => console.log(kleur.bold(`\n${step++}. ${text}\n`)); + + if (ctx.installs.length > 0) { + heading("Install the packages"); + for (const install of ctx.installs) console.log(` ${install}`); + } + + if (mode === "local") { + heading("Start the auth server"); + console.log(` docker compose -f ${AUTH_STACK_DIR}/docker-compose.yml up -d`); + } + + heading( + `Add Seamless Auth to your ${frameworkName(backend.framework)} app` + + (backend.entry ? ` (${path.join(backend.dir, backend.entry)})` : ""), + ); + 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`, + ), + ); + 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))); + } + + heading("Sign in"); + console.log( + ` Start your app, open ${ctx.webOrigin} and register. In development the one-time\n code prints in your backend's console.`, + ); + if (mode === "local" && ctx.serveConsole) { + console.log( + kleur.dim( + " The admin dashboard is at /console on your backend once you add the console lines above.", + ), + ); + } + console.log(kleur.dim("\nDocs: https://docs.seamlessauth.com\n")); +} + +function indent(text: string) { + return text + .split("\n") + .map((line) => (line ? ` ${line}` : line)) + .join("\n"); +} diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index 76745da..b21f4c0 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -103,6 +103,62 @@ NON-INTERACTIVE → Scaffold the recommended local stack with no prompts`, ], }, + { + name: "add", + usage: [ + "seamless add [path] [--local | --app ] [--email=
] [--api-url=] [--web-url=] [--admin=] [--skip-install] [--yes] [--force]", + ], + sections: [ + { + 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: + • 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 + • Prints the lines of code to add. It never edits your source files + +--local + • Run the auth server on this machine: seamless/docker-compose.yml with its + secrets in seamless/.env (gitignored). Start it with + docker compose -f seamless/docker-compose.yml up -d + +--app + • Connect a managed application instead (needs seamless login). Issuing the + service token replaces an existing one, so it confirms first + +--email=
+ • 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) + +--admin= + • Whether your backend serves the admin dashboard at /console (default: api) + +--skip-install + • Print the install commands instead of running them + +--yes, -y / --force + • --yes answers every question with its default; pass --local or --app with it. + --force replaces an existing seamless/ stack or rotates a managed + application's service token without asking`, + }, + ], + examples: [ + `seamless add --local --email=you@example.com + → A local auth server for the project in this directory`, + `seamless add ./my-app --app app_123 + → Connect ./my-app to a managed application`, + ], + }, { name: "templates", usage: ["seamless templates list [--json]"], diff --git a/src/commands/init.ts b/src/commands/init.ts index 6a0bd93..1970733 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -67,14 +67,17 @@ import { type PortalApp, } from "../core/portal.js"; import { selectApplication } from "../prompts/appSelect.js"; +import { + FALLBACK_JWKS_KID, + connectable, + issueServiceToken, + requireInstanceUrl, + resolveJwksKid, +} from "../core/managedConnect.js"; const AUTH_SERVER_URL = "http://localhost:5312"; const API_URL = "http://localhost:3000"; -// Only reached when the instance cannot be asked. Adapters resolve the signing key -// from the token header through the remote JWKS, so this value verifies nothing, but -// they warn on boot while it is the dev default, so the real kid is worth fetching. -const FALLBACK_JWKS_KID = "dev-main"; export interface InitOptions { profileFlag?: string; @@ -709,26 +712,6 @@ function wireApiEnv( writeEnv(envPath, values); } -// Asked of the instance rather than assumed: instances pin their kid per tier -// (trialkey1, paidkey1), so writing the dev default made every managed scaffold boot -// an app warning that it was misconfigured. -async function resolveJwksKid(authServerUrl: string): Promise { - const kid = await fetchActiveJwksKid(authServerUrl); - if (kid) return kid; - - console.log( - kleur.yellow( - `Could not read the signing key id from ${authServerUrl}, so JWKS_KID is set to "${FALLBACK_JWKS_KID}".`, - ), - ); - console.log( - kleur.dim( - " Nothing verifies against it, the SDK resolves the key from the token, but your adapter will warn on boot until it matches. Read it from /.well-known/jwks.json once the instance is up.", - ), - ); - return FALLBACK_JWKS_KID; -} - function printManagedValues( authServerUrl: string, serviceToken: string, @@ -755,70 +738,6 @@ function printManagedValues( ); } -// Issues the app's service token, confirming first when one already exists so a -// scaffold does not silently break an app that is already deployed. Declining -// cancels the whole command, so nothing is left half-wired. -async function issueServiceToken( - client: AuthClient, - app: PortalApp, - opts: InitOptions, -): Promise { - if (app.hasServiceToken) { - // Rotation breaks whatever is running on the old token, so it confirms like - // the overwrite step does. - if (opts.yes) { - if (!opts.force) { - throw new Error( - `"${app.name}" already has a service token, and issuing a new one invalidates it (breaking anything already deployed with it). Re-run with --force to rotate it anyway.`, - ); - } - console.log( - kleur.yellow( - `Rotating the existing service token for "${app.name}" (--force).`, - ), - ); - } else { - requireInteractive( - `"${app.name}" already has a service token. Issue a new one?`, - "Pass --force to rotate it, which invalidates the existing token.", - ); - const proceed = orCancel( - await confirm({ - message: `"${app.name}" already has a service token. Issuing a new one invalidates the existing token. Continue?`, - initialValue: false, - }), - ); - if (!proceed) { - throw new CancelledError("Cancelled. No token was issued."); - } - } - } - return rotateServiceToken(client, app.id); -} - -// connectable() filters out applications with no auth server, so this throwing means -// the filtered list and the selection disagreed, not that one is simply not ready yet. -function requireInstanceUrl(app: PortalApp): string { - const url = resolveAppInstanceUrl(app); - if (!url) { - throw new Error( - `Application "${app.name}" has no auth instance URL yet. Run seamless apps list to check whether it has finished provisioning.`, - ); - } - return url; -} - -// listApplications reports applications that have not finished provisioning too, -// because `seamless apps list` has to show them. Managed connect needs an auth -// server to point at, so it keeps considering only the ones that have one. -// -// Asked through resolveAppInstanceUrl rather than of `domain` directly: an -// application served at an instanceUrl its stale domain column never carried would -// otherwise be filtered out here and never offered. -function connectable(apps: PortalApp[]): PortalApp[] { - return apps.filter((app) => resolveAppInstanceUrl(app) !== undefined); -} - interface SelectedTemplate { entry: RegistryEntry; manifest: TemplateManifest; diff --git a/src/core/detect.test.ts b/src/core/detect.test.ts new file mode 100644 index 0000000..adc8429 --- /dev/null +++ b/src/core/detect.test.ts @@ -0,0 +1,100 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { addPackagesCommand, detectJsPackageManager, detectProject } from "./detect.js"; + +let root: string; + +function write(rel: string, content: string | object) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, typeof content === "string" ? content : JSON.stringify(content)); +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-detect-")); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("detectProject", () => { + it("finds an Express api and a Vite React web app in their folders", () => { + write("api/package.json", { scripts: { dev: "tsx watch src/server.ts" }, dependencies: { express: "^5" } }); + write("api/tsconfig.json", "{}"); + write("api/src/server.ts", ""); + write("web/package.json", { dependencies: { react: "^19" }, devDependencies: { vite: "^7" } }); + write("web/src/main.tsx", ""); + + const project = detectProject(root); + + expect(project.backend).toMatchObject({ + framework: "express", + dir: "api", + typescript: true, + entry: "src/server.ts", + packageManager: "npm", + }); + expect(project.web).toMatchObject({ framework: "react", bundler: "vite", dir: "web", entry: "src/main.tsx" }); + expect(project.unsupported).toEqual([]); + }); + + 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", ""); + write("apps/server/package.json", { main: "index.js", dependencies: { fastify: "^5" } }); + write("apps/server/index.js", ""); + + const project = detectProject(root); + + expect(project.backend).toMatchObject({ + framework: "fastify", + dir: path.join("apps", "server"), + typescript: false, + entry: "index.js", + packageManager: "pnpm", + }); + expect(project.web).toBeUndefined(); + }); + + it("reports stacks it does not wire yet", () => { + write("go.mod", "module x"); + 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"]); + }); + + it("reads a Create React App build as react-scripts", () => { + write("package.json", { dependencies: { express: "^5", react: "^19", "react-scripts": "5" } }); + expect(detectProject(root).web?.bundler).toBe("react-scripts"); + expect(detectProject(root).backend?.dir).toBe("."); + }); +}); + +describe("detectJsPackageManager", () => { + it.each([ + ["yarn.lock", "yarn"], + ["bun.lock", "bun"], + ["package-lock.json", "npm"], + ] as const)("reads %s as %s", (lock, manager) => { + write(lock, ""); + write("api/package.json", {}); + expect(detectJsPackageManager(path.join(root, "api"), root)).toBe(manager); + }); +}); + +describe("addPackagesCommand", () => { + it("uses each manager's own verbs", () => { + expect(addPackagesCommand("npm", ["a"])).toEqual({ command: "npm", args: ["install", "a"] }); + expect(addPackagesCommand("npm", ["a"], true)).toEqual({ command: "npm", args: ["install", "--save-dev", "a"] }); + expect(addPackagesCommand("pnpm", ["a"], true)).toEqual({ command: "pnpm", args: ["add", "-D", "a"] }); + expect(addPackagesCommand("yarn", ["a", "b"])).toEqual({ command: "yarn", args: ["add", "a", "b"] }); + expect(addPackagesCommand("bun", ["a"])).toEqual({ command: "bun", args: ["add", "a"] }); + }); +}); diff --git a/src/core/detect.ts b/src/core/detect.ts new file mode 100644 index 0000000..ed5c2b3 --- /dev/null +++ b/src/core/detect.ts @@ -0,0 +1,211 @@ +import fs from "fs"; +import path from "path"; + +export type BackendFramework = "express" | "fastify"; +export type JsPackageManager = "npm" | "pnpm" | "yarn" | "bun"; + +export interface DetectedBackend { + framework: BackendFramework; + dir: string; + 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. + entry?: string; + packageManager: JsPackageManager; +} + +export interface DetectedWeb { + framework: "react"; + bundler: "vite" | "react-scripts" | "other"; + dir: string; + typescript: boolean; + entry?: string; + packageManager: JsPackageManager; +} + +export interface DetectedProject { + backend?: DetectedBackend; + web?: DetectedWeb; + // Stacks found that `seamless add` does not wire yet, as human-readable names. + unsupported: string[]; +} + +// Where an existing repository usually keeps its backend and frontend. Checked in +// this order, so a root package wins over a nested one of the same kind. +const CANDIDATE_DIRS = [ + ".", + "api", + "server", + "backend", + "web", + "client", + "frontend", + "app", +]; +const WORKSPACE_PARENTS = ["apps", "packages"]; + +interface PackageJson { + main?: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; +} + +function readPackageJson(dir: string): PackageJson | undefined { + try { + return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")); + } catch { + return undefined; + } +} + +function candidateDirs(root: string): string[] { + const dirs = CANDIDATE_DIRS.map((d) => path.join(root, d)); + for (const parent of WORKSPACE_PARENTS) { + const full = path.join(root, parent); + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(full, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory()) dirs.push(path.join(full, entry.name)); + } + } + return dirs.filter((d) => fs.existsSync(d)); +} + +function dependencies(pkg: PackageJson): Record { + return { ...pkg.dependencies, ...pkg.devDependencies }; +} + +/** + * The package manager for a project directory, read from the nearest lock file + * between it and the repository root (a workspace keeps one lock file at the top). + */ +export function detectJsPackageManager(dir: string, root: string): JsPackageManager { + let current = path.resolve(dir); + const top = path.resolve(root); + for (;;) { + if (fs.existsSync(path.join(current, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(current, "yarn.lock"))) return "yarn"; + if ( + fs.existsSync(path.join(current, "bun.lock")) || + fs.existsSync(path.join(current, "bun.lockb")) + ) { + return "bun"; + } + if (fs.existsSync(path.join(current, "package-lock.json"))) return "npm"; + if (current === top || path.dirname(current) === current) return "npm"; + current = path.dirname(current); + } +} + +const SOURCE_FILE = /([\w./-]+\.(?:[cm]?[jt]sx?))/; + +// Reads the entry off the scripts that start the app, then `main`, then the paths +// a starter usually uses. Only paths that exist are returned. +function findEntry(dir: string, pkg: PackageJson, conventional: string[]): string | undefined { + const fromScripts = ["dev", "start", "serve"] + .map((name) => pkg.scripts?.[name]?.match(SOURCE_FILE)?.[1]) + .filter((p): p is string => Boolean(p)); + const candidates = [...fromScripts, ...(pkg.main ? [pkg.main] : []), ...conventional]; + for (const candidate of candidates) { + const clean = candidate.replace(/^\.\//, ""); + if (fs.existsSync(path.join(dir, clean))) return clean; + } + return undefined; +} + +const BACKEND_ENTRIES = [ + "src/index.ts", + "src/app.ts", + "src/server.ts", + "src/main.ts", + "index.ts", + "app.ts", + "server.ts", + "src/index.js", + "src/app.js", + "src/server.js", + "index.js", + "app.js", + "server.js", + "index.mjs", + "server.mjs", +]; + +const WEB_ENTRIES = [ + "src/main.tsx", + "src/main.jsx", + "src/index.tsx", + "src/index.jsx", + "src/App.tsx", + "src/App.jsx", +]; + +/** What `seamless add` can wire in a repository, found from its manifests. */ +export function detectProject(root: string): DetectedProject { + const result: DetectedProject = { unsupported: [] }; + const note = (name: string) => { + if (!result.unsupported.includes(name)) result.unsupported.push(name); + }; + + 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"); + } + if (fs.existsSync(path.join(dir, "angular.json"))) note("Angular"); + + 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", + dir: rel, + typescript, + entry: findEntry(dir, pkg, BACKEND_ENTRIES), + packageManager: detectJsPackageManager(dir, root), + }; + } + + 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 (!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), + }; + } + } + return result; +} + +/** The command that adds packages with a project's own package manager. */ +export function addPackagesCommand( + manager: JsPackageManager, + packages: string[], + dev = false, +): { command: string; args: string[] } { + const flag = dev ? (manager === "npm" ? ["--save-dev"] : ["-D"]) : []; + const verb = manager === "npm" ? "install" : "add"; + return { command: manager, args: [verb, ...flag, ...packages] }; +} diff --git a/src/core/env.ts b/src/core/env.ts index a6fc03c..dd271b4 100644 --- a/src/core/env.ts +++ b/src/core/env.ts @@ -50,7 +50,7 @@ export function parseEnvString(content: string): Record { // Double-quotes (and escapes) any value a downstream dotenv parser would otherwise // misread: whitespace, `#` (starts a comment), embedded quotes/backslashes, or // newlines. Simple values (tokens, URLs, hex secrets) are written bare, unchanged. -function formatValue(v: string): string { +export function formatValue(v: string): string { const needsQuoting = v !== "" && (/[\s#"'\\]/.test(v) || v !== v.trim()); if (!needsQuoting) return v; diff --git a/src/core/envFile.test.ts b/src/core/envFile.test.ts new file mode 100644 index 0000000..db92db3 --- /dev/null +++ b/src/core/envFile.test.ts @@ -0,0 +1,54 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { mergeEnvFile } from "./envFile.js"; + +let file: string; + +beforeEach(() => { + file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "seamless-env-")), ".env"); +}); +afterEach(() => { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); +}); + +describe("mergeEnvFile", () => { + it("keeps every line it does not own", () => { + fs.writeFileSync( + file, + "# My app\nDATABASE_URL=postgres://me@db/app\n\n# Auth\nAUTH_SERVER_URL=http://old\nCOOKIE_SIGNING_KEY=mine\n", + ); + + const result = mergeEnvFile(file, { + set: { AUTH_SERVER_URL: "http://localhost:5312", JWKS_KID: "dev-main" }, + defaults: { COOKIE_SIGNING_KEY: "generated", UI_ORIGINS: "http://localhost:5173" }, + }); + + expect(fs.readFileSync(file, "utf-8")).toBe( + "# My app\nDATABASE_URL=postgres://me@db/app\n\n# Auth\nAUTH_SERVER_URL=http://localhost:5312\nCOOKIE_SIGNING_KEY=mine\n\n# Seamless Auth\nJWKS_KID=dev-main\nUI_ORIGINS=http://localhost:5173\n", + ); + expect(result.written.sort()).toEqual(["AUTH_SERVER_URL", "JWKS_KID", "UI_ORIGINS"]); + expect(result.kept).toEqual(["COOKIE_SIGNING_KEY"]); + }); + + it("creates the file and quotes what needs quoting", () => { + mergeEnvFile(file, { set: { A: "plain", B: "has space" } }); + expect(fs.readFileSync(file, "utf-8")).toBe('# Seamless Auth\nA=plain\nB="has space"\n'); + }); + + it("leaves an unchanged file untouched", () => { + fs.writeFileSync(file, "# unchanged\nA=1\n"); + const before = fs.statSync(file).mtimeMs; + const result = mergeEnvFile(file, { set: { A: "1" } }); + expect(result).toEqual({ written: [], kept: ["A"] }); + expect(fs.statSync(file).mtimeMs).toBe(before); + }); + + it("updates an exported assignment in place", () => { + fs.writeFileSync(file, "export A=old\n"); + mergeEnvFile(file, { set: { A: "new" } }); + expect(fs.readFileSync(file, "utf-8")).toBe("A=new\n"); + }); +}); diff --git a/src/core/envFile.ts b/src/core/envFile.ts new file mode 100644 index 0000000..ab0757e --- /dev/null +++ b/src/core/envFile.ts @@ -0,0 +1,65 @@ +import fs from "fs"; + +import { formatValue, parseEnvString } from "./env.js"; + +export interface EnvMergeResult { + // Keys written with a new value. + written: string[]; + // Keys left alone because the file already had a value. + kept: string[]; +} + +const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/; + +/** + * Updates an existing .env in place, or creates it. Unlike writeEnv, every line it + * does not own survives: comments, blank lines, ordering and other keys. `set` keys + * are always written; `defaults` only when the file has no value for them. + */ +export function mergeEnvFile( + filePath: string, + updates: { set?: Record; defaults?: Record }, + heading = "Seamless Auth", +): EnvMergeResult { + const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : ""; + const current = parseEnvString(existing); + const result: EnvMergeResult = { written: [], kept: [] }; + + const wanted: Record = {}; + for (const [key, value] of Object.entries(updates.set ?? {})) { + if (current[key] === value) { + result.kept.push(key); + } else { + wanted[key] = value; + } + } + for (const [key, value] of Object.entries(updates.defaults ?? {})) { + if (current[key]) { + result.kept.push(key); + } else { + wanted[key] = value; + } + } + + const lines = existing === "" ? [] : existing.replace(/\n$/, "").split("\n"); + const placed = new Set(); + const out = lines.map((line) => { + const key = line.match(ASSIGNMENT)?.[1]; + if (!key || !(key in wanted) || placed.has(key)) return line; + placed.add(key); + return `${key}=${formatValue(wanted[key])}`; + }); + + const appended = Object.keys(wanted).filter((key) => !placed.has(key)); + if (appended.length > 0) { + if (out.length > 0 && out[out.length - 1].trim() !== "") out.push(""); + out.push(`# ${heading}`); + for (const key of appended) out.push(`${key}=${formatValue(wanted[key])}`); + } + + result.written = Object.keys(wanted); + if (result.written.length > 0 || existing === "") { + fs.writeFileSync(filePath, out.join("\n") + "\n"); + } + return result; +} diff --git a/src/core/exec.test.ts b/src/core/exec.test.ts index 1bee57d..735cd17 100644 --- a/src/core/exec.test.ts +++ b/src/core/exec.test.ts @@ -24,7 +24,7 @@ describe("runCommand", () => { expect(spawn).toHaveBeenCalledWith("echo", ["hi"], { stdio: "inherit", cwd: "/tmp/proj", - shell: true, + shell: process.platform === "win32", env: process.env, }); }); @@ -41,7 +41,7 @@ describe("runCommand", () => { expect(spawn).toHaveBeenCalledWith("echo", [], { stdio: "inherit", cwd: "/tmp/proj", - shell: true, + shell: process.platform === "win32", env: customEnv, }); }); diff --git a/src/core/exec.ts b/src/core/exec.ts index 375b17f..8709854 100644 --- a/src/core/exec.ts +++ b/src/core/exec.ts @@ -10,7 +10,10 @@ export function runCommand( const child = spawn(command, args, { stdio: "inherit", cwd, - shell: true, + // Only Windows needs a shell, to run npm and friends as .cmd scripts. + // Elsewhere the arguments go to the program as they are: a shell would + // re-split a path with spaces, and Node 24 warns about passing args to one. + shell: process.platform === "win32", env: env ?? process.env, }); diff --git a/src/core/managedConnect.ts b/src/core/managedConnect.ts new file mode 100644 index 0000000..f5db94a --- /dev/null +++ b/src/core/managedConnect.ts @@ -0,0 +1,100 @@ +import { confirm } from "@clack/prompts"; +import kleur from "kleur"; + +import type { AuthClient } from "./authClient.js"; +import { CancelledError, orCancel } from "./cancel.js"; +import { fetchActiveJwksKid } from "./jwksKid.js"; +import { resolveAppInstanceUrl, rotateServiceToken, type PortalApp } from "./portal.js"; +import { requireInteractive } from "./tty.js"; + +// Shared by `seamless init` and `seamless add`, which both connect a project to a +// managed application. + +// Only reached when the instance cannot be asked. Adapters resolve the signing key +// from the token header through the remote JWKS, so this value verifies nothing, but +// they warn on boot while it is the dev default, so the real kid is worth fetching. +export const FALLBACK_JWKS_KID = "dev-main"; + +// Asked of the instance rather than assumed: instances pin their kid per tier +// (trialkey1, paidkey1), so writing the dev default made every managed scaffold boot +// an app warning that it was misconfigured. +export async function resolveJwksKid(authServerUrl: string): Promise { + const kid = await fetchActiveJwksKid(authServerUrl); + if (kid) return kid; + + console.log( + kleur.yellow( + `Could not read the signing key id from ${authServerUrl}, so JWKS_KID is set to "${FALLBACK_JWKS_KID}".`, + ), + ); + console.log( + kleur.dim( + " Nothing verifies against it, the SDK resolves the key from the token, but your adapter will warn on boot until it matches. Read it from /.well-known/jwks.json once the instance is up.", + ), + ); + return FALLBACK_JWKS_KID; +} + +// Issues the app's service token, confirming first when one already exists so a +// scaffold does not silently break an app that is already deployed. Declining +// cancels the whole command, so nothing is left half-wired. +export async function issueServiceToken( + client: AuthClient, + app: PortalApp, + opts: { yes?: boolean; force?: boolean }, +): Promise { + if (app.hasServiceToken) { + // Rotation breaks whatever is running on the old token, so it confirms like + // the overwrite step does. + if (opts.yes) { + if (!opts.force) { + throw new Error( + `"${app.name}" already has a service token, and issuing a new one invalidates it (breaking anything already deployed with it). Re-run with --force to rotate it anyway.`, + ); + } + console.log( + kleur.yellow( + `Rotating the existing service token for "${app.name}" (--force).`, + ), + ); + } else { + requireInteractive( + `"${app.name}" already has a service token. Issue a new one?`, + "Pass --force to rotate it, which invalidates the existing token.", + ); + const proceed = orCancel( + await confirm({ + message: `"${app.name}" already has a service token. Issuing a new one invalidates the existing token. Continue?`, + initialValue: false, + }), + ); + if (!proceed) { + throw new CancelledError("Cancelled. No token was issued."); + } + } + } + return rotateServiceToken(client, app.id); +} + +// connectable() filters out applications with no auth server, so this throwing means +// the filtered list and the selection disagreed, not that one is simply not ready yet. +export function requireInstanceUrl(app: PortalApp): string { + const url = resolveAppInstanceUrl(app); + if (!url) { + throw new Error( + `Application "${app.name}" has no auth instance URL yet. Run seamless apps list to check whether it has finished provisioning.`, + ); + } + return url; +} + +// listApplications reports applications that have not finished provisioning too, +// because `seamless apps list` has to show them. Managed connect needs an auth +// server to point at, so it keeps considering only the ones that have one. +// +// Asked through resolveAppInstanceUrl rather than of `domain` directly: an +// application served at an instanceUrl its stale domain column never carried would +// otherwise be filtered out here and never offered. +export function connectable(apps: PortalApp[]): PortalApp[] { + return apps.filter((app) => resolveAppInstanceUrl(app) !== undefined); +} diff --git a/src/core/snippets.test.ts b/src/core/snippets.test.ts new file mode 100644 index 0000000..2a7e2e7 --- /dev/null +++ b/src/core/snippets.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import type { DetectedBackend, DetectedWeb } from "./detect.js"; +import { backendPackages, backendSnippet, webApiUrlVariable, webSnippet } from "./snippets.js"; + +const backend = (framework: "express" | "fastify", typescript: boolean): DetectedBackend => ({ + framework, + typescript, + dir: "api", + packageManager: "npm", +}); + +describe("backendSnippet", () => { + it("types req.user and the handlers for a TypeScript Express app", () => { + const code = backendSnippet(backend("express", true), { webOrigin: "http://localhost:5173", serveConsole: true }); + expect(code).toContain("type SeamlessAuthUser"); + expect(code).toContain("user?: SeamlessAuthUser;"); + expect(code).toContain('app.use("/auth", createSeamlessAuthServer(seamless));'); + expect(code).toContain("app.use(cookieParser());"); + expect(code).toContain("createSeamlessConsoleProxy"); + expect(code).toContain("process.env.AUTH_SERVER_URL!"); + }); + + it("is plain JavaScript for a JavaScript app", () => { + for (const framework of ["express", "fastify"] as const) { + const code = backendSnippet(backend(framework, false), { webOrigin: "http://localhost:5173", serveConsole: false }); + expect(code).not.toMatch(/!\s*[,;.)]/); + expect(code).not.toContain("}: {"); + expect(code).not.toContain("as const"); + expect(code).not.toContain("declare global"); + expect(code).not.toContain("Console"); + } + }); + + it("registers the Fastify plugin at /auth with a preHandler guard", () => { + const code = backendSnippet(backend("fastify", true), { webOrigin: "http://localhost:5173", serveConsole: true }); + expect(code).toContain('await app.register(seamlessAuth, { prefix: "/auth", ...seamless });'); + expect(code).toContain("{ preHandler: signedIn }"); + expect(code).toContain("seamlessConsoleProxy"); + }); +}); + +describe("web", () => { + const web = (bundler: DetectedWeb["bundler"]): DetectedWeb => ({ + framework: "react", + bundler, + dir: "web", + typescript: true, + packageManager: "npm", + }); + + it("reads the API URL the way the bundler exposes it", () => { + expect(webApiUrlVariable(web("vite"))).toBe("VITE_API_URL"); + expect(webSnippet(web("vite"))).toContain("import.meta.env.VITE_API_URL"); + expect(webApiUrlVariable(web("react-scripts"))).toBe("REACT_APP_API_URL"); + expect(webSnippet(web("react-scripts"))).toContain("process.env.REACT_APP_API_URL"); + }); +}); + +describe("backendPackages", () => { + it("installs the adapter and what the snippet imports", () => { + expect(backendPackages(backend("express", true))).toEqual({ + deps: ["@seamless-auth/express", "cookie-parser", "cors"], + dev: ["@types/cookie-parser", "@types/cors"], + }); + expect(backendPackages(backend("fastify", false))).toEqual({ + deps: ["@seamless-auth/fastify", "@fastify/cors"], + dev: [], + }); + }); +}); diff --git a/src/core/snippets.ts b/src/core/snippets.ts new file mode 100644 index 0000000..63745b9 --- /dev/null +++ b/src/core/snippets.ts @@ -0,0 +1,155 @@ +import type { DetectedBackend, DetectedWeb } from "./detect.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, +// verify/adapter-fastify-app), with the starters' development-only code logging. + +// The env reads both adapters take. `!` only where TypeScript would otherwise +// object, so the JavaScript version is still valid JavaScript. +function seamlessOptions(ts: boolean): string { + const bang = ts ? "!" : ""; + return `const issuer = process.env.AUTH_SERVER_ISSUER || process.env.AUTH_SERVER_URL${bang}; +const seamless = { + authServerUrl: process.env.AUTH_SERVER_URL${bang}, + authServerIssuer: issuer, + audience: issuer, + cookieSecret: process.env.COOKIE_SIGNING_KEY${bang}, + serviceSecret: process.env.API_SERVICE_TOKEN${bang}, + jwksKid: process.env.JWKS_KID${bang}, + // Development only: log one-time codes and magic links instead of sending them, + // so you can sign in without a mail or SMS provider. Replace before deploying. + messaging: + process.env.NODE_ENV === "production" + ? undefined + : { + handlers: { + sendOtpEmail: async ({ to, token }${ts ? ": { to: string; token: string }" : ""}) => { + console.log(\`Dev OTP to=\${to} code=\${token}\`); + return { accepted: true, provider: "console", channel: "email" as const }; + }, + sendOtpSms: async ({ to, token }${ts ? ": { to: string; token: string }" : ""}) => { + console.log(\`Dev OTP to=\${to} code=\${token}\`); + return { accepted: true, provider: "console", channel: "sms" as const }; + }, + sendMagicLinkEmail: async ({ to, magicLinkUrl }${ts ? ": { to: string; magicLinkUrl: string }" : ""}) => { + console.log(\`Dev magic link to=\${to} url=\${magicLinkUrl}\`); + return { accepted: true, provider: "console", channel: "email" as const }; + }, + }, + }, +};`.replace(/ as const/g, ts ? " as const" : ""); +} + +export function backendSnippet( + backend: DetectedBackend, + opts: { webOrigin: string; serveConsole: boolean }, +): string { + const ts = backend.typescript; + if (backend.framework === "express") { + const typing = ts + ? ` +// requireAuth puts the signed-in user on req.user. +declare global { + namespace Express { + interface Request { + user?: SeamlessAuthUser; + } + } +} +` + : ""; + return `import cookieParser from "cookie-parser"; +import cors from "cors"; +import createSeamlessAuthServer, {${opts.serveConsole ? "\n createSeamlessConsoleProxy," : ""} + requireAuth,${ts ? "\n type SeamlessAuthUser," : ""} +} from "@seamless-auth/express"; +${typing} +${seamlessOptions(ts)} +${ + opts.serveConsole + ? ` +// The admin dashboard at /console, ahead of CORS: it is same-origin static content. +if (process.env.SERVE_ADMIN_CONSOLE === "true") { + app.use("/console", createSeamlessConsoleProxy({ authServerUrl: seamless.authServerUrl })); +} +` + : "" +} +// Your web app calls /auth on this server with cookies, so it needs credentialed CORS. +app.use(cors({ origin: process.env.UI_ORIGINS?.split(",") ?? "${opts.webOrigin}", credentials: true })); +app.use(express.json()); +app.use(cookieParser()); +app.use("/auth", createSeamlessAuthServer(seamless)); + +// Protect a route. req.user is the signed-in user. +const signedIn = requireAuth({ + cookieSecret: seamless.cookieSecret, + authServerUrl: seamless.authServerUrl, + authServerIssuer: issuer, + audience: issuer, +}); +app.get("/api/me", signedIn, (req, res) => res.json({ id: req.user${ts ? "!" : ""}.id }));`; + } + + return `import cors from "@fastify/cors"; +import seamlessAuth, { requireAuth${opts.serveConsole ? ", seamlessConsoleProxy" : ""} } from "@seamless-auth/fastify"; + +${seamlessOptions(ts)} +${ + opts.serveConsole + ? ` +// The admin dashboard at /console. +if (process.env.SERVE_ADMIN_CONSOLE === "true") { + await app.register(seamlessConsoleProxy, { prefix: "/console", authServerUrl: seamless.authServerUrl }); +} +` + : "" +} +// Your web app calls /auth on this server with cookies, so it needs credentialed CORS. +await app.register(cors, { origin: process.env.UI_ORIGINS?.split(",") ?? "${opts.webOrigin}", credentials: true }); +await app.register(seamlessAuth, { prefix: "/auth", ...seamless }); + +// Protect a route. req.user is the signed-in user. +const signedIn = requireAuth({ + cookieSecret: seamless.cookieSecret, + authServerUrl: seamless.authServerUrl, + authServerIssuer: issuer, + audience: issuer, +}); +app.get("/api/me", { preHandler: signedIn }, async (req) => ({ id: req.user${ts ? "!" : ""}.id }));`; +} + +/** The environment variable a React build reads the backend URL from. */ +export function webApiUrlVariable(web: DetectedWeb): string { + return web.bundler === "react-scripts" ? "REACT_APP_API_URL" : "VITE_API_URL"; +} + +export function webSnippet(web: DetectedWeb): string { + const variable = webApiUrlVariable(web); + const read = + web.bundler === "react-scripts" + ? `process.env.${variable}` + : `import.meta.env.${variable}`; + return `import { AuthProvider, useAuth } from "@seamless-auth/react"; + +// Wrap your app once, at the root. It talks to your backend's /auth routes. + + + + +// Anywhere below it: +const { isAuthenticated, user, logout } = useAuth();`; +} + +/** What to install in each half, with dev dependencies listed apart. */ +export function backendPackages(backend: DetectedBackend): { deps: string[]; dev: string[] } { + if (backend.framework === "express") { + return { + deps: ["@seamless-auth/express", "cookie-parser", "cors"], + dev: backend.typescript ? ["@types/cookie-parser", "@types/cors"] : [], + }; + } + return { deps: ["@seamless-auth/fastify", "@fastify/cors"], dev: [] }; +} + +export const WEB_PACKAGES = ["@seamless-auth/react"]; diff --git a/src/generators/auth/auth.test.ts b/src/generators/auth/auth.test.ts index 026568f..9f3c690 100644 --- a/src/generators/auth/auth.test.ts +++ b/src/generators/auth/auth.test.ts @@ -71,7 +71,7 @@ describe("generateAuthServer local mode", () => { expect(spawn).toHaveBeenCalledWith( "git", ["clone", AUTH_REPO, "auth"], - { stdio: "inherit", cwd: tmpDir, shell: true, env: process.env }, + { stdio: "inherit", cwd: tmpDir, shell: process.platform === "win32", env: process.env }, ); expect(shared.kid).toBe("dev-main"); diff --git a/src/generators/docker/authStack.test.ts b/src/generators/docker/authStack.test.ts new file mode 100644 index 0000000..a9fe69a --- /dev/null +++ b/src/generators/docker/authStack.test.ts @@ -0,0 +1,64 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../core/fetch.js", () => ({ + fetchEnvExample: async () => "LOGIN_METHODS=passkey\nAPP_NAME=Example\n", +})); + +import { parseEnv } from "../../core/env.js"; +import { SEAMLESS_AUTH_API_IMAGE } from "../../core/images.js"; +import { generateAuthStack } from "./authStack.js"; + +let root: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "seamless-stack-")); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("generateAuthStack", () => { + it("writes an auth server and Postgres into seamless/, with the secrets kept out of git", async () => { + const stack = await generateAuthStack(root, { + apiOrigin: "http://localhost:4000", + webOrigins: ["http://localhost:5173"], + serveConsole: true, + ownerEmail: "owner@example.com", + }); + + const dir = path.join(root, "seamless"); + const compose = fs.readFileSync(path.join(dir, "docker-compose.yml"), "utf-8"); + expect(compose).toContain(`image: ${SEAMLESS_AUTH_API_IMAGE}`); + expect(compose).toContain('"127.0.0.1:5312:5312"'); + expect(compose).toContain("env_file:"); + // An existing project may run its own database, or another Seamless stack. + expect(compose).not.toContain("5432:5432"); + expect(compose).not.toContain("container_name"); + expect(compose).not.toContain(stack.apiToken); + + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf-8")).toBe(".env\n"); + const env = parseEnv(path.join(dir, ".env")); + expect(env.API_SERVICE_TOKEN).toBe(stack.apiToken); + expect(env.APP_ORIGINS).toBe("http://localhost:4000"); + expect(env.ORIGINS).toBe("http://localhost:5173,http://localhost:4000"); + expect(env.SERVE_ADMIN_DASHBOARD).toBe("true"); + expect(env.OWNER_EMAIL).toBe("owner@example.com"); + expect(env.ISSUER).toBe("http://auth:5312"); + expect(env.DB_HOST).toBe("db"); + expect(stack.kid).toBe("dev-main"); + }); + + it("leaves the console origin out when the backend does not serve it", async () => { + await generateAuthStack(root, { + apiOrigin: "http://localhost:3000", + webOrigins: ["http://localhost:5173"], + serveConsole: false, + }); + const env = parseEnv(path.join(root, "seamless", ".env")); + expect(env.ORIGINS).toBe("http://localhost:5173"); + expect(env.SERVE_ADMIN_DASHBOARD).toBe("false"); + expect(env.OWNER_EMAIL).toBeUndefined(); + }); +}); diff --git a/src/generators/docker/authStack.ts b/src/generators/docker/authStack.ts new file mode 100644 index 0000000..111137d --- /dev/null +++ b/src/generators/docker/authStack.ts @@ -0,0 +1,110 @@ +import fs from "fs"; +import path from "path"; + +import { fetchEnvExample } from "../../core/fetch.js"; +import { formatValue, parseEnvString } from "../../core/env.js"; +import { POSTGRES_IMAGE, SEAMLESS_AUTH_API_IMAGE } from "../../core/images.js"; +import { buildAuthEnv } from "./docker.js"; + +// Where `seamless add --local` keeps the auth server for an existing repository, so +// nothing at the repository root changes and the whole stack is one folder to +// delete or ignore. +export const AUTH_STACK_DIR = "seamless"; + +export interface AuthStackOptions { + // The adopter's backend origin, which calls the auth server. + apiOrigin: string; + // The browser origin(s) passkey ceremonies start from. + webOrigins: string[]; + // Whether the backend serves the admin dashboard at /console. + serveConsole: boolean; + ownerEmail?: string; +} + +/** + * Writes a local auth server and its Postgres into `/seamless/`: a compose + * file, the server's environment (secrets included) in seamless/.env, and a + * .gitignore that keeps that file out of the repository. Unlike the `init` stack it + * names no containers and publishes no Postgres port, because an existing project + * may run its own database, or another Seamless stack, on the same machine. + */ +export async function generateAuthStack(root: string, options: AuthStackOptions) { + const dir = path.join(root, AUTH_STACK_DIR); + fs.mkdirSync(dir, { recursive: true }); + + const example = parseEnvString(await fetchEnvExample()); + const { env, shared } = buildAuthEnv( + example, + "docker", + [], + options.serveConsole ? "api" : "none", + options.ownerEmail, + ); + // The adopter's own origins, rather than the starters' fixed ports. + env.APP_ORIGINS = options.apiOrigin; + env.ORIGINS = [ + ...options.webOrigins, + ...(options.serveConsole ? [options.apiOrigin] : []), + ].join(","); + + const envFile = Object.entries(env) + .map(([key, value]) => `${key}=${formatValue(value)}`) + .join("\n"); + fs.writeFileSync( + path.join(dir, ".env"), + `# The local Seamless Auth server's configuration, written by seamless add. +# It holds secrets: keep it out of version control (see .gitignore). +${envFile} +`, + ); + fs.writeFileSync(path.join(dir, ".gitignore"), ".env\n"); + + fs.writeFileSync( + path.join(dir, "docker-compose.yml"), + `# A local Seamless Auth server for this project, written by seamless add. +# +# docker compose -f ${AUTH_STACK_DIR}/docker-compose.yml up -d +# +# The auth server is published on 127.0.0.1 so it is reachable from this machine +# only: it is configured for local development, where it returns one-time codes +# to the adapter instead of sending them. Postgres is not published at all. +services: + db: + image: ${POSTGRES_IMAGE} + environment: + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypassword + POSTGRES_DB: postgres + volumes: + # PostgreSQL 18+ images store data in a major-versioned subdirectory, so the + # mount goes one level up. See docker-library/postgres#1259. + - pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U myuser -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + auth: + image: ${SEAMLESS_AUTH_API_IMAGE} + ports: + - "127.0.0.1:5312:5312" + env_file: + - .env + volumes: + # The dev signing key pair is generated into /app/keys on first use. Without + # a volume a recreated container mints a new pair under the same kid, and + # every adapter that cached the old public key fails verification. + - auth-keys:/app/keys + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: + auth-keys: +`, + ); + + return { dir, apiToken: shared.apiToken, kid: shared.kid }; +} diff --git a/src/index.ts b/src/index.ts index abacaac..9cf89d9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { runOrg } from "./commands/org.js"; import { runApps } from "./commands/apps.js"; import { runMigrate } from "./commands/migrate.js"; import { runTemplates } from "./commands/templates.js"; +import { runAdd } from "./commands/add.js"; import { isCancelled } from "./core/cancel.js"; import kleur from "kleur"; @@ -67,6 +68,11 @@ async function main() { return; } + if (command === "add") { + await runAdd(args.slice(1)); + return; + } + if (command === "templates") { await runTemplates(args.slice(1)); return; diff --git a/src/prompts/projectSetup.ts b/src/prompts/projectSetup.ts index 9642981..a7f7677 100644 --- a/src/prompts/projectSetup.ts +++ b/src/prompts/projectSetup.ts @@ -408,7 +408,7 @@ async function resolveChoice( return ask(); } -async function resolveOwnerEmail( +export async function resolveOwnerEmail( supplied: string | undefined, knownEmail: string | undefined, assumeYes: boolean,