Skip to content

Commit 3d1ff2d

Browse files
ejntaylorclaude
andauthored
[feat] Detect and inject a build-stack descriptor in mark-build (#35)
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 <noreply@anthropic.com>
1 parent fbe3a6f commit 3d1ff2d

6 files changed

Lines changed: 296 additions & 7 deletions

File tree

src/cli.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
injectMarker,
1212
resolveBuildDir,
1313
} from './mark-build.js';
14+
import { detectStack, type StackDescriptor } from './stack.js';
1415
import { PatchstackError } from './types.js';
1516

1617
const HELP = `@patchstack/connect — scan your lockfile and report packages to Patchstack.
@@ -205,20 +206,34 @@ async function runStatus(args: ParsedArgs): Promise<number> {
205206
return 0;
206207
}
207208

209+
/** One-line, human-readable summary of a detected stack for CLI output. */
210+
function describeStack(stack: StackDescriptor): string | null {
211+
const parts = [stack.builder, stack.framework, stack.ui, stack.runtime].filter(
212+
(part): part is string => part !== null,
213+
);
214+
if (stack.hostingEnvKeys.length > 0) {
215+
parts.push(`${stack.hostingEnvKeys.length} hosting env key(s)`);
216+
}
217+
return parts.length > 0 ? parts.join(' · ') : null;
218+
}
219+
208220
async function runMarkBuild(args: ParsedArgs): Promise<number> {
209221
const cwd = process.cwd();
210222

211-
// Compute the build fingerprint from the lockfile. Best-effort: mark-build is a
212-
// postbuild step and must never fail the build, so a missing/unreadable lockfile
213-
// just means we stamp the production flag without a fingerprint.
223+
// Compute the build fingerprint and stack descriptor from the lockfile.
224+
// Best-effort: mark-build is a postbuild step and must never fail the build, so
225+
// a missing/unreadable lockfile just means we stamp the production flag without
226+
// a fingerprint or stack.
214227
let checksum: string | null = null;
228+
let stack: StackDescriptor | null = null;
215229
try {
216230
const manifest = await scanLockfile(cwd);
217231
for (const warning of manifest.warnings ?? []) {
218232
console.warn(`mark-build: ${warning}`);
219233
}
220234
const { payload } = buildWirePayload(manifest);
221235
checksum = computeManifestChecksum(payload.packages);
236+
stack = detectStack(payload.packages);
222237
} catch (err) {
223238
console.warn(
224239
`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<number> {
239254
return 0;
240255
}
241256

242-
const snippet = buildInjectionSnippet(checksum);
257+
const snippet = buildInjectionSnippet(checksum, stack);
243258
let marked = 0;
244259
for (const file of files) {
245260
const before = readFileSync(file, 'utf8');
@@ -250,8 +265,11 @@ async function runMarkBuild(args: ParsedArgs): Promise<number> {
250265
}
251266
}
252267

