diff --git a/docs/storybook/package.json b/docs/storybook/package.json index 9087cb986..e9ef91c25 100644 --- a/docs/storybook/package.json +++ b/docs/storybook/package.json @@ -5,6 +5,8 @@ "license": "Apache-2.0", "main": "index.js", "scripts": { + "build:deps": "pnpm --filter @livekit/component-docs-storybook^... build", + "predev": "pnpm build:deps", "build": "storybook build", "clean": "rm -rf .turbo && rm -rf node_modules", "dev": "storybook dev -p 6006", diff --git a/package.json b/package.json index 320f3fe3a..2e8065a89 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "gen:docs:watch": "nodemon --watch \"tooling/api-documenter/src/**/*\" --watch \"packages/react/src/**/*\" --ignore \"packages/react/src/assets/**/*\" -e js,jsx,ts,tsx -x \"pnpm gen:docs\"", "lint": "turbo run lint -- --quiet", "preinstall": "npx only-allow pnpm", + "shadcn:publish:all": "TURBO_UI=true turbo run shadcn:publish:all --filter=@livekit/agents-ui...", "ci:publish": "turbo run build --filter=./packages/* && pnpm gen:docs && changeset publish", "start:next": "turbo run start --filter=@livekit/component-example-next...", "test": "turbo run test", diff --git a/packages/shadcn/README.md b/packages/shadcn/README.md index a9b7d772a..b12da9d76 100644 --- a/packages/shadcn/README.md +++ b/packages/shadcn/README.md @@ -144,18 +144,64 @@ You can find the components in [Storybook](http://localhost:6006) under the `Age ```bash # Build the shadcn registry -pnpm registry:build +pnpm shadcn:build # Serve the registry locally (http://localhost:3210) -pnpm registry:serve +pnpm shadcn:serve # Generate prop documentation -pnpm registry:doc-gen +pnpm shadcn:doc-gen # Build and deploy to configured destination paths -pnpm registry:update +pnpm shadcn:deploy ``` +### Publishing downstream updates + +These scripts are maintainer-only release helpers. They require an authenticated `gh` +CLI session with access to the target repositories, push branches to those +repositories, and open GitHub PRs. + +```bash +# Sync the registry into livekit-examples/agent-starter-react +pnpm shadcn:publish:agent-starter-react + +# Sync the hosted registry, prop docs, and installed @agents-ui components +# in apps/www and apps/docs into livekit/web +pnpm shadcn:publish:livekit-web + +# Run both downstream publish scripts +pnpm shadcn:publish:all +``` + +Each script clones the target repo into a temp directory, makes the change, and prints the +repo, branch, title, body, and a `git diff --stat` summary before asking `Create this PR? +(y/N)`. Pass `-y`/`--yes` to skip that prompt and auto-approve (still prints the summary +first): + +```bash +# From packages/shadcn — no -- needed +pnpm shadcn:publish:livekit-web -y + +# From the repo root — shadcn:publish:all goes through turbo, which requires +# -- to forward flags to the underlying task +pnpm shadcn:publish:all -- -y +``` + +#### Dry run + +To see the full diff a run would produce — including the confirmation summary — without +ever committing, pushing, or opening a PR, just answer `n` (or let it default) at the +prompt. This still does all the real work (build, clone, install, format), it just stops +before touching anything remote: + +```bash +echo "n" | pnpm shadcn:publish:livekit-web +``` + +The temp clone, local registry server, and any `.env.local` changes are cleaned up +automatically whether you confirm, decline, or `Ctrl-C` out mid-run. + ### Environment Variables Create a `.env.local` file with: diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx index c01e39cc3..305fed9a7 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx @@ -1,3 +1,5 @@ +'use client'; + /** * @license * @@ -10,8 +12,6 @@ * © 2026 UNCRN LLC */ -'use client'; - import React, { useMemo, type ComponentProps } from 'react'; import { type VariantProps, cva } from 'class-variance-authority'; import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'; diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx index f38ac261f..b049b6081 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx @@ -24,7 +24,7 @@ import { cn } from '@/lib/utils'; * Excess values are trimmed from the end; if there are too few, the last * value is duplicated to fill the remainder. An empty array is padded with 0s. */ -function normalizeVolumeBands(bands: number[], count: number): number[] { +export function normalizeVolumeBands(bands: number[], count: number): number[] { if (bands.length === count) return bands; if (bands.length > count) return bands.slice(0, count); const lastValue = bands[bands.length - 1] ?? 0; diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx index 84603b947..3b0ca9b12 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx @@ -11,7 +11,7 @@ import React, { useMemo, } from 'react'; import { type VariantProps, cva } from 'class-variance-authority'; -import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'; +import type { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'; import { type AgentState, type TrackReferenceOrPlaceholder, @@ -28,7 +28,7 @@ import { cn } from '@/lib/utils'; * Excess values are trimmed from the end; if there are too few, the last * value is duplicated to fill the remainder. An empty array is padded with 0s. */ -function normalizeVolumeBands(bands: number[], count: number): number[] { +export function normalizeVolumeBands(bands: number[], count: number): number[] { if (bands.length === count) return bands; if (bands.length > count) return bands.slice(0, count); const lastValue = bands[bands.length - 1] ?? 0; diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx index 1676f992b..2961a6079 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx @@ -16,7 +16,7 @@ import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-age * Excess values are trimmed from the end; if there are too few, the last * value is duplicated to fill the remainder. An empty array is padded with 0s. */ -function normalizeVolumeBands(bands: number[], count: number): number[] { +export function normalizeVolumeBands(bands: number[], count: number): number[] { if (bands.length === count) return bands; if (bands.length > count) return bands.slice(0, count); const lastValue = bands[bands.length - 1] ?? 0; diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx index 1d2190674..9eb790d12 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx @@ -7,7 +7,7 @@ import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/comp import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy'; import { useAgentAudioVisualizerWave } from '@/hooks/agents-ui/use-agent-audio-visualizer-wave'; import { cn } from '@/lib/utils'; -import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'; +import type { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'; const DEFAULT_COLOR = '#1FD5F9'; diff --git a/packages/shadcn/components/agents-ui/agent-control-bar.tsx b/packages/shadcn/components/agents-ui/agent-control-bar.tsx index f64cc116f..ea39cdd91 100644 --- a/packages/shadcn/components/agents-ui/agent-control-bar.tsx +++ b/packages/shadcn/components/agents-ui/agent-control-bar.tsx @@ -397,8 +397,8 @@ export function AgentControlBar({ 'bg-destructive/10 dark:bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/4 rounded-full font-mono text-xs font-bold tracking-wider', )} > - End call - End + End call + End )} diff --git a/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx b/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx index d4e2f9044..976e7bc0c 100644 --- a/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx +++ b/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx @@ -1,7 +1,7 @@ 'use client'; import { type ComponentProps } from 'react'; -import { Button, buttonVariants } from '@/components/ui/button'; +import { Button, type buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { useSessionContext } from '@livekit/components-react'; import { type VariantProps } from 'class-variance-authority'; diff --git a/packages/shadcn/components/agents-ui/agent-session-provider.tsx b/packages/shadcn/components/agents-ui/agent-session-provider.tsx index 54ffe7465..d9c0af47c 100644 --- a/packages/shadcn/components/agents-ui/agent-session-provider.tsx +++ b/packages/shadcn/components/agents-ui/agent-session-provider.tsx @@ -5,7 +5,7 @@ import { type SessionProviderProps, type RoomAudioRendererProps, } from '@livekit/components-react'; -import { Room } from 'livekit-client'; +import { type Room } from 'livekit-client'; /** * Props for the AgentSessionProvider component. diff --git a/packages/shadcn/components/agents-ui/agent-track-control.tsx b/packages/shadcn/components/agents-ui/agent-track-control.tsx index 21e3050c7..d74c3e147 100644 --- a/packages/shadcn/components/agents-ui/agent-track-control.tsx +++ b/packages/shadcn/components/agents-ui/agent-track-control.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'; import { type VariantProps, cva } from 'class-variance-authority'; -import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'; import { type TrackReferenceOrPlaceholder, useMaybeRoomContext, @@ -17,7 +16,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { toggleVariants } from '@/components/ui/toggle'; +import { type toggleVariants } from '@/components/ui/toggle'; import { cn } from '@/lib/utils'; const selectVariants = cva( diff --git a/packages/shadcn/components/agents-ui/react-shader-toy.tsx b/packages/shadcn/components/agents-ui/react-shader-toy.tsx index 8d05a2e9a..e46e36161 100644 --- a/packages/shadcn/components/agents-ui/react-shader-toy.tsx +++ b/packages/shadcn/components/agents-ui/react-shader-toy.tsx @@ -171,10 +171,6 @@ const uniformTypeToGLSLType = (t: string) => { const LinearFilter = 9729; const NearestFilter = 9728; const LinearMipMapLinearFilter = 9987; -const NearestMipMapLinearFilter = 9986; -const LinearMipMapNearestFilter = 9985; -const NearestMipMapNearestFilter = 9984; -const MirroredRepeatWrapping = 33648; const ClampToEdgeWrapping = 33071; const RepeatWrapping = 10497; diff --git a/packages/shadcn/components/agents-ui/start-audio-button.tsx b/packages/shadcn/components/agents-ui/start-audio-button.tsx index d8f74dd83..7ab90d139 100644 --- a/packages/shadcn/components/agents-ui/start-audio-button.tsx +++ b/packages/shadcn/components/agents-ui/start-audio-button.tsx @@ -1,7 +1,7 @@ import { type ComponentProps } from 'react'; import { useEnsureRoom, useStartAudio } from '@livekit/components-react'; import { Button } from '@/components/ui/button'; -import { Room } from 'livekit-client'; +import { type Room } from 'livekit-client'; /** * Props for the StartAudioButton component. diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 2d5ad653a..77e03d664 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -6,10 +6,15 @@ "type": "module", "main": "index.ts", "scripts": { - "registry:build": "pnpm test && rm -rf dist && shadcn build --output ./dist/r && pnpm registry:doc-gen", - "registry:serve": "python3 -m http.server 3210 -d ./dist", - "registry:doc-gen": "node --experimental-strip-types --env-file=.env.local ./scripts/doc-gen.ts", - "registry:update": "pnpm registry:build && node --experimental-strip-types --env-file=.env.local ./scripts/update.ts", + "build:deps": "pnpm --filter @livekit/agents-ui^... build", + "pretest": "pnpm build:deps", + "shadcn:build": "pnpm test && rm -rf dist && shadcn build --output ./dist/r && pnpm shadcn:doc-gen", + "shadcn:serve": "pnpm shadcn:build && python3 -m http.server 3210 -d ./dist", + "shadcn:doc-gen": "node --experimental-strip-types ./scripts/doc-gen.ts", + "shadcn:deploy": "pnpm shadcn:build && node --experimental-strip-types --env-file=.env.local ./scripts/update.ts", + "shadcn:publish:agent-starter-react": "node --experimental-strip-types ./scripts/publish-agent-starter-react.ts", + "shadcn:publish:livekit-web": "node --experimental-strip-types ./scripts/publish-livekit-web.ts", + "shadcn:publish:all": "node --experimental-strip-types ./scripts/publish-downstream.ts", "shadcn:update": "pnpm dlx shadcn@latest add alert button button-group select separator sonner toggle tooltip message-scroller message bubble && pnpm format", "test": "vitest --run", "test:watch": "vitest", diff --git a/packages/shadcn/scripts/lib/publish-utils.ts b/packages/shadcn/scripts/lib/publish-utils.ts new file mode 100644 index 000000000..72730bec2 --- /dev/null +++ b/packages/shadcn/scripts/lib/publish-utils.ts @@ -0,0 +1,308 @@ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline/promises'; + +export function run(cmd: string[], opts: { cwd?: string } = {}): void { + execFileSync(cmd[0], cmd.slice(1), { cwd: opts.cwd, stdio: 'inherit' }); +} + +export function runCapture(cmd: string[], opts: { cwd?: string } = {}): string { + return execFileSync(cmd[0], cmd.slice(1), { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] }) + .toString() + .trim(); +} + +export function mkTempClone(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +export function hasDiff(cwd: string): boolean { + return runCapture(['git', 'status', '--porcelain'], { cwd }).length > 0; +} + +export function buildRegistry(shadcnPkgDir: string): void { + console.log('--------------------------------'); + console.log('Building registry'); + run(['pnpm', 'shadcn:build'], { cwd: shadcnPkgDir }); +} + +export function installDependencies(cwd: string): void { + console.log('--------------------------------'); + console.log('Installing dependencies'); + run(['pnpm', 'install'], { cwd }); +} + +export function formatChanges(cwd: string, cmd: string[] = ['pnpm', 'format']): void { + console.log('--------------------------------'); + console.log('Formatting changes'); + run(cmd, { cwd }); +} + +export async function serveRegistry(shadcnPkgDir: string, port: number): Promise { + console.log('--------------------------------'); + console.log(`Serving registry on port ${port}`); + const server = spawn('python3', ['-m', 'http.server', String(port), '-d', './dist'], { + cwd: shadcnPkgDir, + stdio: 'ignore', + }); + await waitForHttp(`http://localhost:${port}/r/registry.json`); + return server; +} + +export function pointRegistryAt(componentsJsonPath: string, registryUrl: string): void { + console.log('--------------------------------'); + console.log(`Pointing ${componentsJsonPath} at ${registryUrl}`); + const componentsJson = JSON.parse(fs.readFileSync(componentsJsonPath, 'utf-8')); + componentsJson.registries['@agents-ui'] = registryUrl; + fs.writeFileSync(componentsJsonPath, JSON.stringify(componentsJson, null, 2) + '\n'); +} + +export function revertFile(cwd: string, relativePath: string): void { + console.log('--------------------------------'); + console.log(`Reverting temporary change to ${relativePath}`); + run(['git', 'checkout', '--', relativePath], { cwd }); +} + +export function ghAuthPreflight(): void { + try { + execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' }); + } catch { + throw new Error('gh CLI is not authenticated. Run `gh auth login` and try again.'); + } +} + +export function isPortInUse(port: number): boolean { + try { + const pids = execFileSync('lsof', ['-ti', `tcp:${port}`], { + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); + return pids.length > 0; + } catch { + return false; + } +} + +/** + * killServer() sends SIGTERM without waiting for the process to actually exit, so a + * server killed at the end of one publish run may still hold the port for a moment + * when the next run starts (e.g. back-to-back in publish-downstream.ts). Poll instead + * of failing immediately on the first check. + */ +export async function waitForPortFree(port: number, timeoutMs = 5_000): Promise { + const start = Date.now(); + while (isPortInUse(port)) { + if (Date.now() - start > timeoutMs) { + throw new Error( + `Port ${port} is already in use. Stop any running shadcn:serve process and try again.`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } +} + +export async function waitForHttp(url: string, timeoutMs = 10_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (res.ok) return; + } catch { + // server not up yet + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${url} to respond`); +} + +export function branchName(prefix: string): string { + const timestamp = new Date() + .toISOString() + .replace(/[-:]/g, '') + .replace(/\..+/, '') + .replace('T', '-'); + return `${prefix}-${timestamp}`; +} + +export function ghPrCreate(opts: { + repo: string; + cwd: string; + title: string; + body: string; + head: string; + base: string; +}): string { + return runCapture( + [ + 'gh', + 'pr', + 'create', + '--repo', + opts.repo, + '--title', + opts.title, + '--body', + opts.body, + '--head', + opts.head, + '--base', + opts.base, + ], + { cwd: opts.cwd }, + ); +} + +export function killServer(server: ChildProcess | undefined): void { + if (server && !server.killed) { + server.kill(); + } +} + +export function removeTempDir(dir: string | undefined): void { + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +export function hasCliFlag(...flags: string[]): boolean { + return process.argv.slice(2).some((arg) => flags.includes(arg)); +} + +export function onInterrupt(cleanup: () => void): () => void { + const handler = () => { + cleanup(); + process.exit(1); + }; + process.once('SIGINT', handler); + process.once('SIGTERM', handler); + return () => { + process.off('SIGINT', handler); + process.off('SIGTERM', handler); + }; +} + +// A second readline.createInterface() on process.stdin after closing the first one +// never receives further input (it hangs indefinitely) — so when a single process +// prompts more than once (e.g. publish-downstream.ts running both publish scripts +// in-process), all prompts must share one interface, closed exactly once at exit +// via closePromptInterface(). +let sharedPromptInterface: readline.Interface | undefined; + +function getPromptInterface(): readline.Interface { + sharedPromptInterface ??= readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return sharedPromptInterface; +} + +export function closePromptInterface(): void { + sharedPromptInterface?.close(); + sharedPromptInterface = undefined; +} + +export async function promptYesNo(question: string): Promise { + const rl = getPromptInterface(); + const answer = await rl.question(`${question} (y/N): `); + return ['y', 'yes'].includes(answer.trim().toLowerCase()); +} + +export function cloneAndCreateBranch( + repo: string, + tmpDirPrefix: string, + branchPrefix: string, + baseBranch: string, +): { tmpDir: string; branch: string } { + const tmpDir = mkTempClone(tmpDirPrefix); + console.log('--------------------------------'); + console.log(`Cloning ${repo} (${baseBranch}, depth 1) into ${tmpDir}`); + // Shallow, single-branch clone: this helper is only ever used to branch off the + // tip of baseBranch, diff, commit, and push — never git log/blame/tags. Don't + // reuse this path for a caller that needs history beyond HEAD. + run([ + 'gh', + 'repo', + 'clone', + repo, + tmpDir, + '--', + '--depth', + '1', + '--single-branch', + '--branch', + baseBranch, + '--no-tags', + ]); + + const branch = branchName(branchPrefix); + run(['git', 'checkout', '-b', branch, `origin/${baseBranch}`], { cwd: tmpDir }); + + return { tmpDir, branch }; +} + +async function confirmPrCreation(opts: { + repo: string; + cwd: string; + branch: string; + base: string; + title: string; + body: string; + autoApprove?: boolean; +}): Promise { + const diffStat = runCapture(['git', 'diff', '--stat'], { cwd: opts.cwd }); + + console.log('--------------------------------'); + console.log('About to open a pull request:'); + console.log(` Repo: ${opts.repo}`); + console.log(` Branch: ${opts.branch} -> ${opts.base}`); + console.log(` Title: ${opts.title}`); + console.log(' Body:'); + for (const line of opts.body.split('\n')) console.log(` ${line}`); + console.log(' Changes:'); + for (const line of diffStat.split('\n')) console.log(` ${line}`); + console.log('--------------------------------'); + + if (opts.autoApprove) { + console.log('Auto-approved (-y)'); + return true; + } + + return promptYesNo('Create this PR?'); +} + +export async function commitPushAndOpenPr(opts: { + repo: string; + cwd: string; + branch: string; + base: string; + title: string; + body: string; + commitMessage: string; + autoApprove?: boolean; +}): Promise { + const confirmed = await confirmPrCreation(opts); + if (!confirmed) { + console.log('PR creation cancelled by user'); + return undefined; + } + + run(['git', 'add', '-A'], { cwd: opts.cwd }); + run(['git', 'commit', '-m', opts.commitMessage], { cwd: opts.cwd }); + run(['git', 'push', '-u', 'origin', opts.branch], { cwd: opts.cwd }); + + const prUrl = ghPrCreate({ + repo: opts.repo, + cwd: opts.cwd, + title: opts.title, + body: opts.body, + head: opts.branch, + base: opts.base, + }); + + console.log('--------------------------------'); + console.log(`Opened PR: ${prUrl}`); + return prUrl; +} diff --git a/packages/shadcn/scripts/publish-agent-starter-react.ts b/packages/shadcn/scripts/publish-agent-starter-react.ts new file mode 100644 index 000000000..7249a7347 --- /dev/null +++ b/packages/shadcn/scripts/publish-agent-starter-react.ts @@ -0,0 +1,123 @@ +import { type ChildProcess } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + buildRegistry, + cloneAndCreateBranch, + commitPushAndOpenPr, + formatChanges, + closePromptInterface, + ghAuthPreflight, + hasCliFlag, + hasDiff, + killServer, + onInterrupt, + pointRegistryAt, + removeTempDir, + revertFile, + run, + runCapture, + serveRegistry, + waitForPortFree, +} from './lib/publish-utils.ts'; + +type PublishOptions = { + registryAlreadyBuilt?: boolean; + autoApprove?: boolean; +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SHADCN_PKG_DIR = path.join(__dirname, '..'); +const REPO = 'livekit-examples/agent-starter-react'; +const PORT = 3210; +const REGISTRY_URL = `http://localhost:${PORT}/r/{name}.json`; +const BASE_BRANCH = 'main'; +const BRANCH_PREFIX = 'chore/update-agents-ui-registry'; +const COMMIT_MESSAGE = 'chore: sync agents-ui components from livekit/components-js'; +const PR_TITLE = 'chore: sync agents-ui components'; + +function installComponents(tmpDir: string): void { + console.log('--------------------------------'); + console.log('Installing dependencies'); + run(['pnpm', 'install'], { cwd: tmpDir }); + + console.log('--------------------------------'); + console.log('Installing agents-ui components'); + run(['pnpm', 'dlx', 'shadcn@latest', 'add', '--yes', '--overwrite', '@agents-ui/all'], { + cwd: tmpDir, + }); +} + +function buildPrBody(sourceSha: string): string { + return `Syncs \`@agents-ui\` components from the latest build of [livekit/components-js](https://github.com/livekit/components-js) at ${sourceSha}.\n\nGenerated by \`packages/shadcn/scripts/publish-agent-starter-react.ts\`.`; +} + +export async function publishAgentStarterReact(options: PublishOptions = {}): Promise<{ + success: boolean; + skipped?: boolean; + prUrl?: string; +}> { + ghAuthPreflight(); + await waitForPortFree(PORT); + + let server: ChildProcess | undefined; + let tmpDir: string | undefined; + const cleanup = () => { + killServer(server); + removeTempDir(tmpDir); + }; + const unregisterInterruptHandler = onInterrupt(cleanup); + + try { + if (!options.registryAlreadyBuilt) { + buildRegistry(SHADCN_PKG_DIR); + } + server = await serveRegistry(SHADCN_PKG_DIR, PORT); + + const cloned = cloneAndCreateBranch(REPO, 'agent-starter-react-', BRANCH_PREFIX, BASE_BRANCH); + tmpDir = cloned.tmpDir; + + const componentsJsonPath = path.join(tmpDir, 'components.json'); + pointRegistryAt(componentsJsonPath, REGISTRY_URL); + installComponents(tmpDir); + revertFile(tmpDir, 'components.json'); + formatChanges(tmpDir); + + if (!hasDiff(tmpDir)) { + console.log('No changes after sync — skipping PR'); + return { success: true, skipped: true }; + } + + const sourceSha = runCapture(['git', 'rev-parse', 'HEAD'], { cwd: SHADCN_PKG_DIR }); + const prUrl = await commitPushAndOpenPr({ + repo: REPO, + cwd: tmpDir, + branch: cloned.branch, + base: BASE_BRANCH, + title: PR_TITLE, + body: buildPrBody(sourceSha), + commitMessage: COMMIT_MESSAGE, + autoApprove: options.autoApprove, + }); + + if (!prUrl) { + return { success: true, skipped: true }; + } + return { success: true, prUrl }; + } finally { + unregisterInterruptHandler(); + cleanup(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + publishAgentStarterReact({ autoApprove: hasCliFlag('-y', '--yes') }) + .then((result) => { + console.log('Done', result); + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => closePromptInterface()); +} diff --git a/packages/shadcn/scripts/publish-downstream.ts b/packages/shadcn/scripts/publish-downstream.ts new file mode 100644 index 000000000..58f19c6a5 --- /dev/null +++ b/packages/shadcn/scripts/publish-downstream.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { buildRegistry, closePromptInterface, hasCliFlag } from './lib/publish-utils.ts'; +import { publishAgentStarterReact } from './publish-agent-starter-react.ts'; +import { publishLivekitWeb } from './publish-livekit-web.ts'; + +type Result = { name: string; success: boolean; skipped?: boolean; prUrl?: string; error?: string }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SHADCN_PKG_DIR = path.join(__dirname, '..'); + +async function runAgentStarterReact(autoApprove: boolean): Promise { + try { + const result = await publishAgentStarterReact({ registryAlreadyBuilt: true, autoApprove }); + return { name: 'agent-starter-react', ...result }; + } catch (err) { + return { name: 'agent-starter-react', success: false, error: String(err) }; + } +} + +async function runLivekitWeb(autoApprove: boolean): Promise { + try { + const result = await publishLivekitWeb({ registryAlreadyBuilt: true, autoApprove }); + return { name: 'livekit-web', ...result }; + } catch (err) { + return { name: 'livekit-web', success: false, error: String(err) }; + } +} + +function printSummary(results: Result[]): void { + console.log('--------------------------------'); + console.log('Summary'); + for (const result of results) { + const status = !result.success ? '❌' : result.skipped ? '⏭ skipped' : '✅'; + console.log( + `${status} ${result.name}${result.prUrl ? ` — ${result.prUrl}` : ''}${result.error ? ` — ${result.error}` : ''}`, + ); + } +} + +async function publishDownstream(autoApprove: boolean): Promise { + buildRegistry(SHADCN_PKG_DIR); + const results: Result[] = []; + results.push(await runAgentStarterReact(autoApprove)); + results.push(await runLivekitWeb(autoApprove)); + return results; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const autoApprove = hasCliFlag('-y', '--yes'); + const results = await publishDownstream(autoApprove); + closePromptInterface(); + printSummary(results); + process.exitCode = results.some((r) => !r.success) ? 1 : 0; +} diff --git a/packages/shadcn/scripts/publish-livekit-web.ts b/packages/shadcn/scripts/publish-livekit-web.ts new file mode 100644 index 000000000..504750296 --- /dev/null +++ b/packages/shadcn/scripts/publish-livekit-web.ts @@ -0,0 +1,207 @@ +import { type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + buildRegistry, + cloneAndCreateBranch, + commitPushAndOpenPr, + closePromptInterface, + ghAuthPreflight, + hasCliFlag, + hasDiff, + installDependencies, + killServer, + onInterrupt, + pointRegistryAt, + removeTempDir, + revertFile, + run, + runCapture, + serveRegistry, + waitForPortFree, +} from './lib/publish-utils.ts'; + +type PublishOptions = { + registryAlreadyBuilt?: boolean; + autoApprove?: boolean; +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SHADCN_PKG_DIR = path.join(__dirname, '..'); +const ENV_LOCAL_PATH = path.join(SHADCN_PKG_DIR, '.env.local'); +const REPO = 'livekit/web'; +const PORT = 3210; +const REGISTRY_URL = `http://localhost:${PORT}/r/{name}.json`; +const BASE_BRANCH = 'main'; +const BRANCH_PREFIX = 'chore/update-agents-ui-registry'; +const COMMIT_MESSAGE = 'chore(agents-ui): update registry + prop-types from @livekit/agents-ui'; +const PR_TITLE = 'chore(agents-ui): update registry + prop-types'; +const APPS = ['www', 'docs']; +// shadcn add doesn't know about pnpm's catalog: protocol — it overwrites these with a +// concrete version pinned to whatever the @agents-ui registry entry declares. Restore +// the workspace's catalog reference afterward so we don't fork these off the catalog. +const CATALOG_DEPENDENCIES = ['@livekit/components-react', 'livekit-client']; + +function writeTempEnvLocal(tmpDir: string): void { + console.log('--------------------------------'); + console.log('Pointing .env.local at the tmp livekit/web checkout'); + const destRegistryPath = path.join(tmpDir, 'apps/www/agents-ui-registry'); + const destPropTypesPath = path.join(tmpDir, 'apps/docs/lib/shadcn/prop-types.json'); + fs.writeFileSync( + ENV_LOCAL_PATH, + `DEST_REGISTRY_PATH=${destRegistryPath}\nDEST_PROP_TYPES_PATH=${destPropTypesPath}\n`, + ); +} + +function copyRegistryFiles(): void { + console.log('--------------------------------'); + console.log('Copying built registry into the tmp livekit/web checkout'); + run(['node', '--experimental-strip-types', '--env-file=.env.local', './scripts/update.ts'], { + cwd: SHADCN_PKG_DIR, + }); +} + +function buildFormatDependencies(tmpDir: string): void { + console.log('--------------------------------'); + console.log('Building @repo/prettier-plugin-markdoc (required by apps/docs format script)'); + run(['pnpm', '--filter', '@repo/prettier-plugin-markdoc', 'build'], { cwd: tmpDir }); +} + +function restoreCatalogDependencies(packageJsonPath: string): void { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + let changed = false; + + for (const section of ['dependencies', 'devDependencies', 'peerDependencies']) { + const deps = pkg[section]; + if (!deps) continue; + for (const name of CATALOG_DEPENDENCIES) { + if (name in deps && deps[name] !== 'catalog:') { + deps[name] = 'catalog:'; + changed = true; + } + } + } + + if (changed) { + console.log('--------------------------------'); + console.log(`Restoring catalog: versions in ${packageJsonPath}`); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n'); + } +} + +function installAgentsUiComponents(tmpDir: string): void { + const componentsJsonPaths = APPS.map((app) => path.join(tmpDir, 'apps', app, 'components.json')); + + for (const componentsJsonPath of componentsJsonPaths) { + pointRegistryAt(componentsJsonPath, REGISTRY_URL); + } + + for (const app of APPS) { + console.log('--------------------------------'); + console.log(`Installing agents-ui components in apps/${app}`); + run(['pnpm', '--filter', app, 'shadcn:install'], { cwd: tmpDir }); + } + + for (const app of APPS) { + revertFile(tmpDir, path.join('apps', app, 'components.json')); + } + + let restoredAnyCatalogVersion = false; + for (const app of APPS) { + const packageJsonPath = path.join(tmpDir, 'apps', app, 'package.json'); + const before = fs.readFileSync(packageJsonPath, 'utf-8'); + restoreCatalogDependencies(packageJsonPath); + if (fs.readFileSync(packageJsonPath, 'utf-8') !== before) { + restoredAnyCatalogVersion = true; + } + } + if (restoredAnyCatalogVersion) { + installDependencies(tmpDir); + } +} + +function restoreEnvLocal(hadEnvLocal: boolean, envBackup: string | null): void { + if (hadEnvLocal && envBackup !== null) { + fs.writeFileSync(ENV_LOCAL_PATH, envBackup); + } else { + fs.rmSync(ENV_LOCAL_PATH, { force: true }); + } +} + +function buildPrBody(sourceSha: string): string { + return `Updates \`apps/www/agents-ui-registry\`, \`apps/docs/lib/shadcn/prop-types.json\`, and the installed \`@agents-ui\` components in \`apps/www\` and \`apps/docs\` from the latest build of [livekit/components-js](https://github.com/livekit/components-js) at ${sourceSha}.\n\nGenerated by \`packages/shadcn/scripts/publish-livekit-web.ts\`.`; +} + +export async function publishLivekitWeb(options: PublishOptions = {}): Promise<{ + success: boolean; + skipped?: boolean; + prUrl?: string; +}> { + ghAuthPreflight(); + await waitForPortFree(PORT); + + let server: ChildProcess | undefined; + let tmpDir: string | undefined; + const hadEnvLocal = fs.existsSync(ENV_LOCAL_PATH); + const envBackup = hadEnvLocal ? fs.readFileSync(ENV_LOCAL_PATH, 'utf-8') : null; + const cleanup = () => { + killServer(server); + restoreEnvLocal(hadEnvLocal, envBackup); + removeTempDir(tmpDir); + }; + const unregisterInterruptHandler = onInterrupt(cleanup); + + try { + if (!options.registryAlreadyBuilt) { + buildRegistry(SHADCN_PKG_DIR); + } + server = await serveRegistry(SHADCN_PKG_DIR, PORT); + + const cloned = cloneAndCreateBranch(REPO, 'livekit-web-', BRANCH_PREFIX, BASE_BRANCH); + tmpDir = cloned.tmpDir; + + writeTempEnvLocal(tmpDir); + copyRegistryFiles(); + installDependencies(tmpDir); + buildFormatDependencies(tmpDir); + installAgentsUiComponents(tmpDir); + + if (!hasDiff(tmpDir)) { + console.log('No changes after sync — skipping PR'); + return { success: true, skipped: true }; + } + + const sourceSha = runCapture(['git', 'rev-parse', 'HEAD'], { cwd: SHADCN_PKG_DIR }); + const prUrl = await commitPushAndOpenPr({ + repo: REPO, + cwd: tmpDir, + branch: cloned.branch, + base: BASE_BRANCH, + title: PR_TITLE, + body: buildPrBody(sourceSha), + commitMessage: COMMIT_MESSAGE, + autoApprove: options.autoApprove, + }); + + if (!prUrl) { + return { success: true, skipped: true }; + } + return { success: true, prUrl }; + } finally { + unregisterInterruptHandler(); + cleanup(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + publishLivekitWeb({ autoApprove: hasCliFlag('-y', '--yes') }) + .then((result) => { + console.log('Done', result); + }) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => closePromptInterface()); +} diff --git a/packages/shadcn/scripts/update.ts b/packages/shadcn/scripts/update.ts index 9ad0e051e..1b82fe7a9 100644 --- a/packages/shadcn/scripts/update.ts +++ b/packages/shadcn/scripts/update.ts @@ -1,24 +1,50 @@ -import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -// copy .env.example to .env.local and edit DEST_REGISTRY_PATH -if (!process.env.DEST_REGISTRY_PATH) { - throw new Error('DEST_REGISTRY_PATH is not set'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SHADCN_PKG_DIR = path.join(__dirname, '..'); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is not set`); + } + return value; } -console.log('--------------------------------'); -console.log(`Cleaning ${process.env.DEST_REGISTRY_PATH}`); -execSync(`rm -rf ${process.env.DEST_REGISTRY_PATH}/*`); -console.log('--------------------------------'); -console.log(`Copying dist/r to ${process.env.DEST_REGISTRY_PATH}`); -execSync(`cp -r dist/r/* ${process.env.DEST_REGISTRY_PATH}`); +const destRegistryPath = requireEnv('DEST_REGISTRY_PATH'); +const destPropTypesPath = requireEnv('DEST_PROP_TYPES_PATH'); +const registrySourcePath = path.join(SHADCN_PKG_DIR, 'dist/r'); +const propTypesSourcePath = path.join(SHADCN_PKG_DIR, 'dist/prop-types.json'); + +function assertSafeCleanTarget(targetPath: string): void { + const resolvedPath = path.resolve(targetPath); + if ( + resolvedPath === path.parse(resolvedPath).root || + resolvedPath === SHADCN_PKG_DIR || + resolvedPath === path.dirname(SHADCN_PKG_DIR) + ) { + throw new Error(`Refusing to clean unsafe DEST_REGISTRY_PATH: ${targetPath}`); + } +} -if (!process.env.DEST_PROP_TYPES_PATH) { - throw new Error('DEST_PROP_TYPES_PATH is not set'); +console.log('--------------------------------'); +console.log(`Cleaning ${destRegistryPath}`); +assertSafeCleanTarget(destRegistryPath); +fs.mkdirSync(destRegistryPath, { recursive: true }); +for (const entry of fs.readdirSync(destRegistryPath)) { + fs.rmSync(path.join(destRegistryPath, entry), { recursive: true, force: true }); } console.log('--------------------------------'); -console.log(`Copying dist/prop-types.json to ${process.env.DEST_PROP_TYPES_PATH}`); -execSync(`cp dist/prop-types.json ${process.env.DEST_PROP_TYPES_PATH}`); +console.log(`Copying dist/r to ${destRegistryPath}`); +fs.cpSync(registrySourcePath, destRegistryPath, { recursive: true }); + +console.log('--------------------------------'); +console.log(`Copying dist/prop-types.json to ${destPropTypesPath}`); +fs.mkdirSync(path.dirname(destPropTypesPath), { recursive: true }); +fs.copyFileSync(propTypesSourcePath, destPropTypesPath); console.log('--------------------------------'); console.log('Done'); diff --git a/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx index cdb0857f0..b106d1f51 100644 --- a/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx +++ b/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; -import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar'; +import { + AgentAudioVisualizerBar, + normalizeVolumeBands, +} from '@/components/agents-ui/agent-audio-visualizer-bar'; import * as LiveKitComponents from '@livekit/components-react'; // Mock the @livekit/components-react hooks @@ -149,3 +152,25 @@ describe('AgentAudioVisualizerBar', () => { ).toThrow('AgentAudioVisualizerBar children must be a single element.'); }); }); + +describe('normalizeVolumeBands', () => { + it('returns the array unchanged when the length already matches', () => { + expect(normalizeVolumeBands([0.1, 0.2, 0.3], 3)).toEqual([0.1, 0.2, 0.3]); + }); + + it('trims excess trailing values', () => { + expect(normalizeVolumeBands([0.1, 0.2, 0.3, 0.4], 2)).toEqual([0.1, 0.2]); + }); + + it('pads by duplicating the last value', () => { + expect(normalizeVolumeBands([0.1, 0.2], 4)).toEqual([0.1, 0.2, 0.2, 0.2]); + }); + + it('pads a single-element array by duplicating it', () => { + expect(normalizeVolumeBands([0.5], 3)).toEqual([0.5, 0.5, 0.5]); + }); + + it('pads an empty array with 0s', () => { + expect(normalizeVolumeBands([], 3)).toEqual([0, 0, 0]); + }); +}); diff --git a/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx index 4f2548f58..9eefd814d 100644 --- a/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx +++ b/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; -import { AgentAudioVisualizerGrid } from '@/components/agents-ui/agent-audio-visualizer-grid'; +import { + AgentAudioVisualizerGrid, + normalizeVolumeBands, +} from '@/components/agents-ui/agent-audio-visualizer-grid'; import { useMultibandTrackVolume } from '@livekit/components-react'; import { useAgentAudioVisualizerGridAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-grid'; @@ -202,3 +205,25 @@ describe('AgentAudioVisualizerGrid', () => { ).toThrow('AgentAudioVisualizerGrid children must be a single element.'); }); }); + +describe('normalizeVolumeBands', () => { + it('returns the array unchanged when the length already matches', () => { + expect(normalizeVolumeBands([0.1, 0.2, 0.3], 3)).toEqual([0.1, 0.2, 0.3]); + }); + + it('trims excess trailing values', () => { + expect(normalizeVolumeBands([0.1, 0.2, 0.3, 0.4], 2)).toEqual([0.1, 0.2]); + }); + + it('pads by duplicating the last value', () => { + expect(normalizeVolumeBands([0.1, 0.2], 4)).toEqual([0.1, 0.2, 0.2, 0.2]); + }); + + it('pads a single-element array by duplicating it', () => { + expect(normalizeVolumeBands([0.5], 3)).toEqual([0.5, 0.5, 0.5]); + }); + + it('pads an empty array with 0s', () => { + expect(normalizeVolumeBands([], 3)).toEqual([0, 0, 0]); + }); +}); diff --git a/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx index 955f01097..2b4e2f642 100644 --- a/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx +++ b/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; -import { AgentAudioVisualizerRadial } from '@/components/agents-ui/agent-audio-visualizer-radial'; +import { + AgentAudioVisualizerRadial, + normalizeVolumeBands, +} from '@/components/agents-ui/agent-audio-visualizer-radial'; import * as LiveKitComponents from '@livekit/components-react'; // Mock hooks vi.mock('@livekit/components-react', async () => { @@ -108,3 +111,25 @@ describe('AgentAudioVisualizerRadial', () => { expect(bars[2]).not.toHaveStyle({ height: '0px' }); }); }); + +describe('normalizeVolumeBands', () => { + it('returns the array unchanged when the length already matches', () => { + expect(normalizeVolumeBands([0.1, 0.2, 0.3], 3)).toEqual([0.1, 0.2, 0.3]); + }); + + it('trims excess trailing values', () => { + expect(normalizeVolumeBands([0.1, 0.2, 0.3, 0.4], 2)).toEqual([0.1, 0.2]); + }); + + it('pads by duplicating the last value', () => { + expect(normalizeVolumeBands([0.1, 0.2], 4)).toEqual([0.1, 0.2, 0.2, 0.2]); + }); + + it('pads a single-element array by duplicating it', () => { + expect(normalizeVolumeBands([0.5], 3)).toEqual([0.5, 0.5, 0.5]); + }); + + it('pads an empty array with 0s', () => { + expect(normalizeVolumeBands([], 3)).toEqual([0, 0, 0]); + }); +}); diff --git a/packages/shadcn/tsconfig.json b/packages/shadcn/tsconfig.json index 47b54192e..656e2d593 100644 --- a/packages/shadcn/tsconfig.json +++ b/packages/shadcn/tsconfig.json @@ -9,6 +9,7 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", diff --git a/turbo.json b/turbo.json index 8349aff41..967ca825b 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turborepo.org/schema.json", + "ui": "tui", "tasks": { "build": { "dependsOn": ["^build"], @@ -36,6 +37,10 @@ "cache": false, "dependsOn": ["^build"] }, - "api-check:update": {} + "api-check:update": {}, + "shadcn:publish:all": { + "cache": false, + "interactive": true + } } }