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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bootstrap-admin-profile-targeting.md
Original file line number Diff line number Diff line change
@@ -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.
105 changes: 64 additions & 41 deletions src/commands/bootstrapAdmin.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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<typeof vi.spyOn>;
Expand All @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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" }),
Expand All @@ -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;
Expand All @@ -151,37 +132,79 @@ 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();
});
});

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",
expect.anything(),
);
});

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(),
Expand All @@ -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");
Expand All @@ -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" }),
Expand All @@ -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;
Expand All @@ -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");
Expand All @@ -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");
});
Expand All @@ -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)",
);

Expand All @@ -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)",
);

Expand Down
41 changes: 17 additions & 24 deletions src/commands/bootstrapAdmin.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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();

Expand Down
9 changes: 7 additions & 2 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ USAGE

seamless init [project-name] [--<example>]
seamless check
seamless bootstrap-admin [email]
seamless bootstrap-admin [email] [--profile <name>]
seamless verify [--api-only] [--filter=<flow>] [--keep-up]
seamless profile <list|add|use|remove>
seamless login [identifier] [--identifier <email>] [--profile <name>]
Expand Down Expand Up @@ -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 <name>]
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
Expand All @@ -159,6 +163,7 @@ COMMANDS
Examples:
seamless bootstrap-admin
seamless bootstrap-admin admin@example.com
seamless bootstrap-admin admin@example.com --profile prod

────────────────────────────────────────────

Expand Down
10 changes: 7 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
3 changes: 1 addition & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ async function main() {
}

if (command === "bootstrap-admin") {
const email = args[1];
await runBootstrapAdmin(email);
await runBootstrapAdmin(args.slice(1));
return;
}

Expand Down
Loading