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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cli-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile

- name: Build CLI and templates
- name: Build CLI
run: pnpm --filter @openuidev/cli build

- name: Pack CLI
Expand Down
9 changes: 4 additions & 5 deletions packages/openui-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
],
"scripts": {
"build:cli": "tsc -p .",
"build:templates": "node scripts/build-templates.js",
"build": "pnpm run build:cli && pnpm run build:templates",
"build": "pnpm run build:cli",
"build:exec": "node dist/index.js",
"lint:check": "eslint ./src --ignore-pattern 'src/templates/**'",
"lint:fix": "eslint ./src --fix --ignore-pattern 'src/templates/**'",
Expand All @@ -23,8 +22,7 @@
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
"@types/node": "catalog:",
"rimraf": "^5.0.7"
"@types/node": "catalog:"
},
"keywords": [
"openui",
Expand Down Expand Up @@ -58,6 +56,7 @@
"esbuild": "^0.25.10",
"open": "^10.1.0",
"openid-client": "^6.1.7",
"posthog-node": "^5.35.6"
"posthog-node": "^5.35.6",
"tar": "^7.4.3"
}
}
23 changes: 0 additions & 23 deletions packages/openui-cli/scripts/build-templates.js

This file was deleted.

55 changes: 35 additions & 20 deletions packages/openui-cli/src/commands/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,29 @@ import {
resolveInstallPackageManager,
type PackageManagerName,
} from "../lib/detect-package-manager";
import {
fetchTemplate,
getTemplateIndex,
prefetchTemplate,
type FetchedTemplate,
} from "../lib/fetch-template";
import { runSkillInstall, shouldInstallSkill } from "../lib/install-skill";
import { runCommand } from "../lib/process-runner";
import { resolveArgs } from "../lib/resolve-args";
import { CliCancelledError, CreateError, telemetry } from "../lib/telemetry";
import { cliErrorProperties, processErrorProperties } from "../lib/utils";

const FALLBACK_TEMPLATE_CHOICES = [
{
value: "openui-cloud",
name: "OpenUI Cloud — free hosted models, managed history, tools & artifacts; fastest setup (recommended)",
},
{
value: "openui-self-hosted",
name: "Self-hosted — bring your own provider and self-manage the entire backend",
},
];

function shouldCopyTemplatePath(templateDir: string, src: string): boolean {
const rel = path.relative(templateDir, src);
if (!rel) return true;
Expand Down Expand Up @@ -121,6 +138,14 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
immediate_arg: options.immediate,
});

// Prefetched at CLI startup; falls back to the built-in list when the
// index.json fetch fails (offline, GitHub down).
let templateChoices = FALLBACK_TEMPLATE_CHOICES;
if (!options.template && interactive) {
const index = await getTemplateIndex();
if (index) templateChoices = index.map((t) => ({ value: t.name, name: t.description }));
}

const args = await resolveArgs(
{
name: options.name
Expand All @@ -135,16 +160,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
prompt: {
type: "select",
message: "Choose your agent backend",
choices: [
{
value: "openui-cloud",
name: "OpenUI Cloud — free hosted models or bring your own key, managed history, tools & artifacts; fastest setup (recommended)",
},
{
value: "openui-self-hosted",
name: "Self-hosted — bring your own provider and self-manage the entire backend",
},
],
choices: templateChoices,
},
required: true,
},
Expand All @@ -153,6 +169,9 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
);

const { name, template } = args as { name: string; template: TemplateName };
// Start the template download now so it runs while the user answers the
// remaining prompts (auth, skill, dev-server).
prefetchTemplate(template);
const aiSetup = aiSetupFromTemplate(template);
telemetry.register({ template, ai_setup: aiSetup });
telemetry.capture("cli_ai_setup_selected", {
Expand All @@ -171,16 +190,6 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
);
}

