From 6af3216f47a5c6a533acbcfac77cbf4f77e6cb45 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 16 Jul 2026 17:48:28 -0400 Subject: [PATCH] feat(init): connect scaffolds to a managed instance when logged in Previously init hardwired AUTH_SERVER_URL and API_URL to localhost and minted the app's service token locally, so a developer who ran seamless login and then init got an app pointed at localhost. When a profile has an active session, init now defaults to the managed control plane: it reads the keychain session, lists the developer's applications from portal-api, issues the application's service token from the control plane, and points the scaffolded web and api at the managed auth instance. The self-hosted flow stays available with --local and is used automatically when no session is present. Running init inside an existing project wires the managed credentials into api/.env in place, or prints them when there is no api directory. Closes #59 --- .changeset/managed-connect-init.md | 7 + README.md | 37 ++++ src/commands/init.ts | 323 ++++++++++++++++++++++++++--- src/core/output.ts | 82 ++++++++ src/core/portal.test.ts | 172 +++++++++++++++ src/core/portal.ts | 110 ++++++++++ src/generators/config/config.ts | 59 ++++-- src/index.ts | 16 +- src/prompts/appSelect.test.ts | 45 ++++ src/prompts/appSelect.ts | 58 ++++++ src/prompts/projectSetup.ts | 30 +++ 11 files changed, 891 insertions(+), 48 deletions(-) create mode 100644 .changeset/managed-connect-init.md create mode 100644 src/core/portal.test.ts create mode 100644 src/core/portal.ts create mode 100644 src/prompts/appSelect.test.ts create mode 100644 src/prompts/appSelect.ts diff --git a/.changeset/managed-connect-init.md b/.changeset/managed-connect-init.md new file mode 100644 index 0000000..ce46986 --- /dev/null +++ b/.changeset/managed-connect-init.md @@ -0,0 +1,7 @@ +--- +"seamless-cli": minor +--- + +feat(init): connect scaffolds to a managed instance when a profile is logged in + +`seamless init` now defaults to the managed control plane whenever a profile has an active session. It reads the logged-in session, lists your applications, issues the application's service token from the control plane (rather than generating a local secret), and points the scaffolded web and api at the managed auth instance. Running `init` inside an existing project wires the managed credentials into `api/.env` in place, or prints them when there is no api directory. The self-hosted flow stays available with `seamless init --local`, and is still used automatically when no session is present. Set `SEAMLESS_PORTAL_API_URL` to override the control-plane host. diff --git a/README.md b/README.md index 485bb86..0a5724c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,43 @@ You’ll be guided through a short setup process where you can choose: --- +## Connecting to a managed instance + +If you are logged in to a managed Seamless Auth account (see +[Authenticating against an instance](#authenticating-against-an-instance)), `init` connects the new +project to your managed instance instead of scaffolding a local auth server. This is the default +whenever a profile has an active session. + +```bash +seamless login # once, against your managed profile +seamless init my-app # scaffolds web + api wired to the managed instance +``` + +What happens: + +- The CLI lists your applications from the control plane and, when you have more than one, asks + which to connect. Skip the prompt with `--app ` (an application id or infra id). +- It issues the application's service token from the control plane (the real credential, not a + locally generated secret) and writes it, the managed auth server URL, and the JWKS key id into + `api/.env`. The frontend is pointed at the same auth server URL. +- No local auth server, Docker Compose, or admin dashboard is generated. Auth, users, and OAuth + providers are managed from the dashboard. + +Because the service token is shown only once at issue time, `init` confirms before issuing a new +one for an application that already has a token (issuing a new token invalidates the old one). + +Run inside an existing project (a non-empty directory) to wire it up in place: `init` updates +`api/.env` when an `api` directory is present, otherwise it prints the values to set by hand. Your +own source is never overwritten. + +Escape hatches: + +- `seamless init --local` forces the self-hosted flow below, even when logged in. +- With no active session, `init` uses the self-hosted flow automatically. +- `SEAMLESS_PORTAL_API_URL` overrides the control-plane host (defaults to the managed service). + +--- + ## What gets created Depending on your selections, the CLI generates a project like this: diff --git a/src/commands/init.ts b/src/commands/init.ts index 5ffc006..9c1f36a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,25 +1,65 @@ import path from "path"; import fs from "fs"; -import { runProjectSetupPrompts } from "../prompts/projectSetup.js"; +import { confirm, isCancel } from "@clack/prompts"; +import kleur from "kleur"; + +import { + runProjectSetupPrompts, + runManagedTemplatePrompts, +} from "../prompts/projectSetup.js"; import { generateAuthServer } from "../generators/auth/auth.js"; import { generateDockerCompose } from "../generators/docker/docker.js"; -import { printSuccessOutput } from "../core/output.js"; +import { + printManagedSuccessOutput, + printSuccessOutput, +} from "../core/output.js"; import { generateSeamlessConfig } from "../generators/config/config.js"; import { applyTemplateEnv, assertCliSupports, openTemplateSource, type RegistryEntry, + type ScaffoldContext, type TemplateManifest, + type TemplateSource, } from "../core/templates.js"; import { runOAuthSetupPrompts } from "../prompts/oauthSetup.js"; import type { CollectedOAuthProvider } from "../core/oauthProviders.js"; +import { + createAuthClient, + ReauthRequiredError, + type AuthClient, +} from "../core/authClient.js"; +import { normalizeInstanceUrl } from "../core/config.js"; +import { parseEnv, writeEnv } from "../core/env.js"; +import { generateSecret } from "../core/secrets.js"; +import { + listApplications, + rotateServiceToken, + type PortalApp, +} from "../core/portal.js"; +import { selectApplication } from "../prompts/appSelect.js"; const AUTH_SERVER_URL = "http://localhost:5312"; const API_URL = "http://localhost:3000"; -export async function runCLI(projectName?: string, aliases: string[] = []) { +// Managed auth instances resolve signing keys from the token header and do not +// expose a per-application JWKS kid, so the scaffolded backend uses the SDK's +// default. This matches the portal's own "Get connected" guidance. +const MANAGED_JWKS_KID = "dev-main"; + +export interface InitOptions { + profileFlag?: string; + appId?: string; + local?: boolean; +} + +export async function runCLI( + projectName?: string, + aliases: string[] = [], + opts: InitOptions = {}, +) { const cwd = process.cwd(); let root = cwd; @@ -35,41 +75,129 @@ export async function runCLI(projectName?: string, aliases: string[] = []) { console.log(`Creating project in ${root}`); } - const files = fs.readdirSync(root); + // A logged-in profile makes the managed path the default; --local forces the + // self-hosted stack, and a missing session falls back to it automatically. + const client = opts.local ? null : await resolveManagedClient(opts.profileFlag); - const isEmpty = files.length === 0; + const isEmpty = fs.readdirSync(root).length === 0; if (!isEmpty) { - console.log("Existing project detected."); - console.log("Integration flow coming next."); + await integrateExistingProject(root, client, opts); return; } + if (client) { + await scaffoldManaged(root, projectName, aliases, client, opts); + } else { + await scaffoldLocal(root, projectName, aliases); + } +} + +// Returns an authenticated control-plane client when a profile is logged in, or +// null when there is no session (so init drops to the local stack). +async function resolveManagedClient( + profileFlag?: string, +): Promise { + try { + return await createAuthClient({ profileFlag }); + } catch (err) { + if (err instanceof ReauthRequiredError) return null; + throw err; + } +} + +async function scaffoldManaged( + root: string, + projectName: string | undefined, + aliases: string[], + client: AuthClient, + opts: InitOptions, +) { const source = await openTemplateSource(); const preselect = resolveTemplateAliases(aliases, source.registry.templates); - const answers = await runProjectSetupPrompts(source.registry.templates, preselect); + const answers = await runManagedTemplatePrompts( + source.registry.templates, + preselect, + ); - const findEntry = (id: string): RegistryEntry => { - const entry = source.registry.templates.find((t) => t.id === id); - if (!entry) { - throw new Error(`Selected template "${id}" is not in the registry.`); - } - return entry; + const selected = await resolveSelectedTemplates( + source, + answers.webTemplateId, + answers.apiTemplateId, + root, + ); + + // Resolve the target application and its service token before writing files, + // so a cancelled selection or an authorization failure leaves nothing behind. + const apps = await listApplications(client); + const app = await selectApplication(apps, opts.appId); + if (!app) return; + + const serviceToken = await issueServiceToken(client, app); + if (serviceToken === null) return; + + const authServerUrl = normalizeInstanceUrl(app.domain); + + for (const { entry, dir } of selected) { + console.log(`Adding ${entry.label} starter...`); + await source.copyInto(entry, dir); + } + + const ctx: ScaffoldContext = { + authServerUrl, + apiUrl: API_URL, + apiToken: serviceToken, + jwksKid: MANAGED_JWKS_KID, }; - // Resolve the chosen templates (read manifests) before writing anything, so every - // prompt finishes before files are placed. Env wiring waits until the shared auth - // config (tokens, key id) exists below. - const selected: { entry: RegistryEntry; manifest: TemplateManifest; dir: string }[] = - []; - for (const id of [answers.webTemplateId, answers.apiTemplateId]) { - const entry = findEntry(id); - const manifest = await source.readManifest(entry); - assertCliSupports(manifest, entry.label); - const dir = path.join(root, manifest.targetDir); - selected.push({ entry, manifest, dir }); + for (const { manifest, dir } of selected) { + applyTemplateEnv(dir, manifest, ctx); } + const webEntry = findEntry(source.registry.templates, answers.webTemplateId); + const apiEntry = findEntry(source.registry.templates, answers.apiTemplateId); + + generateSeamlessConfig(root, { + projectName, + webFramework: webEntry.framework, + apiFramework: apiEntry.framework, + authMode: "managed", + adminMode: "image", + managed: { + instanceUrl: authServerUrl, + applicationId: app.id, + applicationName: app.name, + }, + }); + + printManagedSuccessOutput({ + projectName, + webFramework: webEntry.framework, + apiFramework: apiEntry.framework, + authServerUrl, + appName: app.name, + }); +} + +async function scaffoldLocal( + root: string, + projectName: string | undefined, + aliases: string[], +) { + const source = await openTemplateSource(); + const preselect = resolveTemplateAliases(aliases, source.registry.templates); + const answers = await runProjectSetupPrompts( + source.registry.templates, + preselect, + ); + + const selected = await resolveSelectedTemplates( + source, + answers.webTemplateId, + answers.apiTemplateId, + root, + ); + // Templates can opt into OAuth setup (manifest setup.oauth). Collect providers now, // before scaffolding, so the auth server can be wired up with them below. const webSelection = selected.find((s) => s.entry.kind === "web"); @@ -102,7 +230,7 @@ export async function runCLI(projectName?: string, aliases: string[] = []) { } } - const ctx = { + const ctx: ScaffoldContext = { authServerUrl: AUTH_SERVER_URL, apiUrl: API_URL, apiToken: sharedConfig.apiToken, @@ -113,8 +241,8 @@ export async function runCLI(projectName?: string, aliases: string[] = []) { applyTemplateEnv(dir, manifest, ctx); } - const webEntry = findEntry(answers.webTemplateId); - const apiEntry = findEntry(answers.apiTemplateId); + const webEntry = findEntry(source.registry.templates, answers.webTemplateId); + const apiEntry = findEntry(source.registry.templates, answers.apiTemplateId); generateSeamlessConfig(root, { projectName, @@ -136,6 +264,145 @@ export async function runCLI(projectName?: string, aliases: string[] = []) { printOAuthNextSteps(oauthProviders); } +// Existing-project integration: wire the managed credentials into an already +// scaffolded repo without re-generating source. Kept intentionally small, it +// updates api/.env when an api directory exists and otherwise prints the values +// to paste in by hand. +async function integrateExistingProject( + root: string, + client: AuthClient | null, + opts: InitOptions, +) { + if (!client) { + console.log("Existing project detected."); + console.log( + "Log in first to connect it to a managed instance: " + + kleur.cyan("seamless login") + + ".", + ); + console.log( + kleur.dim( + "Run init in an empty directory to scaffold a new local project.", + ), + ); + return; + } + + const apps = await listApplications(client); + const app = await selectApplication(apps, opts.appId); + if (!app) return; + + const serviceToken = await issueServiceToken(client, app); + if (serviceToken === null) return; + + const authServerUrl = normalizeInstanceUrl(app.domain); + const apiDir = path.join(root, "api"); + + if (fs.existsSync(apiDir)) { + wireApiEnv(apiDir, authServerUrl, serviceToken); + console.log(kleur.green(`Updated ${path.join("api", ".env")}.`)); + console.log( + kleur.dim(" Set your web app's auth server URL to: ") + + kleur.cyan(authServerUrl), + ); + } else { + printManagedValues(authServerUrl, serviceToken); + } +} + +// Merges the managed auth values into an existing api/.env, preserving any keys +// already there and keeping (or generating) a cookie signing key. +function wireApiEnv( + apiDir: string, + authServerUrl: string, + serviceToken: string, +) { + const envPath = path.join(apiDir, ".env"); + const values = fs.existsSync(envPath) ? parseEnv(envPath) : {}; + + values.AUTH_SERVER_URL = authServerUrl; + values.API_SERVICE_TOKEN = serviceToken; + values.JWKS_KID = values.JWKS_KID || MANAGED_JWKS_KID; + values.COOKIE_SIGNING_KEY = + values.COOKIE_SIGNING_KEY || generateSecret(32); + + fs.mkdirSync(apiDir, { recursive: true }); + writeEnv(envPath, values); +} + +function printManagedValues(authServerUrl: string, serviceToken: string) { + console.log(kleur.green("\nManaged connection values:\n")); + console.log(kleur.dim(" AUTH_SERVER_URL ") + authServerUrl); + console.log(kleur.dim(" API_SERVICE_TOKEN ") + serviceToken); + console.log(kleur.dim(" JWKS_KID ") + MANAGED_JWKS_KID); + console.log( + kleur.yellow( + "\nCopy the service token now. The control plane will not show it again.", + ), + ); + console.log( + kleur.dim( + "Set AUTH_SERVER_URL and API_SERVICE_TOKEN on your backend, and the auth", + ), + ); + console.log( + kleur.dim("server URL on your frontend, then sign in to verify.\n"), + ); +} + +// 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. Returns null +// only when the developer declines the rotation. +async function issueServiceToken( + client: AuthClient, + app: PortalApp, +): Promise { + if (app.hasServiceToken) { + const proceed = await confirm({ + message: `"${app.name}" already has a service token. Issuing a new one invalidates the existing token. Continue?`, + initialValue: false, + }); + if (isCancel(proceed) || !proceed) { + console.log("Cancelled. No token was issued."); + return null; + } + } + return rotateServiceToken(client, app.id); +} + +interface SelectedTemplate { + entry: RegistryEntry; + manifest: TemplateManifest; + dir: string; +} + +// Resolves the chosen templates (reading their manifests and asserting CLI +// support) before any files are written, so every prompt finishes first. +async function resolveSelectedTemplates( + source: TemplateSource, + webTemplateId: string, + apiTemplateId: string, + root: string, +): Promise { + const selected: SelectedTemplate[] = []; + for (const id of [webTemplateId, apiTemplateId]) { + const entry = findEntry(source.registry.templates, id); + const manifest = await source.readManifest(entry); + assertCliSupports(manifest, entry.label); + const dir = path.join(root, manifest.targetDir); + selected.push({ entry, manifest, dir }); + } + return selected; +} + +function findEntry(templates: RegistryEntry[], id: string): RegistryEntry { + const entry = templates.find((t) => t.id === id); + if (!entry) { + throw new Error(`Selected template "${id}" is not in the registry.`); + } + return entry; +} + // Summarizes the OAuth wiring after scaffolding: which providers are ready and which // still need credentials, plus the redirect URI to register with each provider. function printOAuthNextSteps(providers: CollectedOAuthProvider[]) { diff --git a/src/core/output.ts b/src/core/output.ts index c8a9a5f..abe738c 100644 --- a/src/core/output.ts +++ b/src/core/output.ts @@ -135,6 +135,88 @@ export function printSuccessOutput(config: { console.log(kleur.bold().green("Setup complete.\n")); } +export function printManagedSuccessOutput(config: { + projectName?: string; + webFramework: string | null; + apiFramework: string | null; + authServerUrl: string; + appName: string; +}) { + const { projectName, webFramework, apiFramework, authServerUrl, appName } = + config; + + const title = kleur.bold().cyan("SEAMLESS"); + + console.log(` +╔════════════════════════════════════════╗ +║ ${title} ║ +╚════════════════════════════════════════╝ +`); + + console.log(kleur.green("Project connected to a managed instance.\n")); + + console.log(kleur.dim("Managed application: ") + kleur.bold(appName)); + console.log(kleur.dim("Auth server: ") + kleur.cyan(authServerUrl)); + console.log(""); + + if (projectName) { + console.log(kleur.dim("Project directory: ") + kleur.bold(projectName)); + console.log(kleur.cyan(`cd ${projectName}\n`)); + } + + console.log(kleur.bold("Included services:\n")); + if (webFramework) { + console.log( + " • " + + kleur.white("Web application") + + kleur.dim(` (${formatFramework(webFramework)})`), + ); + } + if (apiFramework) { + console.log( + " • " + + kleur.white("API server") + + kleur.dim(` (${formatFramework(apiFramework)})`), + ); + } + console.log( + " • " + kleur.white("Auth server") + kleur.dim(" (managed instance)"), + ); + console.log(""); + + console.log(kleur.bold("Next steps:\n")); + if (apiFramework) { + console.log(kleur.dim(" # API server")); + console.log(" cd api && npm install && npm run dev\n"); + } + if (webFramework) { + console.log(kleur.dim(" # Web app")); + console.log(" cd web && npm install && npm run dev\n"); + } + console.log(" Sign in from the web app to confirm the session resolves.\n"); + + console.log(kleur.bold("Notes:\n")); + console.log( + kleur.dim( + " • The API service token was written to api/.env. Keep it out of version control.", + ), + ); + console.log( + kleur.dim( + " • Auth, users, and OAuth providers are managed from the dashboard, not locally.", + ), + ); + console.log( + kleur.dim(" • Rotate the service token anytime with the dashboard.\n"), + ); + + console.log( + kleur.dim("Docs: ") + kleur.cyan("https://docs.seamlessauth.com\n"), + ); + + console.log(kleur.bold().green("Setup complete.\n")); +} + function formatFramework(name: string) { const map: Record = { react: "React", diff --git a/src/core/portal.test.ts b/src/core/portal.test.ts new file mode 100644 index 0000000..62d8962 --- /dev/null +++ b/src/core/portal.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { AuthClient } from "./authClient.js"; +import type { ApiResponse } from "./http.js"; +import type { Profile } from "./config.js"; +import { + DEFAULT_PORTAL_API_URL, + PortalError, + getPortalApiUrl, + listApplications, + rotateServiceToken, +} from "./portal.js"; + +interface Call { + method: "GET" | "POST" | "REQUEST"; + path: string; + body?: unknown; +} + +function response(status: number, data: T | null): ApiResponse { + return { + ok: status >= 200 && status < 300, + status, + data, + headers: new Headers(), + }; +} + +// A minimal AuthClient stand-in: it records the absolute URLs portal.ts builds +// and replays queued responses, so tests assert both the request shape and the +// parsing of each reply. +function fakeClient(replies: ApiResponse[]): { + client: AuthClient; + calls: Call[]; +} { + const calls: Call[] = []; + let i = 0; + const next = () => replies[Math.min(i++, replies.length - 1)]; + + const profile: Profile = { + name: "default", + instanceUrl: "https://auth.example.com", + }; + + const client: AuthClient = { + profile, + request: async (path) => { + calls.push({ method: "REQUEST", path }); + return next() as ApiResponse; + }, + get: async (path) => { + calls.push({ method: "GET", path }); + return next() as ApiResponse; + }, + post: async (path, body) => { + calls.push({ method: "POST", path, body }); + return next() as ApiResponse; + }, + }; + + return { client, calls }; +} + +describe("getPortalApiUrl", () => { + const original = process.env.SEAMLESS_PORTAL_API_URL; + + afterEach(() => { + if (original === undefined) delete process.env.SEAMLESS_PORTAL_API_URL; + else process.env.SEAMLESS_PORTAL_API_URL = original; + }); + + it("defaults to the production control plane", () => { + delete process.env.SEAMLESS_PORTAL_API_URL; + expect(getPortalApiUrl()).toBe(DEFAULT_PORTAL_API_URL); + }); + + it("honors the override and strips a trailing slash", () => { + process.env.SEAMLESS_PORTAL_API_URL = "http://localhost:5001/"; + expect(getPortalApiUrl()).toBe("http://localhost:5001"); + }); +}); + +describe("listApplications", () => { + beforeEach(() => { + process.env.SEAMLESS_PORTAL_API_URL = "http://localhost:5001"; + }); + afterEach(() => { + delete process.env.SEAMLESS_PORTAL_API_URL; + }); + + it("requests the portal host and maps applications", async () => { + const { client, calls } = fakeClient([ + response(200, { + applications: [ + { + id: "app-1", + name: "Acme", + domain: "https://acme.seamlessauth.com", + infraId: "acme", + serviceTokenMetadata: { maskedToken: "****abcd" }, + }, + { id: "app-2", name: "Beta", domain: "https://beta.seamlessauth.com" }, + { id: "no-domain" }, + ], + }), + ]); + + const apps = await listApplications(client); + + expect(calls[0]).toEqual({ + method: "GET", + path: "http://localhost:5001/applications", + }); + expect(apps).toHaveLength(2); + expect(apps[0]).toMatchObject({ + id: "app-1", + name: "Acme", + domain: "https://acme.seamlessauth.com", + infraId: "acme", + hasServiceToken: true, + }); + expect(apps[1].hasServiceToken).toBe(false); + }); + + it("treats a 401 as an authorization failure", async () => { + const { client } = fakeClient([response(401, null)]); + await expect(listApplications(client)).rejects.toBeInstanceOf(PortalError); + }); + + it("reports other failures with the status code", async () => { + const { client } = fakeClient([response(500, null)]); + await expect(listApplications(client)).rejects.toThrow(/500/); + }); +}); + +describe("rotateServiceToken", () => { + beforeEach(() => { + process.env.SEAMLESS_PORTAL_API_URL = "http://localhost:5001"; + }); + afterEach(() => { + delete process.env.SEAMLESS_PORTAL_API_URL; + }); + + it("posts to the rotate endpoint and returns the token", async () => { + const { client, calls } = fakeClient([ + response(200, { serviceToken: "secret-token", message: "copy it" }), + ]); + + const token = await rotateServiceToken(client, "app 1"); + + expect(token).toBe("secret-token"); + expect(calls[0]).toEqual({ + method: "POST", + path: "http://localhost:5001/applications/app%201/rotateServiceToken", + body: undefined, + }); + }); + + it("maps a 404 to a not-found error", async () => { + const { client } = fakeClient([response(404, null)]); + await expect(rotateServiceToken(client, "missing")).rejects.toThrow( + /not be found|not found/i, + ); + }); + + it("fails when the response omits a token", async () => { + const { client } = fakeClient([response(200, { message: "ok" })]); + await expect(rotateServiceToken(client, "app-1")).rejects.toBeInstanceOf( + PortalError, + ); + }); +}); diff --git a/src/core/portal.ts b/src/core/portal.ts new file mode 100644 index 0000000..4e780b9 --- /dev/null +++ b/src/core/portal.ts @@ -0,0 +1,110 @@ +import type { AuthClient } from "./authClient.js"; +import { joinUrl } from "./http.js"; + +// The managed control plane (seamless-portal-api). It is fronted by the same +// Seamless Auth server a developer logs into, so the CLI reuses the active +// profile's keychain session (Bearer) to call it. Override the host for staging +// or local portal development with SEAMLESS_PORTAL_API_URL. +export const DEFAULT_PORTAL_API_URL = "https://api.seamlessauth.com"; + +export function getPortalApiUrl(): string { + const override = process.env.SEAMLESS_PORTAL_API_URL?.trim(); + const base = override || DEFAULT_PORTAL_API_URL; + return base.replace(/\/+$/, ""); +} + +export class PortalError extends Error { + constructor(message: string) { + super(message); + this.name = "PortalError"; + } +} + +// A managed application as the CLI needs it. `domain` is the application's own +// managed auth instance URL (https://.seamlessauth.com), which the +// scaffold points its AUTH_SERVER_URL at. `hasServiceToken` reflects whether a +// service token was ever issued, so init can confirm before rotating and +// invalidating one that a deployed app may still be using. +export interface PortalApp { + id: string; + name: string; + domain: string; + infraId?: string; + frontendUrl?: string; + servicePlan?: string; + status?: string; + hasServiceToken: boolean; +} + +function str(raw: Record, key: string): string | undefined { + const value = raw[key]; + return typeof value === "string" && value ? value : undefined; +} + +function toApp(raw: Record): PortalApp | null { + const id = str(raw, "id"); + const domain = str(raw, "domain"); + if (!id || !domain) return null; + + return { + id, + name: str(raw, "name") ?? id, + domain, + infraId: str(raw, "infraId"), + frontendUrl: str(raw, "frontendUrl"), + servicePlan: str(raw, "servicePlan"), + status: str(raw, "status"), + hasServiceToken: raw.serviceTokenMetadata != null, + }; +} + +function unauthorized(action: string): PortalError { + return new PortalError( + `Your managed session is not authorized to ${action}. Run: seamless login.`, + ); +} + +export async function listApplications(client: AuthClient): Promise { + const url = joinUrl(getPortalApiUrl(), "/applications"); + const res = await client.get<{ applications?: unknown }>(url); + + if (res.status === 401 || res.status === 403) { + throw unauthorized("read your applications"); + } + if (!res.ok) { + throw new PortalError(`Could not list managed applications (${res.status}).`); + } + + const list = Array.isArray(res.data?.applications) ? res.data.applications : []; + return list + .filter((a): a is Record => !!a && typeof a === "object") + .map(toApp) + .filter((a): a is PortalApp => a !== null); +} + +// Issues (rotates) the application's service token. The control plane only ever +// returns a raw token at rotation time (it is stored write-once), so this is the +// real credential flow: the token the auth instance already recognizes for this +// app, not a locally minted secret. +export async function rotateServiceToken( + client: AuthClient, + appId: string, +): Promise { + const url = joinUrl( + getPortalApiUrl(), + `/applications/${encodeURIComponent(appId)}/rotateServiceToken`, + ); + const res = await client.post<{ serviceToken?: string }>(url); + + if (res.status === 401 || res.status === 403) { + throw unauthorized("issue a service token"); + } + if (res.status === 404) { + throw new PortalError(`Managed application "${appId}" was not found.`); + } + if (!res.ok || !res.data?.serviceToken) { + throw new PortalError(`Could not issue a service token (${res.status}).`); + } + + return res.data.serviceToken; +} diff --git a/src/generators/config/config.ts b/src/generators/config/config.ts index 1de3684..4bc3eb7 100644 --- a/src/generators/config/config.ts +++ b/src/generators/config/config.ts @@ -6,16 +6,53 @@ import { SEAMLESS_AUTH_API_IMAGE, } from "../../core/images.js"; +export interface ManagedConfig { + instanceUrl: string; + applicationId: string; + applicationName: string; +} + export function generateSeamlessConfig( root: string, options: { projectName?: string; webFramework: string; apiFramework: string; - authMode: "local" | "docker"; + authMode: "local" | "docker" | "managed"; adminMode: "image" | "source"; + managed?: ManagedConfig; }, ) { + const managed = options.authMode === "managed"; + + const auth = managed + ? { + mode: "managed" as const, + instanceUrl: options.managed?.instanceUrl ?? null, + applicationId: options.managed?.applicationId ?? null, + applicationName: options.managed?.applicationName ?? null, + image: null, + path: null, + } + : { + mode: options.authMode, + image: options.authMode === "docker" ? SEAMLESS_AUTH_API_IMAGE : null, + path: options.authMode === "local" ? "./auth" : null, + }; + + // A managed instance hosts its own admin dashboard, so no admin service is + // scaffolded locally. + const admin = managed + ? { mode: "hosted" as const, image: null, path: null } + : { + mode: options.adminMode, + image: + options.adminMode === "image" + ? SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE + : null, + path: options.adminMode === "source" ? "./admin" : null, + }; + const config = { version: VERSION, projectName: options.projectName || path.basename(root), @@ -30,27 +67,15 @@ export function generateSeamlessConfig( framework: options.apiFramework, path: "./api", }, - auth: { - mode: options.authMode, - image: options.authMode === "docker" ? SEAMLESS_AUTH_API_IMAGE : null, - path: options.authMode === "local" ? "./auth" : null, - }, - admin: { - mode: options.adminMode, - image: - options.adminMode === "image" - ? SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE - : null, - path: options.adminMode === "source" ? "./admin" : null, - }, + auth, + admin, database: { type: "postgres", }, }, - docker: { - composeFile: "docker-compose.yml", - }, + // Managed projects have no local compose file; the auth stack runs remotely. + docker: managed ? null : { composeFile: "docker-compose.yml" }, }; fs.writeFileSync( diff --git a/src/index.ts b/src/index.ts index c8c9a17..2f9df15 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { runCLI } from "./commands/init.js"; +import { extractFlag } from "./core/args.js"; import { runCheck } from "./commands/check.js"; import { printHelp } from "./commands/help.js"; import pkg from "../package.json" with { type: "json" }; @@ -37,12 +38,21 @@ async function main() { } if (command === "init") { - const rest = args.slice(1); + const profileFlag = extractFlag(args.slice(1), "profile"); + const appFlag = extractFlag(profileFlag.rest, "app"); + const rest = appFlag.rest; + + const local = rest.includes("--local"); const aliases = rest - .filter((a) => a.startsWith("--")) + .filter((a) => a.startsWith("--") && a !== "--local") .map((a) => a.replace(/^--+/, "")); const projectName = rest.find((a) => !a.startsWith("--")); - await runCLI(projectName, aliases); + + await runCLI(projectName, aliases, { + profileFlag: profileFlag.value, + appId: appFlag.value, + local, + }); return; } diff --git a/src/prompts/appSelect.test.ts b/src/prompts/appSelect.test.ts new file mode 100644 index 0000000..78b6ff0 --- /dev/null +++ b/src/prompts/appSelect.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import type { PortalApp } from "../core/portal.js"; +import { NoApplicationsError, selectApplication } from "./appSelect.js"; + +function app(over: Partial = {}): PortalApp { + return { + id: "app-1", + name: "Acme", + domain: "https://acme.seamlessauth.com", + hasServiceToken: false, + ...over, + }; +} + +describe("selectApplication", () => { + it("throws when there are no applications", async () => { + await expect(selectApplication([])).rejects.toBeInstanceOf( + NoApplicationsError, + ); + }); + + it("auto-selects the only application", async () => { + const only = app(); + await expect(selectApplication([only])).resolves.toBe(only); + }); + + it("matches --app by id", async () => { + const a = app({ id: "app-1" }); + const b = app({ id: "app-2", name: "Beta" }); + await expect(selectApplication([a, b], "app-2")).resolves.toBe(b); + }); + + it("matches --app by infra id", async () => { + const a = app({ id: "app-1", infraId: "acme" }); + const b = app({ id: "app-2", infraId: "beta" }); + await expect(selectApplication([a, b], "beta")).resolves.toBe(b); + }); + + it("rejects an unknown --app value", async () => { + await expect( + selectApplication([app(), app({ id: "app-2" })], "nope"), + ).rejects.toThrow(/No managed application matches/); + }); +}); diff --git a/src/prompts/appSelect.ts b/src/prompts/appSelect.ts new file mode 100644 index 0000000..f19efd0 --- /dev/null +++ b/src/prompts/appSelect.ts @@ -0,0 +1,58 @@ +import { select, isCancel, cancel } from "@clack/prompts"; + +import type { PortalApp } from "../core/portal.js"; + +export class NoApplicationsError extends Error { + constructor() { + super( + "No managed applications are available for this account. Create one at https://dashboard.seamlessauth.com, then run init again.", + ); + this.name = "NoApplicationsError"; + } +} + +// Resolves which managed application the scaffold connects to. `--app` matches an +// id or infra id; a single application is auto-selected; otherwise the developer +// picks. Returns null only when an interactive selection is cancelled. +export async function selectApplication( + apps: PortalApp[], + preselectId?: string, +): Promise { + if (apps.length === 0) { + throw new NoApplicationsError(); + } + + if (preselectId) { + const found = apps.find( + (a) => a.id === preselectId || a.infraId === preselectId, + ); + if (!found) { + const available = apps.map((a) => a.id).join(", "); + throw new Error( + `No managed application matches --app "${preselectId}". Available: ${available}.`, + ); + } + return found; + } + + if (apps.length === 1) { + console.log(`Managed application: ${apps[0].name} (${apps[0].domain})`); + return apps[0]; + } + + const choice = await select({ + message: "Which managed application should this project connect to?", + options: apps.map((a) => ({ + value: a.id, + label: a.name, + hint: a.domain, + })), + }); + + if (isCancel(choice)) { + cancel("Cancelled."); + return null; + } + + return apps.find((a) => a.id === choice) ?? null; +} diff --git a/src/prompts/projectSetup.ts b/src/prompts/projectSetup.ts index d50db35..530d8a3 100644 --- a/src/prompts/projectSetup.ts +++ b/src/prompts/projectSetup.ts @@ -40,6 +40,36 @@ function labelFor(templates: RegistryEntry[], id: string): string { return templates.find((t) => t.id === id)?.label ?? id; } +// Managed connect only needs the web and api templates: the auth server is the +// developer's managed instance, so the auth-mode, Docker, and admin-dashboard +// questions (all local-stack concerns) do not apply. +export async function runManagedTemplatePrompts( + templates: RegistryEntry[], + preselect: Preselect = {}, +) { + let webTemplateId = preselect.webTemplateId; + if (webTemplateId) { + console.log(`Web example: ${labelFor(templates, webTemplateId)}`); + } else { + webTemplateId = (await select({ + message: "Web example", + options: toOptions(templates, "web"), + })) as string; + } + + let apiTemplateId = preselect.apiTemplateId; + if (apiTemplateId) { + console.log(`Backend: ${labelFor(templates, apiTemplateId)}`); + } else { + apiTemplateId = (await select({ + message: "Backend framework", + options: toOptions(templates, "api"), + })) as string; + } + + return { webTemplateId, apiTemplateId }; +} + export async function runProjectSetupPrompts( templates: RegistryEntry[], preselect: Preselect = {},