diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index cff82a54..4d19ed25 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -88,7 +88,7 @@ This is a request, not a mechanism: nothing in the install depends on it. Do it **If the request you were given includes a claim token**, pass it exactly as given: `npx @patchstack/connect setup --claim-token `. The site is then created in the person's own Patchstack account and `setup` prints their dashboard link for it — there is no sign-in step to relay. The token comes only from the person's Patchstack dashboard; never invent one, never write it to a file, never print it back. If `setup` reports that the token had expired or was not recognised, the site is not connected: hand over the dashboard link it prints instead, and tell the person they can copy a fresh prompt from the dashboard. - In a hosted builder, run setup with `PATCHSTACK_ENVIRONMENT=sandbox` scoped to the workspace process/command, ensure the CLI's on-disk edits are adopted into the platform's persisted project state, then restart any already-running preview/server process so it loads the guard. Do not persist `"environment": "sandbox"` in `.patchstackrc.json`: deployed builds use the same committed files and should default to `production`. A client-only SPA has no server request path to guard; do not call it protected unless `protect --check` succeeds after a real server or edge seam is wired. + In a hosted builder, run setup with `PATCHSTACK_ENVIRONMENT=sandbox` scoped to the workspace process/command, ensure the CLI's on-disk edits are adopted into the platform's persisted project state, then restart any already-running preview/server process so it loads the guard. Do not persist `"environment": "sandbox"` in `.patchstackrc.json`: deployed builds use the same committed files and report `production` only when the platform's own production signal says so (Vercel, Netlify, Render, Railway); a preview there reports `sandbox`. A scan on a developer's machine, in a generic CI runner, or on a platform with no such signal reports `local` on its own, and the dashboard shows that app as configured, not deployed, until its build is seen live. A client-only SPA or a static site generator has no server request path to guard: `setup` says runtime protection does not apply and installs nothing for it; never call such a project protected. **Finish by telling the user to refresh their preview.** The widget's "Report a vulnerability" button loads with the page, so a preview that was already open still shows the HTML from before setup — the button is missing there until it reloads. Nothing in the CLI can reach the user's browser, so relaying this is your job. Phrase it as a check rather than a required step: a builder that hot reloads, or a preview server you restarted, may have refreshed it already. @@ -194,7 +194,7 @@ It is server-only. Never put it in the widget tag, client bundles, or public env - Never invent or guess a UUID — the scan provisions it, the widget silently no-ops on a fake one. - Never invent or guess a claim token either. One is only ever handed to you by the person, from their own Patchstack dashboard; pass it with `--claim-token` (or `PATCHSTACK_CLAIM_TOKEN`) and nowhere else — not into `.patchstackrc.json`, not into a committed file, not into your reply. - The CLI never opens the dashboard link and never asks for Patchstack credentials. -- Label hosted workspace scans with `PATCHSTACK_ENVIRONMENT=sandbox` in that process only. Leave production builds unset (the default is `production`) and never commit a sandbox label into files shared with production. +- Label hosted workspace scans with `PATCHSTACK_ENVIRONMENT=sandbox` in that process only. Leave production builds unset (a platform's own production signal makes the build report `production`; a developer machine or a generic CI runner reports `local`) and never commit a sandbox label into files shared with production. - If a step fails, stop and report it. Don't proceed with placeholders. - CI never has the credential in a file: `.patchstackrc.local.json` is git-ignored by design, so set `PATCHSTACK_API_KEY` as an env var there (and `PATCHSTACK_SITE_UUID` too where `.patchstackrc.json` is also absent). Precedence for the site UUID and settings: CLI flag → env var → `.patchstackrc.json`. For the API key: env var → `.patchstackrc.local.json` → `.patchstackrc.json` (where installs made before the split still hold it). `login` is interactive and refuses to run in CI, so CI always takes its credential from the environment. diff --git a/README.md b/README.md index db0e51cc..7735e95f 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Environment variables: - `PATCHSTACK_SITE_UUID` — the site UUID from your Patchstack dashboard - `PATCHSTACK_ENDPOINT` — override the API endpoint (default `https://api.patchstack.com/monitor/pulse/manifest`) - `PATCHSTACK_TIMEOUT_MS` — request timeout in milliseconds (default `30000`) -- `PATCHSTACK_ENVIRONMENT` — manifest label: `production` (default) or `sandbox` +- `PATCHSTACK_ENVIRONMENT` — manifest label: `production`, `sandbox` or `local`. Unset, the label comes from the hosting platform's own production/preview signal (Vercel, Netlify, Render, Railway); a preview reports `sandbox`, and anything without such a signal — a developer machine, a generic CI runner, a platform this does not know — reports `local` - `PATCHSTACK_CLAIM_TOKEN` — connect the site straight to your account (see *Connecting straight to your account*) Two files, because one value is public and the other is not. @@ -257,7 +257,7 @@ The token names your account, not the project: it is never written to `.patchsta ### Sandbox and production manifests -Every `scan` sends an environment label with its dependency manifest. The default is `production`; sandboxed builders should set `PATCHSTACK_ENVIRONMENT=sandbox` in the sandbox process only. Patchstack stores and deduplicates manifests per environment, so an iterative workspace scan does not replace the last production manifest. +Every `scan` sends an environment label with its dependency manifest. When nothing sets one, the label comes from the hosting platform's own answer to "is this the production deployment?": Vercel's `VERCEL_ENV`, Netlify's `CONTEXT`, Render's pull-request flag, Railway's environment name. Production reports `production`; a preview those platforms name as such reports `sandbox`. Everything else reports `local` — a developer's machine, a generic CI runner (`CI=true` proves automation, not deployment), and a platform whose build environment carries no such signal, Cloudflare Pages among them. A local manifest is inventory — it tells Patchstack what the app is built from — and never counts as contact with a live site, so an app that has only been set up on a laptop shows in the dashboard as **Configured locally**, not as connected or deployed; once its build is seen on the live site it reads as deployed regardless of the label. Set `PATCHSTACK_ENVIRONMENT=production` where builds run on a platform this list does not know. Sandboxed builders should set `PATCHSTACK_ENVIRONMENT=sandbox` in the sandbox process only. Patchstack stores and deduplicates manifests per environment, so an iterative workspace scan does not replace the last production manifest. Do not commit `"environment": "sandbox"` to `.patchstackrc.json` when the same files are deployed to production. Scope the variable to the sandbox command/process instead: @@ -265,7 +265,7 @@ Do not commit `"environment": "sandbox"` to `.patchstackrc.json` when the same f PATCHSTACK_ENVIRONMENT=sandbox npx @patchstack/connect setup ``` -The generated `prebuild` scan deliberately carries no hard-coded environment. A production builder with no override reports `production`; a preview/sandbox builder must receive `PATCHSTACK_ENVIRONMENT=sandbox` from its host. Runtime protection itself is not environment-specific: `PATCHSTACK_ENVIRONMENT` labels manifests only. Use `PATCHSTACK_MODE=dry-run` when protection should observe rather than block. +The generated `prebuild` scan deliberately carries no hard-coded environment. A production build with no override reports `production` only because the platform's own production signal says it is one; a preview on those platforms reports `sandbox` by itself, and a hosted builder's workspace must receive `PATCHSTACK_ENVIRONMENT=sandbox` from its host. Runtime protection itself is not environment-specific: `PATCHSTACK_ENVIRONMENT` labels manifests only. Use `PATCHSTACK_MODE=dry-run` when protection should observe rather than block. During a build, the `prebuild` scan removes any previous map stamp. A later `map --upload` in the same pre-bundle lifecycle hashes the map's policy content (excluding analyser timing and memory observations) and records that identity in the guard's existing rules file. The guard presents it on the rules request it already makes; only an explicit Patchstack confirmation naming the same map lets a rule scoped to one of your app's parameter names block. A build with no confirmed identity still enforces every ordinary rule — only scoped rules drop to detect-only, with the reason reported. Outside a pre-bundle hook, `map --upload` changes no file and sends no identity. See "Which build a rule belongs to" in `AGENT-INSTALL.md`. diff --git a/src/architecture.ts b/src/architecture.ts new file mode 100644 index 00000000..a005ded1 --- /dev/null +++ b/src/architecture.ts @@ -0,0 +1,223 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +import { detectDeploymentShapes } from './map/sources.js'; +import { + CONDITIONAL_SERVER_DEPENDENCIES, + SERVER_DEPENDENCIES, + SSR_ADAPTERS, + SSR_COMPANIONS, + STATIC_GENERATORS, +} from './map/surface.js'; + +/** + * Is there a request path in this project for a runtime guard to attach to? + * + * The guard screens requests as they arrive, so it needs something that receives one. A project that only + * emits files at build time never does, and every artifact protection would install there — a guard module, + * a rules file, a `--check` that can never pass — is inert. Scaffolding it anyway costs more than nothing: + * it puts security-shaped files in a repository that no request will ever reach, and leaves a permanently + * red check that teaches the reader to ignore the command. + * + * `none` is the dangerous answer, because it withholds protection, so it is only given on positive + * static-only evidence: a static site GENERATOR is named, and nothing else in the project could serve. A + * bundler is not a generator — `vite` and its kin build client apps and server apps alike — so a bundler + * alone never supports `none`. And a generator beside a `server.mjs` that calls `createServer` is not a + * static site, so the usual server entry files are read for the calls that serve. Any of these turns the + * answer to `unknown`, which scaffolds the generic guard and leaves its wiring to be finished, exactly as + * an unrecognised project always has. + * + * This asks a NARROWER question than `map`'s `serverSurface`, and the two differ deliberately on one + * signal. `serverSurface` describes the app, so a platform config (`netlify.toml`, `vercel.json`) blocks it + * from calling anything static — the project deploys somewhere, and that is not a thing the analysis can + * claim to have looked behind. A guard does not need to know where the app deploys; it needs somewhere to + * be invoked from. A config file is not that, so it does not block the verdict here. + */ +export type RequestPath = + /** Something in this project receives requests, so a guard has a seam. */ + | 'server' + /** A static site generator was named and nothing here receives a request. */ + | 'none' + /** Neither could be established. Never to be read as "no server side". */ + | 'unknown'; + +export interface ArchitectureVerdict { + requestPath: RequestPath; + /** The signals behind the verdict, each named, so a reader can disagree with it. */ + evidence: string[]; + /** One sentence a caller can print verbatim. */ + note: string; +} + +interface Manifest { + main?: unknown; + dependencies?: Record; + devDependencies?: Record; +} + +/** + * Bundlers that appear in the static-generator registry because a plain client app is built with them, + * but which say nothing about whether a server sits beside that app. Never enough on their own. + */ +const BUNDLERS_NOT_GENERATORS = new Set(['vite', 'parcel']); + +/** + * Tooling that builds for an edge runtime, which serves requests without any of the server frameworks + * appearing in the manifest. The same toolchain also publishes purely static Pages projects, so this rules + * out `none` without establishing `server`. + */ +const EDGE_RUNTIME_TOOLING = ['wrangler', '@cloudflare/workers-types', 'miniflare']; + +/** Deployment shapes that could be hiding a runtime, so a `none` verdict may not be claimed over them. */ +const RUNTIME_AMBIGUOUS_SHAPES = new Set(['cloudflare-workers']); + +/** + * Where a hand-written server usually lives. The same list the generic installer's wiring plan reads, so + * the file this rules on is the file that plan would have told the reader to wire. + */ +const SERVER_ENTRY_CANDIDATES = [ + 'server.ts', 'server.js', 'server.mjs', 'server.cjs', + 'src/server.ts', 'src/server.js', 'src/server.mjs', + 'index.ts', 'index.js', 'index.mjs', 'index.cjs', + 'src/index.ts', 'src/index.js', 'src/index.mjs', + 'app.ts', 'app.js', 'app.mjs', + 'src/app.ts', 'src/app.js', 'src/app.mjs', + 'src/main.ts', 'src/main.js', +]; + +/** Calls that mean a request is received. Textual, and deliberately broad: a miss here withholds protection. */ +const SERVING_CALL = /\b(?:createServer|createSecureServer|Bun\.serve|Deno\.serve|serve\s*\(|\.listen\s*\(|express\s*\(|fastify\s*\(|new\s+(?:Hono|Koa|Elysia)\b)/; + +const MAX_ENTRY_BYTES = 256 * 1024; + +function readManifest(cwd: string): Manifest { + try { + const parsed = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8')); + return typeof parsed === 'object' && parsed !== null ? (parsed as Manifest) : {}; + } catch { + return {}; + } +} + +/** Server-framework dependencies present, each named. Any one of them means a request can arrive. */ +function serverDependencies(deps: Record): string[] { + const found = SERVER_DEPENDENCIES.filter((dep) => deps[dep] !== undefined); + + // Both-mode frameworks: only a server dependency when the project does not also install the + // dependency that makes it static. + for (const conditional of CONDITIONAL_SERVER_DEPENDENCIES) { + if (deps[conditional.dep] !== undefined && deps[conditional.staticWhen] === undefined) { + found.push(conditional.dep); + } + } + + for (const companion of SSR_COMPANIONS) { + if (deps[companion] !== undefined) found.push(companion); + } + + // `next` and `astro` ship both modes. Read conservatively here: an SSR adapter is positive evidence of a + // server, and bare `next` is treated as one because its default mode serves. + if (deps['next'] !== undefined) found.push('next'); + if (deps['astro'] !== undefined) { + for (const adapter of SSR_ADAPTERS) if (deps[adapter] !== undefined) found.push(adapter); + } + + return [...new Set(found)]; +} + +/** Static site generators this project positively names. Bundlers are excluded — see the header. */ +function staticGenerators(deps: Record): string[] { + return STATIC_GENERATORS.filter( + (generator) => deps[generator.dep] !== undefined && !BUNDLERS_NOT_GENERATORS.has(generator.dep), + ).map((generator) => generator.label); +} + +/** + * Hand-written server entries: the usual file names, plus whatever `package.json#main` points at, each + * read for a call that serves. Project-relative names only, so a `main` pointing outside the project is + * not followed. + */ +function serverEntries(cwd: string, manifest: Manifest): string[] { + const candidates = new Set(SERVER_ENTRY_CANDIDATES); + if (typeof manifest.main === 'string' && manifest.main !== '' && !path.isAbsolute(manifest.main)) { + const main = path.normalize(manifest.main); + if (!main.startsWith('..')) candidates.add(main); + } + + const found: string[] = []; + for (const relative of candidates) { + const file = path.join(cwd, relative); + try { + if (!existsSync(file) || !statSync(file).isFile()) continue; + const text = readFileSync(file, 'utf8').slice(0, MAX_ENTRY_BYTES); + if (SERVING_CALL.test(text)) found.push(relative); + } catch { + // Unreadable is not evidence either way; the other candidates still decide. + } + } + + return found; +} + +export function classifyArchitecture(cwd: string): ArchitectureVerdict { + let manifest: Manifest; + let shapes: ReturnType; + try { + manifest = readManifest(cwd); + shapes = detectDeploymentShapes(cwd); + } catch { + // A project this cannot read says nothing about itself, and silence is not a static build. + return { + requestPath: 'unknown', + evidence: [], + note: 'This project could not be inspected, so whether it has a request path is unknown.', + }; + } + + const deps = { ...manifest.dependencies, ...manifest.devDependencies }; + const servers = serverDependencies(deps); + const statics = staticGenerators(deps); + + // Only a shape that SERVES counts towards `server`: a worker entry, or a provider function directory + // with source in it. A platform config is not one (see the header); a bare root `api/` folder and a + // wrangler config are ambiguous enough to block `none` without supporting `server`. + const serving = shapes.filter((shape) => shape.evidence === 'runtime-entry'); + const ambiguous = [ + ...shapes + .filter((shape) => shape.evidence === 'layout' || RUNTIME_AMBIGUOUS_SHAPES.has(shape.shape)) + .map((shape) => `${shape.shape} (${shape.source})`), + ...EDGE_RUNTIME_TOOLING.filter((dep) => deps[dep] !== undefined).map((dep) => `edge runtime tooling: ${dep}`), + ...serverEntries(cwd, manifest).map((file) => `server entry: ${file}`), + ]; + + if (servers.length > 0 || serving.length > 0) { + return { + requestPath: 'server', + evidence: [ + ...servers.map((dep) => `server dependency: ${dep}`), + ...serving.map((shape) => `serves requests: ${shape.shape} (${shape.source})`), + ], + note: 'This project receives requests, so runtime protection applies to them.', + }; + } + + if (statics.length > 0 && ambiguous.length === 0) { + return { + requestPath: 'none', + evidence: statics.map((label) => `static build: ${label}`), + note: + `This project builds a static site (${statics.join(', ')}) and nothing in it receives a request, ` + + 'so there is no request path for a runtime guard to attach to. Dependency monitoring and the ' + + 'disclosure widget still apply; runtime protection does not.', + }; + } + + return { + requestPath: 'unknown', + evidence: [...statics.map((label) => `static build: ${label}`), ...ambiguous.map((source) => `ambiguous: ${source}`)], + note: + 'Neither a request path nor a purely static build could be identified here. An unparsed framework ' + + 'looks exactly like this, so protection is scaffolded and its wiring left to be finished rather ' + + 'than assumed unnecessary.', + }; +} diff --git a/src/cli.ts b/src/cli.ts index 62fb07a1..ccd507ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { Config } from './types.js'; +import type { GuideState } from './guide.js'; import { createRequire } from 'node:module'; import { scanLockfile } from './parsers/index.js'; @@ -12,6 +15,8 @@ import { buildClaimUrl, fetchSiteStatus, postManifest, + postManifestWithEnvironmentFallback, + canRetryManifestPost, postPackageRemoved, } from './client.js'; import { @@ -33,6 +38,7 @@ import { persistSiteUuid, resolveConfig, writeConfigFile, + persistTimeout, } from './config.js'; import { buildInjectionSnippet, @@ -43,6 +49,7 @@ import { injectMarker, productionGate, resolveBuildDir, + buildDirCandidates, } from './mark-build.js'; import { collectGuideState, @@ -67,6 +74,7 @@ import { formatRuntimeCheck, runRuntimeCheck, runtimeExitCode } from './protect/ import { runMap } from './map-command.js'; import { getStringFlag } from './flags.js'; import { setupProtection, wireBuildScripts } from './setup.js'; +import type { SetupProtectionResult, WireBuildScriptsResult } from './setup.js'; import { isInstallOrBuildHook, isPreBundleBuildHook, undeliveredReportLines } from './build-hook.js'; import { applyBuildStamp } from './build-stamp.js'; import { detectStack, type StackDescriptor } from './stack.js'; @@ -115,7 +123,11 @@ Usage: "Uninstalling" steps in AGENT-INSTALL.md patchstack-connect mark-build [options] Stamp built HTML with a production flag + build fingerprint, and ensure the widget - tag in built pages (run as a postbuild step) + tag in built pages (run as a postbuild step). + Looks in the framework's own output dir, an + --output the build script names, then dist/, + build/, out/, .output/public/, _site/ — inside + the project only. --dir overrides. patchstack-connect protect [--demo|--check] Install always-on runtime protection (the guard). Auto-wires supported server stacks; for others it scaffolds a @@ -211,8 +223,8 @@ Environment: PATCHSTACK_TELEMETRY Set to off to disable block-log reporting PATCHSTACK_API_BASE API origin for /oauth/token and /api/logs/log (default: https://api.patchstack.com) PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest) - PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000) - PATCHSTACK_ENVIRONMENT Manifest environment: production | sandbox (default: production) + PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000; a scan that needs longer saves what worked to .patchstackrc.json) + PATCHSTACK_ENVIRONMENT Manifest label: production, sandbox or local. Unset, a deployment or CI build reports production and a developer machine reports local. PATCHSTACK_MODE (protect) Runtime guard mode: block (default) | dry-run PATCHSTACK_ROUTE_WAF (protect) Set to 1 to also screen every request at the route level (opt-in) @@ -516,6 +528,78 @@ async function runLogin(args: ParsedArgs): Promise { return 1; } +/** How much longer a timed-out report is given, and the most it will ever be given. */ +const RETRY_TIMEOUT_FACTOR = 4; +const MAX_RETRY_TIMEOUT_MS = 180_000; + +/** + * Post the manifest, and if the request times out, once more with room to finish. + * + * A first report is the slow one: the server registers the site and checks every package, and on a + * large manifest that outruns the default. Failing there is the worst place to fail — in a build hook the + * scan then fails open, the build succeeds, and the site quietly never reports. So a timeout is retried + * with the limit raised, and a limit that turned out to be needed is written to `.patchstackrc.json`, where + * the build that runs in CI reads it too. A limit nobody needed is not written: the file stays as the + * person left it. + */ +/** + * Post the manifest under the label the server accepts, and say so when that was not the label asked for. + * + * A server that predates the `local` label refuses it; the report then goes as `sandbox`, the nearest + * label that server has for "not the live site". Said out loud, because the dashboard will show the + * scan under that name. + */ +async function postManifestAccepted( + config: Config, + payload: Parameters[1], +): Promise { + const { response, environmentUsed } = await postManifestWithEnvironmentFallback(config, payload); + if (environmentUsed !== config.environment) { + console.warn( + `patchstack: this Patchstack API does not know the ${config.environment} label yet; the report was accepted as ${environmentUsed}, which also keeps it apart from production.`, + ); + } + return response; +} + +async function postManifestWithPatience( + config: Config, + payload: Parameters[1], +): Promise { + // The first report is the one that provisions the site and issues its credential, and it carries no + // idempotency key: a timeout says nothing about whether the server committed, so sending it twice can + // provision two sites. It is never retried. It is also the slow one — the server registers the site and + // checks every package — so it gets the room a retry would have given it, up front and once. + if (!canRetryManifestPost(config)) { + const timeoutMs = Math.max(config.timeoutMs, Math.min(config.timeoutMs * RETRY_TIMEOUT_FACTOR, MAX_RETRY_TIMEOUT_MS)); + return postManifestAccepted({ ...config, timeoutMs }, payload); + } + + try { + return await postManifestAccepted(config, payload); + } catch (err) { + if (!(err instanceof PatchstackError) || err.code !== 'NETWORK_TIMEOUT') throw err; + + const timeoutMs = Math.min(config.timeoutMs * RETRY_TIMEOUT_FACTOR, MAX_RETRY_TIMEOUT_MS); + if (timeoutMs <= config.timeoutMs) throw err; + + console.warn( + `patchstack: the report timed out after ${config.timeoutMs}ms; trying once more with ${timeoutMs}ms.`, + ); + const response = await postManifestAccepted({ ...config, timeoutMs }, payload); + + try { + const target = await persistTimeout(process.cwd(), timeoutMs); + console.log(`Saved a ${timeoutMs}ms request timeout to ${target}, so builds inherit it.`); + } catch { + console.warn( + `patchstack: could not save the timeout; set PATCHSTACK_TIMEOUT_MS=${timeoutMs} where builds run.`, + ); + } + return response; + } +} + async function runScan( args: ParsedArgs, options: { showRemainingSetup?: boolean } = {}, @@ -549,9 +633,18 @@ async function runScan( : `Including install locations (--install-paths), for ${located} of ${payload.packages.length} — this lockfile format does not record them for the rest, which will be reported as "not recorded" rather than "not installed there".`, ); } - console.log( - `Reporting under the ${config.environment} environment (override with PATCHSTACK_ENVIRONMENT).`, - ); + // The label decides how the dashboard reads this report — a production build is contact with a live + // site, a local one is inventory — so the line says which, and what decided it, every time. + if (config.environment === 'local') { + console.log( + 'Reporting from this machine as the local environment: the dashboard will show the app as configured, not deployed. Builds on your hosting platform report as production.', + ); + } else { + const because = (config.environmentEvidence ?? []).length > 0 + ? ` (${config.environmentEvidence!.join('; ')})` + : ''; + console.log(`Reporting under the ${config.environment} environment${because}. Override with PATCHSTACK_ENVIRONMENT.`); + } if (config.endpoint !== DEFAULT_ENDPOINT) { console.log( `Using endpoint override: ${config.endpoint} (set via --endpoint, PATCHSTACK_ENDPOINT, or .patchstackrc.json).`, @@ -622,7 +715,7 @@ async function runScan( // failure is said in full on stderr and the build goes on. A direct `scan` still exits non-zero for it. let response: StoreManifestResponse; try { - response = await postManifest(config, payload); + response = await postManifestWithPatience(config, payload); } catch (err) { if (!(err instanceof PatchstackError) || !isInstallOrBuildHook()) throw err; for (const line of undeliveredReportLines(err, config, process.cwd())) console.error(line); @@ -838,7 +931,13 @@ async function runProtectCommand(args: ParsedArgs): Promise { }; for (const c of report.checks.filter((c) => c.group !== 'reporting')) line(c); - console.log(report.wired ? 'guard is wired ✓' : 'guard is NOT fully wired ✗'); + console.log( + !report.applicable + ? 'no guard to wire — this project has no request path' + : report.wired + ? 'guard is wired ✓' + : 'guard is NOT fully wired ✗', + ); // Printed apart, and after the verdict, because they answer a different question and none of them // decides it. A failing line here does not mean the app is unprotected. @@ -850,6 +949,9 @@ async function runProtectCommand(args: ParsedArgs): Promise { if (report.checks.some((c) => c.unverifiable)) { console.log('One or more checks could not be answered from here — see the `?` lines above.'); } + // A project that cannot have a guard is not a failing one: exiting non-zero forever would make this + // command a permanent red light on a correctly configured project. There is nothing to start, either. + if (!report.applicable) return 0; // The structural verdict comes first either way: an app whose guard is not wired has nothing to // gain from being started, and its runtime result would only be a second way of saying the same no. if (!report.wired) return 1; @@ -911,6 +1013,11 @@ async function runDemoCommand(args: ParsedArgs): Promise { `Runtime protection is not supported for this stack. Supported: ${result.supported.join(', ')}.`, ); } + // The demo exists to show a request being screened, so a project with no request path cannot host + // one. Saying so is the useful answer; scaffolding a guard nothing would ever call is not. + if (result.status === 'not-applicable') { + throw new DemoError(`${result.reason} Run the demo from a project that serves requests.`); + } console.log(`Guard installer: ${result.adapter} (${result.status})`); const report = runVerify(cwd); for (const check of report.checks) { @@ -1033,7 +1140,17 @@ async function runSetup(args: ParsedArgs): Promise { console.log(''); console.log(' 2. Install and verify runtime protection'); const protection = setupProtection(process.cwd()); - if (protection.verification.wired) { + if (protection.install.status === 'not-applicable') { + // Nothing was written, and nothing is owed. Said before the checklist so the reader has the shape of + // their project before they read a list that no longer mentions protection. + console.log('Runtime protection: not applicable to this project — nothing installed.'); + console.log(` ${protection.install.reason}`); + if (protection.install.leftovers.length > 0) { + console.log(' An earlier run scaffolded a guard here before that was established. These files do'); + console.log(' nothing on this project and can be deleted:'); + for (const file of protection.install.leftovers) console.log(` ${file}`); + } + } else if (protection.verification.wired) { console.log(`Runtime protection: wired (${protection.verification.stack}).`); } else { console.log(`Runtime protection: manual wiring remains (${protection.verification.stack}):`); @@ -1063,15 +1180,61 @@ async function runSetup(args: ParsedArgs): Promise { // Setup ends on a page the user is already looking at, which loaded before the widget // tag existed, and against a deployed site still serving its previous build. Nothing // here can reach either one, so the agent relaying these is the whole mechanism. + // + // The outcome is a short, fixed-shape block rather than prose: an agent relays it as + // it is, and the words it does NOT contain matter as much as the ones it does. Nothing + // here says "connected" or "protected" — the app exists in a working tree, and every + // line says only what is true of that. + const outcome = setupOutcome(after, protection, wired); console.log(''); - console.log('Tell the user:'); - if (widgetTagInPlace(after)) { - console.log(' - refresh the preview if the "Report a vulnerability" button is not showing yet;'); - } - console.log(' - deploy (or hit Publish) when ready, so the live site serves these changes.'); + console.log('Outcome — relay this to the user as it is:'); + for (const [label, value] of outcome) console.log(` ${label}: ${value}`); return 0; } +/** + * What setup established, as label/value pairs. + * + * Every value is a fact about the working tree or a step the person still owns. "Ready to deploy" is + * the strongest claim setup can make: it has not seen the live site and cannot, so it does not say + * anything about it. + */ +function setupOutcome( + state: GuideState, + protection: SetupProtectionResult, + wired: WireBuildScriptsResult, +): Array<[string, string]> { + const remaining = countRemainingSteps(state); + const lines: Array<[string, string]> = []; + + lines.push(['Status', remaining === 0 ? 'Ready to deploy (configured locally; nothing is live yet)' : `Configured locally; ${remaining} step(s) still to finish (see the checklist above)`]); + lines.push(['Monitoring', 'starts with the first build that runs on your hosting platform']); + + if (protection.install.status === 'not-applicable') { + lines.push(['Runtime protection', 'not applicable — this project has no request path (static build)']); + } else if (protection.verification.wired) { + lines.push(['Runtime protection', `wired (${protection.verification.stack}) — local wiring verified; verify against the live site after deploy with \`protect --check --runtime\``]); + } else { + lines.push(['Runtime protection', `not wired yet (${protection.verification.stack}) — finish the checks above, then \`npx @patchstack/connect protect --check\``]); + } + + lines.push(['Next steps', [ + 'add PATCHSTACK_API_KEY (from .patchstackrc.local.json) to your hosting platform\'s environment variables', + 'commit .patchstackrc.json, package.json and the widget change (never .patchstackrc.local.json)', + 'deploy, then check the site in the Patchstack dashboard — it reads "Deployed" once the live site is seen', + ].join('; ')]); + + const warnings: string[] = []; + if (!wired.changed && wired.strategy === 'postinstall-only') warnings.push('no build script, so only dependency installs are scanned'); + if (protection.install.status === 'not-applicable' && protection.install.leftovers.length > 0) { + warnings.push(`earlier guard scaffold does nothing here and can be deleted: ${protection.install.leftovers.join(', ')}`); + } + if (state.claimUrl !== null) warnings.push('the site is not attached to an account until the dashboard link is opened or `npx @patchstack/connect claim` completes'); + if (warnings.length > 0) lines.push(['Warnings', warnings.join('; ')]); + + return lines; +} + async function runStatus(args: ParsedArgs): Promise { const config = await resolveConfig({ cwd: process.cwd(), @@ -1163,6 +1326,18 @@ function describeStack(stack: StackDescriptor): string | null { return parts.length > 0 ? parts.join(' · ') : null; } +/** The project's `build` script, so `mark-build` can honour an output directory it names. Best-effort. */ +function readBuildScript(cwd: string): string | undefined { + try { + const pkg = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8')) as { + scripts?: Record; + }; + return pkg.scripts?.build; + } catch { + return undefined; + } +} + async function runMarkBuild(args: ParsedArgs): Promise { const cwd = process.cwd(); @@ -1202,10 +1377,18 @@ async function runMarkBuild(args: ParsedArgs): Promise { ); } - const dir = resolveBuildDir(cwd, getStringFlag(args.flags, 'dir')); + const buildDirOptions = { framework: stack?.framework ?? null, buildScript: readBuildScript(cwd) }; + const dir = resolveBuildDir(cwd, getStringFlag(args.flags, 'dir'), buildDirOptions); if (dir === null) { + // Loud, and specific: this runs as a postbuild hook whose output scrolls past, and a build that + // finished with nothing stamped is exactly what a published site with no production marker looks + // like. Naming the directories actually tried is what lets the reader fix it in one edit. + const tried = buildDirCandidates(buildDirOptions).map((candidate) => `${candidate}/`).join(', '); + console.warn( + `mark-build: no build output directory found (looked for ${tried}). Nothing to mark.`, + ); console.warn( - 'mark-build: no build output directory found (looked for dist/, build/, out/, .output/public). Pass --dir if it is elsewhere. Nothing to mark.', + 'mark-build: if the build writes elsewhere, set it once in package.json → "postbuild": "patchstack-connect mark-build --dir ".', ); return 0; } diff --git a/src/client.ts b/src/client.ts index 6e045d15..78aeb7fa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,10 +3,13 @@ import { type Config, type ManifestClaimOutcome, type StoreManifestResponse, + type Environment, } from './types.js'; import type { WirePayload } from './normalize.js'; +import { detectHostingPlatform } from './hosting.js'; import { pulseFetch } from './pulse-token.js'; import { canonicalBuildId } from './build-id.js'; +import type { EnvLike } from './stack.js'; export const DEFAULT_ENDPOINT = 'https://api.patchstack.com/monitor/pulse/manifest'; export const DEFAULT_TIMEOUT_MS = 30_000; @@ -337,7 +340,16 @@ export async function fetchSiteStatus(config: Config): Promise { * so a repeated push does not re-point an address or replace a name. Both are omitted when they are not * strings, which is what a caller that built its own `Config` without them sends. */ -export function buildManifestBody(config: Config, payload: WirePayload): Record { +export function buildManifestBody( + config: Config, + payload: WirePayload, + env: EnvLike = process.env, +): Record { + // Where this build is running, when it is a deployment. A local machine has no hosting to + // report, and even if its shell carried a platform's variable that would describe the shell, + // not the site. Servers that predate the field ignore it. + const hosting = config.environment === 'local' ? null : detectHostingPlatform(env); + return { ...payload, environment: config.environment, @@ -345,9 +357,75 @@ export function buildManifestBody(config: Config, payload: WirePayload): Record< ...(typeof config.siteName === 'string' && config.siteName !== '' ? { name: config.siteName } : {}), + ...(hosting !== null && hosting.platform !== null ? { hosting } : {}), }; } +/** + * The request fields a validation response names as refused. + * + * A validation refusal carries an `errors` object keyed by field name, alongside the human sentence. The + * keys are the contract; the sentence is for people. Anything not shaped like that names no field. + */ +function validationFields(body: unknown): string[] { + if (typeof body !== 'object' || body === null) return []; + const errors = (body as { errors?: unknown }).errors; + if (typeof errors !== 'object' || errors === null || Array.isArray(errors)) return []; + return Object.keys(errors); +} + +/** + * Whether a manifest was refused because the server does not accept the environment label it carried. + * + * A Patchstack API that predates the `local` label refuses a local scan on the `environment` field. The + * scan is still worth reporting; it just has to say `sandbox` to that server, which is the label it does + * have for "not the live site". Decided on the field the refusal names, never on its wording: a refusal + * of any other field, or one that names no field, is a real refusal. + */ +export function environmentRejected(err: unknown): boolean { + return ( + err instanceof PatchstackError && + err.code === 'VALIDATION_ERROR' && + err.fields.includes('environment') + ); +} + +/** + * Whether a manifest post may be sent again after a timeout. + * + * Only a report for a site that already exists. The first post — no site UUID yet — is the one that + * provisions the site and issues its credential, and it has no idempotency key: the server may have + * committed before the client gave up, and a second post would provision a second site. + */ +export function canRetryManifestPost(config: Config): boolean { + return config.siteUuid !== null; +} + +export interface ManifestPostResult { + response: StoreManifestResponse; + /** The label the report was accepted under — `sandbox` when the server refused `local`. */ + environmentUsed: Environment; +} + +/** + * Post the manifest, falling back from `local` to `sandbox` for a server that does not know `local`. + * + * Keeps the connector publishable ahead of the server change: the honest label is tried first, and the + * older server's nearest label is used only when it refuses, never silently on a server that accepts. + */ +export async function postManifestWithEnvironmentFallback( + config: Config, + payload: WirePayload, +): Promise { + try { + return { response: await postManifest(config, payload), environmentUsed: config.environment }; + } catch (err) { + if (config.environment !== 'local' || !environmentRejected(err)) throw err; + const fallback: Config = { ...config, environment: 'sandbox' }; + return { response: await postManifest(fallback, payload), environmentUsed: 'sandbox' }; + } +} + export async function postManifest( config: Config, payload: WirePayload, @@ -401,10 +479,12 @@ export async function postManifest( } if (response.status === 422) { - throw new PatchstackError( + const refused = new PatchstackError( body?.message ?? 'Patchstack rejected the manifest payload (validation failed).', 'VALIDATION_ERROR', ); + refused.fields = validationFields(body); + throw refused; } const refused = authFailureMessage(response.status, config); diff --git a/src/config.ts b/src/config.ts index 21d7f5ff..df3f9f6b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { readFile, writeFile, chmod } from 'node:fs/promises'; import path from 'node:path'; import { PatchstackError, type Config, type Environment } from './types.js'; import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from './client.js'; +import { inferEnvironment } from './environment.js'; import { detectSiteUrl, normaliseSiteUrl } from './site-url.js'; import { detectSiteName, normaliseSiteName } from './site-name.js'; @@ -21,7 +22,6 @@ const CONFIG_FILENAME = '.patchstackrc.json'; */ const SECRET_FILENAME = '.patchstackrc.local.json'; -export const DEFAULT_ENVIRONMENT: Environment = 'production'; interface ConfigFile { siteUuid?: string; @@ -153,11 +153,15 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise 0 && !isUuid(siteUuid)) { throw new PatchstackError( @@ -199,6 +203,7 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise { + const existing = await readConfigFile(cwd); + return writeConfigFile(cwd, { ...existing, timeoutMs }); +} + /** * Persist the WP-format api_key issued at provision. Authenticates both the Pulse endpoints and connector * log reporting. Never embed it in the public disclosure widget. @@ -482,5 +499,5 @@ function isUuid(value: string): boolean { } function isEnvironment(value: string): value is Environment { - return value === 'production' || value === 'sandbox'; + return value === 'production' || value === 'sandbox' || value === 'local'; } diff --git a/src/demo.ts b/src/demo.ts index 5c7d7db1..60ceaf07 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import type { Environment } from './types.js'; import { join } from 'node:path'; import { scanLockfile } from './parsers/index.js'; @@ -50,7 +51,7 @@ export interface DemoGuideState { packageManager: PackageManager; siteUuid: string | null; dependency: DemoDependencyState; - environment: 'production' | 'sandbox'; + environment: Environment; url: string; } @@ -162,7 +163,7 @@ export function renderDemoGuide(state: DemoGuideState): string { ? `npx @patchstack/connect demo ${scenario.name}` : `npx @patchstack/connect demo ${scenario.name} --url ${shellQuote(state.url)}`; const siteReady = state.siteUuid !== null; - const envReady = state.environment === 'production'; + const envReady = state.environment !== 'sandbox'; const dependencyDetail = state.dependency.ready ? `${scenario.packageName}@${scenario.packageVersion} is in the lockfile` : state.dependency.versions.length > 0 @@ -173,7 +174,7 @@ export function renderDemoGuide(state: DemoGuideState): string { if (!siteReady) { next = 'Click “Connect Patchstack” in Bolt, then run this guide again.'; } else if (!envReady) { - next = 'Unset PATCHSTACK_ENVIRONMENT (or set it to production), then run this guide again.'; + next = 'Unset PATCHSTACK_ENVIRONMENT (a local run reports as local on its own), then run this guide again.'; } else if (!state.dependency.ready) { next = install; } else { diff --git a/src/environment.ts b/src/environment.ts new file mode 100644 index 00000000..d8a14a37 --- /dev/null +++ b/src/environment.ts @@ -0,0 +1,96 @@ +import type { EnvLike } from './stack.js'; +import type { Environment } from './types.js'; + +/** + * Where a scan is running, when nothing has said. + * + * A manifest is labelled with the environment it was built in, and Patchstack grades the site on that + * label: a production build is contact with a live site, a local one is inventory. Defaulting to + * `production` made every first scan — the one that runs on a laptop the moment the package is installed — + * a claim that the site was deployed and in contact, before anything had been committed, let alone + * published. The label has to come from evidence. + * + * `production` is claimed only when a hosting platform's OWN discriminator says this build is the + * production one — Vercel's `VERCEL_ENV`, Netlify's `CONTEXT`, and so on. The same discriminator saying + * preview makes the build `sandbox`: not the live site, and known not to be. Everything else is `local`. + * + * Three things deliberately do not count as production evidence. A generic CI marker (`CI`, + * `GITHUB_ACTIONS`) proves automation, not deployment: the same runner builds pull requests and runs + * tests. A hosting platform's variable with no production/preview discriminator names where the build + * runs, not which deployment it is. And a variable that is set but empty is not set. Any of these read as + * production recreates the false-live-site state this inference exists to end. + * + * The cost of a wrong answer is asymmetric, which is why the default is `local` and not the other way + * round. A deployment mislabelled `local` still stamps its fingerprint into the page, and the live sighting + * corrects the record on its own; a laptop mislabelled `production` is a deployed, connected site that does + * not exist. `PATCHSTACK_ENVIRONMENT` remains the override for a platform this does not know. + */ + +const set = (value: string | undefined): value is string => value !== undefined && value !== ''; + +interface Discriminator { + platform: string; + /** `production`, `sandbox` (a preview the platform names as such), or null when the platform is absent. */ + read: (env: EnvLike) => { environment: Environment; evidence: string } | null; +} + +/** + * Each platform's own answer to "is this the production deployment?". Only platforms that answer are + * listed: Cloudflare Pages exposes a branch name but no way to know which branch is production, so a + * Pages build stays `local` unless `PATCHSTACK_ENVIRONMENT` says otherwise. + */ +const DISCRIMINATORS: readonly Discriminator[] = [ + { + platform: 'vercel', + read: (env) => { + if (!set(env.VERCEL) || !set(env.VERCEL_ENV)) return null; + return env.VERCEL_ENV === 'production' + ? { environment: 'production', evidence: 'VERCEL_ENV=production' } + : { environment: 'sandbox', evidence: `VERCEL_ENV=${env.VERCEL_ENV}` }; + }, + }, + { + platform: 'netlify', + read: (env) => { + if (env.NETLIFY !== 'true' || !set(env.CONTEXT)) return null; + return env.CONTEXT === 'production' + ? { environment: 'production', evidence: 'CONTEXT=production' } + : { environment: 'sandbox', evidence: `CONTEXT=${env.CONTEXT}` }; + }, + }, + { + platform: 'render', + read: (env) => { + if (env.RENDER !== 'true') return null; + return env.IS_PULL_REQUEST === 'true' + ? { environment: 'sandbox', evidence: 'IS_PULL_REQUEST=true' } + : { environment: 'production', evidence: 'RENDER=true, not a pull request' }; + }, + }, + { + platform: 'railway', + read: (env) => { + if (!set(env.RAILWAY_ENVIRONMENT_NAME)) return null; + return env.RAILWAY_ENVIRONMENT_NAME === 'production' + ? { environment: 'production', evidence: 'RAILWAY_ENVIRONMENT_NAME=production' } + : { environment: 'sandbox', evidence: `RAILWAY_ENVIRONMENT_NAME=${env.RAILWAY_ENVIRONMENT_NAME}` }; + }, + }, +]; + +export interface InferredEnvironment { + environment: Environment; + /** What decided it, for the line the CLI prints. Empty for `local`, which is decided by absence. */ + evidence: string[]; +} + +export function inferEnvironment(env: EnvLike = process.env): InferredEnvironment { + for (const discriminator of DISCRIMINATORS) { + const verdict = discriminator.read(env); + if (verdict !== null) { + return { environment: verdict.environment, evidence: [`${discriminator.platform}: ${verdict.evidence}`] }; + } + } + + return { environment: 'local', evidence: [] }; +} diff --git a/src/guide.ts b/src/guide.ts index 0f76cc8a..149e126f 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -63,6 +63,11 @@ export interface GuideState { protectionWired: boolean; protectionStack: string; protectionChecks: VerifyCheck[]; + /** + * Whether a runtime guard is a thing this project can have at all. False for a static build, where + * `protectionWired: false` is not a step anyone owes — see `VerifyReport.applicable`. + */ + protectionApplicable: boolean; } const INSTALL_COMMANDS: Record = { @@ -376,6 +381,7 @@ export async function collectGuideState(cwd: string): Promise { protectionWired: protection.wired, protectionStack: protection.stack, protectionChecks: protection.checks, + protectionApplicable: protection.applicable, }; } @@ -423,7 +429,7 @@ export function countRemainingSteps(state: GuideState): number { !state.hasBuildScript || (state.prebuildWired && state.postbuildWired), state.widgetOptOut || (state.widgetInstalled && state.widgetTokenMatches !== false), !needsSourceProductionMarker(state) || state.productionMarkerWired, - state.protectionWired, + !state.protectionApplicable || state.protectionWired, ].filter((step) => !step).length; } @@ -578,7 +584,16 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri } // 6. Runtime protection - if (state.protectionWired) { + if (!state.protectionApplicable) { + // Not a green tick for a step that was done, and not a red one for a step still owed. A guard screens + // requests, and a project that only emits files never receives one, so the honest line says the + // capability does not apply here — and says what does, since "no runtime protection" read alone + // sounds like a gap rather than a shape. + lines.push(done('Runtime protection: not applicable — this project has no request path')); + for (const check of state.protectionChecks.filter((item) => item.hint !== undefined && item.group !== 'reporting')) { + lines.push(detail(check.hint ?? '')); + } + } else if (state.protectionWired) { lines.push(done(`Runtime protection wired (${state.protectionStack})`)); } else { lines.push(todo(`Finish runtime protection (${state.protectionStack})`)); @@ -631,12 +646,12 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri done( paint( ANSI.bold, - 'All setup steps complete. Commit .patchstackrc.json, package.json, the runtime guard changes, and the file carrying the widget snippet. Never commit .patchstackrc.local.json — it holds the API key, and setup has already added it to .gitignore.', + 'Ready to deploy. Everything above is in the working tree only: commit .patchstackrc.json, package.json, the runtime guard changes, and the file carrying the widget snippet; add PATCHSTACK_API_KEY to the hosting platform; then deploy. Never commit .patchstackrc.local.json — it holds the API key, and setup has already added it to .gitignore.', ), ), ); if (state.claimUrl !== null) { - lines.push(detail('The only manual action left is opening the dashboard link above (if not already connected).')); + lines.push(detail('Until the site is attached to an account (dashboard link above, or `claim`), nobody can see its reports.')); } } else { lines.push( diff --git a/src/hosting.ts b/src/hosting.ts new file mode 100644 index 00000000..ca27830b --- /dev/null +++ b/src/hosting.ts @@ -0,0 +1,59 @@ +import type { EnvLike } from './stack.js'; + +/** + * Which hosting platform a build is running on, from the variables the platform sets. + * + * Reported alongside the manifest so the dashboard can say "hosted on Netlify" — and, when a later build + * runs somewhere else, that the site moved. Only variable NAMES are read and only names are reported as + * evidence; a value here would be a secret in a log line. + * + * Absence means nothing: a platform this list does not know reports no platform, not a wrong one. The + * server has a second witness (the served page's headers) that covers platforms a build environment + * cannot name, DigitalOcean's App Platform among them. + */ +export interface HostingDetection { + platform: string | null; + /** The variable names that decided it. Empty when no platform was recognised. */ + evidence: string[]; +} + +interface HostingRule { + platform: string; + /** Variable names that, present and non-empty, name this platform. */ + any: string[]; +} + +/** + * Most specific first. Cloudflare Pages builds also carry generic CI variables and Vercel/Netlify never + * carry each other's, so order only matters where one platform runs on another (a Cloudflare Pages + * build of a Netlify-style project still says Cloudflare, because that is where it will be served). + */ +const RULES: readonly HostingRule[] = [ + { platform: 'netlify', any: ['NETLIFY', 'NETLIFY_BUILD_BASE', 'DEPLOY_PRIME_URL'] }, + { platform: 'vercel', any: ['VERCEL', 'VERCEL_ENV', 'VERCEL_URL'] }, + { platform: 'cloudflare', any: ['CF_PAGES', 'CF_PAGES_URL', 'CLOUDFLARE_ACCOUNT_ID'] }, + { platform: 'aws', any: ['AWS_APP_ID', 'AWS_LAMBDA_FUNCTION_NAME', 'AWS_EXECUTION_ENV'] }, + { platform: 'render', any: ['RENDER', 'RENDER_SERVICE_ID', 'RENDER_EXTERNAL_URL'] }, + { platform: 'railway', any: ['RAILWAY_ENVIRONMENT', 'RAILWAY_ENVIRONMENT_NAME', 'RAILWAY_PROJECT_ID'] }, + { platform: 'fly', any: ['FLY_APP_NAME', 'FLY_REGION', 'FLY_ALLOC_ID'] }, + { platform: 'heroku', any: ['DYNO', 'HEROKU_APP_NAME'] }, + { platform: 'google-cloud', any: ['K_SERVICE', 'GAE_APPLICATION', 'GAE_SERVICE'] }, + { platform: 'deno-deploy', any: ['DENO_DEPLOYMENT_ID'] }, + { platform: 'azure', any: ['WEBSITE_SITE_NAME', 'WEBSITE_INSTANCE_ID'] }, +]; + +const present = (env: EnvLike, name: string): boolean => { + const value = env[name]; + return value !== undefined && value !== ''; +}; + +export function detectHostingPlatform(env: EnvLike = process.env): HostingDetection { + for (const rule of RULES) { + const evidence = rule.any.filter((name) => present(env, name)); + if (evidence.length > 0) { + return { platform: rule.platform, evidence }; + } + } + + return { platform: null, evidence: [] }; +} diff --git a/src/map/surface.ts b/src/map/surface.ts index bfe73146..42c4577f 100644 --- a/src/map/surface.ts +++ b/src/map/surface.ts @@ -39,12 +39,15 @@ import type { DeploymentShape, ServerSurface, SurfaceSignal, TsModule } from './ // analysis never sees it. /** Dependencies that build a static bundle and, on their own, no server. */ -const STATIC_GENERATORS: Array<{ dep: string; label: string }> = [ +export const STATIC_GENERATORS: Array<{ dep: string; label: string }> = [ { dep: 'vite', label: 'vite' }, { dep: 'react-scripts', label: 'create-react-app' }, { dep: 'gatsby', label: 'gatsby' }, { dep: 'parcel', label: 'parcel' }, { dep: '@sveltejs/adapter-static', label: 'sveltekit-static-adapter' }, + { dep: '@11ty/eleventy', label: 'eleventy' }, + { dep: '@docusaurus/core', label: 'docusaurus' }, + { dep: 'vitepress', label: 'vitepress' }, ]; /** @@ -55,7 +58,7 @@ const STATIC_GENERATORS: Array<{ dep: string; label: string }> = [ * static reading and leaves the app `unknown`, which is the conservative direction for a state whose product * meaning is "no request-path protection needed". */ -const SSR_COMPANIONS = ['vike', 'vite-plugin-ssr', '@react-router/node', '@react-router/serve', 'vite-plugin-node']; +export const SSR_COMPANIONS = ['vike', 'vite-plugin-ssr', '@react-router/node', '@react-router/serve', 'vite-plugin-node']; /** * Dependencies that mean a server, so a static claim is off the table. @@ -63,7 +66,7 @@ const SSR_COMPANIONS = ['vike', 'vite-plugin-ssr', '@react-router/node', '@react * Deliberately wider than the framework detector: this list only has to answer "might this app serve * requests", and over-answering yes costs a claim we would rather not make anyway. */ -const SERVER_DEPENDENCIES = [ +export const SERVER_DEPENDENCIES = [ 'express', 'fastify', 'hono', 'koa', '@nestjs/core', '@hapi/hapi', 'h3', 'polka', 'restify', '@tanstack/react-start', '@tanstack/start', '@tanstack/solid-start', 'nuxt', 'remix', '@remix-run/node', '@remix-run/server-runtime', @@ -77,12 +80,12 @@ const SERVER_DEPENDENCIES = [ * SvelteKit project permanently `unknown` — and the test that "proved" the adapter worked installed the * adapter with no kit, which is not a package set anyone ships. */ -const CONDITIONAL_SERVER_DEPENDENCIES: Array<{ dep: string; staticWhen: string }> = [ +export const CONDITIONAL_SERVER_DEPENDENCIES: Array<{ dep: string; staticWhen: string }> = [ { dep: '@sveltejs/kit', staticWhen: '@sveltejs/adapter-static' }, ]; /** Astro and Next ship both modes, so the adapter or the output setting decides. */ -const SSR_ADAPTERS = ['@astrojs/node', '@astrojs/vercel', '@astrojs/cloudflare', '@astrojs/netlify', '@astrojs/deno']; +export const SSR_ADAPTERS = ['@astrojs/node', '@astrojs/vercel', '@astrojs/cloudflare', '@astrojs/netlify', '@astrojs/deno']; /** How each deployment-evidence level appears in the surface evidence list. */ const SHAPE_SIGNAL: Record = { diff --git a/src/mark-build.ts b/src/mark-build.ts index 4a912700..c7379178 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { isEmptyStack, type StackDescriptor } from './stack.js'; @@ -6,23 +6,93 @@ import { isEmptyStack, type StackDescriptor } from './stack.js'; /** Attribute that tags our injected