268+
const stackSummary = stack !== null ? describeStack(stack) : null;
253269
console.log(
254-
`mark-build: marked ${marked} HTML file(s) in ${dir}${checksum !== null ? ` (build ${checksum})` : ''}.`,
270+
`mark-build: marked ${marked} HTML file(s) in ${dir}` +
271+
`${checksum !== null ? ` (build ${checksum})` : ''}` +
272+
`${stackSummary !== null ? ` [${stackSummary}]` : ''}.`,
255273
);
256274
return 0;
257275
}

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ export { scanLockfile, detectLockfile } from './parsers/index.js';
88
export { buildWirePayload, compareVersions } from './normalize.js';
99
export { postManifest, buildClaimUrl, buildEndpointUrl, DEFAULT_ENDPOINT } from './client.js';
1010
export { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
11+
export {
12+
detectStack,
13+
collectHostingEnvKeys,
14+
isEmptyStack,
15+
type StackDescriptor,
16+
} from './stack.js';
1117
export {
1218
PatchstackError,
1319
type Config,

src/mark-build.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { existsSync, readdirSync, statSync } from 'node:fs';
22
import path from 'node:path';
33

4+
import { isEmptyStack, type StackDescriptor } from './stack.js';
5+
46
/** Attribute that tags our injected <script> so re-runs replace it instead of stacking. */
57
export const MARKER_ATTR = 'data-patchstack-build';
68

@@ -50,13 +52,20 @@ export function findHtmlFiles(dir: string): string[] {
5052
/**
5153
* The <script> we inject into built HTML. Always marks the build as production
5254
* (so the widget hides the connect/claim prompt on the published site) and, when
53-
* a fingerprint is available, exposes it for the widget's parity heartbeat.
55+
* available, exposes the build fingerprint (for the parity heartbeat) and the
56+
* detected stack descriptor (so the widget can report how the site was built).
5457
*/
55-
export function buildInjectionSnippet(checksum: string | null): string {
58+
export function buildInjectionSnippet(
59+
checksum: string | null,
60+
stack?: StackDescriptor | null,
61+
): string {
5662
const statements = ['window.__PATCHSTACK_PROD__=true;'];
5763
if (checksum !== null && checksum !== '') {
5864
statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`);
5965
}
66+
if (stack != null && !isEmptyStack(stack)) {
67+
statements.push(`window.__PATCHSTACK_STACK__=${JSON.stringify(stack)};`);
68+
}
6069
return `<script ${MARKER_ATTR}>${statements.join('')}</script>`;
6170
}
6271

src/stack.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import type { WirePackage } from './normalize.js';
2+
3+
/**
4+
* A best-effort description of the stack a build was produced with, derived
5+
* entirely from the lockfile (ground truth) plus the build-time environment.
6+
*
7+
* The disclosure widget reads this from `window.__PATCHSTACK_STACK__` (injected
8+
* by `mark-build`) and reports it to Patchstack, so we learn how the sites we
9+
* protect are actually built and hosted — across every "vibe" platform — without
10+
* shipping a runtime probe onto the host server. Every field is a coarse label
11+
* or a bare key *name*: no versions beyond the framework, and never an env value.
12+
*/
13+
export interface StackDescriptor {
14+
/** App / meta-framework, e.g. "next", "nuxt", "tanstack-start", "remix". */
15+
framework: string | null;
16+
/** UI runtime, e.g. "react", "vue", "svelte", "solid". */
17+
ui: string | null;
18+
/** Build tool / bundler, e.g. "vite", "webpack", "rspack". */
19+
bundler: string | null;
20+
/** Deployment-runtime hint from deps, e.g. "cloudflare-workers", "vercel". */
21+
runtime: string | null;
22+
/** The vibe/builder platform that generated the project, e.g. "lovable". */
23+
builder: string | null;
24+
/** Package ecosystem the manifest came from. */
25+
ecosystem: 'npm';
26+
/** Hosting-related build-environment variable NAMES (never their values). */
27+
hostingEnvKeys: string[];
28+
}
29+
30+
type Category = 'framework' | 'ui' | 'bundler' | 'runtime' | 'builder';
31+
32+
interface StackRule {
33+
category: Category;
34+
/** Exact package name that signals this label. */
35+
pkg: string;
36+
label: string;
37+
}
38+
39+
/**
40+
* Package → stack-label registry. First match per category wins, so order
41+
* within a category is priority order (most specific first). Add a row to teach
42+
* the connector a new framework, bundler, or vibe platform.
43+
*/
44+
const STACK_RULES: readonly StackRule[] = [
45+
// Meta-frameworks (most specific first).
46+
{ category: 'framework', pkg: '@tanstack/react-start', label: 'tanstack-start' },
47+
{ category: 'framework', pkg: '@tanstack/start', label: 'tanstack-start' },
48+
{ category: 'framework', pkg: 'next', label: 'next' },
49+
{ category: 'framework', pkg: 'nuxt', label: 'nuxt' },
50+
{ category: 'framework', pkg: '@remix-run/react', label: 'remix' },
51+
{ category: 'framework', pkg: '@remix-run/node', label: 'remix' },
52+
{ category: 'framework', pkg: 'react-router', label: 'react-router' },
53+
{ category: 'framework', pkg: 'astro', label: 'astro' },
54+
{ category: 'framework', pkg: '@sveltejs/kit', label: 'sveltekit' },
55+
{ category: 'framework', pkg: '@builder.io/qwik-city', label: 'qwik-city' },
56+
{ category: 'framework', pkg: 'gatsby', label: 'gatsby' },
57+
{ category: 'framework', pkg: 'express', label: 'express' },
58+
{ category: 'framework', pkg: 'fastify', label: 'fastify' },
59+
60+
// UI runtimes.
61+
{ category: 'ui', pkg: '@angular/core', label: 'angular' },
62+
{ category: 'ui', pkg: 'react-dom', label: 'react' },
63+
{ category: 'ui', pkg: 'react', label: 'react' },
64+
{ category: 'ui', pkg: 'vue', label: 'vue' },
65+
{ category: 'ui', pkg: 'svelte', label: 'svelte' },
66+
{ category: 'ui', pkg: 'solid-js', label: 'solid' },
67+
{ category: 'ui', pkg: 'preact', label: 'preact' },
68+
69+
// Bundlers / build tools.
70+
{ category: 'bundler', pkg: 'vite', label: 'vite' },
71+
{ category: 'bundler', pkg: '@rspack/core', label: 'rspack' },
72+
{ category: 'bundler', pkg: 'webpack', label: 'webpack' },
73+
{ category: 'bundler', pkg: 'parcel', label: 'parcel' },
74+
{ category: 'bundler', pkg: 'rollup', label: 'rollup' },
75+
{ category: 'bundler', pkg: 'esbuild', label: 'esbuild' },
76+
77+
// Deployment-runtime hints from build deps.
78+
{ category: 'runtime', pkg: 'wrangler', label: 'cloudflare-workers' },
79+
{ category: 'runtime', pkg: '@cloudflare/workers-types', label: 'cloudflare-workers' },
80+
{ category: 'runtime', pkg: '@cloudflare/vite-plugin', label: 'cloudflare-workers' },
81+
{ category: 'runtime', pkg: '@vercel/node', label: 'vercel' },
82+
{ category: 'runtime', pkg: '@netlify/functions', label: 'netlify' },
83+
{ category: 'runtime', pkg: '@netlify/blobs', label: 'netlify' },
84+
85+
// Vibe / builder platforms — the "learn from each platform" signal.
86+
{ category: 'builder', pkg: 'lovable-tagger', label: 'lovable' },
87+
{ category: 'builder', pkg: '@replit/vite-plugin-runtime-error-modal', label: 'replit' },
88+
{ category: 'builder', pkg: '@replit/vite-plugin-cartographer', label: 'replit' },
89+
];
90+
91+
/** Build-environment variable-name patterns that fingerprint a host, from server-insight. */
92+
const HOSTING_ENV_PATTERNS: readonly RegExp[] = [
93+
/^CF_/,
94+
/^CLOUDFLARE_/,
95+
/^VERCEL/,
96+
/^NETLIFY/,
97+
/^AWS_(LAMBDA|REGION|EXECUTION)/,
98+
/^FLY_/,
99+
/^RENDER/,
100+
/^RAILWAY_/,
101+
/^DENO_/,
102+
/^EDGE_RUNTIME/,
103+
/^DYNO$/,
104+
/^K_SERVICE$/,
105+
/^GAE_/,
106+
/^FUNCTION_/,
107+
];
108+
109+
/**
110+
* Return the sorted NAMES of hosting-related environment variables present in
111+
* `env`. Only names are surfaced — never values — so a build fingerprint can
112+
* distinguish "deployed on Cloudflare" from "deployed on Vercel" without ever
113+
* disclosing a secret.
114+
*/
115+
export function collectHostingEnvKeys(env: NodeJS.ProcessEnv = process.env): string[] {
116+
return Object.keys(env)
117+
.filter((key) => HOSTING_ENV_PATTERNS.some((pattern) => pattern.test(key)))
118+
.sort();
119+
}
120+
121+
/**
122+
* Derive a {@link StackDescriptor} from the wire packages and the build
123+
* environment. Never throws: an unrecognised stack just yields nulls.
124+
*/
125+
export function detectStack(
126+
packages: readonly WirePackage[],
127+
env: NodeJS.ProcessEnv = process.env,
128+
): StackDescriptor {
129+
const present = new Set(packages.map((pkg) => pkg.name));
130+
131+
const firstMatch = (category: Category): string | null => {
132+
for (const rule of STACK_RULES) {
133+
if (rule.category === category && present.has(rule.pkg)) {
134+
return rule.label;
135+
}
136+
}
137+
return null;
138+
};
139+
140+
return {
141+
framework: firstMatch('framework'),
142+
ui: firstMatch('ui'),
143+
bundler: firstMatch('bundler'),
144+
runtime: firstMatch('runtime'),
145+
builder: firstMatch('builder'),
146+
ecosystem: 'npm',
147+
hostingEnvKeys: collectHostingEnvKeys(env),
148+
};
149+
}
150+
151+
/** True when the descriptor carries no useful signal (nothing worth injecting). */
152+
export function isEmptyStack(stack: StackDescriptor): boolean {
153+
return (
154+
stack.framework === null &&
155+
stack.ui === null &&
156+
stack.bundler === null &&
157+
stack.runtime === null &&
158+
stack.builder === null &&
159+
stack.hostingEnvKeys.length === 0
160+
);
161+
}

tests/mark-build.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,34 @@ describe('buildInjectionSnippet', () => {
2323
expect(snippet).toContain('__PATCHSTACK_PROD__');
2424
expect(snippet).not.toContain('__PATCHSTACK_BUILD__');
2525
});
26+
27+
it('injects the stack descriptor when one carries signal', () => {
28+
const snippet = buildInjectionSnippet('abc123def456', {
29+
framework: 'tanstack-start',
30+
ui: 'react',
31+
bundler: 'vite',
32+
runtime: 'cloudflare-workers',
33+
builder: 'lovable',
34+
ecosystem: 'npm',
35+
hostingEnvKeys: ['CF_PAGES'],
36+
});
37+
expect(snippet).toContain('window.__PATCHSTACK_STACK__=');
38+
expect(snippet).toContain('"builder":"lovable"');
39+
});
40+
41+
it('omits the stack when it is empty or absent', () => {
42+
const empty = buildInjectionSnippet('c1', {
43+
framework: null,
44+
ui: null,
45+
bundler: null,
46+
runtime: null,
47+
builder: null,
48+
ecosystem: 'npm',
49+
hostingEnvKeys: [],
50+
});
51+
expect(empty).not.toContain('__PATCHSTACK_STACK__');
52+
expect(buildInjectionSnippet('c1')).not.toContain('__PATCHSTACK_STACK__');
53+
});
2654
});
2755

2856
describe('injectMarker', () => {

tests/stack.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { collectHostingEnvKeys, detectStack, isEmptyStack } from '../src/stack.js';
3+
import type { WirePackage } from '../src/normalize.js';
4+
5+
const pkgs = (...names: string[]): WirePackage[] =>
6+
names.map((name) => ({ name, version: '1.0.0' }));
7+
8+
describe('detectStack', () => {
9+
it('identifies a Lovable + TanStack Start + React + Vite + Cloudflare build', () => {
10+
const stack = detectStack(
11+
pkgs(
12+
'lovable-tagger',
13+
'@tanstack/react-start',
14+
'react',
15+
'react-dom',
16+
'vite',
17+
'wrangler',
18+
),
19+
{},
20+
);
21+
expect(stack.builder).toBe('lovable');
22+
expect(stack.framework).toBe('tanstack-start');
23+
expect(stack.ui).toBe('react');
24+
expect(stack.bundler).toBe('vite');
25+
expect(stack.runtime).toBe('cloudflare-workers');
26+
expect(stack.ecosystem).toBe('npm');
27+
});
28+
29+
it('prefers the more specific meta-framework over react-router', () => {
30+
const next = detectStack(pkgs('next', 'react-router', 'react'), {});
31+
expect(next.framework).toBe('next');
32+
});
33+
34+
it('returns nulls for an unrecognised stack without throwing', () => {
35+
const stack = detectStack(pkgs('left-pad'), {});
36+
expect(stack.framework).toBeNull();
37+
expect(stack.ui).toBeNull();
38+
expect(stack.bundler).toBeNull();
39+
expect(stack.builder).toBeNull();
40+
expect(isEmptyStack(stack)).toBe(true);
41+
});
42+
43+
it('detects vue/nuxt independently of react rules', () => {
44+
const stack = detectStack(pkgs('nuxt', 'vue'), {});
45+
expect(stack.framework).toBe('nuxt');
46+
expect(stack.ui).toBe('vue');
47+
});
48+
});
49+
50+
describe('collectHostingEnvKeys', () => {
51+
it('surfaces only hosting-related key names, sorted, never values', () => {
52+
const keys = collectHostingEnvKeys({
53+
VERCEL: '1',
54+
VERCEL_REGION: 'iad1',
55+
CF_PAGES: '1',
56+
HOME: '/root',
57+
SECRET_TOKEN: 'shh',
58+
});
59+
expect(keys).toEqual(['CF_PAGES', 'VERCEL', 'VERCEL_REGION']);
60+
expect(keys).not.toContain('SECRET_TOKEN');
61+
expect(keys).not.toContain('HOME');
62+
});
63+
64+
it('returns an empty array when nothing matches', () => {
65+
expect(collectHostingEnvKeys({ HOME: '/root' })).toEqual([]);
66+
});
67+
});

0 commit comments

Comments
 (0)