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
23 changes: 23 additions & 0 deletions .changeset/console-hosting-choice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"seamless-cli": minor
---

Let adopters choose how the admin console is hosted during `seamless init`.

The old "Include Admin Dashboard?" / image-vs-source prompts are replaced by a
single question with four options:

- **Served by your API at /console** (recommended default) — the app backend
proxies the console via the SDK's `createSeamlessConsoleProxy`, so it loads
from the API's own origin. The scaffold sets `SERVE_ADMIN_CONSOLE=true` on the
API, `SERVE_ADMIN_DASHBOARD=true` on the auth server, and adds the API origin
to the auth server's `ORIGINS` so console passkey ceremonies verify. No
separate admin container.
- **Separate container** — official image or cloned source, as before, on
`http://localhost:5174`.
- **None** — no console is scaffolded.

Each choice pre-configures the auth-server env, the app-backend env, the Docker
Compose services, `seamless.config.json`, the success output, and `seamless
check` accordingly. Pins the auth API image to `v0.3.0` and the templates to
`v0.3.0` (which env-gate the console proxy).
47 changes: 47 additions & 0 deletions src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,53 @@ describe("runCheck", () => {
expect(execSync).not.toHaveBeenCalled();
});

it("probes the API /console URL in API-served mode", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
...CONFIG,
services: {
...CONFIG.services,
admin: { mode: "api", url: "http://localhost:3000/console" },
},
}),
);
vi.mocked(execSync).mockReturnValue(Buffer.from("api\n"));
const seen: string[] = [];
vi.mocked(fetch).mockImplementation(async (url: unknown) => {
seen.push(String(url));
return { ok: true, status: 200 } as Response;
});

await runCheck();

expect(seen).toContain("http://localhost:3000/console");
expect(seen).not.toContain("http://localhost:5174");
expect(output()).toContain("Console is healthy");
});

it("skips the console probe when hosting is none", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
...CONFIG,
services: { ...CONFIG.services, admin: { mode: "none" } },
}),
);
vi.mocked(execSync).mockReturnValue(Buffer.from("api\n"));
const seen: string[] = [];
vi.mocked(fetch).mockImplementation(async (url: unknown) => {
seen.push(String(url));
return { ok: true, status: 200 } as Response;
});

await runCheck();

expect(seen).not.toContain("http://localhost:5174");
expect(seen).not.toContain("http://localhost:3000/console");
expect(output()).not.toContain("Console");
});

