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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 5 additions & 3 deletions hooks/session-hooks-platform-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ describe("session hook platform compatibility", () => {
{},
);
const envVars = buildSessionStartProfilerEnvVars({
agentBrowserAvailable: true,
greenfield: true,
likelySkills: ["ai-sdk", "nextjs"],
setupSignals: {
Expand All @@ -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",
Expand Down
83 changes: 69 additions & 14 deletions hooks/session-start-profiler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -493,6 +545,7 @@ if (isSessionStartProfilerEntrypoint) {
export {
buildSessionStartProfilerEnvVars,
buildSessionStartProfilerUserMessages,
buildSessionStartTelemetryEntries,
checkGreenfield,
detectSessionStartPlatform,
formatSessionStartProfilerCursorOutput,
Expand All @@ -501,5 +554,7 @@ export {
parseSessionStartInput,
profileBootstrapSignals,
profileProject,
resolveSessionStartProjectRoot
readLinkedVercelProject,
resolveSessionStartProjectRoot,
trackSessionStartTelemetry
};
125 changes: 112 additions & 13 deletions hooks/src/session-start-profiler.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +60,11 @@ interface GreenfieldResult {
entries: string[];
}

interface VercelProjectLink {
projectId: string;
orgId: string;
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work for all operating systems?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node:path.join(projectRoot, ".vercel", "project.json") is cross-platform, so this works on macOS, Linux, and Windows.
I pushed a follow-up that validates the behavior by:

  • preferring the hook payload root before reading .vercel/project.json
  • adding coverage for valid, malformed, and incomplete linked-project metadata
  • adding a session-start telemetry test that verifies payload-root attribution
  • bumping the plugin version to 0.32.1

if (!existsSync(projectJsonPath)) {
return null;
}

const project = safeReadJson<Record<string, unknown>>(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<void> {
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.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -618,7 +719,7 @@ async function main(): Promise<void> {
const hookInput = parseSessionStartInput(readFileSync(0, "utf8"));
const platform = detectSessionStartPlatform(hookInput);
const sessionId = normalizeSessionStartSessionId(hookInput);
const projectRoot = resolveSessionStartProjectRoot();
const projectRoot = resolveSessionStartProjectRoot(hookInput);

logBrokenSkillFrontmatterSummary();

Expand Down Expand Up @@ -698,15 +799,13 @@ async function main(): Promise<void> {

// 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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vercel-plugin",
"version": "0.32.0",
"version": "0.32.1",
"private": true,
"bin": {
"vercel-plugin": "src/cli/index.ts"
Expand Down
Loading
Loading