From 56c92f994e1453e0bc16201be760a8633f577822 Mon Sep 17 00:00:00 2001 From: Elliot Taylor Date: Mon, 13 Jul 2026 15:14:40 +0200 Subject: [PATCH] [feat] Detect and inject a build-stack descriptor in mark-build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mark-build now derives a coarse stack descriptor from the lockfile (framework, UI, bundler, deployment runtime, and the vibe/builder platform such as Lovable) plus the NAMES of hosting-related build-env vars, and injects it as `window.__PATCHSTACK_STACK__` alongside the existing production flag and build fingerprint. The disclosure widget reads this to report how each site was built, so Patchstack learns the deployment landscape it protects across every vibe platform — without shipping a runtime probe onto the host server. Detection is a data-driven registry (add a row to teach a new stack) and only surfaces coarse labels and env-var key names, never values. Co-Authored-By: Claude Fable 5 --- src/cli.ts | 28 +++++-- src/index.ts | 6 ++ src/mark-build.ts | 13 +++- src/stack.ts | 161 +++++++++++++++++++++++++++++++++++++++ tests/mark-build.test.ts | 28 +++++++ tests/stack.test.ts | 67 ++++++++++++++++ 6 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 src/stack.ts create mode 100644 tests/stack.test.ts 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([]); + }); +});