const templateDir = path.join(__dirname, "..", "templates", template);
if (!fs.existsSync(templateDir)) {
throw new CreateError(
"preflight",
`Template "${template}" not found. Rebuild the CLI with \`pnpm build\`.`,
"filesystem",
"TEMPLATE_MISSING",
);
}

telemetry.capture("cli_env_resolution_started", {
...createFunnelProps("env_resolution_started"),
template,
Expand Down Expand Up @@ -214,7 +223,11 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
template,
ai_setup: aiSetup,
});

let fetchedTemplate: FetchedTemplate | undefined;
try {
fetchedTemplate = await fetchTemplate(template);
const templateDir = fetchedTemplate.dir;
fs.cpSync(templateDir, targetDir, {
recursive: true,
filter: (src) => shouldCopyTemplatePath(templateDir, src),
Expand Down Expand Up @@ -250,6 +263,8 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
properties.error_class,
properties.error_code,
);
} finally {
fetchedTemplate?.cleanup();
}
telemetry.capture("cli_scaffold_succeeded", {
...createFunnelProps("scaffold_succeeded"),
Expand Down
8 changes: 8 additions & 0 deletions packages/openui-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ import { Command } from "commander";
import { runCreateApp } from "./commands/create-app";
import { GenerateOptions, runGenerate } from "./commands/generate";
import { detectAgent, UNKNOWN_AGENT_NAME } from "./lib/detect-agent";
import { prefetchTemplateIndex } from "./lib/fetch-template";
import { resolveArgs } from "./lib/resolve-args";
import { telemetry } from "./lib/telemetry";
import { handleCliError, normalizeAuth, normalizeTemplate } from "./lib/utils"; // Ensure utils.ts is included for type declarations

const program = new Command();

// Kick off the template index download the moment the process starts, so it
// runs in the background while commander parses and prompts render. The
// template folder itself prefetches once its name is known (see create-app).
if (process.argv[2] === "create") {
prefetchTemplateIndex();
}

const cliVersion = (
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")) as {
version: string;
Expand Down
180 changes: 180 additions & 0 deletions packages/openui-cli/src/lib/fetch-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { execFile } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";

import * as tar from "tar";

// Templates live in the dedicated thesysdev/openui-templates repo: one folder
// per template at the root, listed in index.json. They are fetched from main
// at scaffold time, so template changes go live on merge — no CLI release.
// Fetches start in the background as soon as they can (see prefetch*) so the
// network overlaps with the interactive prompts instead of blocking scaffold.
const TEMPLATES_REPO = "thesysdev/openui-templates";
const INDEX_URL = `https://raw.githubusercontent.com/${TEMPLATES_REPO}/main/index.json`;

const execFileAsync = promisify(execFile);

export interface TemplateIndexEntry {
name: string;
description: string;
}

export interface FetchedTemplate {
dir: string;
/** Removes the temp extraction dir once the scaffold copy is done. */
cleanup: () => void;
}

// Prefetched templates that were never consumed (user cancelled mid-prompt)
// still get their temp dirs removed on exit.
const pendingCleanups = new Set<() => void>();
process.on("exit", () => {
for (const cleanup of pendingCleanups) cleanup();
});

function makeTempRoot(): { tempRoot: string; cleanup: () => void } {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openui-template-"));
const cleanup = () => {
pendingCleanups.delete(cleanup);
// maxRetries: git object files are read-only, which Windows rm can race on.
fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 3 });
};
pendingCleanups.add(cleanup);
return { tempRoot, cleanup };
}

/** Sparse partial clone: transfers only commit/tree metadata plus the
* requested template's blobs. Returns `null` when git is unavailable or the
* clone fails, so the caller can fall back to the tarball download.
**/
async function tryGitSparseFetch(template: string): Promise<FetchedTemplate | null> {
const git = (args: string[]) =>
// autocrlf=false: byte-identical checkout even when the user's global git
// config would rewrite line endings (Windows default).
execFileAsync("git", ["-c", "core.autocrlf=false", ...args], { timeout: 60_000 });
try {
await git(["--version"]);
} catch {
return null;
}

const { tempRoot, cleanup } = makeTempRoot();
const repoDir = path.join(tempRoot, "repo");
try {
// Step 1: clone the latest main commit with tree metadata only —
// --depth=1 (no history), --filter=blob:none (no file contents yet),
// --sparse (start with just the repo-root files checked out).
await git([
"clone",
"--depth=1",
"--filter=blob:none",
"--sparse",
"--single-branch",
"--branch=main",
`https://github.com/${TEMPLATES_REPO}.git`,
repoDir,
]);
// Step 2: widen the checkout to the template's folder — git fetches only
// that subtree's blobs and writes them into the working tree.
await git(["-C", repoDir, "sparse-checkout", "set", template]);
} catch {
cleanup();
return null;
}

const dir = path.join(repoDir, template);
if (!fs.existsSync(path.join(dir, "package.json"))) {
// The clone worked, so the template genuinely doesn't exist on main —
// don't burn a tarball download discovering the same thing.
cleanup();
throw new Error(`Template "${template}" not found`);
}
return { dir, cleanup };
}

async function fetchTemplateTarball(template: string): Promise<FetchedTemplate> {
// codeload serves plain tarballs for any ref without the rate limits of
// api.github.com.
const url = `https://codeload.github.com/${TEMPLATES_REPO}/tar.gz/main`;
let response: Response;
try {
response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not download the "${template}" template from GitHub (${reason}). ` +
"Scaffolding needs network access — check your connection and retry.",
);
}
if (!response.ok) {
throw new Error(`Template download failed: ${url} responded ${response.status}`);
}

const tarball = Buffer.from(await response.arrayBuffer());

const { tempRoot, cleanup } = makeTempRoot();
try {
const tarFile = path.join(tempRoot, "repo.tgz");
const extractDir = path.join(tempRoot, template);
fs.writeFileSync(tarFile, tarball);
fs.mkdirSync(extractDir);
// Entries are prefixed "<repo>-<ref>/"; keep only the requested template's
// folder and strip the prefix so it extracts at extractDir.
await tar.extract({
file: tarFile,
cwd: extractDir,
strip: 2,
filter: (entryPath: string) => entryPath.split("/")[1] === template,
});
if (!fs.existsSync(path.join(extractDir, "package.json"))) {
throw new Error(`Template "${template}" not found`);
}
return { dir: extractDir, cleanup };
} catch (err) {
cleanup();
throw err;
}
}

const templateFetches = new Map<string, Promise<FetchedTemplate>>();

/** Starts downloading a template in the background. Errors are surfaced when
* the promise is consumed by fetchTemplate at scaffold time. */
export function prefetchTemplate(template: string): void {
if (templateFetches.has(template)) return;
const fetching = (async () =>
(await tryGitSparseFetch(template)) ?? fetchTemplateTarball(template))();
fetching.catch(() => {}); // avoid unhandled rejection while in the background
templateFetches.set(template, fetching);
}

export function fetchTemplate(template: string): Promise<FetchedTemplate> {
prefetchTemplate(template);
return templateFetches.get(template)!;
}

let indexFetch: Promise<TemplateIndexEntry[] | null> | undefined;

async function fetchIndex(): Promise<TemplateIndexEntry[] | null> {
const response = await fetch(INDEX_URL, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) return null;
const parsed = (await response.json()) as { templates?: TemplateIndexEntry[] };
const templates = parsed.templates?.filter(
(t) => typeof t?.name === "string" && typeof t?.description === "string",
);
return templates?.length ? templates : null;
}

/** Starts downloading index.json in the background. */
export function prefetchTemplateIndex(): void {
indexFetch ??= fetchIndex().catch(() => null);
}

/** Resolves to the template list, or null when the index can't be fetched —
* callers fall back to the built-in list. */
export function getTemplateIndex(): Promise<TemplateIndexEntry[] | null> {
prefetchTemplateIndex();
return indexFetch!;
}
Loading