Skip to content
Open
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: 4 additions & 1 deletion packages/openui-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
"@inquirer/core": "^11.1.5",
"@inquirer/prompts": "^8.3.0",
"commander": "^14.0.3",
"esbuild": "^0.25.10"
"esbuild": "^0.25.10",
"open": "^10.1.0",
"openid-client": "^6.1.7",
"posthog-node": "^5.35.6"
}
}
33 changes: 15 additions & 18 deletions packages/openui-cli/scripts/build-templates.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
const fs = require("node:fs");
const path = require("node:path");

const srcDir = path.resolve(__dirname, "../src/templates/openui-chat");
const destDir = path.resolve(__dirname, "../dist/templates/openui-chat");
const TEMPLATES = ["openui-chat", "openui-cloud"];

if (!fs.existsSync(srcDir)) {
throw new Error(`Template source directory not found: ${srcDir}`);
}
for (const template of TEMPLATES) {
const srcDir = path.resolve(__dirname, "../src/templates", template);
const destDir = path.resolve(__dirname, "../dist/templates", template);

if (!fs.existsSync(srcDir)) {
throw new Error(`Template source directory not found: ${srcDir}`);
}

// Equivalent to: rm -rf dist/templates/openui-chat
fs.rmSync(destDir, {
recursive: true,
force: true,
});
// Equivalent to: rm -rf dist/templates/<template>
fs.rmSync(destDir, { recursive: true, force: true });

// Equivalent to: mkdir -p dist/templates
fs.mkdirSync(path.dirname(destDir), {
recursive: true,
});
// Equivalent to: mkdir -p dist/templates
fs.mkdirSync(path.dirname(destDir), { recursive: true });

// Equivalent to: cp -R src/templates/openui-chat dist/templates/openui-chat
fs.cpSync(srcDir, destDir, {
recursive: true,
});
// Equivalent to: cp -R src/templates/<template> dist/templates/<template>
fs.cpSync(srcDir, destDir, { recursive: true });
}
148 changes: 148 additions & 0 deletions packages/openui-cli/src/auth/authenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import http from "node:http";

// openid-client's Configuration type is ESM-only; keep it opaque in this CJS build.
type Configuration = any;

export interface AuthConfig {
issuerUrl: string;
clientId: string;
redirectUri?: string;
scopes?: string[];
}

export interface AuthResult {
accessToken: string;
refreshToken?: string;
idToken?: string;
userInfo?: Record<string, unknown>;
}

