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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions .github/workflows/cli-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
name: CLI End to End

on:
push:
branches: [main]
paths:
- ".github/workflows/cli-e2e.yml"
- "packages/openui-cli/**"
- "tsconfig.json"
pull_request:
branches: [main]
paths:
- ".github/workflows/cli-e2e.yml"
- "packages/openui-cli/**"
- "tsconfig.json"

permissions:
contents: read

jobs:
package-cli:
name: Package CLI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v6
with:
version: 10.33.0

- uses: actions/setup-node@v6
with:
node-version: 20

- name: Install workspace dependencies
run: pnpm install --frozen-lockfile

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

- name: Pack CLI
working-directory: packages/openui-cli
run: |
mkdir -p "${RUNNER_TEMP}/cli-package"
npm pack --ignore-scripts --pack-destination "${RUNNER_TEMP}/cli-package"

- uses: actions/upload-artifact@v7
with:
name: openui-cli-package
path: ${{ runner.temp }}/cli-package/*.tgz
if-no-files-found: error
retention-days: 1

cli-e2e:
name: ${{ matrix.template }} (${{ matrix.package-manager }}, ${{ matrix.os }}, Node ${{ matrix.node-version }})
needs: package-cli
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
template:
- openui-cloud
- openui-self-hosted
package-manager:
- npm
- pnpm
environment:
- ubuntu-node20
- ubuntu-node22
- ubuntu-node24
- macos-node22
- windows-node22
include:
- environment: ubuntu-node20
os: ubuntu-latest
node-version: 20
- environment: ubuntu-node22
os: ubuntu-latest
node-version: 22
- environment: ubuntu-node24
os: ubuntu-latest
node-version: 24
- environment: macos-node22
os: macos-latest
node-version: 22
- environment: windows-node22
os: windows-latest
node-version: 22

steps:
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}

- uses: pnpm/action-setup@v6
if: matrix.package-manager == 'pnpm'
with:
version: 10.33.0

- uses: actions/download-artifact@v8
with:
name: openui-cli-package
path: ${{ runner.temp }}/cli-package

- name: Locate CLI package and prepare workspace
id: cli-package
shell: bash
env:
CLI_PACKAGE_DIR: ${{ runner.temp }}/cli-package
CLI_E2E_DIR: ${{ runner.temp }}/cli-e2e
run: |
node --input-type=module -e "
import fs from 'node:fs';
import path from 'node:path';
const tarballs = fs.readdirSync(process.env.CLI_PACKAGE_DIR).filter((file) => file.endsWith('.tgz'));
if (tarballs.length !== 1) throw new Error('Expected exactly one CLI tarball');
const tarball = path.join(process.env.CLI_PACKAGE_DIR, tarballs[0]);
fs.mkdirSync(process.env.CLI_E2E_DIR, { recursive: true });
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'path=' + tarball + '\n');
"

- name: Run CLI with npm
if: matrix.package-manager == 'npm'
working-directory: ${{ runner.temp }}/cli-e2e
run: >-
npm exec --yes --package="${{ steps.cli-package.outputs.path }}" --
openui --no-telemetry create
--name generated-app
--template ${{ matrix.template }}
--api-key sk-test
--no-interactive
--no-skill

- name: Run CLI with pnpm
if: matrix.package-manager == 'pnpm'
working-directory: ${{ runner.temp }}/cli-e2e
run: >-
pnpm dlx "${{ steps.cli-package.outputs.path }}"
--no-telemetry create
--name generated-app
--template ${{ matrix.template }}
--api-key sk-test
--no-interactive
--no-skill

# npm- and pnpm-installed node_modules can both satisfy either run command.
# Check manager metadata and lockfile retention to verify CLI selection.
- name: Verify selected package manager
working-directory: ${{ runner.temp }}/cli-e2e/generated-app
shell: bash
env:
PACKAGE_MANAGER: ${{ matrix.package-manager }}
run: |
node --input-type=module -e "
import fs from 'node:fs';
import path from 'node:path';
const marker = process.env.PACKAGE_MANAGER === 'pnpm'
? path.join('node_modules', '.modules.yaml')
: path.join('node_modules', '.package-lock.json');
if (!fs.existsSync(marker)) throw new Error('Missing ' + process.env.PACKAGE_MANAGER + ' install marker: ' + marker);
const hasNpmLockfile = fs.existsSync('package-lock.json');
if (process.env.PACKAGE_MANAGER === 'npm' && !hasNpmLockfile) {
throw new Error('npm-generated project is missing package-lock.json');
}
if (process.env.PACKAGE_MANAGER !== 'npm' && hasNpmLockfile) {
throw new Error(process.env.PACKAGE_MANAGER + '-generated project retained package-lock.json');
}
"

- name: Build generated app
working-directory: ${{ runner.temp }}/cli-e2e/generated-app
run: ${{ matrix.package-manager }} run build
env:
OPENAI_API_KEY: sk-test
THESYS_API_KEY: sk-test
DEMO_USER_ID: test-user
72 changes: 72 additions & 0 deletions .github/workflows/cli-template-package-managers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: CLI Template Package Manager Parity

on:
push:
branches: [main]
paths:
- ".github/workflows/cli-template-package-managers.yml"
- "packages/openui-cli/scripts/compare-template-installs.mjs"
- "packages/openui-cli/src/commands/create-app.ts"
- "packages/openui-cli/src/lib/detect-package-manager.ts"
- "packages/openui-cli/src/templates/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/cli-template-package-managers.yml"
- "packages/openui-cli/scripts/compare-template-installs.mjs"
- "packages/openui-cli/src/commands/create-app.ts"
- "packages/openui-cli/src/lib/detect-package-manager.ts"
- "packages/openui-cli/src/templates/**"

permissions:
contents: read

jobs:
package-manager-parity:
name: ${{ matrix.template }} (npm + pnpm)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
template:
- openui-cloud
- openui-self-hosted

steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v6
with:
version: 10.33.0

- uses: actions/setup-node@v6
with:
node-version: 20

- name: Prepare isolated template copies
shell: bash
run: |
node --input-type=module -e "
import fs from 'node:fs';
import path from 'node:path';
const source = path.resolve('packages/openui-cli/src/templates/${{ matrix.template }}');
const npmTarget = path.join(process.env.RUNNER_TEMP, 'npm-template');
const pnpmTarget = path.join(process.env.RUNNER_TEMP, 'pnpm-template');
fs.cpSync(source, npmTarget, { recursive: true });
fs.cpSync(source, pnpmTarget, { recursive: true });
fs.rmSync(path.join(pnpmTarget, 'package-lock.json'));
"

- name: Install with npm
working-directory: ${{ runner.temp }}/npm-template
run: npm ci --prefer-offline --no-audit --no-fund --progress=false

- name: Install with pnpm
working-directory: ${{ runner.temp }}/pnpm-template
run: pnpm install

- name: Compare resolved direct dependencies
run: >-
node packages/openui-cli/scripts/compare-template-installs.mjs
"${{ runner.temp }}/npm-template"
"${{ runner.temp }}/pnpm-template"
2 changes: 1 addition & 1 deletion packages/openui-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openuidev/cli",
"version": "0.1.8",
"version": "0.1.9",
"description": "CLI for OpenUI — scaffold generative UI chat apps and generate LLM system prompts from component libraries",
"bin": {
"openui": "dist/index.js"
Expand Down
60 changes: 60 additions & 0 deletions packages/openui-cli/scripts/compare-template-installs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import fs from "node:fs";
import path from "node:path";

const [npmDirArg, pnpmDirArg] = process.argv.slice(2);

if (!npmDirArg || !pnpmDirArg) {
console.error(
"Usage: compare-template-installs.mjs <npm-project-dir> <pnpm-project-dir>",
);
process.exit(2);
}

const npmDir = path.resolve(npmDirArg);
const pnpmDir = path.resolve(pnpmDirArg);
const manifest = readJson(path.join(npmDir, "package.json"));

// Dependencies declared with ranges can resolve differently: npm uses the
// committed lockfile while pnpm resolves them independently. Compare installed
// direct dependencies so CI catches that drift even when both builds succeed.
const dependencyNames = [
...new Set([
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.devDependencies ?? {}),
]),
].sort();

const mismatches = [];

for (const dependency of dependencyNames) {
const npmVersion = installedVersion(npmDir, dependency);
const pnpmVersion = installedVersion(pnpmDir, dependency);

if (npmVersion !== pnpmVersion) {
mismatches.push({ dependency, npmVersion, pnpmVersion });
}
}

if (mismatches.length > 0) {
console.error("npm and pnpm resolved different direct dependency versions:");
console.table(mismatches);
process.exit(1);
}

console.info(
`npm and pnpm resolved the same versions for ${dependencyNames.length} direct dependencies.`,
);

function installedVersion(projectDir, dependency) {
const manifestPath = path.join(
projectDir,
"node_modules",
dependency,
"package.json",
);
return readJson(manifestPath).version;
}

function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
29 changes: 29 additions & 0 deletions packages/openui-cli/src/commands/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ function rewritePackageJson(projectDir: string, name: string) {
}
}
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");

// Keep the copied npm lockfile's root package metadata aligned so npm ci can
// consume the template without having to rewrite or re-resolve it.
const lockPath = path.join(projectDir, "package-lock.json");
if (fs.existsSync(lockPath)) {
const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")) as {
name?: string;
packages?: Record<
string,
{
name?: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
>;
};
lock.name = name;
const lockRoot = lock.packages?.[""];
if (lockRoot) {
lockRoot.name = name;
lockRoot.dependencies = pkg.dependencies;
lockRoot.devDependencies = pkg.devDependencies;
}
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n");
}
}

export async function runCreateApp(options: CreateAppOptions): Promise<void> {
Expand Down Expand Up @@ -150,6 +175,10 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
filter: (src) => shouldCopyTemplatePath(templateDir, src),
});
rewritePackageJson(targetDir, name);
// The template lockfile enables npm ci; other managers should resolve from package.json.
if (packageManager.name !== "npm") {
fs.rmSync(path.join(targetDir, "package-lock.json"), { force: true });
}
} catch (err) {
captureScaffoldFailed();
throw err;
Expand Down
Loading
Loading