Skip to content
Merged
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
28 changes: 23 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -205,20 +206,34 @@ async function runStatus(args: ParsedArgs): Promise<number> {
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<number> {
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 ?? []) {
console.warn(`mark-build: ${warning}`);
}
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.`,
Expand All @@ -239,7 +254,7 @@ async function runMarkBuild(args: ParsedArgs): Promise<number> {
return 0;
}

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

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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions src/mark-build.ts
Original file line number Diff line number Diff line change
@@ -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 <script> so re-runs replace it instead of stacking. */
export const MARKER_ATTR = 'data-patchstack-build';

Expand Down Expand Up @@ -50,13 +52,20 @@ export function findHtmlFiles(dir: string): string[] {
/**
* The <script> we inject into built HTML. Always marks the build as production
* (so the widget hides the connect/claim prompt on the published site) and, when
* a fingerprint is available, exposes it for the widget's parity heartbeat.
* available, exposes the build fingerprint (for the parity heartbeat) and the
* detected stack descriptor (so the widget can report how the site was built).
*/
export function buildInjectionSnippet(checksum: string | null): string {
export function buildInjectionSnippet(
checksum: string | null,
stack?: StackDescriptor | null,
): string {
const statements = ['window.__PATCHSTACK_PROD__=true;'];
if (checksum !== null && checksum !== '') {
statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`);
}
if (stack != null && !isEmptyStack(stack)) {
statements.push(`window.__PATCHSTACK_STACK__=${JSON.stringify(stack)};`);
}
return `<script ${MARKER_ATTR}>${statements.join('')}</script>`;
}

Expand Down
161 changes: 161 additions & 0 deletions src/stack.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
28 changes: 28 additions & 0 deletions tests/mark-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
67 changes: 67 additions & 0 deletions tests/stack.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading