From cb818d96092e5f24d6d09c795b6f662ad37ac948 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 27 Aug 2026 18:16:50 +0530 Subject: [PATCH 01/54] cli: quiet dependency install, spinner, and thesys next.config workaround Extract shared spinner/command-output helpers and quiet create installs; disable turbopack minify in the cloud template so thesys SSR survives remangling. --- packages/openui-cli/src/auth/mint.ts | 2 +- .../openui-cli/src/commands/create-app.ts | 45 ++++++++++--------- packages/openui-cli/src/lib/command-output.ts | 10 +++++ packages/openui-cli/src/lib/process-runner.ts | 37 ++++++++++----- packages/openui-cli/src/lib/spinner.ts | 39 ++++++++++++++++ .../src/templates/openui-cloud/next.config.ts | 5 ++- 6 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 packages/openui-cli/src/lib/command-output.ts create mode 100644 packages/openui-cli/src/lib/spinner.ts diff --git a/packages/openui-cli/src/auth/mint.ts b/packages/openui-cli/src/auth/mint.ts index eab12073e..117ae34e4 100644 --- a/packages/openui-cli/src/auth/mint.ts +++ b/packages/openui-cli/src/auth/mint.ts @@ -185,7 +185,7 @@ export async function resolveCloudApiKey(opts: { if (method === "manual") { console.warn( - "⚠ --auth manual is deprecated. Use browser sign-in or pass --api-key for scripted setup.", + "[!] --auth manual is deprecated. Use browser sign-in or pass --api-key for scripted setup.", ); const { password } = await import("@inquirer/prompts"); const key = await cloudAuthPrompt("manual_key_prompt", "MANUAL_KEY_PROMPT_FAILED", () => diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index e7392a653..fce3648c5 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -5,6 +5,7 @@ import * as path from "node:path"; import { resolveCloudApiKey, THESYS_KEYS_URL } from "../auth/mint"; import { aiSetupFromTemplate, createFunnelProps } from "../lib/create-telemetry"; import type { CreateAppOptions, EnvResult, OverlayName, TemplateName } from "../lib/create-types"; +import { dumpFailureLog, QUIET_COMMAND_CAPTURE_LIMIT } from "../lib/command-output"; import { resolveInstallPackageManager, type PackageManagerName, @@ -14,6 +15,7 @@ import { runSkillInstall, shouldInstallSkill } from "../lib/install-skill"; import { applyOverlay, OVERLAYS_DIR, resolveOverlay, type OverlayManifest } from "../lib/overlays"; import { runCommand } from "../lib/process-runner"; import { resolveArgs } from "../lib/resolve-args"; +import { withSpinner } from "../lib/spinner"; import { resolveAvailableTarget } from "../lib/target-dir"; import { CliCancelledError, CreateError, telemetry } from "../lib/telemetry"; import { cliErrorProperties, processErrorProperties } from "../lib/utils"; @@ -416,15 +418,26 @@ export async function runCreateApp(options: CreateAppOptions): Promise { }); console.info(`Skipping dependency install (--no-install). Run \`${installCmd}\` later.\n`); } else { - console.info(`Installing dependencies with: ${installCmd}\n`); telemetry.capture("cli_dependency_install_started", { ...createFunnelProps("dependency_install_started"), template, ai_setup: aiSetup, }); - const installResult = await runCommand(packageManager.runCmd, installArgs, targetDir); + const installResult = await withSpinner("Installing dependencies...", () => + runCommand(packageManager.runCmd, installArgs, targetDir, { + echo: false, + stdin: "ignore", + captureLimit: QUIET_COMMAND_CAPTURE_LIMIT, + env: { + ...process.env, + npm_config_loglevel: "error", + NPM_CONFIG_LOGLEVEL: "error", + }, + }), + ); if (!installResult.error && installResult.status === 0) { dependencyInstalled = true; + console.info("✓ Dependencies installed\n"); telemetry.capture("cli_dependency_install_succeeded", { ...createFunnelProps("dependency_install_succeeded"), template, @@ -432,6 +445,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise { dependency_installed: dependencyInstalled, }); } else { + dumpFailureLog(installResult.diagnosticTail, "install log (tail)"); const properties = processErrorProperties(installResult, "dependency_install", { error_class: "dependency", error_code: "NONZERO_EXIT", @@ -490,7 +504,6 @@ export async function runCreateApp(options: CreateAppOptions): Promise { skillInstalled, envWritten: envResult.envWritten, startDev, - devStartBlockedByMissingApiKey, installCmd, dependencyInstalled, }), @@ -501,15 +514,6 @@ export async function runCreateApp(options: CreateAppOptions): Promise { skip_reason: "missing_api_key", required_env: apiKeyEnv, }); - const keyHint = - apiKeyEnv === "THESYS_API_KEY" - ? `Get a key at ${THESYS_KEYS_URL}, then add:\n\n ${apiKeyEnv}=…\n\nto ${name}/.env` - : `Add your key to ${name}/.env:\n\n ${apiKeyEnv}=…`; - console.error( - `\nSkipped starting the development server — ${apiKeyEnv} is missing.\n\n` + - `${keyHint}\n\n` + - `Then run:\n\n> cd ${name}\n> ${devCmd} run dev\n`, - ); process.exitCode = 1; return; } @@ -687,7 +691,7 @@ async function resolveCloudEnv( auth_succeeded: false, ...properties, }); - console.error(`\n⚠ Could not obtain an API key: ${msg}`); + console.error(`\n[!] Could not obtain an API key: ${msg}`); console.error(` Add THESYS_API_KEY to .env later (keys: ${THESYS_KEYS_URL}).\n`); } const lines = [`THESYS_API_KEY=${apiKey ?? ""}`, `DEMO_USER_ID=demo-user`]; @@ -707,7 +711,6 @@ function getStartedMessage(o: { skillInstalled: boolean; envWritten: boolean; startDev: boolean; - devStartBlockedByMissingApiKey: boolean; installCmd: string; dependencyInstalled: boolean; }): string { @@ -719,20 +722,18 @@ function getStartedMessage(o: { o.template === "openui-cloud" ? o.envWritten ? "✅ .env created with your OpenUI Cloud API key + base URL." - : `⚠ .env created without a key. Add THESYS_API_KEY=… (get one at ${THESYS_KEYS_URL}).` + : `[!] .env created without a key. Add THESYS_API_KEY=… (get one at ${THESYS_KEYS_URL}).` : o.envWritten ? "✅ .env created with your API key." : "Add your API key to .env:\nOPENAI_API_KEY=sk-your-key-here"; const nextStep = o.startDev ? `Starting the development server in "${o.name}"...\n\n> ${o.devCmd} run dev` - : o.devStartBlockedByMissingApiKey - ? "" - : [ - `> cd ${o.name}`, - ...(o.dependencyInstalled ? [] : [`> ${o.installCmd}`]), - `> ${o.devCmd} run dev`, - ].join("\n"); + : [ + `> cd ${o.name}`, + ...(o.dependencyInstalled ? [] : [`> ${o.installCmd}`]), + `> ${o.devCmd} run dev`, + ].join("\n"); const frameworkNote = o.backendGettingStarted?.replaceAll("{{packageManager}}", o.devCmd) ?? ""; diff --git a/packages/openui-cli/src/lib/command-output.ts b/packages/openui-cli/src/lib/command-output.ts new file mode 100644 index 000000000..fc880b618 --- /dev/null +++ b/packages/openui-cli/src/lib/command-output.ts @@ -0,0 +1,10 @@ +/** Enough build/install output to dump a useful failure tail in quiet mode. */ +export const QUIET_COMMAND_CAPTURE_LIMIT = 256 * 1024; + +export function dumpFailureLog(log: string, title = "build log (tail)"): void { + const trimmed = log.trim(); + if (!trimmed) return; + console.error(`\n--- ${title} ---\n`); + process.stderr.write(trimmed + "\n"); + console.error("---\n"); +} diff --git a/packages/openui-cli/src/lib/process-runner.ts b/packages/openui-cli/src/lib/process-runner.ts index 4559ecb69..8e7417825 100644 --- a/packages/openui-cli/src/lib/process-runner.ts +++ b/packages/openui-cli/src/lib/process-runner.ts @@ -12,6 +12,14 @@ export type CommandResult = { export type RunCommandOptions = { env?: NodeJS.ProcessEnv; + /** When false, capture stdout/stderr without writing them to the parent. Default true. */ + echo?: boolean; + /** Default inherit so interactive CLIs can prompt. Use ignore for login probes. */ + stdin?: "inherit" | "ignore"; + /** Inherit stdout/stderr so the child sees a TTY (needed for `vercel login`). */ + inheritOutput?: boolean; + /** Max bytes retained in `diagnosticTail` when output is piped. Default 16KiB. */ + captureLimit?: number; }; /** @@ -31,10 +39,17 @@ export function runCommand( ): Promise { return new Promise((resolve) => { const startedAt = Date.now(); + const echo = options.echo !== false; + const inheritOutput = Boolean(options.inheritOutput); + const captureLimit = options.captureLimit ?? DIAGNOSTIC_TAIL_LIMIT; const child = spawn(command, args, { cwd, env: options.env, - stdio: ["inherit", "pipe", "pipe"], + stdio: [ + options.stdin === "ignore" ? "ignore" : "inherit", + inheritOutput ? "inherit" : "pipe", + inheritOutput ? "inherit" : "pipe", + ], }); let diagnosticTail = ""; let settled = false; @@ -42,16 +57,18 @@ export function runCommand( let forceKillTimer: NodeJS.Timeout | undefined; const observe = (chunk: Buffer) => { - diagnosticTail = (diagnosticTail + chunk.toString("utf8")).slice(-DIAGNOSTIC_TAIL_LIMIT); + diagnosticTail = (diagnosticTail + chunk.toString("utf8")).slice(-captureLimit); }; - child.stdout?.on("data", (chunk: Buffer) => { - process.stdout.write(chunk); - observe(chunk); - }); - child.stderr?.on("data", (chunk: Buffer) => { - process.stderr.write(chunk); - observe(chunk); - }); + if (!inheritOutput) { + child.stdout?.on("data", (chunk: Buffer) => { + if (echo) process.stdout.write(chunk); + observe(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + if (echo) process.stderr.write(chunk); + observe(chunk); + }); + } const finish = (result: Omit) => { if (settled) return; diff --git a/packages/openui-cli/src/lib/spinner.ts b/packages/openui-cli/src/lib/spinner.ts new file mode 100644 index 000000000..d74d85ce0 --- /dev/null +++ b/packages/openui-cli/src/lib/spinner.ts @@ -0,0 +1,39 @@ +import * as readline from "node:readline"; + +/** + * Terminal spinner for long-running work. Safe in non-TTY (prints the label once). + * Clears the spinner line before resolving so callers can print a final status. + * + * Uses readline cursor APIs instead of bare `\r` — some terminals (including + * Cursor/VS Code) don't reliably overwrite the current line with carriage return. + */ +export async function withSpinner(label: string, run: () => Promise): Promise { + if (!process.stdout.isTTY) { + console.info(label); + return run(); + } + + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let frame = 0; + + const render = () => { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(`${frames[frame]} ${label}`); + }; + + render(); + const timer = setInterval(() => { + frame = (frame + 1) % frames.length; + render(); + }, 80); + timer.unref(); + + try { + return await run(); + } finally { + clearInterval(timer); + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } +} diff --git a/packages/openui-cli/src/templates/openui-cloud/next.config.ts b/packages/openui-cli/src/templates/openui-cloud/next.config.ts index 30ac02797..1ed3ef668 100644 --- a/packages/openui-cli/src/templates/openui-cloud/next.config.ts +++ b/packages/openui-cli/src/templates/openui-cloud/next.config.ts @@ -2,10 +2,13 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { allowedDevOrigins: ["127.0.0.1"], - output: "standalone", + ...(process.env.VERCEL ? {} : { output: "standalone" as const }), turbopack: { root: process.cwd(), }, + experimental: { + turbopackMinify: false, + }, }; export default nextConfig; From 9668dc73651a3ad85fac10b2d98d011e9d38e07a Mon Sep 17 00:00:00 2001 From: AB <55152006+AbhinRustagi@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:20:24 +0530 Subject: [PATCH 02/54] Update next.config.ts --- packages/openui-cli/src/templates/openui-cloud/next.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openui-cli/src/templates/openui-cloud/next.config.ts b/packages/openui-cli/src/templates/openui-cloud/next.config.ts index 1ed3ef668..9cb6e36da 100644 --- a/packages/openui-cli/src/templates/openui-cloud/next.config.ts +++ b/packages/openui-cli/src/templates/openui-cloud/next.config.ts @@ -2,7 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { allowedDevOrigins: ["127.0.0.1"], - ...(process.env.VERCEL ? {} : { output: "standalone" as const }), + output: "standalone", turbopack: { root: process.cwd(), }, From 114c19ed9becf7b0131d35a0e0930cc353fbf79b Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 28 Aug 2026 11:40:07 +0530 Subject: [PATCH 03/54] fix: format --- packages/openui-cli/src/commands/create-app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index fce3648c5..ebf698757 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -3,9 +3,9 @@ import * as os from "node:os"; import * as path from "node:path"; import { resolveCloudApiKey, THESYS_KEYS_URL } from "../auth/mint"; +import { dumpFailureLog, QUIET_COMMAND_CAPTURE_LIMIT } from "../lib/command-output"; import { aiSetupFromTemplate, createFunnelProps } from "../lib/create-telemetry"; import type { CreateAppOptions, EnvResult, OverlayName, TemplateName } from "../lib/create-types"; -import { dumpFailureLog, QUIET_COMMAND_CAPTURE_LIMIT } from "../lib/command-output"; import { resolveInstallPackageManager, type PackageManagerName, From 2a87cb71ae7d09c6506b2efaf705321a968847b9 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 28 Aug 2026 15:10:16 +0530 Subject: [PATCH 04/54] cli: add --verbose to create for streaming install logs Default stays quiet with spinner; pass --verbose to stream package manager output. --- .../openui-cli/src/commands/create-app.ts | 40 ++++++++++++------- packages/openui-cli/src/index.ts | 3 ++ packages/openui-cli/src/lib/create-types.ts | 2 + 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index ebf698757..922b301c9 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -423,21 +423,31 @@ export async function runCreateApp(options: CreateAppOptions): Promise { template, ai_setup: aiSetup, }); - const installResult = await withSpinner("Installing dependencies...", () => - runCommand(packageManager.runCmd, installArgs, targetDir, { - echo: false, - stdin: "ignore", - captureLimit: QUIET_COMMAND_CAPTURE_LIMIT, - env: { - ...process.env, - npm_config_loglevel: "error", - NPM_CONFIG_LOGLEVEL: "error", - }, - }), - ); + const runInstall = () => + options.verbose + ? runCommand(packageManager.runCmd, installArgs, targetDir) + : runCommand(packageManager.runCmd, installArgs, targetDir, { + echo: false, + stdin: "ignore", + captureLimit: QUIET_COMMAND_CAPTURE_LIMIT, + env: { + ...process.env, + npm_config_loglevel: "error", + NPM_CONFIG_LOGLEVEL: "error", + }, + }); + + if (options.verbose) { + console.info(`Installing dependencies with: ${installCmd}\n`); + } + const installResult = options.verbose + ? await runInstall() + : await withSpinner("Installing dependencies...", runInstall); if (!installResult.error && installResult.status === 0) { dependencyInstalled = true; - console.info("✓ Dependencies installed\n"); + if (!options.verbose) { + console.info("✓ Dependencies installed\n"); + } telemetry.capture("cli_dependency_install_succeeded", { ...createFunnelProps("dependency_install_succeeded"), template, @@ -445,7 +455,9 @@ export async function runCreateApp(options: CreateAppOptions): Promise { dependency_installed: dependencyInstalled, }); } else { - dumpFailureLog(installResult.diagnosticTail, "install log (tail)"); + if (!options.verbose) { + dumpFailureLog(installResult.diagnosticTail, "install log (tail)"); + } const properties = processErrorProperties(installResult, "dependency_install", { error_class: "dependency", error_code: "NONZERO_EXIT", diff --git a/packages/openui-cli/src/index.ts b/packages/openui-cli/src/index.ts index fbeeecf00..9b2837613 100644 --- a/packages/openui-cli/src/index.ts +++ b/packages/openui-cli/src/index.ts @@ -72,6 +72,7 @@ program .option("--no-install", "Scaffold without running the package install") .option("-i, --immediate", "Start the development server after installing dependencies") .option("--no-immediate", "Install dependencies without starting the development server") + .option("--verbose", "Stream full dependency install logs") .addHelpText( "after", ` @@ -103,6 +104,7 @@ Backend frameworks: interactive: boolean; install: boolean; immediate?: boolean; + verbose?: boolean; }) => { try { rejectConflictingImmediateFlags(process.argv.slice(2)); @@ -116,6 +118,7 @@ Backend frameworks: noInteractive: !options.interactive, noInstall: !options.install, immediate: options.immediate, + verbose: options.verbose, }); } catch (e) { handleCliError(e, "cli_create_failed"); diff --git a/packages/openui-cli/src/lib/create-types.ts b/packages/openui-cli/src/lib/create-types.ts index 0a0ef97ff..4465652c8 100644 --- a/packages/openui-cli/src/lib/create-types.ts +++ b/packages/openui-cli/src/lib/create-types.ts @@ -12,6 +12,8 @@ export interface CreateAppOptions { noInteractive?: boolean; noInstall?: boolean; immediate?: boolean; + /** Stream full dependency install logs instead of quiet spinner output. */ + verbose?: boolean; apiKey?: string; auth?: CloudAuthMethod; } From 164231109ebb184d61519a1b698bd2aaaf83160e Mon Sep 17 00:00:00 2001 From: AB <55152006+AbhinRustagi@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:11:01 +0530 Subject: [PATCH 05/54] Update create-types.ts --- packages/openui-cli/src/lib/create-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/openui-cli/src/lib/create-types.ts b/packages/openui-cli/src/lib/create-types.ts index 4465652c8..bbbddc91d 100644 --- a/packages/openui-cli/src/lib/create-types.ts +++ b/packages/openui-cli/src/lib/create-types.ts @@ -12,7 +12,6 @@ export interface CreateAppOptions { noInteractive?: boolean; noInstall?: boolean; immediate?: boolean; - /** Stream full dependency install logs instead of quiet spinner output. */ verbose?: boolean; apiKey?: string; auth?: CloudAuthMethod; From 62ad9a3536726b27ff15c81f7f6f0f44c69b4f2c Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 28 Aug 2026 15:12:00 +0530 Subject: [PATCH 06/54] cli: rename dumpFailureLog to printLogTail --- packages/openui-cli/src/commands/create-app.ts | 4 ++-- packages/openui-cli/src/lib/command-output.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index 922b301c9..d1be251e0 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { resolveCloudApiKey, THESYS_KEYS_URL } from "../auth/mint"; -import { dumpFailureLog, QUIET_COMMAND_CAPTURE_LIMIT } from "../lib/command-output"; +import { printLogTail, QUIET_COMMAND_CAPTURE_LIMIT } from "../lib/command-output"; import { aiSetupFromTemplate, createFunnelProps } from "../lib/create-telemetry"; import type { CreateAppOptions, EnvResult, OverlayName, TemplateName } from "../lib/create-types"; import { @@ -456,7 +456,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise { }); } else { if (!options.verbose) { - dumpFailureLog(installResult.diagnosticTail, "install log (tail)"); + printLogTail(installResult.diagnosticTail, "install log (tail)"); } const properties = processErrorProperties(installResult, "dependency_install", { error_class: "dependency", diff --git a/packages/openui-cli/src/lib/command-output.ts b/packages/openui-cli/src/lib/command-output.ts index fc880b618..6f98f17b5 100644 --- a/packages/openui-cli/src/lib/command-output.ts +++ b/packages/openui-cli/src/lib/command-output.ts @@ -1,7 +1,7 @@ /** Enough build/install output to dump a useful failure tail in quiet mode. */ export const QUIET_COMMAND_CAPTURE_LIMIT = 256 * 1024; -export function dumpFailureLog(log: string, title = "build log (tail)"): void { +export function printLogTail(log: string, title = "build log (tail)"): void { const trimmed = log.trim(); if (!trimmed) return; console.error(`\n--- ${title} ---\n`); From 3ce6761959a1068e15d701ebe63781314bdf9f37 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 27 Aug 2026 18:17:13 +0530 Subject: [PATCH 07/54] cli: add openui deploy for Vercel Wire a quiet Vercel deploy flow with login/link, optional env sync from .env, and docs. --- docs/content/docs/api-reference/cli.mdx | 83 +++++++++- docs/content/docs/api-reference/index.mdx | 8 +- packages/openui-cli/README.md | 49 +++++- packages/openui-cli/package.json | 4 +- packages/openui-cli/src/commands/deploy.ts | 118 ++++++++++++++ packages/openui-cli/src/index.ts | 60 +++++++ packages/openui-cli/src/lib/cli-bin.ts | 74 +++++++++ .../src/lib/deploy-targets/index.ts | 30 ++++ .../src/lib/deploy-targets/vercel/args.ts | 72 ++++++++ .../src/lib/deploy-targets/vercel/auth.ts | 105 ++++++++++++ .../src/lib/deploy-targets/vercel/index.ts | 130 +++++++++++++++ .../lib/deploy-targets/vercel/project-env.ts | 154 ++++++++++++++++++ .../src/lib/deploy-targets/vercel/summary.ts | 16 ++ packages/openui-cli/src/lib/deploy/failure.ts | 15 ++ .../openui-cli/src/lib/deploy/project-env.ts | 56 +++++++ packages/openui-cli/src/lib/deploy/prompt.ts | 28 ++++ packages/openui-cli/src/lib/deploy/quiet.ts | 48 ++++++ packages/openui-cli/src/lib/deploy/types.ts | 14 ++ .../src/lib/detect-package-manager.ts | 29 ++++ packages/openui-cli/src/lib/env-file.ts | 55 +++++++ .../src/templates/openui-cloud/README.md | 13 ++ .../templates/openui-self-hosted/README.md | 13 ++ 22 files changed, 1162 insertions(+), 12 deletions(-) create mode 100644 packages/openui-cli/src/commands/deploy.ts create mode 100644 packages/openui-cli/src/lib/cli-bin.ts create mode 100644 packages/openui-cli/src/lib/deploy-targets/index.ts create mode 100644 packages/openui-cli/src/lib/deploy-targets/vercel/args.ts create mode 100644 packages/openui-cli/src/lib/deploy-targets/vercel/auth.ts create mode 100644 packages/openui-cli/src/lib/deploy-targets/vercel/index.ts create mode 100644 packages/openui-cli/src/lib/deploy-targets/vercel/project-env.ts create mode 100644 packages/openui-cli/src/lib/deploy-targets/vercel/summary.ts create mode 100644 packages/openui-cli/src/lib/deploy/failure.ts create mode 100644 packages/openui-cli/src/lib/deploy/project-env.ts create mode 100644 packages/openui-cli/src/lib/deploy/prompt.ts create mode 100644 packages/openui-cli/src/lib/deploy/quiet.ts create mode 100644 packages/openui-cli/src/lib/deploy/types.ts create mode 100644 packages/openui-cli/src/lib/env-file.ts diff --git a/docs/content/docs/api-reference/cli.mdx b/docs/content/docs/api-reference/cli.mdx index 454627902..220c96f69 100644 --- a/docs/content/docs/api-reference/cli.mdx +++ b/docs/content/docs/api-reference/cli.mdx @@ -1,9 +1,9 @@ --- title: "@openuidev/cli" -description: API reference for the OpenUI CLI to scaffold apps and generate system prompts or library specs. +description: API reference for the OpenUI CLI to scaffold apps, generate system prompts or library specs, and deploy projects. --- -A command-line tool for scaffolding OpenUI chat apps and generating system prompts, JSON schemas, or serialized library specs from library definitions. +A command-line tool for scaffolding OpenUI chat apps, generating system prompts, JSON schemas, or serialized library specs from library definitions, and deploying those apps. ## Installation @@ -201,6 +201,80 @@ npx @openuidev/cli@latest create --name my-app --skill npx @openuidev/cli@latest create --name my-app --no-skill ``` +## `openui deploy` + +Deploys an OpenUI project. The default platform supported is **Vercel**. + +``` +openui deploy [dir] [options] +``` + +**Arguments** + +| Argument | Description | +| -------- | ---------------------------------------------- | +| `[dir]` | Project directory (default: current directory) | + +**Options** + +| Flag | Description | +| --------------------- | --------------------------------------------------------------------------- | +| `-y, --yes` | Skip confirmation prompts (also saves missing env keys to the Vercel project) | +| `--skip-env` | Do not pass or save local `.env` / `.env.local` values | +| `--no-interactive` | Skip prompts (implies `--yes`) | +| `--verbose` | Stream full Vercel build logs (hidden by default; failures print a log tail) | +| `--agent-name ` | Declare the invoking coding-agent slug (default: `unknown`) | + +Extra flags after `deploy` are forwarded as-is to the target deployment platform, which validates them (for example `--prod` or `--force`). `--skip-env` is OpenUI-specific so it does not collide with Vercel's `--env KEY=value`. + +Unlinked projects run `vercel link` first. Allowlisted keys from `.env` / `.env.local` that are missing on production, preview, or development can be saved to the project (prompted; auto-accepted with `--yes`). Existing project keys are left unchanged. The current deployment still receives those keys via `--env` / `--build-env`. Build logs are quiet by default; use `--verbose` to stream them. + +**Examples** + +```bash tab="pnpm" tab-group="pkg" +# Preview deploy from the project directory +pnpx @openuidev/cli@latest deploy + +# Production deploy +pnpx @openuidev/cli@latest deploy --prod --yes + +# Deploy a specific directory without forwarding local env +pnpx @openuidev/cli@latest deploy ./my-app --skip-env +``` + +```bash tab="bun" tab-group="pkg" +# Preview deploy from the project directory +bunx @openuidev/cli@latest deploy + +# Production deploy +bunx @openuidev/cli@latest deploy --prod --yes + +# Deploy a specific directory without forwarding local env +bunx @openuidev/cli@latest deploy ./my-app --skip-env +``` + +```bash tab="yarn" tab-group="pkg" +# Preview deploy from the project directory +yarn dlx @openuidev/cli@latest deploy + +# Production deploy +yarn dlx @openuidev/cli@latest deploy --prod --yes + +# Deploy a specific directory without forwarding local env +yarn dlx @openuidev/cli@latest deploy ./my-app --skip-env +``` + +```bash tab="npm" tab-group="pkg" +# Preview deploy from the project directory +npx @openuidev/cli@latest deploy + +# Production deploy +npx @openuidev/cli@latest deploy --prod --yes + +# Deploy a specific directory without forwarding local env +npx @openuidev/cli@latest deploy ./my-app --skip-env +``` + ## `openui generate` Generates the system prompt **and** the serialized library spec from a file that exports a `createLibrary()` result. A single run emits both artifacts — they derive from the same `Library` instance, so they can never drift apart. Use the spec with `generateSystemPrompt` in backend routes; the prompt file supports static or legacy integrations. @@ -377,7 +451,10 @@ and `detected_agent_name`, inferred best-effort from known product environment m value can be spoofed, inherited, missing, or ambiguous, so neither should be treated as an authentication or security signal. Every invocation gets an ephemeral, unpersisted `cli_run_id` so its events can be correlated. For `create`, analytics also include `package_manager`, the -immediate-start selection, and best-effort dev-command start and result events. Failure events use +immediate-start selection, and best-effort dev-command start and result events. For `deploy`, analytics include the target (currently `vercel`), production vs preview, whether +local env was passed, CLI resolution source, and process status — not env +values, project paths, or command output. +Failure events use bounded `failure_stage`, `error_class`, and `error_code` values instead of raw error messages. Dependency failures distinguish peer, registry, network, install-script, workspace, and package-compatibility errors. Process failures include duration, exit code, and signal; Cloud-auth diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index 9096c6513..b6b82e910 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -27,7 +27,7 @@ The OpenUI SDK is split into packages that build on each other: - **`@openuidev/devtools`** — Development-only floating widget that surfaces the events captured by `@openuidev/observability`, with error messages and stack traces. -- **`@openuidev/cli`** — Command-line tool for scaffolding new OpenUI chat apps and generating system prompts or JSON schemas from library definitions. +- **`@openuidev/cli`** — Command-line tool for scaffolding new OpenUI chat apps, generating system prompts or JSON schemas from library definitions, and deploying projects. ## Choosing a package @@ -44,7 +44,7 @@ The OpenUI SDK is split into packages that build on each other: | Svelte integration | [`@openuidev/svelte-lang`](https://github.com/thesysdev/openui/tree/main/packages/svelte-lang) | | Script-tag, CDN, or iframe embeds | [`@openuidev/browser-bundle`](https://github.com/thesysdev/openui/tree/main/packages/browser-bundle) | | An in-app panel showing captured errors during development | [`@openuidev/devtools`](/docs/api-reference/devtools) | -| App scaffolding and prompt/schema generation from the command line | [`@openuidev/cli`](/docs/api-reference/cli) | +| App scaffolding, prompt/schema generation, and deploy from the command line | [`@openuidev/cli`](/docs/api-reference/cli) | ## Packages @@ -100,7 +100,7 @@ The OpenUI SDK is split into packages that build on each other: Development-only floating widget surfacing captured events with error messages and stack traces. - openui create (scaffold a Next.js app) and openui generate (system prompt + library spec from a - library definition). + openui create (scaffold a Next.js app), openui deploy, and openui generate (system prompt + + library spec from a library definition). diff --git a/packages/openui-cli/README.md b/packages/openui-cli/README.md index 773c3fbb9..5ad1b3498 100644 --- a/packages/openui-cli/README.md +++ b/packages/openui-cli/README.md @@ -1,19 +1,20 @@ # @openuidev/cli -Command-line tools for starting OpenUI projects and generating model instructions from component libraries. +Command-line tools for starting OpenUI projects, generating model instructions from component libraries, and deploying apps to Vercel. [![npm](https://img.shields.io/npm/v/@openuidev/cli)](https://www.npmjs.com/package/@openuidev/cli) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/thesysdev/openui/blob/main/LICENSE) **Links:** [CLI docs](https://openui.com/docs/api-reference/cli) | [GitHub repo](https://github.com/thesysdev/openui) -It currently supports two workflows: +It currently supports: - scaffolding a new OpenUI app from one of two templates: - **OpenUI Cloud (recommended)** — hosted models with managed conversations, streaming, built-in tools, and ready-to-use report and presentation artifacts - **Self-hosted** — bring an OpenAI-compatible model key and own the AI route and persistence - keeping the default minimal SDK route or adding a LangGraph or Vercel AI SDK backend to either template - generating a system prompt or JSON Schema from a `createLibrary()` export +- deploying a project with `openui deploy` ## Install @@ -61,6 +62,13 @@ Generate JSON Schema instead: npx @openuidev/cli@latest generate ./src/library.ts --json-schema ``` +Deploy the current project: + +```bash +npx @openuidev/cli@latest deploy +npx @openuidev/cli@latest deploy --prod +``` + ## Commands ### `openui create` @@ -157,6 +165,39 @@ openui create --name my-app --no-skill --no-install openui create --no-interactive --name my-app --template openui-cloud --api-key tk_your_key ``` +### `openui deploy` + +Deploys an OpenUI project. The default platform supported is **Vercel**. + +```bash +openui deploy [dir] [options] +``` + +Arguments: + +- `dir`: Project directory (default: current directory) + +Options: + +- `-y, --yes`: Skip confirmation prompts (also saves missing env keys to the Vercel project) +- `--skip-env`: Do not pass or save local `.env` / `.env.local` values +- `--no-interactive`: Skip prompts (implies `--yes`) +- `--verbose`: Stream full Vercel build logs (hidden by default; failure still prints a log tail) +- `--agent-name `: Declare the invoking coding agent as a lowercase kebab-case product slug (default: `unknown`) + +Extra flags after `deploy` are forwarded as-is to the target deployment platform, which validates them (for example `--prod` or `--force`). `--skip-env` is OpenUI-specific so it does not collide with Vercel's `--env KEY=value`. + +Unlinked projects run `vercel link` first (so env can be saved before the build). Allowlisted keys from `.env` / `.env.local` that are missing on production, preview, or development can be saved to the project (prompted; auto-accepted with `--yes`). Existing project keys are never overwritten. Env is still attached to the current deployment via `--env` / `--build-env`. Build logs are quiet by default. + +Examples: + +```bash +openui deploy +openui deploy ./my-app +openui deploy ./my-app --prod +openui deploy --skip-env -- --force +``` + ### `openui generate` Generates a system prompt and serialized library spec from a file that exports a `createLibrary()` result. Use the spec with `generateSystemPrompt` in backend routes; the prompt file remains available for static or legacy integrations. @@ -233,6 +274,7 @@ Run the built CLI: ```bash node dist/index.js --help node dist/index.js create --help +node dist/index.js deploy --help node dist/index.js generate --help ``` @@ -242,7 +284,7 @@ The CLI sends usage analytics; OAuth sign-ins may link usage to your OIDC accoun When a coding agent invokes the CLI, it should pass `--agent-name` using its stable, lowercase kebab-case product slug—for example, `codex`, `claude-code`, `cline`, `factory-droid`, or `pi`. Do not pass a model/version, user name, session ID, or other unique value. Humans can omit the flag; it defaults to `unknown`. -Telemetry includes both `agent_name` (the CLI declaration) and `detected_agent_name` (best-effort environment detection). Either can be spoofed, inherited, missing, or ambiguous; neither is an authentication signal. Every invocation gets an ephemeral, unpersisted `cli_run_id` so its events can be correlated. Failure events include bounded `failure_stage`, `error_class`, and `error_code` values, never raw error messages. Dependency failures distinguish peer, registry, network, install-script, workspace, and package-compatibility errors. Process failures include duration, exit code, and signal; Cloud-auth failures include a bounded auth substage and HTTP status when known; cancellations use separate events. For `create`, telemetry also includes `package_manager`, the immediate-start selection, and best-effort dev-command start and result events. Dev-command events contain status, duration, exit code, and signal—not project paths, command output, code, or environment values. Disable telemetry with `--no-telemetry` or `DO_NOT_TRACK=1`. +Telemetry includes both `agent_name` (the CLI declaration) and `detected_agent_name` (best-effort environment detection). Either can be spoofed, inherited, missing, or ambiguous; neither is an authentication signal. Every invocation gets an ephemeral, unpersisted `cli_run_id` so its events can be correlated. Failure events include bounded `failure_stage`, `error_class`, and `error_code` values, never raw error messages. Dependency failures distinguish peer, registry, network, install-script, workspace, and package-compatibility errors. Process failures include duration, exit code, and signal; Cloud-auth failures include a bounded auth substage and HTTP status when known; cancellations use separate events. For `create`, telemetry also includes `package_manager`, the immediate-start selection, and best-effort dev-command start and result events. Dev-command events contain status, duration, exit code, and signal—not project paths, command output, code, or environment values. For `deploy`, telemetry includes the target (currently `vercel`), production vs preview, whether the Vercel CLI was logged in, whether local env was passed, CLI resolution source, and process status—not env values, project paths, or command output. Disable telemetry with `--no-telemetry` or `DO_NOT_TRACK=1`. ```bash openui create --no-telemetry @@ -253,6 +295,7 @@ openui create --no-telemetry - interactive prompts can be cancelled without creating output - `create` requires the selected template's files to be present in the built package - `generate` exits with a non-zero code if the file is missing or no valid library export is found +- `deploy` exits with a non-zero code if the directory has no `package.json` or the Vercel CLI fails ## Documentation diff --git a/packages/openui-cli/package.json b/packages/openui-cli/package.json index 1b7ecc344..8607b09df 100644 --- a/packages/openui-cli/package.json +++ b/packages/openui-cli/package.json @@ -1,7 +1,7 @@ { "name": "@openuidev/cli", - "version": "0.2.11", - "description": "CLI for OpenUI — scaffold generative UI chat apps and generate LLM system prompts from component libraries", + "version": "0.2.12", + "description": "CLI for OpenUI — scaffold generative UI chat apps, generate LLM system prompts from component libraries, and deploy projects", "bin": { "openui": "dist/index.js" }, diff --git a/packages/openui-cli/src/commands/deploy.ts b/packages/openui-cli/src/commands/deploy.ts new file mode 100644 index 000000000..c1c01bf59 --- /dev/null +++ b/packages/openui-cli/src/commands/deploy.ts @@ -0,0 +1,118 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { + DEFAULT_DEPLOY_TARGET, + deployToTarget, + type DeployTargetOptions, +} from "../lib/deploy-targets"; +import { resolveInstallPackageManager } from "../lib/detect-package-manager"; +import { CreateError, telemetry } from "../lib/telemetry"; + +/** OpenUI-only flags. Everything else is forwarded for the target CLI to validate. */ +const OWN_FLAGS = new Set(["--skip-env", "--no-interactive", "--verbose"]); + +export type DeployOptions = { + dir?: string; + yes?: boolean; + skipEnv?: boolean; + noInteractive?: boolean; + verbose?: boolean; + extraArgs?: string[]; +}; + +type ResolvedDeploy = { + projectDir?: string; + extraArgs: string[]; +}; + +export async function runDeploy(options: DeployOptions): Promise { + const resolved = resolveDeployInvocation(options); + const projectDir = resolveProjectDir(resolved.projectDir); + const extraArgs = resolved.extraArgs.filter((arg) => arg !== "--verbose"); + const prod = extraArgs.includes("--prod"); + const yes = + Boolean(options.yes) || + Boolean(options.noInteractive) || + extraArgs.includes("--yes") || + extraArgs.includes("-y"); + const skipEnv = Boolean(options.skipEnv); + const verbose = Boolean(options.verbose) || (options.extraArgs ?? []).includes("--verbose"); + + const target = DEFAULT_DEPLOY_TARGET; + const targetOpts: DeployTargetOptions = { + projectDir, + extraArgs, + prod, + yes, + skipEnv, + noInteractive: Boolean(options.noInteractive), + verbose, + }; + + telemetry.register({ package_manager: resolveInstallPackageManager().name }); + telemetry.capture("cli_deploy_started", { + target, + prod, + yes, + skip_env: skipEnv, + verbose, + has_dir_arg: Boolean(resolved.projectDir), + }); + + await deployToTarget(target, targetOpts); +} + +function resolveDeployInvocation(options: DeployOptions): ResolvedDeploy { + const projectDir = unsetIfFlag(options.dir); + const extraArgs = extraDeployArgs(options.extraArgs ?? [], { dir: projectDir }); + if (options.dir?.startsWith("-") && !extraArgs.includes(options.dir)) { + extraArgs.unshift(options.dir); + } + return { projectDir, extraArgs }; +} + +function extraDeployArgs(args: string[], consumed: { dir?: string }): string[] { + const skip = new Set( + [consumed.dir].filter((value): value is string => Boolean(value && !value.startsWith("-"))), + ); + const out: string[] = []; + for (const arg of args) { + if (skip.has(arg) || OWN_FLAGS.has(arg)) continue; + out.push(arg); + } + return out; +} + +function unsetIfFlag(value?: string): string | undefined { + return value?.startsWith("-") ? undefined : value; +} + +function resolveProjectDir(dir?: string): string { + const projectDir = path.resolve(process.cwd(), dir ?? "."); + if (!fs.existsSync(projectDir)) { + throw new CreateError( + "args_resolution", + `Directory not found: ${projectDir}`, + "invalid_input", + "NOT_FOUND", + ); + } + if (!fs.statSync(projectDir).isDirectory()) { + throw new CreateError( + "args_resolution", + `Not a directory: ${projectDir}`, + "invalid_input", + "NOT_A_DIRECTORY", + ); + } + if (!fs.existsSync(path.join(projectDir, "package.json"))) { + throw new CreateError( + "args_resolution", + `No package.json in ${projectDir}. Run this from an OpenUI project, or pass its directory.`, + "invalid_input", + "PROJECT_NOT_FOUND", + ); + } + return projectDir; +} diff --git a/packages/openui-cli/src/index.ts b/packages/openui-cli/src/index.ts index 9b2837613..6a4f1f50e 100644 --- a/packages/openui-cli/src/index.ts +++ b/packages/openui-cli/src/index.ts @@ -7,6 +7,7 @@ import * as path from "node:path"; import { Command } from "commander"; import { runCreateApp } from "./commands/create-app"; +import { runDeploy } from "./commands/deploy"; import { GenerateOptions, runGenerate } from "./commands/generate"; import { detectAgent, UNKNOWN_AGENT_NAME } from "./lib/detect-agent"; import { rejectConflictingImmediateFlags, resolveArgs } from "./lib/resolve-args"; @@ -128,6 +129,65 @@ Backend frameworks: }, ); +program + .command("deploy") + .description("Deploy an OpenUI project") + .usage("[dir] [options]") + .argument("[dir]", "Project directory (default: current directory)") + .option("-y, --yes", "Skip confirmation prompts") + .option("--skip-env", "Do not pass or save local .env values") + .option("--no-interactive", "Skip prompts (implies --yes)") + .option("--verbose", "Stream full deployment build logs") + .allowUnknownOption() + .allowExcessArguments() + .addHelpText( + "after", + ` +Deploys an OpenUI project to Vercel. If you are not logged in, opens vercel login +first. Links the project when needed, then offers to save missing allowlisted +keys from .env / .env.local to the Vercel project (auto-accepted with --yes). +Build logs are hidden by default; pass --verbose to stream them. On failure the +log tail is printed. + +Extra flags after deploy are forwarded as-is to the target deployment platform, +which validates them (for example --prod or --force). + +Examples: + $ openui deploy + $ openui deploy ./my-app + $ openui deploy ./my-app --prod + $ openui deploy --verbose + $ openui deploy -- --archive=tgz +`, + ) + .action( + async ( + dir: string | undefined, + options: { + yes?: boolean; + skipEnv?: boolean; + interactive: boolean; + verbose?: boolean; + }, + command: Command, + ) => { + try { + await runDeploy({ + dir, + yes: options.yes, + skipEnv: options.skipEnv, + noInteractive: !options.interactive, + verbose: options.verbose, + extraArgs: command.args, + }); + } catch (e) { + handleCliError(e, "cli_deploy_failed"); + } finally { + await telemetry.shutdown(); + } + }, + ); + program .command("generate") .description("Generate the system prompt + serialized spec from a library definition") diff --git a/packages/openui-cli/src/lib/cli-bin.ts b/packages/openui-cli/src/lib/cli-bin.ts new file mode 100644 index 000000000..2b7331c88 --- /dev/null +++ b/packages/openui-cli/src/lib/cli-bin.ts @@ -0,0 +1,74 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { + resolveDlxInvocation, + resolveInstallPackageManager, + type PackageManager, +} from "./detect-package-manager"; + +/** How a third-party CLI binary was resolved for spawn. */ +export type CliInvocation = { + command: string; + prefixArgs: string[]; + /** Prefer for non-interactive spawns — suppresses package-manager noise. */ + quietPrefixArgs: string[]; + source: "local" | "path" | "dlx"; +}; + +/** + * Resolve `bin` from local node_modules, PATH, or the active package manager's dlx. + * Shared by deploy targets (Vercel today; other platform CLIs later). + */ +export function resolveCliInvocation( + projectDir: string, + bin: string, + packageManager: PackageManager = resolveInstallPackageManager(), +): CliInvocation { + const localUnix = path.join(projectDir, "node_modules", ".bin", bin); + const localWin = `${localUnix}.cmd`; + if (fs.existsSync(localWin)) { + return { command: localWin, prefixArgs: [], quietPrefixArgs: [], source: "local" }; + } + if (fs.existsSync(localUnix)) { + return { command: localUnix, prefixArgs: [], quietPrefixArgs: [], source: "local" }; + } + + const fromPath = findExecutableOnPath(bin); + if (fromPath) { + return { command: fromPath, prefixArgs: [], quietPrefixArgs: [], source: "path" }; + } + + const dlx = resolveDlxInvocation(packageManager, bin); + return { + command: dlx.command, + prefixArgs: dlx.args, + quietPrefixArgs: dlx.quietArgs, + source: "dlx", + }; +} + +export function formatCliCommand(invocation: CliInvocation, args: string[]): string { + const binName = path.basename(invocation.command).replace(/\.cmd$/i, ""); + const head = + invocation.source === "dlx" ? [invocation.command, ...invocation.prefixArgs] : [binName]; + return [...head, ...args].join(" "); +} + +function findExecutableOnPath(bin: string): string | undefined { + const pathEnv = process.env["PATH"] ?? ""; + const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""]; + for (const dir of pathEnv.split(path.delimiter)) { + if (!dir) continue; + for (const ext of extensions) { + const candidate = path.join(dir, bin + ext); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + /* not executable / missing */ + } + } + } + return undefined; +} diff --git a/packages/openui-cli/src/lib/deploy-targets/index.ts b/packages/openui-cli/src/lib/deploy-targets/index.ts new file mode 100644 index 000000000..4371f6aaa --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/index.ts @@ -0,0 +1,30 @@ +import { + DEFAULT_DEPLOY_TARGET, + DEPLOY_TARGETS, + type DeployTarget, + type DeployTargetOptions, +} from "../deploy/types"; +import { deployToVercel } from "./vercel"; + +export { + DEFAULT_DEPLOY_TARGET, + DEPLOY_TARGETS, + deployToVercel, + type DeployTarget, + type DeployTargetOptions, +}; + +/** Dispatch to a platform adapter. Add branches as new targets land. */ +export async function deployToTarget( + target: DeployTarget, + opts: DeployTargetOptions, +): Promise { + switch (target) { + case "vercel": + return deployToVercel(opts); + default: { + const _exhaustive: never = target; + throw new Error(`Unsupported deploy target: ${_exhaustive}`); + } + } +} diff --git a/packages/openui-cli/src/lib/deploy-targets/vercel/args.ts b/packages/openui-cli/src/lib/deploy-targets/vercel/args.ts new file mode 100644 index 000000000..0d02bc2ef --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/args.ts @@ -0,0 +1,72 @@ +import type { CliInvocation } from "../../cli-bin"; + +const ENV_FLAGS = ["--env", "-e"] as const; +const BUILD_ENV_FLAGS = ["--build-env", "-b"] as const; +const ALL_ENV_FLAGS = [...ENV_FLAGS, ...BUILD_ENV_FLAGS] as const; + +export function buildVercelDeployArgs(opts: { + extraArgs: string[]; + yes: boolean; + localEnv: Record; +}): string[] { + const args = [...opts.extraArgs]; + if (opts.yes && !args.includes("--yes") && !args.includes("-y")) args.unshift("--yes"); + + const envKeys = envKeysInArgs(args, ENV_FLAGS); + const buildEnvKeys = envKeysInArgs(args, BUILD_ENV_FLAGS); + for (const key of Object.keys(opts.localEnv).sort()) { + const value = opts.localEnv[key]; + if (value === undefined) continue; + const assignment = `${key}=${value}`; + // Next.js inlines process.env at `next build`. Runtime `--env` alone is + // not enough — the remote build also needs `--build-env`. + if (!envKeys.has(key)) args.push("--env", assignment); + if (!buildEnvKeys.has(key)) args.push("--build-env", assignment); + } + return args; +} + +export function publicVercelArgs(args: string[]): string[] { + return args.filter((_, index) => !isVercelEnvFlag(args, index)); +} + +export function isVercelEnvFlag(args: string[], index: number): boolean { + const arg = args[index]!; + if ((ALL_ENV_FLAGS as readonly string[]).includes(arg)) return true; + if (/^(?:--env|-e|--build-env|-b)=/.test(arg)) return true; + const prev = args[index - 1]; + return prev !== undefined && (ALL_ENV_FLAGS as readonly string[]).includes(prev); +} + +function envKeysInArgs(args: string[], flags: readonly string[]): Set { + const keys = new Set(); + const flagSet = new Set(flags); + const prefixed = new RegExp( + `^(?:${flags.map((flag) => flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})=(.+)$`, + ); + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (flagSet.has(arg)) { + const assignment = args[i + 1]; + const key = assignment?.split("=")[0]; + if (key) keys.add(key); + i += 1; + continue; + } + const match = arg.match(prefixed); + if (match?.[1]) keys.add(match[1].split("=")[0]!); + } + return keys; +} + +export function mutedNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { + ...base, + npm_config_loglevel: "error", + NPM_CONFIG_LOGLEVEL: "error", + }; +} + +export function vercelSpawnArgs(invocation: CliInvocation, args: string[]): string[] { + return [...invocation.quietPrefixArgs, ...args]; +} diff --git a/packages/openui-cli/src/lib/deploy-targets/vercel/auth.ts b/packages/openui-cli/src/lib/deploy-targets/vercel/auth.ts new file mode 100644 index 000000000..32234e669 --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/auth.ts @@ -0,0 +1,105 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import type { CliInvocation } from "../../cli-bin"; +import { throwCommandFailure } from "../../deploy/failure"; +import { canPromptInteractive } from "../../deploy/prompt"; +import type { DeployTargetOptions } from "../../deploy/types"; +import { runCommand } from "../../process-runner"; +import { withSpinner } from "../../spinner"; +import { CreateError } from "../../telemetry"; +import { mutedNpmEnv, vercelSpawnArgs } from "./args"; + +export function isVercelLinked(projectDir: string): boolean { + return fs.existsSync(path.join(projectDir, ".vercel", "project.json")); +} + +export async function prepareVercelCli(invocation: CliInvocation, cwd: string): Promise { + const preparing = invocation.source === "dlx"; + const runVersion = () => + runCommand(invocation.command, vercelSpawnArgs(invocation, ["--version"]), cwd, { + echo: false, + stdin: "ignore", + }); + + const result = preparing + ? await withSpinner("Preparing Vercel CLI...", runVersion) + : await runVersion(); + + if (!result.error && result.status === 0) { + if (preparing) console.info("✓ Vercel CLI ready\n"); + return; + } + + if (result.diagnosticTail) process.stderr.write(result.diagnosticTail); + throwCommandFailure( + result, + preparing ? "vercel_cli_install" : "vercel_cli_version", + preparing ? "Failed to install Vercel CLI" : "Failed to run Vercel CLI", + ); +} + +export async function isVercelLoggedIn(invocation: CliInvocation, cwd: string): Promise { + const result = await runCommand( + invocation.command, + vercelSpawnArgs(invocation, ["--non-interactive", "whoami"]), + cwd, + { echo: false, stdin: "ignore" }, + ); + return !result.error && result.status === 0; +} + +export async function loginToVercel( + invocation: CliInvocation, + opts: Pick, +): Promise { + if (!canPromptInteractive(opts.noInteractive)) { + throw new CreateError( + "vercel_login", + "Not logged into Vercel. Run `vercel login` or set VERCEL_TOKEN, then retry.", + "authentication", + "NOT_LOGGED_IN", + ); + } + + console.info("Not logged into Vercel. Starting login...\n"); + const result = await runCommand( + invocation.command, + vercelSpawnArgs(invocation, ["login"]), + opts.projectDir, + { inheritOutput: true }, + ); + if (!result.error && result.status === 0) return; + throwCommandFailure(result, "vercel_login", "Vercel login failed"); +} + +export async function linkVercelProject( + invocation: CliInvocation, + opts: Pick, +): Promise { + const skipPrompts = opts.yes || opts.noInteractive; + if (!skipPrompts && !canPromptInteractive(opts.noInteractive)) { + throw new CreateError( + "vercel_link", + "Vercel project is not linked. Run `vercel link` or re-run with a TTY / --yes.", + "invalid_input", + "NOT_LINKED", + ); + } + + console.info( + skipPrompts + ? "Linking Vercel project...\n" + : "Linking Vercel project (choose team / project)...\n", + ); + const args = ["link"]; + if (skipPrompts) args.push("--yes"); + const result = await runCommand( + invocation.command, + vercelSpawnArgs(invocation, args), + opts.projectDir, + { inheritOutput: true, env: mutedNpmEnv() }, + ); + if (!result.error && result.status === 0 && isVercelLinked(opts.projectDir)) return; + throwCommandFailure(result, "vercel_link", "Vercel link failed"); +} diff --git a/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts b/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts new file mode 100644 index 000000000..2a3253ccb --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts @@ -0,0 +1,130 @@ +import { formatCliCommand, resolveCliInvocation } from "../../cli-bin"; +import { throwCommandFailure } from "../../deploy/failure"; +import { loadProjectDeployEnv, warnMissingRequiredDeployEnv } from "../../deploy/project-env"; +import { dumpFailureLog, printQuietDeploySuccess, runQuietCommand } from "../../deploy/quiet"; +import type { DeployTargetOptions } from "../../deploy/types"; +import { resolveInstallPackageManager } from "../../detect-package-manager"; +import { runCommand } from "../../process-runner"; +import { telemetry } from "../../telemetry"; +import { buildVercelDeployArgs, mutedNpmEnv, publicVercelArgs, vercelSpawnArgs } from "./args"; +import { + isVercelLinked, + isVercelLoggedIn, + linkVercelProject, + loginToVercel, + prepareVercelCli, +} from "./auth"; +import { syncLocalEnvToVercelProject } from "./project-env"; +import { extractVercelDeploymentSummary } from "./summary"; + +export type DeployToVercelOptions = DeployTargetOptions; + +export async function deployToVercel(opts: DeployToVercelOptions): Promise { + const t0 = Date.now(); + const packageManager = resolveInstallPackageManager(); + const fileEnv = loadProjectDeployEnv(opts.projectDir); + const localEnv = opts.skipEnv ? {} : fileEnv; + warnMissingRequiredDeployEnv(opts.projectDir, fileEnv, "Vercel"); + + const vercel = resolveCliInvocation(opts.projectDir, "vercel", packageManager); + await prepareVercelCli(vercel, opts.projectDir); + + let loggedIn = await isVercelLoggedIn(vercel, opts.projectDir); + if (!loggedIn) { + await loginToVercel(vercel, opts); + loggedIn = true; + } + + // Link before env sync / deploy so new projects can save env and run a + // non-interactive (quiet) deploy without mid-build prompts. + if (!isVercelLinked(opts.projectDir)) { + await linkVercelProject(vercel, opts); + } + + let envSavedKeyCount = 0; + if (Object.keys(localEnv).length > 0) { + envSavedKeyCount = await syncLocalEnvToVercelProject({ + invocation: vercel, + projectDir: opts.projectDir, + localEnv, + yes: opts.yes, + noInteractive: opts.noInteractive, + }); + } + + const linkedNow = isVercelLinked(opts.projectDir); + const quiet = !opts.verbose; + // Quiet mode needs a non-interactive Vercel deploy (piped stdio). + const deployYes = opts.yes || (quiet && linkedNow); + const vercelArgs = buildVercelDeployArgs({ + extraArgs: opts.extraArgs, + yes: deployYes, + localEnv, + }); + + if (opts.verbose) { + console.info( + `Deploying to Vercel (${vercel.source}): ${formatCliCommand(vercel, publicVercelArgs(vercelArgs))}`, + ); + if (Object.keys(localEnv).length > 0) { + console.info( + envSavedKeyCount > 0 + ? `Also attaching local env on this deployment: ${Object.keys(localEnv).sort().join(", ")}` + : `Passing local env on this deployment: ${Object.keys(localEnv).sort().join(", ")}`, + ); + } + console.info(""); + } + + const deployEnv = mutedNpmEnv(); + const result = quiet + ? await runQuietCommand({ + invocation: vercel, + args: vercelArgs, + cwd: opts.projectDir, + label: "Uploading and building on Vercel...", + env: deployEnv, + }) + : await runCommand(vercel.command, vercelSpawnArgs(vercel, vercelArgs), opts.projectDir, { + inheritOutput: true, + env: deployEnv, + }); + + if (!result.error && result.status === 0) { + if (quiet) { + printQuietDeploySuccess( + extractVercelDeploymentSummary(result.diagnosticTail), + result.durationMs, + ); + } + if ( + Object.keys(localEnv).length > 0 && + envSavedKeyCount === 0 && + isVercelLinked(opts.projectDir) + ) { + envSavedKeyCount += await syncLocalEnvToVercelProject({ + invocation: vercel, + projectDir: opts.projectDir, + localEnv, + yes: opts.yes, + noInteractive: opts.noInteractive, + }); + } + telemetry.capture("cli_deploy_succeeded", { + target: "vercel", + prod: opts.prod, + yes: opts.yes, + skip_env: opts.skipEnv, + verbose: opts.verbose, + cli_source: vercel.source, + logged_in: loggedIn, + env_key_count: Object.keys(localEnv).length, + env_saved_key_count: envSavedKeyCount, + duration_ms: Date.now() - t0, + }); + return; + } + + if (quiet) dumpFailureLog(result.diagnosticTail, "Vercel log (tail)"); + throwCommandFailure(result, "vercel_deploy", "Vercel deploy failed"); +} diff --git a/packages/openui-cli/src/lib/deploy-targets/vercel/project-env.ts b/packages/openui-cli/src/lib/deploy-targets/vercel/project-env.ts new file mode 100644 index 000000000..f8918766a --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/project-env.ts @@ -0,0 +1,154 @@ +import type { CliInvocation } from "../../cli-bin"; +import { SENSITIVE_DEPLOY_ENV_KEYS } from "../../deploy/project-env"; +import { confirmOrDefault } from "../../deploy/prompt"; +import { parseJsonObject } from "../../env-file"; +import { runCommand } from "../../process-runner"; +import { mutedNpmEnv, vercelSpawnArgs } from "./args"; +import { isVercelLinked } from "./auth"; + +/** Environments we keep in sync for template deploys. */ +const PROJECT_ENV_TARGETS = ["production", "preview", "development"] as const; + +type VercelEnvEntry = { + key: string; + target?: string[]; +}; + +/** + * Upserts allowlisted local env onto the linked Vercel project for any + * missing targets. Never overwrites existing targets — secret values aren't + * readable for a safe compare. Returns how many distinct keys were written. + */ +export async function syncLocalEnvToVercelProject(opts: { + invocation: CliInvocation; + projectDir: string; + localEnv: Record; + yes: boolean; + noInteractive: boolean; +}): Promise { + if (!isVercelLinked(opts.projectDir)) { + console.info( + "Vercel project not linked yet — env is attached to this deployment only. After the first deploy, missing keys can be saved to the project.\n", + ); + return 0; + } + + const existing = await listVercelProjectEnv(opts.invocation, opts.projectDir); + if (!existing) { + console.info("Could not read Vercel project env — continuing with deployment-only env.\n"); + return 0; + } + + const pending: { key: string; targets: string[]; value: string }[] = []; + for (const key of Object.keys(opts.localEnv).sort()) { + const value = opts.localEnv[key]; + if (value === undefined) continue; + const targets = missingTargetsForKey(existing, key); + if (targets.length === 0) continue; + pending.push({ key, targets, value }); + } + + if (pending.length === 0) { + console.info( + `Vercel project already has ${Object.keys(opts.localEnv).sort().join(", ")} — leaving project env unchanged.\n`, + ); + return 0; + } + + const keyList = pending.map((item) => item.key).join(", "); + const shouldSave = await confirmOrDefault( + `Save ${keyList} to this Vercel project where missing (production / preview / development)?`, + { + yes: opts.yes, + noInteractive: opts.noInteractive, + cancelStage: "vercel_env_prompt", + }, + ); + if (!shouldSave) { + console.info("Skipping project env save — using deployment-only env for this run.\n"); + return 0; + } + + const savedKeyNames: string[] = []; + for (const item of pending) { + const ok = await addVercelProjectEnv({ + invocation: opts.invocation, + projectDir: opts.projectDir, + key: item.key, + value: item.value, + targets: item.targets, + }); + if (ok) { + savedKeyNames.push(item.key); + existing.push({ key: item.key, target: [...item.targets] }); + } + } + + if (savedKeyNames.length > 0) { + console.info(`Saved ${savedKeyNames.join(", ")} to the Vercel project.\n`); + } + return savedKeyNames.length; +} + +async function listVercelProjectEnv( + invocation: CliInvocation, + projectDir: string, +): Promise { + const result = await runCommand( + invocation.command, + vercelSpawnArgs(invocation, ["env", "list", "--json", "--non-interactive"]), + projectDir, + { echo: false, stdin: "ignore" }, + ); + if (result.error || result.status !== 0) return null; + const parsed = parseJsonObject(result.diagnosticTail) as { envs?: VercelEnvEntry[] } | null; + if (!parsed || !Array.isArray(parsed.envs)) return null; + return parsed.envs; +} + +function missingTargetsForKey(entries: VercelEnvEntry[], key: string): string[] { + const present = new Set(); + for (const entry of entries) { + if (entry.key !== key) continue; + for (const target of entry.target ?? []) present.add(target); + } + return PROJECT_ENV_TARGETS.filter((target) => !present.has(target)); +} + +async function addVercelProjectEnv(opts: { + invocation: CliInvocation; + projectDir: string; + key: string; + value: string; + targets: string[]; +}): Promise { + const args = [ + "env", + "add", + opts.key, + opts.targets.join(","), + "--value", + opts.value, + "--yes", + "--non-interactive", + ]; + if (SENSITIVE_DEPLOY_ENV_KEYS.has(opts.key)) args.push("--sensitive"); + + const result = await runCommand( + opts.invocation.command, + vercelSpawnArgs(opts.invocation, args), + opts.projectDir, + { echo: false, stdin: "ignore", env: mutedNpmEnv() }, + ); + if (!result.error && result.status === 0) return true; + + console.info( + `[!] Could not save ${opts.key} to the Vercel project — it will still be passed on this deployment.`, + ); + if (result.diagnosticTail.trim()) { + const hint = result.diagnosticTail.trim().split(/\r?\n/).slice(-3).join("\n"); + console.info(hint); + } + console.info(""); + return false; +} diff --git a/packages/openui-cli/src/lib/deploy-targets/vercel/summary.ts b/packages/openui-cli/src/lib/deploy-targets/vercel/summary.ts new file mode 100644 index 000000000..a27a07873 --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/summary.ts @@ -0,0 +1,16 @@ +import type { DeploySuccessSummary } from "../../deploy/quiet"; + +/** Parse deployment URL / inspect link from captured `vercel` CLI output. */ +export function extractVercelDeploymentSummary(log: string): DeploySuccessSummary { + return { + url: + firstMatch(log, /^\s*Aliased\s+(https:\/\/\S+)/m) ?? + firstMatch(log, /^\s*Production\s+(https:\/\/\S+)/m) ?? + firstMatch(log, /^\s*Preview\s+(https:\/\/\S+)/m), + inspect: firstMatch(log, /^\s*Inspect\s+(https:\/\/\S+)/m), + }; +} + +function firstMatch(text: string, pattern: RegExp): string | undefined { + return text.match(pattern)?.[1]; +} diff --git a/packages/openui-cli/src/lib/deploy/failure.ts b/packages/openui-cli/src/lib/deploy/failure.ts new file mode 100644 index 000000000..e0e556669 --- /dev/null +++ b/packages/openui-cli/src/lib/deploy/failure.ts @@ -0,0 +1,15 @@ +import type { CommandResult } from "../process-runner"; +import { CliCancelledError, CreateError } from "../telemetry"; +import { processErrorProperties } from "../utils"; + +export function throwCommandFailure(result: CommandResult, stage: string, message: string): never { + const properties = processErrorProperties(result, stage, { + error_class: "process", + error_code: "NONZERO_EXIT", + }); + if (properties.error_class === "user_cancelled") { + throw new CliCancelledError(stage, properties.cancellation_exit_code ?? 0, properties); + } + const { failure_stage, error_class, error_code, ...metadata } = properties; + throw new CreateError(failure_stage, message, error_class, error_code, metadata); +} diff --git a/packages/openui-cli/src/lib/deploy/project-env.ts b/packages/openui-cli/src/lib/deploy/project-env.ts new file mode 100644 index 000000000..537fc5659 --- /dev/null +++ b/packages/openui-cli/src/lib/deploy/project-env.ts @@ -0,0 +1,56 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { loadAllowlistedEnvFiles } from "../env-file"; + +/** Known OpenUI template env keys. Values must never be logged or sent to telemetry. */ +export const DEPLOY_ENV_ALLOWLIST = [ + "THESYS_API_KEY", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL", + "APP_ID", + "DEMO_USER_ID", + "LANGGRAPH_API_URL", + "LANGGRAPH_ASSISTANT_ID", + "LANGSMITH_API_KEY", +] as const; + +/** Prefer secret storage on platforms that distinguish secret vs config. */ +export const SENSITIVE_DEPLOY_ENV_KEYS = new Set([ + "THESYS_API_KEY", + "OPENAI_API_KEY", + "LANGSMITH_API_KEY", +]); + +export function loadProjectDeployEnv(projectDir: string): Record { + return loadAllowlistedEnvFiles( + [path.join(projectDir, ".env"), path.join(projectDir, ".env.local")], + DEPLOY_ENV_ALLOWLIST, + ); +} + +export function detectRequiredDeployEnvNames(projectDir: string): string[] { + const pkgPath = path.join(projectDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + }; + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + if (deps["@openuidev/thesys-server"] || deps["@openuidev/thesys"]) return ["THESYS_API_KEY"]; + if (deps["openai"] || deps["ai"] || deps["@ai-sdk/openai"]) return ["OPENAI_API_KEY"]; + return []; +} + +export function warnMissingRequiredDeployEnv( + projectDir: string, + localEnv: Record, + platformLabel: string, +): void { + for (const key of detectRequiredDeployEnvNames(projectDir)) { + if (localEnv[key] || process.env[key]?.trim()) continue; + console.info( + `[!] ${key} is not set locally. This deployment will fail at runtime unless ${key} is already configured on ${platformLabel}.\n`, + ); + } +} diff --git a/packages/openui-cli/src/lib/deploy/prompt.ts b/packages/openui-cli/src/lib/deploy/prompt.ts new file mode 100644 index 000000000..abdbc6a7b --- /dev/null +++ b/packages/openui-cli/src/lib/deploy/prompt.ts @@ -0,0 +1,28 @@ +import { CliCancelledError } from "../telemetry"; + +export function canPromptInteractive(noInteractive = false): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY) && !noInteractive; +} + +/** + * Confirm with a default of yes. `--yes` / `--no-interactive` / non-TTY skip + * the prompt and return `true` (safe default for deploy happy paths). + */ +export async function confirmOrDefault( + message: string, + opts: { yes?: boolean; noInteractive?: boolean; cancelStage: string }, +): Promise { + if (opts.yes || opts.noInteractive) return true; + if (!canPromptInteractive()) return true; + + try { + const { confirm } = await import("@inquirer/prompts"); + return await confirm({ message, default: true }); + } catch (err) { + const { ExitPromptError } = await import("@inquirer/core"); + if (err instanceof ExitPromptError) { + throw new CliCancelledError(opts.cancelStage); + } + throw err; + } +} diff --git a/packages/openui-cli/src/lib/deploy/quiet.ts b/packages/openui-cli/src/lib/deploy/quiet.ts new file mode 100644 index 000000000..11ccddbeb --- /dev/null +++ b/packages/openui-cli/src/lib/deploy/quiet.ts @@ -0,0 +1,48 @@ +import type { CliInvocation } from "../cli-bin"; +import { + dumpFailureLog, + QUIET_COMMAND_CAPTURE_LIMIT, +} from "../command-output"; +import { runCommand, type CommandResult } from "../process-runner"; +import { withSpinner } from "../spinner"; + +export { dumpFailureLog, QUIET_COMMAND_CAPTURE_LIMIT }; + +export type QuietCommandOptions = { + invocation: CliInvocation; + args: string[]; + cwd: string; + label: string; + env?: NodeJS.ProcessEnv; + captureLimit?: number; +}; + +/** Run a platform CLI with output captured and a spinner in the terminal. */ +export async function runQuietCommand(opts: QuietCommandOptions): Promise { + return withSpinner(opts.label, () => + runCommand( + opts.invocation.command, + [...opts.invocation.quietPrefixArgs, ...opts.args], + opts.cwd, + { + echo: false, + stdin: "ignore", + captureLimit: opts.captureLimit ?? QUIET_COMMAND_CAPTURE_LIMIT, + env: opts.env, + }, + ), + ); +} + +export type DeploySuccessSummary = { + url?: string; + inspect?: string; +}; + +export function printQuietDeploySuccess(summary: DeploySuccessSummary, durationMs: number): void { + const seconds = Math.max(1, Math.round(durationMs / 1000)); + console.info(`✓ Deployed in ${seconds}s`); + if (summary.url) console.info(` ${summary.url}`); + if (summary.inspect) console.info(` Inspect ${summary.inspect}`); + console.info(""); +} diff --git a/packages/openui-cli/src/lib/deploy/types.ts b/packages/openui-cli/src/lib/deploy/types.ts new file mode 100644 index 000000000..8342eb4f4 --- /dev/null +++ b/packages/openui-cli/src/lib/deploy/types.ts @@ -0,0 +1,14 @@ +/** Shared options every deploy target receives from `openui deploy`. */ +export type DeployTargetOptions = { + projectDir: string; + extraArgs: string[]; + prod: boolean; + yes: boolean; + skipEnv: boolean; + noInteractive: boolean; + verbose: boolean; +}; + +export const DEPLOY_TARGETS = ["vercel"] as const; +export type DeployTarget = (typeof DEPLOY_TARGETS)[number]; +export const DEFAULT_DEPLOY_TARGET: DeployTarget = "vercel"; diff --git a/packages/openui-cli/src/lib/detect-package-manager.ts b/packages/openui-cli/src/lib/detect-package-manager.ts index 73f07963c..99ae24a0e 100644 --- a/packages/openui-cli/src/lib/detect-package-manager.ts +++ b/packages/openui-cli/src/lib/detect-package-manager.ts @@ -32,3 +32,32 @@ export function resolveInstallPackageManager(): PackageManager { const invoking = detectInvokingPackageManager(); return PACKAGE_MANAGERS[invoking ?? "npm"]; } + +/** Run a published package without adding it as a dependency (`npx` / `dlx` / `bunx`). */ +export function resolveDlxInvocation( + packageManager: PackageManager, + pkg: string, +): { command: string; args: string[]; quietArgs: string[] } { + switch (packageManager.name) { + case "pnpm": + return { + command: "pnpm", + args: ["dlx", pkg], + quietArgs: ["--reporter=silent", "dlx", pkg], + }; + case "yarn": + return { + command: "yarn", + args: ["dlx", pkg], + quietArgs: ["dlx", "--quiet", pkg], + }; + case "bun": + return { command: "bunx", args: [pkg], quietArgs: ["--silent", pkg] }; + default: + return { + command: "npx", + args: ["--yes", pkg], + quietArgs: ["--yes", "--quiet", pkg], + }; + } +} diff --git a/packages/openui-cli/src/lib/env-file.ts b/packages/openui-cli/src/lib/env-file.ts new file mode 100644 index 000000000..ce3594cc5 --- /dev/null +++ b/packages/openui-cli/src/lib/env-file.ts @@ -0,0 +1,55 @@ +import * as fs from "node:fs"; + +/** Parse a dotenv-style file into key/value pairs (no expansion). */ +export function parseEnvFile(filePath: string): Record { + if (!fs.existsSync(filePath)) return {}; + const out: Record = {}; + for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +/** + * Merge env files (later files win) and keep only allowlisted keys with + * non-empty values. Values are never logged by this helper. + */ +export function loadAllowlistedEnvFiles( + filePaths: string[], + allowlist: readonly string[], +): Record { + const merged: Record = {}; + for (const filePath of filePaths) { + Object.assign(merged, parseEnvFile(filePath)); + } + const allowlisted: Record = {}; + for (const key of allowlist) { + const value = merged[key]?.trim(); + if (value) allowlisted[key] = value; + } + return allowlisted; +} + +/** Extract the first JSON object from mixed CLI stdout/stderr. */ +export function parseJsonObject(text: string): unknown | null { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start < 0 || end < start) return null; + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + return null; + } +} diff --git a/packages/openui-cli/src/templates/openui-cloud/README.md b/packages/openui-cli/src/templates/openui-cloud/README.md index 2d9f4ac89..d8907d785 100644 --- a/packages/openui-cli/src/templates/openui-cloud/README.md +++ b/packages/openui-cli/src/templates/openui-cloud/README.md @@ -19,6 +19,19 @@ You can start editing the page by modifying `src/app/api/chat/route.ts` and impr by adding system prompts or tools. A LangGraph scaffold puts the Agent Server implementation in `src/agent/agent.ts` instead. +## Deploy + +From the project directory: + +```bash +npx @openuidev/cli@latest deploy +npx @openuidev/cli@latest deploy --prod +``` + +Deploys to Vercel. Allowlisted keys from `.env` / `.env.local` (including `THESYS_API_KEY`) are +passed to that deployment unless you use `--skip-env`. Persist them on the Vercel project for later +deploys. + ## Framework deployments The Vercel AI SDK scaffold is a standard Next.js app: `streamText()` owns the diff --git a/packages/openui-cli/src/templates/openui-self-hosted/README.md b/packages/openui-cli/src/templates/openui-self-hosted/README.md index eb031dbb9..e5abb4396 100644 --- a/packages/openui-cli/src/templates/openui-self-hosted/README.md +++ b/packages/openui-cli/src/templates/openui-self-hosted/README.md @@ -27,6 +27,19 @@ Server implementation in `src/agent/agent.ts` instead. If you selected LangGraph or the Vercel AI SDK, the generated route includes a `get_weather` example. Ask “What’s the weather in Berlin?” to exercise its native tool loop. +## Deploy + +From the project directory: + +```bash +npx @openuidev/cli@latest deploy +npx @openuidev/cli@latest deploy --prod +``` + +Deploys to Vercel. Allowlisted keys from `.env` / `.env.local` (including `OPENAI_API_KEY`) are +passed to that deployment unless you use `--skip-env`. Persist them on the Vercel project for later +deploys. + ## Framework deployments The Vercel AI SDK scaffold runs its backend inside the Next.js API route, so the From 5ea539f064f51f7752c0716b1b5487828f35e8cf Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 28 Aug 2026 15:12:21 +0530 Subject: [PATCH 08/54] cli: rename dumpFailureLog to printLogTail in deploy path --- packages/openui-cli/src/lib/deploy-targets/vercel/index.ts | 4 ++-- packages/openui-cli/src/lib/deploy/quiet.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts b/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts index 2a3253ccb..875daeb54 100644 --- a/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts @@ -1,7 +1,7 @@ import { formatCliCommand, resolveCliInvocation } from "../../cli-bin"; import { throwCommandFailure } from "../../deploy/failure"; import { loadProjectDeployEnv, warnMissingRequiredDeployEnv } from "../../deploy/project-env"; -import { dumpFailureLog, printQuietDeploySuccess, runQuietCommand } from "../../deploy/quiet"; +import { printLogTail, printQuietDeploySuccess, runQuietCommand } from "../../deploy/quiet"; import type { DeployTargetOptions } from "../../deploy/types"; import { resolveInstallPackageManager } from "../../detect-package-manager"; import { runCommand } from "../../process-runner"; @@ -125,6 +125,6 @@ export async function deployToVercel(opts: DeployToVercelOptions): Promise return; } - if (quiet) dumpFailureLog(result.diagnosticTail, "Vercel log (tail)"); + if (quiet) printLogTail(result.diagnosticTail, "Vercel log (tail)"); throwCommandFailure(result, "vercel_deploy", "Vercel deploy failed"); } diff --git a/packages/openui-cli/src/lib/deploy/quiet.ts b/packages/openui-cli/src/lib/deploy/quiet.ts index 11ccddbeb..6934bd3b6 100644 --- a/packages/openui-cli/src/lib/deploy/quiet.ts +++ b/packages/openui-cli/src/lib/deploy/quiet.ts @@ -1,12 +1,12 @@ import type { CliInvocation } from "../cli-bin"; import { - dumpFailureLog, + printLogTail, QUIET_COMMAND_CAPTURE_LIMIT, } from "../command-output"; import { runCommand, type CommandResult } from "../process-runner"; import { withSpinner } from "../spinner"; -export { dumpFailureLog, QUIET_COMMAND_CAPTURE_LIMIT }; +export { printLogTail, QUIET_COMMAND_CAPTURE_LIMIT }; export type QuietCommandOptions = { invocation: CliInvocation; From f708cd185e74911228f7f19e57ba97606b4fcb46 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 31 Aug 2026 23:29:48 +0530 Subject: [PATCH 09/54] fix: format --- packages/openui-cli/src/lib/deploy/quiet.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/openui-cli/src/lib/deploy/quiet.ts b/packages/openui-cli/src/lib/deploy/quiet.ts index 6934bd3b6..13f8441bd 100644 --- a/packages/openui-cli/src/lib/deploy/quiet.ts +++ b/packages/openui-cli/src/lib/deploy/quiet.ts @@ -1,8 +1,5 @@ import type { CliInvocation } from "../cli-bin"; -import { - printLogTail, - QUIET_COMMAND_CAPTURE_LIMIT, -} from "../command-output"; +import { printLogTail, QUIET_COMMAND_CAPTURE_LIMIT } from "../command-output"; import { runCommand, type CommandResult } from "../process-runner"; import { withSpinner } from "../spinner"; From 755673f279d746998dfb33aa2b0abb1a98c8f4c7 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 26 Aug 2026 16:06:23 +0530 Subject: [PATCH 10/54] feat: add featured examples to CLI --- docs/content/docs/api-reference/cli.mdx | 43 +- packages/openui-cli/README.md | 22 +- .../openui-cli/src/commands/create-app.ts | 402 ++++++++++++++++-- packages/openui-cli/src/index.ts | 14 + .../openui-cli/src/lib/create-telemetry.ts | 1 + packages/openui-cli/src/lib/create-types.ts | 1 + packages/openui-cli/src/lib/github-tarball.ts | 250 +++++++++++ packages/openui-cli/src/lib/projects.ts | 275 ++++++++++++ .../openui-cli/src/lib/scaffold-example.ts | 255 +++++++++++ 9 files changed, 1231 insertions(+), 32 deletions(-) create mode 100644 packages/openui-cli/src/lib/github-tarball.ts create mode 100644 packages/openui-cli/src/lib/projects.ts create mode 100644 packages/openui-cli/src/lib/scaffold-example.ts diff --git a/docs/content/docs/api-reference/cli.mdx b/docs/content/docs/api-reference/cli.mdx index f385957af..c530cf307 100644 --- a/docs/content/docs/api-reference/cli.mdx +++ b/docs/content/docs/api-reference/cli.mdx @@ -58,6 +58,7 @@ openui create [options] | `-n, --name ` | Project name (interactive default: `openui-agent`) | | `-t, --template