From ffd12cb38d0851f41b209e9e78e4d5d5b515b147 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Thu, 16 Jul 2026 19:12:12 +0530 Subject: [PATCH] Add OpenUI Cloud build telemetry --- packages/openui-cli/README.md | 11 +- packages/openui-cli/package.json | 3 +- .../openui-cli/src/commands/create-app.ts | 9 + .../openui-cli/src/lib/project-telemetry.ts | 39 +++ packages/openui-cli/src/lib/telemetry.ts | 14 +- .../openui-cloud/.openui/build-telemetry.ts | 119 +++++++ .../src/templates/openui-cloud/README.md | 24 ++ .../src/templates/openui-cloud/next.config.ts | 5 + .../openui-cli/tests/build-telemetry.test.mjs | 329 ++++++++++++++++++ 9 files changed, 548 insertions(+), 5 deletions(-) create mode 100644 packages/openui-cli/src/lib/project-telemetry.ts create mode 100644 packages/openui-cli/src/templates/openui-cloud/.openui/build-telemetry.ts create mode 100644 packages/openui-cli/tests/build-telemetry.test.mjs diff --git a/packages/openui-cli/README.md b/packages/openui-cli/README.md index 1f22abe13..f80736a83 100644 --- a/packages/openui-cli/README.md +++ b/packages/openui-cli/README.md @@ -182,10 +182,19 @@ node dist/index.js generate --help ## Telemetry -The CLI sends usage analytics; OAuth sign-ins may link usage to your OIDC account ID. It does not send code, prompts, API keys, email, or name. Disable telemetry with `--no-telemetry` or `DO_NOT_TRACK=1`. +The CLI sends usage analytics; OAuth sign-ins may link CLI usage to your OIDC account ID. When +telemetry is enabled, newly scaffolded OpenUI Cloud projects also receive a random pseudonymous +project ID and report when Next.js production compilation completes. This compiler event occurs +before type checking and static generation, so it is not reported as a successful build. + +Telemetry does not send code, prompts, API keys, email, name, project paths, repository metadata, +or build output. Disable CLI and generated-project telemetry with `--no-telemetry` or +`DO_NOT_TRACK=1`. Existing generated projects also honor `OPENUI_TELEMETRY_DISABLED=1` during +builds. ```bash openui create --no-telemetry +OPENUI_TELEMETRY_DISABLED=1 npm run build ``` ## Notes diff --git a/packages/openui-cli/package.json b/packages/openui-cli/package.json index 5beddc695..e150d21a1 100644 --- a/packages/openui-cli/package.json +++ b/packages/openui-cli/package.json @@ -14,12 +14,13 @@ "build:templates": "node scripts/build-templates.js", "build": "pnpm run build:cli && pnpm run build:templates", "build:exec": "node dist/index.js", + "test": "pnpm run build && node --test tests/*.test.mjs", "lint:check": "eslint ./src --ignore-pattern 'src/templates/**'", "lint:fix": "eslint ./src --fix --ignore-pattern 'src/templates/**'", "format:fix": "prettier --write './src/**' '!./src/templates/**'", "format:check": "prettier --check './src/**' '!./src/templates/**'", "prepare": "pnpm run build", - "ci": "pnpm run lint:check && pnpm run format:check" + "ci": "pnpm run lint:check && pnpm run format:check && pnpm run test" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index c9c5a09df..d92d465cd 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -8,6 +8,7 @@ import { aiSetupFromTemplate, createFunnelProps } from "../lib/create-telemetry" import type { CreateAppOptions, EnvResult, TemplateName } from "../lib/create-types"; import { resolveInstallPackageManager } from "../lib/detect-package-manager"; import { runSkillInstall, shouldInstallSkill } from "../lib/install-skill"; +import { createProjectTelemetryState, writeProjectTelemetryState } from "../lib/project-telemetry"; import { resolveArgs } from "../lib/resolve-args"; import { CreateError, telemetry } from "../lib/telemetry"; @@ -111,6 +112,11 @@ export async function runCreateApp(options: CreateAppOptions): Promise { throw new CreateError("dir_exists", `Directory "${name}" already exists.`); } + const projectTelemetryState = createProjectTelemetryState(template, telemetry.isEnabled()); + if (projectTelemetryState) { + telemetry.register({ project_id: projectTelemetryState.projectId }); + } + const packageManager = resolveInstallPackageManager(); const templateDir = path.join(__dirname, "..", "templates", template); if (!fs.existsSync(templateDir)) { @@ -150,6 +156,9 @@ export async function runCreateApp(options: CreateAppOptions): Promise { filter: (src) => shouldCopyTemplatePath(templateDir, src), }); rewritePackageJson(targetDir, name); + if (projectTelemetryState) { + writeProjectTelemetryState(targetDir, projectTelemetryState); + } } catch (err) { captureScaffoldFailed(); throw err; diff --git a/packages/openui-cli/src/lib/project-telemetry.ts b/packages/openui-cli/src/lib/project-telemetry.ts new file mode 100644 index 000000000..0c4eef0dd --- /dev/null +++ b/packages/openui-cli/src/lib/project-telemetry.ts @@ -0,0 +1,39 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import type { TemplateName } from "./create-types"; + +export const PROJECT_TELEMETRY_SCHEMA_VERSION = 1; +export const PROJECT_TELEMETRY_RELATIVE_PATH = path.join(".openui", "telemetry.json"); + +export type ProjectTelemetryState = { + schemaVersion: typeof PROJECT_TELEMETRY_SCHEMA_VERSION; + projectId: string; +}; + +export function createProjectTelemetryState( + template: TemplateName, + telemetryEnabled: boolean, +): ProjectTelemetryState | undefined { + if (!telemetryEnabled || template !== "openui-cloud") return undefined; + + return { + schemaVersion: PROJECT_TELEMETRY_SCHEMA_VERSION, + projectId: crypto.randomUUID(), + }; +} + +/** Best-effort metadata write: telemetry must never make scaffolding fail. */ +export function writeProjectTelemetryState( + projectDir: string, + state: ProjectTelemetryState, +): boolean { + try { + const file = path.join(projectDir, PROJECT_TELEMETRY_RELATIVE_PATH); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n"); + return true; + } catch { + return false; + } +} diff --git a/packages/openui-cli/src/lib/telemetry.ts b/packages/openui-cli/src/lib/telemetry.ts index bbdb9e79a..48aa58c56 100644 --- a/packages/openui-cli/src/lib/telemetry.ts +++ b/packages/openui-cli/src/lib/telemetry.ts @@ -76,7 +76,10 @@ export class Telemetry { isTruthyEnv(process.env["DO_NOT_TRACK"]) || isTruthyEnv(process.env["OPENUI_TELEMETRY_DISABLED"]) || opts.flagEnabled === false; - if (optedOut) return; // enabled stays false → all capture() are no-ops + if (optedOut) { + this.enabled = false; + return; + } const state = loadOrCreateState(); this.distinctId = state.distinctId; const interactiveTerminal = isInteractiveTerminal(); @@ -116,8 +119,9 @@ export class Telemetry { if (isTelemetryDebug()) this.client.debug(); if (state.isFirstRun) { process.stderr.write( - "\nā—† OpenUI CLI collects usage analytics; OAuth sign-ins may link usage to your OIDC account ID.\n" + - " No code, prompts, API keys, email, or name are collected. Opt out: set DO_NOT_TRACK=1 or pass --no-telemetry.\n\n", + "\nā—† OpenUI collects anonymous CLI analytics; Cloud templates also report pseudonymous production compilation.\n" + + " OAuth may link CLI usage to your OIDC account ID. No code, prompts, API keys, email, or name are collected.\n" + + " Opt out: set DO_NOT_TRACK=1 or pass --no-telemetry. Later builds also honor OPENUI_TELEMETRY_DISABLED=1.\n\n", ); state.persist(); } @@ -127,6 +131,10 @@ export class Telemetry { if (this.enabled) Object.assign(this.superProps, props); } + isEnabled() { + return this.enabled; + } + capture(event: string, properties: Record = {}) { if (!this.enabled || !this.client) return; try { diff --git a/packages/openui-cli/src/templates/openui-cloud/.openui/build-telemetry.ts b/packages/openui-cli/src/templates/openui-cloud/.openui/build-telemetry.ts new file mode 100644 index 000000000..3d0836b8a --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/.openui/build-telemetry.ts @@ -0,0 +1,119 @@ +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; + +const DEFAULT_POSTHOG_KEY = "phc_3OLW53x09ZTVZSV6BEpj5uycj3ooqR6KOemOjx04e3D"; +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; +const PROJECT_STATE_PATH = path.join(".openui", "telemetry.json"); +const TELEMETRY_SCHEMA_VERSION = 1; +const SEND_TIMEOUT_MS = 1000; +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +type ProductionCompileContext = { + distDir: string; + projectDir: string; +}; + +type ProjectTelemetryState = { + schemaVersion?: unknown; + projectId?: unknown; +}; + +const isTruthyEnv = (value?: string) => value === "1" || value?.toLowerCase() === "true"; + +function isCi(): boolean { + const env = process.env; + return [env.CI, env.GITHUB_ACTIONS, env.GITLAB_CI, env.BUILDKITE].some(isTruthyEnv); +} + +function readProjectId(projectDir: string): string | undefined { + try { + const state = JSON.parse( + fs.readFileSync(path.join(projectDir, PROJECT_STATE_PATH), "utf8"), + ) as ProjectTelemetryState; + + if ( + state.schemaVersion !== TELEMETRY_SCHEMA_VERSION || + typeof state.projectId !== "string" || + !UUID_V4_PATTERN.test(state.projectId) + ) { + return undefined; + } + + return state.projectId; + } catch { + return undefined; + } +} + +function readNextVersion(projectDir: string): string { + try { + const requireFromProject = createRequire(path.join(projectDir, "package.json")); + const manifestPath = requireFromProject.resolve("next/package.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { version?: unknown }; + return typeof manifest.version === "string" ? manifest.version : "unknown"; + } catch { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(projectDir, "package.json"), "utf8"), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + const declaredVersion = manifest.dependencies?.next ?? manifest.devDependencies?.next; + return typeof declaredVersion === "string" ? declaredVersion : "unknown"; + } catch { + return "unknown"; + } + } +} + +/** + * Reports completion of Next.js production compilation. This is intentionally + * not named "build succeeded": type checking and static generation run later. + */ +export async function reportOpenUIProductionCompile({ + projectDir, +}: ProductionCompileContext): Promise { + if (isTruthyEnv(process.env.DO_NOT_TRACK) || isTruthyEnv(process.env.OPENUI_TELEMETRY_DISABLED)) { + return; + } + + const projectId = readProjectId(projectDir); + if (!projectId) return; + + const payload = { + api_key: process.env.OPENUI_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY, + event: "openui_template_production_compile_completed", + distinct_id: projectId, + properties: { + project_id: projectId, + template: "openui-cloud", + framework: "nextjs", + next_version: readNextVersion(projectDir), + node_major_version: Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10), + platform: process.platform, + architecture: process.arch, + ci: isCi(), + cloud_key_configured: Boolean(process.env.THESYS_API_KEY?.trim()), + telemetry_schema_version: TELEMETRY_SCHEMA_VERSION, + $process_person_profile: false, + $geoip_disable: true, + }, + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); + try { + await fetch(new URL("/i/v0/e/", process.env.OPENUI_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } catch { + // Telemetry is best-effort and must never affect or add noise to a build. + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/openui-cli/src/templates/openui-cloud/README.md b/packages/openui-cli/src/templates/openui-cloud/README.md index c16c16c5c..685fe01ce 100644 --- a/packages/openui-cli/src/templates/openui-cloud/README.md +++ b/packages/openui-cli/src/templates/openui-cloud/README.md @@ -55,6 +55,30 @@ OPENUI_MODEL=google/gemini-3.1-pro-free - `@openuidev/react-headless` / `@openuidev/react-ui` — the chat UI runtime (`AgentInterface`, storage/stream contracts, `defineArtifactRenderer`). +## Pseudonymous build telemetry + +Projects scaffolded with CLI telemetry enabled contain a random project ID in +`.openui/telemetry.json`. After Next.js production compilation completes, the hook in +`next.config.ts` sends one best-effort event to OpenUI's PostHog project. This hook runs before type +checking and static generation, so the event means compilation completed, not that the full build +succeeded. + +The event contains only the random project ID, template and framework identifiers, Next.js and Node +versions, platform and architecture, whether the build is running in CI, whether a Cloud key is +configured, and the telemetry schema version. It does not contain code, prompts, API keys, project +names or paths, repository metadata, environment-variable values, or build output. +Geo-IP enrichment and PostHog person-profile creation are disabled for this event. + +Disable the event with either environment variable: + +```bash +DO_NOT_TRACK=1 npm run build +OPENUI_TELEMETRY_DISABLED=1 npm run build +``` + +Projects originally created with `openui create --no-telemetry` do not receive a project ID, so the +build hook is a no-op. + ## Learn More To learn more about OpenUI, take a look at the following resources: diff --git a/packages/openui-cli/src/templates/openui-cloud/next.config.ts b/packages/openui-cli/src/templates/openui-cloud/next.config.ts index 68a6c64d2..1b163b6d3 100644 --- a/packages/openui-cli/src/templates/openui-cloud/next.config.ts +++ b/packages/openui-cli/src/templates/openui-cloud/next.config.ts @@ -1,7 +1,12 @@ import type { NextConfig } from "next"; +import { reportOpenUIProductionCompile } from "./.openui/build-telemetry"; + const nextConfig: NextConfig = { output: "standalone", + compiler: { + runAfterProductionCompile: reportOpenUIProductionCompile, + }, }; export default nextConfig; diff --git a/packages/openui-cli/tests/build-telemetry.test.mjs b/packages/openui-cli/tests/build-telemetry.test.mjs new file mode 100644 index 000000000..54a68155a --- /dev/null +++ b/packages/openui-cli/tests/build-telemetry.test.mjs @@ -0,0 +1,329 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, afterEach, before, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { build } from "esbuild"; + +const packageDir = fileURLToPath(new URL("..", import.meta.url)); +const bundleDir = mkdtempSync(path.join(tmpdir(), "openui-cli-telemetry-tests-")); +const projectDirs = []; +const originalFetch = globalThis.fetch; +const trackedEnvironment = [ + "BUILDKITE", + "CI", + "DO_NOT_TRACK", + "GITHUB_ACTIONS", + "GITLAB_CI", + "OPENUI_POSTHOG_HOST", + "OPENUI_POSTHOG_KEY", + "OPENUI_TELEMETRY_DISABLED", + "THESYS_API_KEY", +]; +const originalEnvironment = Object.fromEntries( + trackedEnvironment.map((name) => [name, process.env[name]]), +); + +let buildTelemetry; +let projectTelemetry; + +async function bundleAndImport(entry, name) { + const outfile = path.join(bundleDir, `${name}.mjs`); + await build({ + entryPoints: [path.join(packageDir, entry)], + outfile, + bundle: true, + format: "esm", + platform: "node", + target: "node20", + }); + return import(pathToFileURL(outfile).href); +} + +function restoreEnvironment() { + for (const name of trackedEnvironment) { + const original = originalEnvironment[name]; + if (original === undefined) delete process.env[name]; + else process.env[name] = original; + } +} + +function makeProject( + state = { schemaVersion: 1, projectId: "123e4567-e89b-42d3-a456-426614174000" }, +) { + const projectDir = mkdtempSync(path.join(tmpdir(), "openui-build-telemetry-project-")); + projectDirs.push(projectDir); + + if (state) { + mkdirSync(path.join(projectDir, ".openui"), { recursive: true }); + writeFileSync(path.join(projectDir, ".openui", "telemetry.json"), JSON.stringify(state)); + } + + mkdirSync(path.join(projectDir, "node_modules", "next"), { recursive: true }); + writeFileSync( + path.join(projectDir, "node_modules", "next", "package.json"), + JSON.stringify({ version: "16.1.6" }), + ); + + return projectDir; +} + +before(async () => { + buildTelemetry = await bundleAndImport( + "src/templates/openui-cloud/.openui/build-telemetry.ts", + "build-telemetry", + ); + projectTelemetry = await bundleAndImport("src/lib/project-telemetry.ts", "project-telemetry"); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + restoreEnvironment(); +}); + +after(() => { + rmSync(bundleDir, { recursive: true, force: true }); + for (const projectDir of projectDirs) { + rmSync(projectDir, { recursive: true, force: true }); + } +}); + +test("production compilation sends only the allowlisted payload", async () => { + const projectDir = makeProject({ + schemaVersion: 1, + projectId: "123e4567-e89b-42d3-a456-426614174000", + ignoredSecret: "marker-secret", + }); + process.env.OPENUI_POSTHOG_HOST = "https://telemetry.example.test/base"; + process.env.OPENUI_POSTHOG_KEY = "test-public-key"; + process.env.THESYS_API_KEY = "secret-cloud-key"; + process.env.CI = "1"; + + let captured; + globalThis.fetch = async (url, init) => { + captured = { url: String(url), init }; + return new Response(null, { status: 200 }); + }; + + await buildTelemetry.reportOpenUIProductionCompile({ + projectDir, + distDir: path.join(projectDir, ".next"), + }); + + assert.ok(captured); + assert.equal(captured.url, "https://telemetry.example.test/i/v0/e/"); + assert.equal(captured.init.method, "POST"); + assert.equal(captured.init.headers["content-type"], "application/json"); + assert.ok(captured.init.signal instanceof AbortSignal); + + const body = String(captured.init.body); + const payload = JSON.parse(body); + assert.deepEqual(Object.keys(payload).sort(), ["api_key", "distinct_id", "event", "properties"]); + assert.equal(payload.api_key, "test-public-key"); + assert.equal(payload.distinct_id, "123e4567-e89b-42d3-a456-426614174000"); + assert.equal(payload.event, "openui_template_production_compile_completed"); + assert.deepEqual(Object.keys(payload.properties).sort(), [ + "$geoip_disable", + "$process_person_profile", + "architecture", + "ci", + "cloud_key_configured", + "framework", + "next_version", + "node_major_version", + "platform", + "project_id", + "telemetry_schema_version", + "template", + ]); + assert.equal(payload.properties.project_id, payload.distinct_id); + assert.equal(payload.properties.template, "openui-cloud"); + assert.equal(payload.properties.framework, "nextjs"); + assert.equal(payload.properties.next_version, "16.1.6"); + assert.equal(payload.properties.ci, true); + assert.equal(payload.properties.cloud_key_configured, true); + assert.equal(payload.properties.$process_person_profile, false); + assert.equal(payload.properties.$geoip_disable, true); + assert.equal(body.includes("secret-cloud-key"), false); + assert.equal(body.includes("marker-secret"), false); + assert.equal(body.includes(projectDir), false); + assert.equal(body.includes(".next"), false); +}); + +test("the project marker produces a stable project identity", async () => { + const projectDir = makeProject(); + const distinctIds = []; + globalThis.fetch = async (_url, init) => { + distinctIds.push(JSON.parse(String(init.body)).distinct_id); + return new Response(null, { status: 200 }); + }; + + await buildTelemetry.reportOpenUIProductionCompile({ projectDir, distDir: ".next" }); + await buildTelemetry.reportOpenUIProductionCompile({ projectDir, distDir: ".next" }); + + assert.deepEqual(distinctIds, [ + "123e4567-e89b-42d3-a456-426614174000", + "123e4567-e89b-42d3-a456-426614174000", + ]); +}); + +test("environment opt-outs suppress the compiler event", async () => { + const projectDir = makeProject(); + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(null, { status: 200 }); + }; + + process.env.DO_NOT_TRACK = "1"; + await buildTelemetry.reportOpenUIProductionCompile({ projectDir, distDir: ".next" }); + delete process.env.DO_NOT_TRACK; + + process.env.OPENUI_TELEMETRY_DISABLED = "true"; + await buildTelemetry.reportOpenUIProductionCompile({ projectDir, distDir: ".next" }); + + assert.equal(calls, 0); +}); + +test("missing or invalid project metadata suppresses the compiler event", async () => { + const missingStateProject = makeProject(null); + const invalidStateProject = makeProject({ schemaVersion: 1, projectId: "not-a-uuid" }); + const wrongSchemaProject = makeProject({ + schemaVersion: 2, + projectId: "123e4567-e89b-42d3-a456-426614174000", + }); + const malformedStateProject = makeProject(); + writeFileSync(path.join(malformedStateProject, ".openui", "telemetry.json"), "{not-json"); + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(null, { status: 200 }); + }; + + await buildTelemetry.reportOpenUIProductionCompile({ + projectDir: missingStateProject, + distDir: ".next", + }); + await buildTelemetry.reportOpenUIProductionCompile({ + projectDir: invalidStateProject, + distDir: ".next", + }); + await buildTelemetry.reportOpenUIProductionCompile({ + projectDir: wrongSchemaProject, + distDir: ".next", + }); + await buildTelemetry.reportOpenUIProductionCompile({ + projectDir: malformedStateProject, + distDir: ".next", + }); + + assert.equal(calls, 0); +}); + +test("network failures never fail compilation", async () => { + const projectDir = makeProject(); + globalThis.fetch = async () => { + throw new Error("offline"); + }; + + await assert.doesNotReject( + buildTelemetry.reportOpenUIProductionCompile({ projectDir, distDir: ".next" }), + ); +}); + +test("a stalled telemetry request is capped by the sender timeout", async () => { + const projectDir = makeProject(); + globalThis.fetch = async (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }); + }); + + const startedAt = Date.now(); + await assert.doesNotReject( + buildTelemetry.reportOpenUIProductionCompile({ projectDir, distDir: ".next" }), + ); + const elapsed = Date.now() - startedAt; + + assert.ok(elapsed >= 750, `expected the one-second timeout, got ${elapsed}ms`); + assert.ok(elapsed < 2500, `telemetry timeout took too long: ${elapsed}ms`); +}); + +test("project telemetry state is Cloud-only and best-effort", () => { + assert.equal(projectTelemetry.createProjectTelemetryState("openui-self-hosted", true), undefined); + assert.equal(projectTelemetry.createProjectTelemetryState("openui-cloud", false), undefined); + + const state = projectTelemetry.createProjectTelemetryState("openui-cloud", true); + const otherState = projectTelemetry.createProjectTelemetryState("openui-cloud", true); + assert.equal(state.schemaVersion, 1); + assert.match( + state.projectId, + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + assert.notEqual(state.projectId, otherState.projectId); + + const projectDir = mkdtempSync(path.join(tmpdir(), "openui-project-state-")); + projectDirs.push(projectDir); + assert.equal(projectTelemetry.writeProjectTelemetryState(projectDir, state), true); + assert.deepEqual( + JSON.parse(readFileSync(path.join(projectDir, ".openui", "telemetry.json"), "utf8")), + state, + ); + + const blockingFile = path.join(projectDir, "not-a-directory"); + writeFileSync(blockingFile, "file"); + assert.equal(projectTelemetry.writeProjectTelemetryState(blockingFile, state), false); +}); + +test("CLI opt-outs propagate to generated Cloud projects", () => { + const cases = [ + { name: "flag", prefixArgs: ["--no-telemetry"], environment: {} }, + { name: "do-not-track", prefixArgs: [], environment: { DO_NOT_TRACK: "1" } }, + { + name: "openui-disabled", + prefixArgs: [], + environment: { OPENUI_TELEMETRY_DISABLED: "1" }, + }, + ]; + + for (const testCase of cases) { + const root = mkdtempSync(path.join(tmpdir(), `openui-cli-opt-out-${testCase.name}-`)); + projectDirs.push(root); + const projectName = "generated-app"; + const result = spawnSync( + process.execPath, + [ + path.join(packageDir, "dist", "index.js"), + ...testCase.prefixArgs, + "create", + "--name", + projectName, + "--template", + "openui-cloud", + "--api-key", + "test-key", + "--no-skill", + "--no-install", + "--no-interactive", + ], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + DO_NOT_TRACK: "", + OPENUI_TELEMETRY_DISABLED: "", + ...testCase.environment, + }, + timeout: 15_000, + }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const generatedProject = path.join(root, projectName); + assert.equal(existsSync(path.join(generatedProject, ".openui", "telemetry.json")), false); + assert.equal(existsSync(path.join(generatedProject, ".openui", "build-telemetry.ts")), true); + } +});