From 84688633678e70f0cfba5077dd5602d687b3fc54 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 20 Jul 2026 21:02:15 -0400 Subject: [PATCH] refactor: target bootstrap-admin at the active profile instance Drop the dead seamless.config.json presence gate from bootstrap-admin (its parsed contents were never used) and resolve the target instance from the active profile's instanceUrl instead. Precedence is SEAMLESS_API_URL override, then the active profile (respecting --profile / SEAMLESS_PROFILE), then the local http://localhost:3000 fallback. The bootstrap-secret Bearer auth is intentionally unchanged: bootstrap runs before any admin user exists, so it cannot authenticate as the profile's user. --- .../bootstrap-admin-profile-targeting.md | 5 + src/commands/bootstrapAdmin.test.ts | 105 +++++++++++------- src/commands/bootstrapAdmin.ts | 41 +++---- src/commands/help.ts | 9 +- src/index.test.ts | 10 +- src/index.ts | 3 +- 6 files changed, 101 insertions(+), 72 deletions(-) create mode 100644 .changeset/bootstrap-admin-profile-targeting.md diff --git a/.changeset/bootstrap-admin-profile-targeting.md b/.changeset/bootstrap-admin-profile-targeting.md new file mode 100644 index 0000000..3379e43 --- /dev/null +++ b/.changeset/bootstrap-admin-profile-targeting.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": minor +--- + +Target `bootstrap-admin` at the active profile's instance URL instead of requiring a local `seamless.config.json`. The command now resolves the instance from the active profile (respecting `--profile` and `SEAMLESS_PROFILE`), with `SEAMLESS_API_URL` as an override and `http://localhost:3000` as the fallback when no profile is configured. The bootstrap-secret auth flow is unchanged. diff --git a/src/commands/bootstrapAdmin.test.ts b/src/commands/bootstrapAdmin.test.ts index 1804e75..7f803d6 100644 --- a/src/commands/bootstrapAdmin.test.ts +++ b/src/commands/bootstrapAdmin.test.ts @@ -1,21 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import fs from "fs"; import { resolveBootstrapSecret } from "../core/bootstrapSecret.js"; +import { getActiveProfile } from "../core/config.js"; import { runBootstrapAdmin } from "./bootstrapAdmin.js"; -vi.mock("fs", () => { - const fns = { - existsSync: vi.fn(), - readFileSync: vi.fn(), - }; - return { default: fns, ...fns }; -}); - vi.mock("../core/bootstrapSecret.js", () => ({ resolveBootstrapSecret: vi.fn(), })); +vi.mock("../core/config.js", () => ({ + getActiveProfile: vi.fn(), +})); + vi.mock("@clack/prompts", () => ({ intro: vi.fn(), outro: vi.fn(), @@ -26,8 +22,6 @@ vi.mock("@clack/prompts", () => ({ import { intro, outro, text, confirm, spinner } from "@clack/prompts"; -const CONFIG_JSON = JSON.stringify({ services: { auth: { mode: "local" } } }); - let logs: string[]; let errors: string[]; let exitSpy: ReturnType; @@ -39,17 +33,15 @@ beforeEach(() => { logs = []; errors = []; - vi.mocked(fs.existsSync).mockReset(); - vi.mocked(fs.readFileSync).mockReset(); vi.mocked(resolveBootstrapSecret).mockReset(); + vi.mocked(getActiveProfile).mockReset(); vi.mocked(intro).mockReset(); vi.mocked(outro).mockReset(); vi.mocked(text).mockReset(); vi.mocked(confirm).mockReset(); vi.mocked(spinner).mockReset(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(CONFIG_JSON); + vi.mocked(getActiveProfile).mockReturnValue(undefined); vi.mocked(confirm).mockResolvedValue(true); spinnerStart = vi.fn(); @@ -89,17 +81,6 @@ function errOutput(): string { return errors.join("\n"); } -describe("runBootstrapAdmin — config loading", () => { - it("throws when seamless.config.json is missing", async () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - await expect(runBootstrapAdmin("admin@example.com")).rejects.toThrow( - "No seamless.config.json found. Run init first.", - ); - expect(intro).toHaveBeenCalledWith("Seamless Auth Bootstrap"); - }); -}); - describe("runBootstrapAdmin — email prompt", () => { it("uses the provided email argument without prompting", async () => { vi.mocked(resolveBootstrapSecret).mockReturnValue("auto-secret"); @@ -108,7 +89,7 @@ describe("runBootstrapAdmin — email prompt", () => { json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); expect(text).not.toHaveBeenCalled(); expect(output()).toContain("Invite sent to admin@example.com"); @@ -121,7 +102,7 @@ describe("runBootstrapAdmin — email prompt", () => { json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin(); + await runBootstrapAdmin([]); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Admin email address" }), @@ -136,7 +117,7 @@ describe("runBootstrapAdmin — email prompt", () => { json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin(); + await runBootstrapAdmin([]); const call = vi.mocked(text).mock.calls[0][0] as { validate: (v?: string) => string | undefined; @@ -151,7 +132,7 @@ describe("runBootstrapAdmin — confirm cancellation", () => { it("outputs Cancelled and returns without calling fetch", async () => { vi.mocked(confirm).mockResolvedValue(false); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); expect(outro).toHaveBeenCalledWith("Cancelled."); expect(fetch).not.toHaveBeenCalled(); @@ -159,13 +140,13 @@ describe("runBootstrapAdmin — confirm cancellation", () => { }); describe("runBootstrapAdmin — API URL resolution", () => { - it("defaults to localhost:3000 when SEAMLESS_API_URL is unset", async () => { + it("defaults to localhost:3000 when no profile or override is set", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); expect(fetch).toHaveBeenCalledWith( "http://localhost:3000/auth/internal/bootstrap/admin-invite", @@ -173,15 +154,57 @@ describe("runBootstrapAdmin — API URL resolution", () => { ); }); - it("uses SEAMLESS_API_URL when set", async () => { + it("targets the active profile's instance URL", async () => { + vi.mocked(getActiveProfile).mockReturnValue({ + name: "prod", + instanceUrl: "https://auth.prod.example.com", + }); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin(["admin@example.com"]); + + expect(fetch).toHaveBeenCalledWith( + "https://auth.prod.example.com/auth/internal/bootstrap/admin-invite", + expect.anything(), + ); + }); + + it("resolves the profile named by the --profile flag", async () => { + vi.mocked(getActiveProfile).mockReturnValue({ + name: "staging", + instanceUrl: "https://auth.staging.example.com", + }); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }), + } as Response); + + await runBootstrapAdmin(["--profile", "staging", "admin@example.com"]); + + expect(getActiveProfile).toHaveBeenCalledWith({ profileFlag: "staging" }); + expect(fetch).toHaveBeenCalledWith( + "https://auth.staging.example.com/auth/internal/bootstrap/admin-invite", + expect.anything(), + ); + }); + + it("lets SEAMLESS_API_URL override the profile", async () => { process.env.SEAMLESS_API_URL = "https://api.example.com"; + vi.mocked(getActiveProfile).mockReturnValue({ + name: "prod", + instanceUrl: "https://auth.prod.example.com", + }); vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); + expect(getActiveProfile).not.toHaveBeenCalled(); expect(fetch).toHaveBeenCalledWith( "https://api.example.com/auth/internal/bootstrap/admin-invite", expect.anything(), @@ -197,7 +220,7 @@ describe("runBootstrapAdmin — bootstrap secret resolution", () => { json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); expect(text).not.toHaveBeenCalled(); expect(output()).toContain("Using bootstrap secret from local environment"); @@ -215,7 +238,7 @@ describe("runBootstrapAdmin — bootstrap secret resolution", () => { json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); expect(text).toHaveBeenCalledWith( expect.objectContaining({ message: "Bootstrap secret" }), @@ -235,7 +258,7 @@ describe("runBootstrapAdmin — bootstrap secret resolution", () => { json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); const call = vi.mocked(text).mock.calls[0][0] as { validate: (v?: string) => string | undefined; @@ -253,7 +276,7 @@ describe("runBootstrapAdmin — request outcomes", () => { json: async () => ({ data: { url: "https://auth.example.com/register/abc" } }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); expect(spinnerStart).toHaveBeenCalledWith("Creating bootstrap invite..."); expect(spinnerStop).toHaveBeenCalledWith("Done"); @@ -269,7 +292,7 @@ describe("runBootstrapAdmin — request outcomes", () => { json: async () => ({ data: {} }), } as Response); - await runBootstrapAdmin("admin@example.com"); + await runBootstrapAdmin(["admin@example.com"]); expect(output()).toContain("Invite sent to admin@example.com"); }); @@ -281,7 +304,7 @@ describe("runBootstrapAdmin — request outcomes", () => { json: async () => ({ error: "already bootstrapped" }), } as Response); - await expect(runBootstrapAdmin("admin@example.com")).rejects.toThrow( + await expect(runBootstrapAdmin(["admin@example.com"])).rejects.toThrow( "process.exit(1)", ); @@ -294,7 +317,7 @@ describe("runBootstrapAdmin — request outcomes", () => { vi.mocked(resolveBootstrapSecret).mockReturnValue("secret"); vi.mocked(fetch).mockRejectedValue(new Error("network down")); - await expect(runBootstrapAdmin("admin@example.com")).rejects.toThrow( + await expect(runBootstrapAdmin(["admin@example.com"])).rejects.toThrow( "process.exit(1)", ); diff --git a/src/commands/bootstrapAdmin.ts b/src/commands/bootstrapAdmin.ts index 33f85c2..05671a3 100644 --- a/src/commands/bootstrapAdmin.ts +++ b/src/commands/bootstrapAdmin.ts @@ -1,33 +1,30 @@ -import fs from "fs"; -import path from "path"; import { intro, outro, text, confirm, spinner } from "@clack/prompts"; import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { getActiveProfile } from "../core/config.js"; import { resolveBootstrapSecret } from "../core/bootstrapSecret.js"; -type SeamlessConfig = { - services: { - auth: { - mode: "local" | "docker"; - }; - }; -}; +const DEFAULT_API_URL = "http://localhost:3000"; -function loadConfig(): SeamlessConfig { - const configPath = path.join(process.cwd(), "seamless.config.json"); +// Bootstrap authenticates with the shared bootstrap secret (not a user +// session), so the profile is only used to target the right instance. The +// SEAMLESS_API_URL env override wins for backward compatibility; otherwise fall +// back to the local dev default when no profile is configured. +function resolveApiUrl(profileFlag?: string): string { + const override = process.env.SEAMLESS_API_URL?.trim(); + if (override) return override; - if (!fs.existsSync(configPath)) { - throw new Error("No seamless.config.json found. Run init first."); - } + const profile = getActiveProfile({ profileFlag }); + if (profile) return profile.instanceUrl; - return JSON.parse(fs.readFileSync(configPath, "utf-8")); + return DEFAULT_API_URL; } -export async function runBootstrapAdmin(emailArg?: string) { +export async function runBootstrapAdmin(args: string[] = []) { intro("Seamless Auth Bootstrap"); - const config = loadConfig(); - - let email = emailArg; + const { value: profileFlag, rest } = extractFlag(args, "profile"); + let email = rest.find((a) => !a.startsWith("-")); if (!email) { email = (await text({ @@ -51,11 +48,7 @@ export async function runBootstrapAdmin(emailArg?: string) { return; } - let apiUrl = process.env.SEAMLESS_API_URL; - - if (!apiUrl) { - apiUrl = "http://localhost:3000"; - } + const apiUrl = resolveApiUrl(profileFlag); let secret = resolveBootstrapSecret(); diff --git a/src/commands/help.ts b/src/commands/help.ts index 4dfb978..211b3fe 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -12,7 +12,7 @@ USAGE seamless init [project-name] [--] seamless check - seamless bootstrap-admin [email] + seamless bootstrap-admin [email] [--profile ] seamless verify [--api-only] [--filter=] [--keep-up] seamless profile seamless login [identifier] [--identifier ] [--profile ] @@ -146,9 +146,13 @@ COMMANDS seamless-auth-server, override with SEAMLESS_SERVER_DIR) instead of the published npm packages — so you can catch SDK regressions before publishing. - bootstrap-admin [email] + bootstrap-admin [email] [--profile ] Create a bootstrap admin invite + Targets the active profile's instance URL (override with --profile or + SEAMLESS_API_URL). Falls back to http://localhost:3000 when no profile is + configured. + Automatically resolves bootstrap secret from: • .env • auth/.env @@ -159,6 +163,7 @@ COMMANDS Examples: seamless bootstrap-admin seamless bootstrap-admin admin@example.com + seamless bootstrap-admin admin@example.com --profile prod ──────────────────────────────────────────── diff --git a/src/index.test.ts b/src/index.test.ts index e89328b..41335fc 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -83,10 +83,14 @@ describe("index dispatcher", () => { expect(runCheck).toHaveBeenCalledTimes(1); }); - it("dispatches bootstrap-admin with the email argument", async () => { - await dispatch(["bootstrap-admin", "admin@example.com"]); + it("dispatches bootstrap-admin with the remaining arguments", async () => { + await dispatch(["bootstrap-admin", "--profile", "prod", "admin@example.com"]); const { runBootstrapAdmin } = await import("./commands/bootstrapAdmin.js"); - expect(runBootstrapAdmin).toHaveBeenCalledWith("admin@example.com"); + expect(runBootstrapAdmin).toHaveBeenCalledWith([ + "--profile", + "prod", + "admin@example.com", + ]); }); it("dispatches verify with the remaining args", async () => { diff --git a/src/index.ts b/src/index.ts index 2f9df15..abbdf04 100755 --- a/src/index.ts +++ b/src/index.ts @@ -62,8 +62,7 @@ async function main() { } if (command === "bootstrap-admin") { - const email = args[1]; - await runBootstrapAdmin(email); + await runBootstrapAdmin(args.slice(1)); return; }