diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index d07f593..92157f2 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel", - "version": "0.32.0", + "version": "0.32.1", "description": "Build and deploy web apps and agents", "author": { "name": "Vercel Labs", diff --git a/.plugin/plugin.json b/.plugin/plugin.json index 1eb568e..a1cbe91 100644 --- a/.plugin/plugin.json +++ b/.plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel-plugin", - "version": "0.32.0", + "version": "0.32.1", "description": "Comprehensive Vercel ecosystem plugin — relational knowledge graph, skills for every major product, specialized agents, and Vercel conventions. Turns any AI agent into a Vercel expert.", "author": { "name": "Vercel Labs", diff --git a/hooks/session-hooks-platform-compat.test.ts b/hooks/session-hooks-platform-compat.test.ts index 318ea94..5a98ce0 100644 --- a/hooks/session-hooks-platform-compat.test.ts +++ b/hooks/session-hooks-platform-compat.test.ts @@ -52,7 +52,6 @@ describe("session hook platform compatibility", () => { {}, ); const envVars = buildSessionStartProfilerEnvVars({ - agentBrowserAvailable: true, greenfield: true, likelySkills: ["ai-sdk", "nextjs"], setupSignals: { @@ -63,12 +62,15 @@ describe("session hook platform compatibility", () => { }); expect(platform).toBe("cursor"); - expect(resolveSessionStartProjectRoot({ CURSOR_PROJECT_DIR: "/tmp/cursor-root" })).toBe( + expect(resolveSessionStartProjectRoot( + { cwd: "/tmp/payload-root", workspace_roots: ["/tmp/workspace-root"] }, + { CURSOR_PROJECT_DIR: "/tmp/cursor-root" }, + )).toBe("/tmp/payload-root"); + expect(resolveSessionStartProjectRoot(null, { CURSOR_PROJECT_DIR: "/tmp/cursor-root" })).toBe( "/tmp/cursor-root", ); expect(JSON.parse(formatSessionStartProfilerCursorOutput(envVars, ["profile ready"]))).toEqual({ env: { - VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE: "1", VERCEL_PLUGIN_GREENFIELD: "true", VERCEL_PLUGIN_LIKELY_SKILLS: "ai-sdk,nextjs", VERCEL_PLUGIN_BOOTSTRAP_HINTS: "greenfield", diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 92123db..1db704d 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -18,7 +18,7 @@ import { import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; +import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; var FILE_MARKERS = [ { file: "next.config.js", skills: ["nextjs", "turbopack"] }, { file: "next.config.mjs", skills: ["nextjs", "turbopack"] }, @@ -297,6 +297,56 @@ function checkVercelCli() { const needsUpdate = versionComparison === null ? !!(currentVersion && latestVersion && currentVersion !== latestVersion) : versionComparison < 0; return { installed: true, currentVersion, latestVersion, needsUpdate }; } +function readLinkedVercelProject(projectRoot) { + const projectJsonPath = join(projectRoot, ".vercel", "project.json"); + if (!existsSync(projectJsonPath)) { + return null; + } + const project = safeReadJson(projectJsonPath); + if (!project) { + return null; + } + const projectId = typeof project.projectId === "string" && project.projectId.trim() !== "" ? project.projectId : null; + const orgId = typeof project.orgId === "string" && project.orgId.trim() !== "" ? project.orgId : null; + if (!projectId || !orgId) { + return null; + } + return { projectId, orgId }; +} +function buildSessionStartTelemetryEntries(args) { + const entries = [ + { key: "session:device_id", value: args.deviceId }, + { key: "session:platform", value: process.platform }, + { key: "session:likely_skills", value: args.likelySkills.join(",") }, + { key: "session:greenfield", value: String(args.greenfield) }, + { key: "session:vercel_cli_installed", value: String(args.cliStatus.installed) }, + { key: "session:vercel_cli_version", value: args.cliStatus.currentVersion || "" } + ]; + if (args.vercelProjectLink) { + entries.push( + { key: "session:vercel_project_id", value: args.vercelProjectLink.projectId }, + { key: "session:vercel_org_id", value: args.vercelProjectLink.orgId } + ); + } + return entries; +} +async function trackSessionStartTelemetry(args) { + if (!(args.telemetryEnabled ?? isBaseTelemetryEnabled())) { + return; + } + const deviceId = (args.getDeviceId ?? getOrCreateDeviceId)(); + const vercelProjectLink = (args.readProjectLink ?? readLinkedVercelProject)(args.projectRoot); + await (args.trackEvents ?? trackBaseEvents)( + args.sessionId, + buildSessionStartTelemetryEntries({ + deviceId, + likelySkills: args.likelySkills, + greenfield: args.greenfield, + cliStatus: args.cliStatus, + vercelProjectLink + }) + ); +} function parseSessionStartInput(raw) { try { if (!raw.trim()) return null; @@ -319,8 +369,12 @@ function normalizeSessionStartSessionId(input) { const sessionId = normalizeInput(input).sessionId; return sessionId || null; } -function resolveSessionStartProjectRoot(env = process.env) { - return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); +function nonEmptySessionStartPath(value) { + return typeof value === "string" && value.trim() !== "" ? value : null; +} +function resolveSessionStartProjectRoot(input, env = process.env) { + const workspaceRoot = Array.isArray(input?.workspace_roots) ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") : null; + return nonEmptySessionStartPath(input?.cwd) ?? workspaceRoot ?? nonEmptySessionStartPath(env.CLAUDE_PROJECT_ROOT) ?? nonEmptySessionStartPath(env.CURSOR_PROJECT_DIR) ?? process.cwd(); } function collectBrokenSkillFrontmatterNames(files) { return [...new Set( @@ -408,7 +462,7 @@ async function main() { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveSessionStartProjectRoot(); + const projectRoot = resolveSessionStartProjectRoot(hookInput); logBrokenSkillFrontmatterSummary(); const greenfield = checkGreenfield(projectRoot); const cliStatus = checkVercelCli(); @@ -469,15 +523,13 @@ async function main() { } } if (sessionId) { - const deviceId = getOrCreateDeviceId(); - await trackBaseEvents(sessionId, [ - { key: "session:device_id", value: deviceId }, - { key: "session:platform", value: process.platform }, - { key: "session:likely_skills", value: likelySkills.join(",") }, - { key: "session:greenfield", value: String(greenfield !== null) }, - { key: "session:vercel_cli_installed", value: String(cliStatus.installed) }, - { key: "session:vercel_cli_version", value: cliStatus.currentVersion || "" } - ]).catch(() => { + await trackSessionStartTelemetry({ + sessionId, + projectRoot, + likelySkills, + greenfield: greenfield !== null, + cliStatus + }).catch(() => { }); } if (cursorOutput) { @@ -493,6 +545,7 @@ if (isSessionStartProfilerEntrypoint) { export { buildSessionStartProfilerEnvVars, buildSessionStartProfilerUserMessages, + buildSessionStartTelemetryEntries, checkGreenfield, detectSessionStartPlatform, formatSessionStartProfilerCursorOutput, @@ -501,5 +554,7 @@ export { parseSessionStartInput, profileBootstrapSignals, profileProject, - resolveSessionStartProjectRoot + readLinkedVercelProject, + resolveSessionStartProjectRoot, + trackSessionStartTelemetry }; diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 6c280c9..35f7643 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -32,7 +32,7 @@ import { import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; +import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; // --------------------------------------------------------------------------- // Types @@ -60,6 +60,11 @@ interface GreenfieldResult { entries: string[]; } +interface VercelProjectLink { + projectId: string; + orgId: string; +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -456,6 +461,87 @@ function checkVercelCli(): VercelCliStatus { return { installed: true, currentVersion, latestVersion, needsUpdate }; } +export function readLinkedVercelProject(projectRoot: string): VercelProjectLink | null { + const projectJsonPath = join(projectRoot, ".vercel", "project.json"); + if (!existsSync(projectJsonPath)) { + return null; + } + + const project = safeReadJson>(projectJsonPath); + if (!project) { + return null; + } + + const projectId = typeof project.projectId === "string" && project.projectId.trim() !== "" + ? project.projectId + : null; + const orgId = typeof project.orgId === "string" && project.orgId.trim() !== "" + ? project.orgId + : null; + + if (!projectId || !orgId) { + return null; + } + + return { projectId, orgId }; +} + +export function buildSessionStartTelemetryEntries(args: { + deviceId: string; + likelySkills: string[]; + greenfield: boolean; + cliStatus: VercelCliStatus; + vercelProjectLink?: VercelProjectLink | null; +}): Array<{ key: string; value: string }> { + const entries = [ + { key: "session:device_id", value: args.deviceId }, + { key: "session:platform", value: process.platform }, + { key: "session:likely_skills", value: args.likelySkills.join(",") }, + { key: "session:greenfield", value: String(args.greenfield) }, + { key: "session:vercel_cli_installed", value: String(args.cliStatus.installed) }, + { key: "session:vercel_cli_version", value: args.cliStatus.currentVersion || "" }, + ]; + + if (args.vercelProjectLink) { + entries.push( + { key: "session:vercel_project_id", value: args.vercelProjectLink.projectId }, + { key: "session:vercel_org_id", value: args.vercelProjectLink.orgId }, + ); + } + + return entries; +} + +export async function trackSessionStartTelemetry(args: { + sessionId: string; + projectRoot: string; + likelySkills: string[]; + greenfield: boolean; + cliStatus: VercelCliStatus; + telemetryEnabled?: boolean; + getDeviceId?: () => string; + readProjectLink?: (projectRoot: string) => VercelProjectLink | null; + trackEvents?: typeof trackBaseEvents; +}): Promise { + if (!(args.telemetryEnabled ?? isBaseTelemetryEnabled())) { + return; + } + + const deviceId = (args.getDeviceId ?? getOrCreateDeviceId)(); + const vercelProjectLink = (args.readProjectLink ?? readLinkedVercelProject)(args.projectRoot); + + await (args.trackEvents ?? trackBaseEvents)( + args.sessionId, + buildSessionStartTelemetryEntries({ + deviceId, + likelySkills: args.likelySkills, + greenfield: args.greenfield, + cliStatus: args.cliStatus, + vercelProjectLink, + }), + ); +} + // --------------------------------------------------------------------------- // Main entry point — profile the project and write env vars. // --------------------------------------------------------------------------- @@ -500,8 +586,23 @@ export function normalizeSessionStartSessionId(input: SessionStartInput | null): return sessionId || null; } -export function resolveSessionStartProjectRoot(env: NodeJS.ProcessEnv = process.env): string { - return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); +function nonEmptySessionStartPath(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + +export function resolveSessionStartProjectRoot( + input: SessionStartInput | null, + env: NodeJS.ProcessEnv = process.env, +): string { + const workspaceRoot = Array.isArray(input?.workspace_roots) + ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") + : null; + + return nonEmptySessionStartPath(input?.cwd) + ?? workspaceRoot + ?? nonEmptySessionStartPath(env.CLAUDE_PROJECT_ROOT) + ?? nonEmptySessionStartPath(env.CURSOR_PROJECT_DIR) + ?? process.cwd(); } function collectBrokenSkillFrontmatterNames(files: string[]): string[] { @@ -618,7 +719,7 @@ async function main(): Promise { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveSessionStartProjectRoot(); + const projectRoot = resolveSessionStartProjectRoot(hookInput); logBrokenSkillFrontmatterSummary(); @@ -698,15 +799,13 @@ async function main(): Promise { // Base telemetry — enabled by default unless VERCEL_PLUGIN_TELEMETRY=off if (sessionId) { - const deviceId = getOrCreateDeviceId(); - await trackBaseEvents(sessionId, [ - { key: "session:device_id", value: deviceId }, - { key: "session:platform", value: process.platform }, - { key: "session:likely_skills", value: likelySkills.join(",") }, - { key: "session:greenfield", value: String(greenfield !== null) }, - { key: "session:vercel_cli_installed", value: String(cliStatus.installed) }, - { key: "session:vercel_cli_version", value: cliStatus.currentVersion || "" }, - ]).catch(() => {}); + await trackSessionStartTelemetry({ + sessionId, + projectRoot, + likelySkills, + greenfield: greenfield !== null, + cliStatus, + }).catch(() => {}); } if (cursorOutput) { diff --git a/package.json b/package.json index 751e72a..b38f45b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vercel-plugin", - "version": "0.32.0", + "version": "0.32.1", "private": true, "bin": { "vercel-plugin": "src/cli/index.ts" diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index 905b3aa..36966e0 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, existsSync, @@ -9,7 +9,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { readSessionFile } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); @@ -21,7 +21,13 @@ let testSessionId: string; // Helpers // --------------------------------------------------------------------------- -async function runProfiler(env: Record): Promise<{ +async function runProfiler( + env: Record, + options?: { + input?: Record; + nodeArgs?: string[]; + }, +): Promise<{ code: number; stdout: string; stderr: string; @@ -38,14 +44,14 @@ async function runProfiler(env: Record): Promise<{ mergedEnv[key] = value; } - const proc = Bun.spawn([NODE_BIN, PROFILER], { + const proc = Bun.spawn([NODE_BIN, ...(options?.nodeArgs ?? []), PROFILER], { stdin: "pipe", stdout: "pipe", stderr: "pipe", env: mergedEnv, }); - proc.stdin.write(JSON.stringify({ session_id: testSessionId })); + proc.stdin.write(JSON.stringify(options?.input ?? { session_id: testSessionId })); proc.stdin.end(); const code = await proc.exited; @@ -69,12 +75,64 @@ function readGreenfieldState(): string { return readSessionFile(testSessionId, "greenfield"); } -function makeMockCommand(binDir: string, commandName: string, body: string): void { +function makeMockCommand( + binDir: string, + commandName: string, + options: { + stdoutLine?: string; + exitCode?: number; + sleepSeconds?: number; + }, +): void { + if (process.platform === "win32") { + const commandPath = join(binDir, `${commandName}.cmd`); + const lines = ["@echo off"]; + + if (options.sleepSeconds) { + lines.push(`powershell -NoProfile -Command "Start-Sleep -Seconds ${options.sleepSeconds}"`); + } + if (options.stdoutLine) { + lines.push(`echo ${options.stdoutLine}`); + } + + lines.push(`exit /b ${options.exitCode ?? 0}`); + writeFileSync(commandPath, `${lines.join("\r\n")}\r\n`, "utf-8"); + return; + } + + const lines = ["#!/bin/sh"]; + + if (options.sleepSeconds) { + lines.push(`sleep ${options.sleepSeconds}`); + } + if (options.stdoutLine) { + const escapedLine = options.stdoutLine.replace(/'/g, `'\\''`); + lines.push(`printf '%s\\n' '${escapedLine}'`); + } + + lines.push(`exit ${options.exitCode ?? 0}`); + const commandPath = join(binDir, commandName); - writeFileSync(commandPath, `#!/bin/sh\n${body}\n`, "utf-8"); + writeFileSync(commandPath, `${lines.join("\n")}\n`, "utf-8"); chmodSync(commandPath, 0o755); } +function createTelemetryPreload(capturePath: string): string { + const preloadPath = join(tempDir, `mock-fetch-${Math.random().toString(36).slice(2)}.mjs`); + writeFileSync(preloadPath, ` +import { writeFileSync } from "node:fs"; + +globalThis.fetch = async (_url, init) => { + writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ + headers: init?.headers, + body: typeof init?.body === "string" ? init.body : null, + }), "utf-8"); + return new Response(null, { status: 204 }); +}; +`, "utf-8"); + return preloadPath; +} + // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- @@ -570,6 +628,52 @@ describe("session-start-profiler", () => { expect(readGreenfieldState()).toBe("true"); }); + test("uses the hook payload root for linked project telemetry", async () => { + const envProjectDir = join(tempDir, "env-project-root"); + const payloadProjectDir = join(tempDir, "payload-project-root"); + const capturePath = join(tempDir, "session-start-telemetry.json"); + const binDir = join(tempDir, "empty-bin"); + mkdirSync(join(envProjectDir, ".vercel"), { recursive: true }); + mkdirSync(join(payloadProjectDir, ".vercel"), { recursive: true }); + mkdirSync(binDir); + writeFileSync( + join(envProjectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_env", orgId: "team_env" }), + "utf-8", + ); + writeFileSync( + join(payloadProjectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_payload", orgId: "team_payload" }), + "utf-8", + ); + const preloadPath = createTelemetryPreload(capturePath); + + const result = await runProfiler( + { + CLAUDE_ENV_FILE: envFile, + CLAUDE_PROJECT_ROOT: envProjectDir, + PATH: binDir, + }, + { + input: { + session_id: testSessionId, + cwd: payloadProjectDir, + }, + nodeArgs: ["--import", preloadPath], + }, + ); + + expect(result.code).toBe(0); + const captured = JSON.parse(readFileSync(capturePath, "utf-8")) as { + body: string; + }; + const events = JSON.parse(captured.body) as Array<{ key: string; value: string }>; + expect(events.some((event) => event.key === "session:vercel_project_id" && event.value === "prj_payload")).toBe(true); + expect(events.some((event) => event.key === "session:vercel_org_id" && event.value === "team_payload")).toBe(true); + expect(events.some((event) => event.key === "session:vercel_project_id" && event.value === "prj_env")).toBe(false); + expect(events.some((event) => event.key === "session:vercel_org_id" && event.value === "team_env")).toBe(false); + }); + test("hooks.json registers profiler after seen-skills init", () => { const hooksJson = JSON.parse( readFileSync(join(ROOT, "hooks", "hooks.json"), "utf-8"), @@ -602,13 +706,13 @@ describe("session-start-profiler", () => { const binDir = join(tempDir, "mock-bin"); mkdirSync(projectDir); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "printf 'Vercel CLI 1.9.0\\n'"); - makeMockCommand(binDir, "npm", "printf '1.10.0\\n'"); + makeMockCommand(binDir, "vercel", { stdoutLine: "Vercel CLI 1.9.0" }); + makeMockCommand(binDir, "npm", { stdoutLine: "1.10.0" }); const result = await runProfiler({ CLAUDE_ENV_FILE: envFile, CLAUDE_PROJECT_ROOT: projectDir, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}${delimiter}${process.env.PATH || ""}`, }); expect(result.code).toBe(0); @@ -624,7 +728,7 @@ describe("session-start-profiler", () => { const binDir = join(tempDir, "missing-npm-bin"); mkdirSync(projectDir); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "printf 'Vercel CLI 44.0.0\\n'"); + makeMockCommand(binDir, "vercel", { stdoutLine: "Vercel CLI 44.0.0" }); const result = await runProfiler({ CLAUDE_ENV_FILE: envFile, @@ -644,13 +748,13 @@ describe("session-start-profiler", () => { const binDir = join(tempDir, "slow-vercel-bin"); mkdirSync(projectDir); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "sleep 5"); + makeMockCommand(binDir, "vercel", { sleepSeconds: 5 }); const startedAt = Date.now(); const result = await runProfiler({ CLAUDE_ENV_FILE: envFile, CLAUDE_PROJECT_ROOT: projectDir, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}${delimiter}${process.env.PATH || ""}`, VERCEL_PLUGIN_LOG_LEVEL: "debug", }); const durationMs = Date.now() - startedAt; @@ -663,7 +767,7 @@ describe("session-start-profiler", () => { test("emits debug logs when swallowed profiler errors occur", async () => { const binDir = join(tempDir, "debug-bin"); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "exit 1"); + makeMockCommand(binDir, "vercel", { exitCode: 1 }); const result = await runProfiler({ CLAUDE_ENV_FILE: join(tempDir, "missing-dir", "claude.env"), @@ -784,6 +888,117 @@ describe("profileProject (unit)", () => { }); }); +describe("session-start telemetry helpers (unit)", () => { + test("reads linked Vercel project IDs from .vercel/project.json", async () => { + const { readLinkedVercelProject } = await import("../hooks/session-start-profiler.mjs"); + const projectDir = join(tempDir, "unit-vercel-link"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); + writeFileSync( + join(projectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_123", orgId: "team_456" }), + "utf-8", + ); + + expect(readLinkedVercelProject(projectDir)).toEqual({ + projectId: "prj_123", + orgId: "team_456", + }); + }); + + test("ignores malformed linked Vercel project metadata", async () => { + const { readLinkedVercelProject } = await import("../hooks/session-start-profiler.mjs"); + const projectDir = join(tempDir, "unit-vercel-link-malformed"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); + writeFileSync(join(projectDir, ".vercel", "project.json"), "{not valid json", "utf-8"); + + expect(readLinkedVercelProject(projectDir)).toBeNull(); + }); + + test("ignores incomplete linked Vercel project metadata", async () => { + const { readLinkedVercelProject } = await import("../hooks/session-start-profiler.mjs"); + const projectDir = join(tempDir, "unit-vercel-link-incomplete"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); + writeFileSync( + join(projectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_123" }), + "utf-8", + ); + + expect(readLinkedVercelProject(projectDir)).toBeNull(); + }); + + test("includes linked Vercel project IDs in session telemetry only when present", async () => { + const { buildSessionStartTelemetryEntries } = await import("../hooks/session-start-profiler.mjs"); + + const withLink = buildSessionStartTelemetryEntries({ + deviceId: "device_123", + likelySkills: ["nextjs", "vercel-cli"], + greenfield: false, + cliStatus: { + installed: true, + currentVersion: "44.7.3", + needsUpdate: false, + }, + vercelProjectLink: { + projectId: "prj_123", + orgId: "team_456", + }, + }); + expect(withLink).toContainEqual({ + key: "session:vercel_project_id", + value: "prj_123", + }); + expect(withLink).toContainEqual({ + key: "session:vercel_org_id", + value: "team_456", + }); + + const withoutLink = buildSessionStartTelemetryEntries({ + deviceId: "device_123", + likelySkills: ["nextjs", "vercel-cli"], + greenfield: false, + cliStatus: { + installed: true, + currentVersion: "44.7.3", + needsUpdate: false, + }, + vercelProjectLink: null, + }); + expect(withoutLink.find((entry) => entry.key === "session:vercel_project_id")).toBeUndefined(); + expect(withoutLink.find((entry) => entry.key === "session:vercel_org_id")).toBeUndefined(); + }); + + test("skips linked project lookup when base telemetry is disabled", async () => { + const { trackSessionStartTelemetry } = await import("../hooks/session-start-profiler.mjs"); + let readProjectLinkCalls = 0; + let trackEventsCalls = 0; + + await trackSessionStartTelemetry({ + sessionId: "session-disabled-telemetry", + projectRoot: join(tempDir, "telemetry-off-project"), + likelySkills: ["nextjs"], + greenfield: false, + cliStatus: { + installed: true, + currentVersion: "44.7.3", + needsUpdate: false, + }, + telemetryEnabled: false, + getDeviceId: () => "device_123", + readProjectLink: () => { + readProjectLinkCalls += 1; + return { projectId: "prj_123", orgId: "team_456" }; + }, + trackEvents: async () => { + trackEventsCalls += 1; + }, + }); + + expect(readProjectLinkCalls).toBe(0); + expect(trackEventsCalls).toBe(0); + }); +}); + describe("logBrokenSkillFrontmatterSummary (unit)", () => { test("emits one summary warning when a skill has malformed frontmatter", async () => { const { logBrokenSkillFrontmatterSummary } = await import("../hooks/session-start-profiler.mjs");