it("reports a container check failure when docker ps throws", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,6 @@ describe("scaffoldLocal", () => {
expect(generateDockerCompose).toHaveBeenCalledWith("/work", {
authMode: "docker",
adminMode: "image",
includeAdmin: true,
oauth: [],
});
// env applied with the docker-provided token/kid for both templates.
Expand Down Expand Up @@ -388,6 +387,7 @@ describe("scaffoldLocal", () => {
expect.arrayContaining([
expect.objectContaining({ catalog: { label: "Google" } }),
]),
"image",
);
// Docker not requested, so the compose generator is untouched.
expect(generateDockerCompose).not.toHaveBeenCalled();
Expand Down
13 changes: 11 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ async function scaffoldManaged(
apiUrl: API_URL,
apiToken: serviceToken,
jwksKid: MANAGED_JWKS_KID,
// A managed instance hosts its own dashboard, so the app API does not proxy
// the console. Keeps the template's SERVE_ADMIN_CONSOLE gate off.
serveAdminConsole: "false",
};

for (const { manifest, dir } of selected) {
Expand Down Expand Up @@ -263,14 +266,18 @@ async function scaffoldLocal(
let sharedConfig: any = {};

if (answers.authMode === "local") {
sharedConfig = await generateAuthServer({ root }, "local", oauthProviders);
sharedConfig = await generateAuthServer(
{ root },
"local",
oauthProviders,
answers.adminMode,
);
}

if (answers.useDocker) {
const dockerShared = await generateDockerCompose(root, {
authMode: answers.authMode,
adminMode: answers.adminMode,
includeAdmin: answers.includeAdmin,
oauth: oauthProviders,
});

Expand All @@ -284,6 +291,7 @@ async function scaffoldLocal(
apiUrl: API_URL,
apiToken: sharedConfig.apiToken,
jwksKid: sharedConfig.kid,
serveAdminConsole: answers.adminMode === "api" ? "true" : "false",
};

for (const { manifest, dir } of selected) {
Expand All @@ -308,6 +316,7 @@ async function scaffoldLocal(
apiFramework: apiEntry.framework,
authMode: answers.authMode,
useDocker: answers.useDocker,
adminMode: answers.adminMode,
});

printOAuthNextSteps(oauthProviders);
Expand Down
4 changes: 2 additions & 2 deletions src/core/images.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const POSTGRES_IMAGE = "postgres:17";

export const SEAMLESS_AUTH_API_VERSION = "v0.2.1";
export const SEAMLESS_AUTH_API_VERSION = "v0.3.0";

export const SEAMLESS_AUTH_API_IMAGE = `ghcr.io/fells-code/seamless-auth-api:${SEAMLESS_AUTH_API_VERSION}`;

Expand All @@ -13,4 +13,4 @@ export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless-
// SEAMLESS_TEMPLATES_REF, or point at a local checkout with SEAMLESS_TEMPLATES_DIR.
export const SEAMLESS_TEMPLATES_REPO = "fells-code/seamless-templates";

export const SEAMLESS_TEMPLATES_REF = "v0.2.4";
export const SEAMLESS_TEMPLATES_REF = "v0.3.0";
42 changes: 41 additions & 1 deletion src/core/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("printSuccessOutput", () => {
apiFramework: "express",
authMode: "local",
useDocker: true,
adminMode: "image",
});

const out = allLogs();
Expand All @@ -37,7 +38,7 @@ describe("printSuccessOutput", () => {
expect(out).toContain("API server");
expect(out).toContain("(Express)");
expect(out).toContain("(local source)");
expect(out).toContain("Admin dashboard");
expect(out).toContain("Admin console");
expect(out).toContain("1. Start services");
expect(out).toContain("docker compose up");
expect(out).toContain("2. Create your first admin user");
Expand All @@ -52,13 +53,49 @@ describe("printSuccessOutput", () => {
expect(out).toContain("Setup complete.");
});

it("prints the /console URL for API-served hosting", () => {
printSuccessOutput({
projectName: "my-app",
root: "/tmp/my-app",
webFramework: "react",
apiFramework: "express",
authMode: "docker",
useDocker: true,
adminMode: "api",
});

const out = allLogs();
expect(out).toContain("Admin console");
expect(out).toContain("served by API at /console");
expect(out).toContain("Console: http://localhost:3000/console");
expect(out).not.toContain("http://localhost:5174");
});

it("omits the console line entirely when hosting is none", () => {
printSuccessOutput({
projectName: "my-app",
root: "/tmp/my-app",
webFramework: "react",
apiFramework: "express",
authMode: "docker",
useDocker: true,
adminMode: "none",
});

const out = allLogs();
expect(out).not.toContain("Admin console");
expect(out).not.toContain("Console:");
expect(out).not.toContain("http://localhost:5174");
});

it("prints docker authMode label even when useDocker is falsy (symbol edge case not exercised here)", () => {
printSuccessOutput({
root: "/tmp/my-app",
webFramework: null,
apiFramework: null,
authMode: "docker",
useDocker: false,
adminMode: "image",
});

const out = allLogs();
Expand All @@ -83,6 +120,7 @@ describe("printSuccessOutput", () => {
apiFramework: "fastapi",
authMode: "local",
useDocker: false,
adminMode: "image",
});

const out = allLogs();
Expand Down Expand Up @@ -112,6 +150,7 @@ describe("printSuccessOutput", () => {
apiFramework: "django",
authMode: "docker",
useDocker: true,
adminMode: "image",
});

const out = allLogs();
Expand All @@ -126,6 +165,7 @@ describe("printSuccessOutput", () => {
apiFramework: null,
authMode: "docker",
useDocker: Symbol("cancel"),
adminMode: "none",
});

const out = allLogs();
Expand Down
38 changes: 32 additions & 6 deletions src/core/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ export function printSuccessOutput(config: {
apiFramework: string | null;
authMode: "local" | "docker";
useDocker: boolean | symbol;
adminMode: "api" | "image" | "source" | "none";
}) {
const { projectName, webFramework, apiFramework, authMode, useDocker } =
const { projectName, webFramework, apiFramework, authMode, useDocker, adminMode } =
config;

// Where the admin console lives: proxied by the app API at /console, or a
// standalone container on 5174. "none" scaffolds no console at all.
const consoleUrl =
adminMode === "api"
? "http://localhost:3000/console"
: adminMode === "none"
? null
: "http://localhost:5174";

const title = kleur.bold().cyan("SEAMLESS");

console.log(`
Expand Down Expand Up @@ -50,9 +60,15 @@ export function printSuccessOutput(config: {
kleur.dim(authMode === "local" ? " (local source)" : " (Docker image)"),
);

console.log(
" • " + kleur.white("Admin dashboard") + kleur.dim(" (management UI)"),
);
if (consoleUrl) {
console.log(
" • " +
kleur.white("Admin console") +
kleur.dim(
adminMode === "api" ? " (served by API at /console)" : " (management UI)",
),
);
}

console.log("");

Expand Down Expand Up @@ -114,15 +130,25 @@ export function printSuccessOutput(config: {
console.log(" Web: " + kleur.cyan("http://localhost:5173"));
}

console.log(" Admin: " + kleur.cyan("http://localhost:5174"));
if (consoleUrl) {
console.log(" Console:" + kleur.cyan(` ${consoleUrl}`));
}

console.log("");

console.log(kleur.bold("Notes:\n"));

console.log(kleur.dim(" • Web connects to API automatically"));
console.log(kleur.dim(" • API connects to Auth automatically"));
console.log(kleur.dim(" • Admin dashboard uses the same auth system"));
if (consoleUrl) {
console.log(
kleur.dim(
adminMode === "api"
? " • Admin console is served by the API at /console"
: " • Admin console uses the same auth system",
),
);
}
console.log(
kleur.dim(" • Bootstrap command provisions the first admin user"),
);
Expand Down
14 changes: 14 additions & 0 deletions src/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,20 @@ describe("applyTemplateEnv", () => {
expect(written).toContain("PLAIN=literal-value");
});

it("resolves the serveAdminConsole placeholder from the scaffold context", () => {
applyTemplateEnv(
destDir,
{
id: "x",
targetDir: ".",
env: { set: { SERVE_ADMIN_CONSOLE: "{{serveAdminConsole}}" } },
},
{ ...ctx, serveAdminConsole: "true" },
);

expect(readEnv()).toContain("SERVE_ADMIN_CONSOLE=true");
});

it("resolves secret:N placeholders to N bytes of hex", () => {
applyTemplateEnv(
destDir,
Expand Down
4 changes: 4 additions & 0 deletions src/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export interface ScaffoldContext {
apiUrl: string;
apiToken?: string;
jwksKid?: string;
// "true" when the app API should serve the admin console at /console, "false"
// when the console is hosted elsewhere (a standalone container) or omitted.
serveAdminConsole?: string;
}

// Build artifacts and local-only files that must never be copied into a scaffold,
Expand Down Expand Up @@ -246,6 +249,7 @@ function resolveToken(token: string, ctx: ScaffoldContext): string {
apiUrl: ctx.apiUrl,
apiToken: ctx.apiToken,
jwksKid: ctx.jwksKid,
serveAdminConsole: ctx.serveAdminConsole,
};

if (token in known) {
Expand Down
12 changes: 9 additions & 3 deletions src/generators/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,30 @@ import {
envToDockerBlock,
} from "../docker/docker.js";
import type { CollectedOAuthProvider } from "../../core/oauthProviders.js";
import type { AdminMode } from "../docker/docker.js";

const AUTH_REPO = "https://github.com/fells-code/seamless-auth-api";

export async function generateAuthServer(
context: any,
mode: "local" | "docker" | Symbol,
oauth: CollectedOAuthProvider[] = [],
adminMode: AdminMode = "api",
) {
const { root } = context;

if (mode === "local") {
return await setupLocalAuth(root, oauth);
return await setupLocalAuth(root, oauth, adminMode);
}

return await setupDockerAuth(root);
}

async function setupLocalAuth(root: string, oauth: CollectedOAuthProvider[] = []) {
async function setupLocalAuth(
root: string,
oauth: CollectedOAuthProvider[] = [],
adminMode: AdminMode = "api",
) {
const authDir = path.join(root, "auth");

console.log("Cloning SeamlessAuth server...");
Expand All @@ -36,7 +42,7 @@ async function setupLocalAuth(root: string, oauth: CollectedOAuthProvider[] = []

console.log("Writing auth environment...");

const shared = await configureAuthLocalEnv(root, oauth);
const shared = await configureAuthLocalEnv(root, oauth, adminMode);

console.log("Auth server ready in /auth");
return shared;
Expand Down
Loading
Loading