const SUCCESS_HTML = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Signed in</title><style>body{font-family:system-ui,Arial,sans-serif;text-align:center;padding:64px}h1{color:#16a34a}</style></head><body><h1>✓ Signed in</h1><p>You can close this tab and return to your terminal.</p></body></html>`;

const errorHtml = (msg: string) =>
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Sign-in failed</title><style>body{font-family:system-ui,Arial,sans-serif;text-align:center;padding:64px}h1{color:#dc2626}</style></head><body><h1>Sign-in failed</h1><p>${msg}</p><p>Return to your terminal and try again.</p></body></html>`;

/** OAuth 2.0 + PKCE via a local loopback callback. ESM-only deps load via dynamic import(). */
export class Authenticator {
private readonly config: AuthConfig & { redirectUri: string; scopes: string[] };
private clientConfig?: Configuration;
private codeVerifier?: string;

constructor(config: AuthConfig) {
this.config = {
redirectUri: "http://localhost:0/cb", // 0 = any free port
scopes: ["openid", "profile", "email"],
...config,
};
}

getClientConfig(): Configuration {
if (!this.clientConfig) {
throw new Error("Client not initialized. Call initialize() first.");
}
return this.clientConfig;
}

async initialize(): Promise<void> {
const { discovery } = await import("openid-client");
this.clientConfig = await discovery(new URL(this.config.issuerUrl), this.config.clientId);
}

async authenticate(): Promise<AuthResult> {
if (!this.clientConfig) {
throw new Error("Client not initialized. Call initialize() first.");
}
const { randomPKCECodeVerifier, calculatePKCECodeChallenge } = await import("openid-client");
this.codeVerifier = randomPKCECodeVerifier();
const codeChallenge = await calculatePKCECodeChallenge(this.codeVerifier);
return this.handleBrowserAuth(codeChallenge);
}

private async handleBrowserAuth(codeChallenge: string): Promise<AuthResult> {
const { authorizationCodeGrant, buildAuthorizationUrl } = await import("openid-client");
const { default: open } = await import("open");

return new Promise<AuthResult>((resolve, reject) => {
let settled = false;
let actualPort = 0;

const finish = (run: () => void) => {
if (settled) return;
settled = true;
server.close();
run();
};

const server = http.createServer(async (req, res) => {
if (!req.url?.startsWith("/cb")) {
res.writeHead(404, { Connection: "close" });
res.end("Not found");
return;
}
try {
const callbackUrl = new URL(req.url, `http://localhost:${actualPort}`);
if (!this.clientConfig || !this.codeVerifier) {
throw new Error("Client not properly initialized");
}
const tokens = await authorizationCodeGrant(this.clientConfig, callbackUrl, {
pkceCodeVerifier: this.codeVerifier,
});
let userInfo: Record<string, unknown> | undefined;
try {
const claims = tokens.claims();
if (claims) userInfo = claims as Record<string, unknown>;
} catch {
/* no id_token claims */
}
res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
res.end(SUCCESS_HTML);
finish(() =>
resolve({
accessToken: tokens.access_token ?? "",
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
userInfo,
}),
);
} catch (error) {
const msg = error instanceof Error ? error.message : "Unknown error";
res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
res.end(errorHtml(msg));
finish(() => reject(new Error(`Token exchange failed: ${msg}`)));
}
});

server.on("error", (error) => finish(() => reject(error)));

server.listen(0, async () => {
const address = server.address();
if (!address || typeof address === "string") {
finish(() => reject(new Error("Failed to bind a local callback port.")));
return;
}
actualPort = address.port;
const url = buildAuthorizationUrl(this.clientConfig!, {
redirect_uri: `http://localhost:${actualPort}/cb`,
scope: this.config.scopes.join(" "),
code_challenge: codeChallenge,
code_challenge_method: "S256",
prompt: "consent",
}).toString();

console.info("\n🌐 Opening your browser to sign in to Thesys…");
console.info(` If it doesn't open, visit:\n ${url}\n`);
try {
await open(url);
} catch {
/* user opens the URL manually */
}
console.info("⏳ Waiting for you to finish signing in…");
});

setTimeout(
() => finish(() => reject(new Error("Sign-in timed out after 5 minutes."))),
5 * 60 * 1000,
);
});
}
}
92 changes: 92 additions & 0 deletions packages/openui-cli/src/auth/mint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Authenticator } from "./authenticator";

// Thesys console OAuth + key mint (same flow as create-c1-app). The OpenUI Cloud
// master key is the same C1-flavored org API key (usageType "C1").
const THESYS_API_URL = "https://api.app.thesys.dev";
const THESYS_ISSUER_URL = "https://api.app.thesys.dev/oidc";
const THESYS_CLIENT_ID = "create-c1-app"; // public PKCE client (no secret)
export const THESYS_KEYS_URL = "https://console.thesys.dev/keys";

export type CloudAuthMethod = "oauth" | "manual" | "skip";
/** How the cloud key was obtained (for telemetry) — auth method + the `--api-key` flag case. */
export type ResolvedAuthMethod = CloudAuthMethod | "apikey-flag";

/** Sign in via the browser and mint an OpenUI Cloud API key for the user's org. */
export async function mintCloudApiKey(projectName: string): Promise<string> {
const auth = new Authenticator({ issuerUrl: THESYS_ISSUER_URL, clientId: THESYS_CLIENT_ID });
await auth.initialize();
const { accessToken, userInfo } = await auth.authenticate();

const { fetchUserInfo } = await import("openid-client");
const profile = await fetchUserInfo(
auth.getClientConfig(),
accessToken,
(userInfo?.["sub"] as string | undefined) ?? "",
);
const orgId = (profile["org_claims"] as { orgId: string }[] | undefined)?.[0]?.orgId;
if (!orgId) {
throw new Error(`No organization found for your account. Create a key at ${THESYS_KEYS_URL}.`);
}

console.info("🔑 Creating an OpenUI Cloud API key…");
const res = await fetch(`${THESYS_API_URL}/application/application.createApiKeyWithOidc`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ name: projectName || "OpenUI Cloud App", orgId, usageType: "C1" }),
});
if (!res.ok) {
throw new Error(`Failed to create API key (HTTP ${res.status}): ${await res.text()}`);
}
const data = (await res.json()) as { apiKey?: string };
if (!data.apiKey) throw new Error("The server did not return an API key.");
return data.apiKey;
}

/**
* Resolve a cloud API key by the chosen method: an explicitly provided key, a
* browser OAuth mint, a manual paste, or skip (null → leave the .env slot empty).
*/
export async function resolveCloudApiKey(opts: {
apiKey?: string;
auth?: CloudAuthMethod;
projectName: string;
interactive: boolean;
}): Promise<{ key: string | null; method: ResolvedAuthMethod }> {
const provided = opts.apiKey?.trim();
if (provided) return { key: provided, method: "apikey-flag" };

let method = opts.auth;
if (!method) {
if (!opts.interactive) {
throw new Error(
`An API key is required in non-interactive mode. Pass --api-key <key> ` +
`(get one at ${THESYS_KEYS_URL}).`,
);
}
const { select } = await import("@inquirer/prompts");
method = (await select({
message: "Connect to OpenUI Cloud:",
choices: [
{ name: "Sign in with Thesys (opens a browser, mints a key)", value: "oauth" },
{ name: "Paste an existing API key", value: "manual" },
{ name: "Skip — add THESYS_MASTER_API_KEY to .env later", value: "skip" },
],
})) as CloudAuthMethod;
}

if (method === "skip") return { key: null, method: "skip" };

if (method === "manual") {
const { password } = await import("@inquirer/prompts");
const key = (
await password({ message: "Paste your OpenUI Cloud API key:", mask: true })
).trim();
return { key: key || null, method: "manual" };
}

return { key: await mintCloudApiKey(opts.projectName), method: "oauth" };
}
Loading