diff --git a/docs/content/docs/api-reference/cli.mdx b/docs/content/docs/api-reference/cli.mdx index be4f8216d..dd02a8ea7 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, mint Cloud API keys, and generate system prompts or library specs. +description: API reference for the OpenUI CLI to scaffold apps, mint Cloud API keys, and generate system prompts or library specs, and deploy projects. --- -A command-line tool for scaffolding OpenUI chat apps, minting OpenUI Cloud API key, and generating system prompts, JSON schemas, or serialized library specs from library definitions. +A command-line tool for scaffolding OpenUI chat apps, minting OpenUI Cloud API key, and generating system prompts, JSON schemas, or serialized library specs from library definitions, and deploying those apps. ## Installation @@ -210,6 +210,78 @@ 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. + +```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-api-key` Signs in with Thesys in the browser, mints an OpenUI Cloud API key, and writes it to a project env file. @@ -428,7 +500,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 afc517163..56c06d986 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), openui generate-api-key (mint a Cloud key), and openui generate (system prompt + library spec from a library + openui create (scaffold a Next.js app), openui generate-api-key (mint a Cloud key), 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 2d9808cb8..3fe061122 100644 --- a/packages/openui-cli/README.md +++ b/packages/openui-cli/README.md @@ -1,6 +1,6 @@ # @openuidev/cli -Command-line tools for starting OpenUI projects, minting OpenUI Cloud API keys, and generating model instructions from component libraries. +Command-line tools for starting OpenUI projects, minting OpenUI Cloud API keys, and 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) @@ -15,6 +15,7 @@ It currently supports: - keeping the default minimal SDK route or adding a LangGraph, Vercel AI SDK, or Vercel Eve backend to either template - minting an OpenUI Cloud API key into an existing project's env file - generating a system prompt or JSON Schema from a `createLibrary()` export +- deploying a project with `openui deploy` ## Install @@ -71,6 +72,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` @@ -172,6 +180,38 @@ 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. + + +```bash +openui deploy +openui deploy ./my-app +openui deploy ./my-app --prod +openui deploy --skip-env -- --force +``` + ### `openui generate-api-key` Signs in with Thesys in the browser, mints an OpenUI Cloud API key, and writes it to a project env file. @@ -270,6 +310,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-api-key --help node dist/index.js generate --help ``` @@ -280,7 +321,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 @@ -291,6 +332,7 @@ openui create --no-telemetry - interactive prompts can be cancelled without creating output - `create` fetches `templates/templates.json` and the selected template from GitHub (`thesysdev/openui@main`) - `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 c3cf0e6b0..e8c506ad3 100644 --- a/packages/openui-cli/package.json +++ b/packages/openui-cli/package.json @@ -1,7 +1,7 @@ { "name": "@openuidev/cli", "version": "0.2.12", - "description": "CLI for OpenUI — scaffold generative UI chat apps, mint Cloud API keys, and generate LLM system prompts from component libraries", + "description": "CLI for OpenUI — scaffold generative UI chat apps, mint Cloud API keys, and generate LLM system prompts from component libraries, and deploy projects", "bin": { "openui": "dist/index.js" }, diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index 1e1a027e2..812f7db62 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -19,7 +19,7 @@ import { type OverlayManifest, type TemplateOverlay, } from "../lib/overlays"; -import { runCommand } from "../lib/process-runner"; +import { mutedNpmEnv, runCommand } from "../lib/process-runner"; import { resolveArgs } from "../lib/resolve-args"; import { resolveTemplateSource } from "../lib/scaffold-template"; import { withSpinner } from "../lib/spinner"; @@ -393,11 +393,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise { echo: false, stdin: "ignore", captureLimit: QUIET_COMMAND_CAPTURE_LIMIT, - env: { - ...process.env, - npm_config_loglevel: "error", - NPM_CONFIG_LOGLEVEL: "error", - }, + env: mutedNpmEnv(), }); if (options.verbose) { 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/commands/generate-api-key.ts b/packages/openui-cli/src/commands/generate-api-key.ts index ffc85bbc9..2ff9cd26d 100644 --- a/packages/openui-cli/src/commands/generate-api-key.ts +++ b/packages/openui-cli/src/commands/generate-api-key.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { mintCloudApiKey } from "../auth/mint"; -import { upsertEnvVar } from "../lib/env"; +import { DEFAULT_ENV_FILE, upsertEnvVar } from "../lib/env"; import { telemetry } from "../lib/telemetry"; export interface GenerateApiKeyOptions { @@ -11,7 +11,6 @@ export interface GenerateApiKeyOptions { name?: string; } -const DEFAULT_ENV_FILE = ".env"; const DEFAULT_ENV_KEY = "THESYS_API_KEY"; function resolveProjectName(explicit?: string): string { diff --git a/packages/openui-cli/src/index.ts b/packages/openui-cli/src/index.ts index 1ebb0b8f3..41e5527e8 100644 --- a/packages/openui-cli/src/index.ts +++ b/packages/openui-cli/src/index.ts @@ -7,9 +7,11 @@ 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 { runGenerateApiKey } from "./commands/generate-api-key"; import { detectAgent, UNKNOWN_AGENT_NAME } from "./lib/detect-agent"; +import { DEFAULT_ENV_FILE } from "./lib/env"; import { rejectConflictingImmediateFlags, resolveArgs } from "./lib/resolve-args"; import { telemetry } from "./lib/telemetry"; import { @@ -130,10 +132,69 @@ 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-api-key") .description("Mint an OpenUI Cloud API key and write it to a project env file") - .option("-f, --file ", "Env file to write", ".env") + .option("-f, --file ", "Env file to write", DEFAULT_ENV_FILE) .option("-k, --key ", "Environment variable name", "THESYS_API_KEY") .option("-n, --name ", "Name of the minted key in the Thesys console") .addHelpText( 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..1bae7524f --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/args.ts @@ -0,0 +1,64 @@ +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 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..b33fc20c6 --- /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 { mutedNpmEnv, runCommand } from "../../process-runner"; +import { withSpinner } from "../../spinner"; +import { CreateError } from "../../telemetry"; +import { 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..c45962345 --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/index.ts @@ -0,0 +1,131 @@ +import { formatCliCommand, resolveCliInvocation } from "../../cli-bin"; +import { printLogTail } from "../../command-output"; +import { throwCommandFailure } from "../../deploy/failure"; +import { loadProjectDeployEnv, warnMissingRequiredDeployEnv } from "../../deploy/project-env"; +import { printQuietDeploySuccess, runQuietCommand } from "../../deploy/quiet"; +import type { DeployTargetOptions } from "../../deploy/types"; +import { resolveInstallPackageManager } from "../../detect-package-manager"; +import { mutedNpmEnv, runCommand } from "../../process-runner"; +import { telemetry } from "../../telemetry"; +import { buildVercelDeployArgs, 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) printLogTail(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..062b4f7aa --- /dev/null +++ b/packages/openui-cli/src/lib/deploy-targets/vercel/project-env.ts @@ -0,0 +1,165 @@ +import type { CliInvocation } from "../../cli-bin"; +import { SENSITIVE_DEPLOY_ENV_KEYS } from "../../deploy/project-env"; +import { confirmOrDefault } from "../../deploy/prompt"; +import { mutedNpmEnv, runCommand } from "../../process-runner"; +import { 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 = extractJsonObject(result.diagnosticTail) as { envs?: VercelEnvEntry[] } | null; + if (!parsed || !Array.isArray(parsed.envs)) return null; + return parsed.envs; +} + +/** First JSON object in mixed CLI stdout/stderr. */ +function extractJsonObject(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; + } +} + +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..f55210d33 --- /dev/null +++ b/packages/openui-cli/src/lib/deploy/project-env.ts @@ -0,0 +1,53 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { loadAllowlistedProjectEnv } from "../env"; + +/** 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 loadAllowlistedProjectEnv(projectDir, 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..c9a77198f --- /dev/null +++ b/packages/openui-cli/src/lib/deploy/quiet.ts @@ -0,0 +1,43 @@ +import type { CliInvocation } from "../cli-bin"; +import { QUIET_COMMAND_CAPTURE_LIMIT } from "../command-output"; +import { runCommand, type CommandResult } from "../process-runner"; +import { withSpinner } from "../spinner"; + +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.ts b/packages/openui-cli/src/lib/env.ts index 95c6b3979..ca97086f2 100644 --- a/packages/openui-cli/src/lib/env.ts +++ b/packages/openui-cli/src/lib/env.ts @@ -6,6 +6,63 @@ import { CreateError } from "./telemetry"; /** True for `"1"` or `"true"` (any case). */ export const isTruthyEnv = (value?: string) => value === "1" || value?.toLowerCase() === "true"; +export const DEFAULT_ENV_FILE = ".env"; +export const PROJECT_ENV_FILES = [".env", ".env.local"] as const; + +/** 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; +} + +/** Load allowlisted keys from the usual project env files (`.env`, `.env.local`). */ +export function loadAllowlistedProjectEnv( + projectDir: string, + allowlist: readonly string[], + fileNames: readonly string[] = PROJECT_ENV_FILES, +): Record { + return loadAllowlistedEnvFiles( + fileNames.map((name) => path.join(projectDir, name)), + allowlist, + ); +} + const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=[^\r\n]*$/; function isKeyLine(line: string, name: string): boolean { diff --git a/packages/openui-cli/src/lib/process-runner.ts b/packages/openui-cli/src/lib/process-runner.ts index 8e7417825..d61ca4437 100644 --- a/packages/openui-cli/src/lib/process-runner.ts +++ b/packages/openui-cli/src/lib/process-runner.ts @@ -10,6 +10,15 @@ export type CommandResult = { diagnosticTail: string; }; +/** Quiet npm/npx progress when we spawn a nested package-manager CLI. */ +export function mutedNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { + ...base, + npm_config_loglevel: "error", + NPM_CONFIG_LOGLEVEL: "error", + }; +} + export type RunCommandOptions = { env?: NodeJS.ProcessEnv; /** When false, capture stdout/stderr without writing them to the parent. Default true. */ diff --git a/templates/openui-cloud/README.md b/templates/openui-cloud/README.md index 290592430..f03ad4f3c 100644 --- a/templates/openui-cloud/README.md +++ b/templates/openui-cloud/README.md @@ -20,6 +20,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 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/templates/openui-self-hosted/README.md b/templates/openui-self-hosted/README.md index dad0a500a..9f15c0d46 100644 --- a/templates/openui-self-hosted/README.md +++ b/templates/openui-self-hosted/README.md @@ -27,6 +27,19 @@ implementation in `src/agent/agent.ts` instead. If you selected LangGraph, the Vercel AI SDK, or Vercel Eve, the generated app 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