diff --git a/src/cli.ts b/src/cli.ts index 5833bbf..45faf80 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { injectMarker, resolveBuildDir, } from './mark-build.js'; +import { detectStack, type StackDescriptor } from './stack.js'; import { PatchstackError } from './types.js'; const HELP = `@patchstack/connect — scan your lockfile and report packages to Patchstack. @@ -205,13 +206,26 @@ async function runStatus(args: ParsedArgs): Promise { return 0; } +/** One-line, human-readable summary of a detected stack for CLI output. */ +function describeStack(stack: StackDescriptor): string | null { + const parts = [stack.builder, stack.framework, stack.ui, stack.runtime].filter( + (part): part is string => part !== null, + ); + if (stack.hostingEnvKeys.length > 0) { + parts.push(`${stack.hostingEnvKeys.length} hosting env key(s)`); + } + return parts.length > 0 ? parts.join(' · ') : null; +} + async function runMarkBuild(args: ParsedArgs): Promise { const cwd = process.cwd(); - // Compute the build fingerprint from the lockfile. Best-effort: mark-build is a - // postbuild step and must never fail the build, so a missing/unreadable lockfile - // just means we stamp the production flag without a fingerprint. + // Compute the build fingerprint and stack descriptor from the lockfile. + // Best-effort: mark-build is a postbuild step and must never fail the build, so + // a missing/unreadable lockfile just means we stamp the production flag without + // a fingerprint or stack. let checksum: string | null = null; + let stack: StackDescriptor | null = null; try { const manifest = await scanLockfile(cwd); for (const warning of manifest.warnings ?? []) { @@ -219,6 +233,7 @@ async function runMarkBuild(args: ParsedArgs): Promise { } const { payload } = buildWirePayload(manifest); checksum = computeManifestChecksum(payload.packages); + stack = detectStack(payload.packages); } catch (err) { console.warn( `mark-build: could not compute the build fingerprint (${(err as Error).message}). Stamping the production flag only.`, @@ -239,7 +254,7 @@ async function runMarkBuild(args: ParsedArgs): Promise { return 0; } - const snippet = buildInjectionSnippet(checksum); + const snippet = buildInjectionSnippet(checksum, stack); let marked = 0; for (const file of files) { const before = readFileSync(file, 'utf8'); @@ -250,8 +265,11 @@ async function runMarkBuild(args: ParsedArgs): Promise { } } + const stackSummary = stack !== null ? describeStack(stack) : null; console.log( - `mark-build: marked ${marked} HTML file(s) in ${dir}${checksum !== null ? ` (build ${checksum})` : ''}.`, + `mark-build: marked ${marked} HTML file(s) in ${dir}` + + `${checksum !== null ? ` (build ${checksum})` : ''}` + + `${stackSummary !== null ? ` [${stackSummary}]` : ''}.`, ); return 0; } diff --git a/src/index.ts b/src/index.ts index 113bfed..77fb4b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,12 @@ export { scanLockfile, detectLockfile } from './parsers/index.js'; export { buildWirePayload, compareVersions } from './normalize.js'; export { postManifest, buildClaimUrl, buildEndpointUrl, DEFAULT_ENDPOINT } from './client.js'; export { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js'; +export { + detectStack, + collectHostingEnvKeys, + isEmptyStack, + type StackDescriptor, +} from './stack.js'; export { PatchstackError, type Config, diff --git a/src/mark-build.ts b/src/mark-build.ts index 1e62c98..e95b373 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -1,6 +1,8 @@ import { existsSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; +import { isEmptyStack, type StackDescriptor } from './stack.js'; + /** Attribute that tags our injected `; } diff --git a/src/stack.ts b/src/stack.ts new file mode 100644 index 0000000..4966b21 --- /dev/null +++ b/src/stack.ts @@ -0,0 +1,161 @@ +import type { WirePackage } from './normalize.js'; + +/** + * A best-effort description of the stack a build was produced with, derived + * entirely from the lockfile (ground truth) plus the build-time environment. + * + * The disclosure widget reads this from `window.__PATCHSTACK_STACK__` (injected + * by `mark-build`) and reports it to Patchstack, so we learn how the sites we + * protect are actually built and hosted — across every "vibe" platform — without + * shipping a runtime probe onto the host server. Every field is a coarse label + * or a bare key *name*: no versions beyond the framework, and never an env value. + */ +export interface StackDescriptor { + /** App / meta-framework, e.g. "next", "nuxt", "tanstack-start", "remix". */ + framework: string | null; + /** UI runtime, e.g. "react", "vue", "svelte", "solid". */ + ui: string | null; + /** Build tool / bundler, e.g. "vite", "webpack", "rspack". */ + bundler: string | null; + /** Deployment-runtime hint from deps, e.g. "cloudflare-workers", "vercel". */ + runtime: string | null; + /** The vibe/builder platform that generated the project, e.g. "lovable". */ + builder: string | null; + /** Package ecosystem the manifest came from. */ + ecosystem: 'npm'; + /** Hosting-related build-environment variable NAMES (never their values). */ + hostingEnvKeys: string[]; +} + +type Category = 'framework' | 'ui' | 'bundler' | 'runtime' | 'builder'; + +interface StackRule { + category: Category; + /** Exact package name that signals this label. */ + pkg: string; + label: string; +} + +/** + * Package → stack-label registry. First match per category wins, so order + * within a category is priority order (most specific first). Add a row to teach + * the connector a new framework, bundler, or vibe platform. + */ +const STACK_RULES: readonly StackRule[] = [ + // Meta-frameworks (most specific first). + { category: 'framework', pkg: '@tanstack/react-start', label: 'tanstack-start' }, + { category: 'framework', pkg: '@tanstack/start', label: 'tanstack-start' }, + { category: 'framework', pkg: 'next', label: 'next' }, + { category: 'framework', pkg: 'nuxt', label: 'nuxt' }, + { category: 'framework', pkg: '@remix-run/react', label: 'remix' }, + { category: 'framework', pkg: '@remix-run/node', label: 'remix' }, + { category: 'framework', pkg: 'react-router', label: 'react-router' }, + { category: 'framework', pkg: 'astro', label: 'astro' }, + { category: 'framework', pkg: '@sveltejs/kit', label: 'sveltekit' }, + { category: 'framework', pkg: '@builder.io/qwik-city', label: 'qwik-city' }, + { category: 'framework', pkg: 'gatsby', label: 'gatsby' }, + { category: 'framework', pkg: 'express', label: 'express' }, + { category: 'framework', pkg: 'fastify', label: 'fastify' }, + + // UI runtimes. + { category: 'ui', pkg: '@angular/core', label: 'angular' }, + { category: 'ui', pkg: 'react-dom', label: 'react' }, + { category: 'ui', pkg: 'react', label: 'react' }, + { category: 'ui', pkg: 'vue', label: 'vue' }, + { category: 'ui', pkg: 'svelte', label: 'svelte' }, + { category: 'ui', pkg: 'solid-js', label: 'solid' }, + { category: 'ui', pkg: 'preact', label: 'preact' }, + + // Bundlers / build tools. + { category: 'bundler', pkg: 'vite', label: 'vite' }, + { category: 'bundler', pkg: '@rspack/core', label: 'rspack' }, + { category: 'bundler', pkg: 'webpack', label: 'webpack' }, + { category: 'bundler', pkg: 'parcel', label: 'parcel' }, + { category: 'bundler', pkg: 'rollup', label: 'rollup' }, + { category: 'bundler', pkg: 'esbuild', label: 'esbuild' }, + + // Deployment-runtime hints from build deps. + { category: 'runtime', pkg: 'wrangler', label: 'cloudflare-workers' }, + { category: 'runtime', pkg: '@cloudflare/workers-types', label: 'cloudflare-workers' }, + { category: 'runtime', pkg: '@cloudflare/vite-plugin', label: 'cloudflare-workers' }, + { category: 'runtime', pkg: '@vercel/node', label: 'vercel' }, + { category: 'runtime', pkg: '@netlify/functions', label: 'netlify' }, + { category: 'runtime', pkg: '@netlify/blobs', label: 'netlify' }, + + // Vibe / builder platforms — the "learn from each platform" signal. + { category: 'builder', pkg: 'lovable-tagger', label: 'lovable' }, + { category: 'builder', pkg: '@replit/vite-plugin-runtime-error-modal', label: 'replit' }, + { category: 'builder', pkg: '@replit/vite-plugin-cartographer', label: 'replit' }, +]; + +/** Build-environment variable-name patterns that fingerprint a host, from server-insight. */ +const HOSTING_ENV_PATTERNS: readonly RegExp[] = [ + /^CF_/, + /^CLOUDFLARE_/, + /^VERCEL/, + /^NETLIFY/, + /^AWS_(LAMBDA|REGION|EXECUTION)/, + /^FLY_/, + /^RENDER/, + /^RAILWAY_/, + /^DENO_/, + /^EDGE_RUNTIME/, + /^DYNO$/, + /^K_SERVICE$/, + /^GAE_/, + /^FUNCTION_/, +]; + +/** + * Return the sorted NAMES of hosting-related environment variables present in + * `env`. Only names are surfaced — never values — so a build fingerprint can + * distinguish "deployed on Cloudflare" from "deployed on Vercel" without ever + * disclosing a secret. + */ +export function collectHostingEnvKeys(env: NodeJS.ProcessEnv = process.env): string[] { + return Object.keys(env) + .filter((key) => HOSTING_ENV_PATTERNS.some((pattern) => pattern.test(key))) + .sort(); +} + +/** + * Derive a {@link StackDescriptor} from the wire packages and the build + * environment. Never throws: an unrecognised stack just yields nulls. + */ +export function detectStack( + packages: readonly WirePackage[], + env: NodeJS.ProcessEnv = process.env, +): StackDescriptor { + const present = new Set(packages.map((pkg) => pkg.name)); + + const firstMatch = (category: Category): string | null => { + for (const rule of STACK_RULES) { + if (rule.category === category && present.has(rule.pkg)) { + return rule.label; + } + } + return null; + }; + + return { + framework: firstMatch('framework'), + ui: firstMatch('ui'), + bundler: firstMatch('bundler'), + runtime: firstMatch('runtime'), + builder: firstMatch('builder'), + ecosystem: 'npm', + hostingEnvKeys: collectHostingEnvKeys(env), + }; +} + +/** True when the descriptor carries no useful signal (nothing worth injecting). */ +export function isEmptyStack(stack: StackDescriptor): boolean { + return ( + stack.framework === null && + stack.ui === null && + stack.bundler === null && + stack.runtime === null && + stack.builder === null && + stack.hostingEnvKeys.length === 0 + ); +} diff --git a/tests/mark-build.test.ts b/tests/mark-build.test.ts index 029c28b..787a711 100644 --- a/tests/mark-build.test.ts +++ b/tests/mark-build.test.ts @@ -23,6 +23,34 @@ describe('buildInjectionSnippet', () => { expect(snippet).toContain('__PATCHSTACK_PROD__'); expect(snippet).not.toContain('__PATCHSTACK_BUILD__'); }); + + it('injects the stack descriptor when one carries signal', () => { + const snippet = buildInjectionSnippet('abc123def456', { + framework: 'tanstack-start', + ui: 'react', + bundler: 'vite', + runtime: 'cloudflare-workers', + builder: 'lovable', + ecosystem: 'npm', + hostingEnvKeys: ['CF_PAGES'], + }); + expect(snippet).toContain('window.__PATCHSTACK_STACK__='); + expect(snippet).toContain('"builder":"lovable"'); + }); + + it('omits the stack when it is empty or absent', () => { + const empty = buildInjectionSnippet('c1', { + framework: null, + ui: null, + bundler: null, + runtime: null, + builder: null, + ecosystem: 'npm', + hostingEnvKeys: [], + }); + expect(empty).not.toContain('__PATCHSTACK_STACK__'); + expect(buildInjectionSnippet('c1')).not.toContain('__PATCHSTACK_STACK__'); + }); }); describe('injectMarker', () => { diff --git a/tests/stack.test.ts b/tests/stack.test.ts new file mode 100644 index 0000000..f41bac8 --- /dev/null +++ b/tests/stack.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { collectHostingEnvKeys, detectStack, isEmptyStack } from '../src/stack.js'; +import type { WirePackage } from '../src/normalize.js'; + +const pkgs = (...names: string[]): WirePackage[] => + names.map((name) => ({ name, version: '1.0.0' })); + +describe('detectStack', () => { + it('identifies a Lovable + TanStack Start + React + Vite + Cloudflare build', () => { + const stack = detectStack( + pkgs( + 'lovable-tagger', + '@tanstack/react-start', + 'react', + 'react-dom', + 'vite', + 'wrangler', + ), + {}, + ); + expect(stack.builder).toBe('lovable'); + expect(stack.framework).toBe('tanstack-start'); + expect(stack.ui).toBe('react'); + expect(stack.bundler).toBe('vite'); + expect(stack.runtime).toBe('cloudflare-workers'); + expect(stack.ecosystem).toBe('npm'); + }); + + it('prefers the more specific meta-framework over react-router', () => { + const next = detectStack(pkgs('next', 'react-router', 'react'), {}); + expect(next.framework).toBe('next'); + }); + + it('returns nulls for an unrecognised stack without throwing', () => { + const stack = detectStack(pkgs('left-pad'), {}); + expect(stack.framework).toBeNull(); + expect(stack.ui).toBeNull(); + expect(stack.bundler).toBeNull(); + expect(stack.builder).toBeNull(); + expect(isEmptyStack(stack)).toBe(true); + }); + + it('detects vue/nuxt independently of react rules', () => { + const stack = detectStack(pkgs('nuxt', 'vue'), {}); + expect(stack.framework).toBe('nuxt'); + expect(stack.ui).toBe('vue'); + }); +}); + +describe('collectHostingEnvKeys', () => { + it('surfaces only hosting-related key names, sorted, never values', () => { + const keys = collectHostingEnvKeys({ + VERCEL: '1', + VERCEL_REGION: 'iad1', + CF_PAGES: '1', + HOME: '/root', + SECRET_TOKEN: 'shh', + }); + expect(keys).toEqual(['CF_PAGES', 'VERCEL', 'VERCEL_REGION']); + expect(keys).not.toContain('SECRET_TOKEN'); + expect(keys).not.toContain('HOME'); + }); + + it('returns an empty array when nothing matches', () => { + expect(collectHostingEnvKeys({ HOME: '/root' })).toEqual([]); + }); +});