@patchstack/connect 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,11 +1,12 @@
1
1
  type Ecosystem = 'npm' | 'composer';
2
2
  /**
3
- * Which environment a manifest was captured in. The connector reports from real
4
- * builds (prebuild scan / build hooks), so it defaults to 'production'. Override
5
- * with PATCHSTACK_ENVIRONMENT=sandbox (or "environment" in .patchstackrc.json)
6
- * for test manifests.
3
+ * Where a manifest was built. `production` only when the hosting platform's own discriminator says this
4
+ * build is the production one; `sandbox` for a preview it names as such, or a hosted builder's workspace;
5
+ * `local` for everything else, a developer's machine included — inventory, never evidence of a live site.
6
+ * Inferred when nothing sets it (see `environment.ts`); `PATCHSTACK_ENVIRONMENT` or `"environment"` in
7
+ * .patchstackrc.json overrides.
7
8
  */
8
- type Environment = 'production' | 'sandbox';
9
+ type Environment = 'production' | 'sandbox' | 'local';
9
10
  interface PackageEntry {
10
11
  name: string;
11
12
  version: string;
@@ -59,8 +60,14 @@ interface Config {
59
60
  siteName?: string | null;
60
61
  endpoint: string;
61
62
  timeoutMs: number;
62
- /** Environment to report the manifest under. Defaults to 'production'. */
63
+ /** Environment to report the manifest under. Inferred from the build environment when not stated. */
63
64
  environment: Environment;
65
+ /**
66
+ * What decided `environment` when nothing set it: the hosting platform's own production or preview
67
+ * discriminator. Empty when it was stated, and empty for `local`, which is decided by the absence of
68
+ * any such evidence.
69
+ */
70
+ environmentEvidence?: string[];
64
71
  /**
65
72
  * Whether the connector manages the disclosure-widget tag (source shell on
66
73
  * `scan`, built HTML on `mark-build`). Defaults to true; persist
@@ -109,6 +116,12 @@ interface StoreManifestResponse {
109
116
  declare class PatchstackError extends Error {
110
117
  readonly code: 'CONFIG_MISSING' | 'CONFIG_INVALID' | 'LOCKFILE_NOT_FOUND' | 'LOCKFILE_UNSUPPORTED' | 'LOCKFILE_PARSE_ERROR' | 'NETWORK_ERROR' | 'NETWORK_TIMEOUT' | 'SITE_NOT_FOUND' | 'UNAUTHORIZED' | 'VALIDATION_ERROR' | 'SERVER_ERROR';
111
118
  readonly cause?: unknown | undefined;
119
+ /**
120
+ * For a `VALIDATION_ERROR`, the request fields the server refused, as it named them. Read from the
121
+ * structured `errors` object a validation response carries, so a caller deciding what to do about a
122
+ * refusal keys on the field rather than on the wording of a sentence.
123
+ */
124
+ fields: readonly string[];
112
125
  constructor(message: string, code: 'CONFIG_MISSING' | 'CONFIG_INVALID' | 'LOCKFILE_NOT_FOUND' | 'LOCKFILE_UNSUPPORTED' | 'LOCKFILE_PARSE_ERROR' | 'NETWORK_ERROR' | 'NETWORK_TIMEOUT' | 'SITE_NOT_FOUND' | 'UNAUTHORIZED' | 'VALIDATION_ERROR' | 'SERVER_ERROR', cause?: unknown | undefined);
113
126
  }
114
127
 
@@ -185,6 +198,55 @@ interface WireOptions {
185
198
  declare function buildWirePayload(manifest: Manifest, options?: WireOptions): NormalizeResult;
186
199
  declare function compareVersions(a: string, b: string): number;
187
200
 
201
+ /**
202
+ * A best-effort description of the stack a build was produced with, derived
203
+ * entirely from the lockfile (ground truth) plus the build-time environment.
204
+ *
205
+ * The disclosure widget reads this from `window.__PATCHSTACK_STACK__` (injected
206
+ * by `mark-build`) and reports it to Patchstack, so we learn how the sites we
207
+ * protect are actually built and hosted — across every "vibe" platform — without
208
+ * shipping a runtime probe onto the host server. Every field is a coarse label
209
+ * or a bare key *name*: no versions beyond the framework, and never an env value.
210
+ */
211
+ interface StackDescriptor {
212
+ /** App / meta-framework, e.g. "next", "nuxt", "tanstack-start", "remix". */
213
+ framework: string | null;
214
+ /** UI runtime, e.g. "react", "vue", "svelte", "solid". */
215
+ ui: string | null;
216
+ /** Build tool / bundler, e.g. "vite", "webpack", "rspack". */
217
+ bundler: string | null;
218
+ /** Deployment-runtime hint from deps, e.g. "cloudflare-workers", "vercel". */
219
+ runtime: string | null;
220
+ /** The vibe/builder platform that generated the project, e.g. "lovable". */
221
+ builder: string | null;
222
+ /** Package ecosystem the manifest came from. */
223
+ ecosystem: 'npm';
224
+ /** Hosting-related build-environment variable NAMES (never their values). */
225
+ hostingEnvKeys: string[];
226
+ }
227
+ /**
228
+ * An environment, described structurally so it needs no ambient types.
229
+ *
230
+ * `@types/node` is not a dependency of this package, so nothing in its public surface may name
231
+ * `NodeJS.ProcessEnv`: a consumer without those types cannot compile against a signature that does.
232
+ * `process.env` satisfies this, and so does a plain object.
233
+ */
234
+ type EnvLike = Record<string, string | undefined>;
235
+ /**
236
+ * Return the sorted NAMES of hosting-related environment variables present in
237
+ * `env`. Only names are surfaced — never values — so a build fingerprint can
238
+ * distinguish "deployed on Cloudflare" from "deployed on Vercel" without ever
239
+ * disclosing a secret.
240
+ */
241
+ declare function collectHostingEnvKeys(env?: EnvLike): string[];
242
+ /**
243
+ * Derive a {@link StackDescriptor} from the wire packages and the build
244
+ * environment. Never throws: an unrecognised stack just yields nulls.
245
+ */
246
+ declare function detectStack(packages: readonly WirePackage[], env?: EnvLike): StackDescriptor;
247
+ /** True when the descriptor carries no useful signal (nothing worth injecting). */
248
+ declare function isEmptyStack(stack: StackDescriptor): boolean;
249
+
188
250
  declare const DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
189
251
  declare function buildEndpointUrl(base: string, siteUuid?: string | null): string;
190
252
  /**
@@ -295,55 +357,6 @@ declare function persistApiKey(cwd: string, apiKey: string): Promise<SecretFileR
295
357
  */
296
358
  declare function persistPulseAuth(cwd: string, pulseAuth: string): Promise<SecretFileResult>;
297
359
 
298
- /**
299
- * A best-effort description of the stack a build was produced with, derived
300
- * entirely from the lockfile (ground truth) plus the build-time environment.
301
- *
302
- * The disclosure widget reads this from `window.__PATCHSTACK_STACK__` (injected
303
- * by `mark-build`) and reports it to Patchstack, so we learn how the sites we
304
- * protect are actually built and hosted — across every "vibe" platform — without
305
- * shipping a runtime probe onto the host server. Every field is a coarse label
306
- * or a bare key *name*: no versions beyond the framework, and never an env value.
307
- */
308
- interface StackDescriptor {
309
- /** App / meta-framework, e.g. "next", "nuxt", "tanstack-start", "remix". */
310
- framework: string | null;
311
- /** UI runtime, e.g. "react", "vue", "svelte", "solid". */
312
- ui: string | null;
313
- /** Build tool / bundler, e.g. "vite", "webpack", "rspack". */
314
- bundler: string | null;
315
- /** Deployment-runtime hint from deps, e.g. "cloudflare-workers", "vercel". */
316
- runtime: string | null;
317
- /** The vibe/builder platform that generated the project, e.g. "lovable". */
318
- builder: string | null;
319
- /** Package ecosystem the manifest came from. */
320
- ecosystem: 'npm';
321
- /** Hosting-related build-environment variable NAMES (never their values). */
322
- hostingEnvKeys: string[];
323
- }
324
- /**
325
- * An environment, described structurally so it needs no ambient types.
326
- *
327
- * `@types/node` is not a dependency of this package, so nothing in its public surface may name
328
- * `NodeJS.ProcessEnv`: a consumer without those types cannot compile against a signature that does.
329
- * `process.env` satisfies this, and so does a plain object.
330
- */
331
- type EnvLike = Record<string, string | undefined>;
332
- /**
333
- * Return the sorted NAMES of hosting-related environment variables present in
334
- * `env`. Only names are surfaced — never values — so a build fingerprint can
335
- * distinguish "deployed on Cloudflare" from "deployed on Vercel" without ever
336
- * disclosing a secret.
337
- */
338
- declare function collectHostingEnvKeys(env?: EnvLike): string[];
339
- /**
340
- * Derive a {@link StackDescriptor} from the wire packages and the build
341
- * environment. Never throws: an unrecognised stack just yields nulls.
342
- */
343
- declare function detectStack(packages: readonly WirePackage[], env?: EnvLike): StackDescriptor;
344
- /** True when the descriptor carries no useful signal (nothing worth injecting). */
345
- declare function isEmptyStack(stack: StackDescriptor): boolean;
346
-
347
360
  declare const WIDGET_SCRIPT_URL = "https://cdn.patchstack.com/patchstack-widget.js";
348
361
  /** Attribute that tags the connector-managed widget tag so re-runs update it. */
349
362
  declare const WIDGET_MARKER_ATTR = "data-patchstack-connect-widget";
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  type Ecosystem = 'npm' | 'composer';
2
2
  /**
3
- * Which environment a manifest was captured in. The connector reports from real
4
- * builds (prebuild scan / build hooks), so it defaults to 'production'. Override
5
- * with PATCHSTACK_ENVIRONMENT=sandbox (or "environment" in .patchstackrc.json)
6
- * for test manifests.
3
+ * Where a manifest was built. `production` only when the hosting platform's own discriminator says this
4
+ * build is the production one; `sandbox` for a preview it names as such, or a hosted builder's workspace;
5
+ * `local` for everything else, a developer's machine included — inventory, never evidence of a live site.
6
+ * Inferred when nothing sets it (see `environment.ts`); `PATCHSTACK_ENVIRONMENT` or `"environment"` in
7
+ * .patchstackrc.json overrides.
7
8
  */
8
- type Environment = 'production' | 'sandbox';
9
+ type Environment = 'production' | 'sandbox' | 'local';
9
10
  interface PackageEntry {
10
11
  name: string;
11
12
  version: string;
@@ -59,8 +60,14 @@ interface Config {
59
60
  siteName?: string | null;
60
61
  endpoint: string;
61
62
  timeoutMs: number;
62
- /** Environment to report the manifest under. Defaults to 'production'. */
63
+ /** Environment to report the manifest under. Inferred from the build environment when not stated. */
63
64
  environment: Environment;
65
+ /**
66
+ * What decided `environment` when nothing set it: the hosting platform's own production or preview
67
+ * discriminator. Empty when it was stated, and empty for `local`, which is decided by the absence of
68
+ * any such evidence.
69
+ */
70
+ environmentEvidence?: string[];
64
71
  /**
65
72
  * Whether the connector manages the disclosure-widget tag (source shell on
66
73
  * `scan`, built HTML on `mark-build`). Defaults to true; persist
@@ -109,6 +116,12 @@ interface StoreManifestResponse {
109
116
  declare class PatchstackError extends Error {
110
117
  readonly code: 'CONFIG_MISSING' | 'CONFIG_INVALID' | 'LOCKFILE_NOT_FOUND' | 'LOCKFILE_UNSUPPORTED' | 'LOCKFILE_PARSE_ERROR' | 'NETWORK_ERROR' | 'NETWORK_TIMEOUT' | 'SITE_NOT_FOUND' | 'UNAUTHORIZED' | 'VALIDATION_ERROR' | 'SERVER_ERROR';
111
118
  readonly cause?: unknown | undefined;
119
+ /**
120
+ * For a `VALIDATION_ERROR`, the request fields the server refused, as it named them. Read from the
121
+ * structured `errors` object a validation response carries, so a caller deciding what to do about a
122
+ * refusal keys on the field rather than on the wording of a sentence.
123
+ */
124
+ fields: readonly string[];
112
125
  constructor(message: string, code: 'CONFIG_MISSING' | 'CONFIG_INVALID' | 'LOCKFILE_NOT_FOUND' | 'LOCKFILE_UNSUPPORTED' | 'LOCKFILE_PARSE_ERROR' | 'NETWORK_ERROR' | 'NETWORK_TIMEOUT' | 'SITE_NOT_FOUND' | 'UNAUTHORIZED' | 'VALIDATION_ERROR' | 'SERVER_ERROR', cause?: unknown | undefined);
113
126
  }
114
127
 
@@ -185,6 +198,55 @@ interface WireOptions {
185
198
  declare function buildWirePayload(manifest: Manifest, options?: WireOptions): NormalizeResult;
186
199
  declare function compareVersions(a: string, b: string): number;
187
200
 
201
+ /**
202
+ * A best-effort description of the stack a build was produced with, derived
203
+ * entirely from the lockfile (ground truth) plus the build-time environment.
204
+ *
205
+ * The disclosure widget reads this from `window.__PATCHSTACK_STACK__` (injected
206
+ * by `mark-build`) and reports it to Patchstack, so we learn how the sites we
207
+ * protect are actually built and hosted — across every "vibe" platform — without
208
+ * shipping a runtime probe onto the host server. Every field is a coarse label
209
+ * or a bare key *name*: no versions beyond the framework, and never an env value.
210
+ */
211
+ interface StackDescriptor {
212
+ /** App / meta-framework, e.g. "next", "nuxt", "tanstack-start", "remix". */
213
+ framework: string | null;
214
+ /** UI runtime, e.g. "react", "vue", "svelte", "solid". */
215
+ ui: string | null;
216
+ /** Build tool / bundler, e.g. "vite", "webpack", "rspack". */
217
+ bundler: string | null;
218
+ /** Deployment-runtime hint from deps, e.g. "cloudflare-workers", "vercel". */
219
+ runtime: string | null;
220
+ /** The vibe/builder platform that generated the project, e.g. "lovable". */
221
+ builder: string | null;
222
+ /** Package ecosystem the manifest came from. */
223
+ ecosystem: 'npm';
224
+ /** Hosting-related build-environment variable NAMES (never their values). */
225
+ hostingEnvKeys: string[];
226
+ }
227
+ /**
228
+ * An environment, described structurally so it needs no ambient types.
229
+ *
230
+ * `@types/node` is not a dependency of this package, so nothing in its public surface may name
231
+ * `NodeJS.ProcessEnv`: a consumer without those types cannot compile against a signature that does.
232
+ * `process.env` satisfies this, and so does a plain object.
233
+ */
234
+ type EnvLike = Record<string, string | undefined>;
235
+ /**
236
+ * Return the sorted NAMES of hosting-related environment variables present in
237
+ * `env`. Only names are surfaced — never values — so a build fingerprint can
238
+ * distinguish "deployed on Cloudflare" from "deployed on Vercel" without ever
239
+ * disclosing a secret.
240
+ */
241
+ declare function collectHostingEnvKeys(env?: EnvLike): string[];
242
+ /**
243
+ * Derive a {@link StackDescriptor} from the wire packages and the build
244
+ * environment. Never throws: an unrecognised stack just yields nulls.
245
+ */
246
+ declare function detectStack(packages: readonly WirePackage[], env?: EnvLike): StackDescriptor;
247
+ /** True when the descriptor carries no useful signal (nothing worth injecting). */
248
+ declare function isEmptyStack(stack: StackDescriptor): boolean;
249
+
188
250
  declare const DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
189
251
  declare function buildEndpointUrl(base: string, siteUuid?: string | null): string;
190
252
  /**
@@ -295,55 +357,6 @@ declare function persistApiKey(cwd: string, apiKey: string): Promise<SecretFileR
295
357
  */
296
358
  declare function persistPulseAuth(cwd: string, pulseAuth: string): Promise<SecretFileResult>;
297
359
 
298
- /**
299
- * A best-effort description of the stack a build was produced with, derived
300
- * entirely from the lockfile (ground truth) plus the build-time environment.
301
- *
302
- * The disclosure widget reads this from `window.__PATCHSTACK_STACK__` (injected
303
- * by `mark-build`) and reports it to Patchstack, so we learn how the sites we
304
- * protect are actually built and hosted — across every "vibe" platform — without
305
- * shipping a runtime probe onto the host server. Every field is a coarse label
306
- * or a bare key *name*: no versions beyond the framework, and never an env value.
307
- */
308
- interface StackDescriptor {
309
- /** App / meta-framework, e.g. "next", "nuxt", "tanstack-start", "remix". */
310
- framework: string | null;
311
- /** UI runtime, e.g. "react", "vue", "svelte", "solid". */
312
- ui: string | null;
313
- /** Build tool / bundler, e.g. "vite", "webpack", "rspack". */
314
- bundler: string | null;
315
- /** Deployment-runtime hint from deps, e.g. "cloudflare-workers", "vercel". */
316
- runtime: string | null;
317
- /** The vibe/builder platform that generated the project, e.g. "lovable". */
318
- builder: string | null;
319
- /** Package ecosystem the manifest came from. */
320
- ecosystem: 'npm';
321
- /** Hosting-related build-environment variable NAMES (never their values). */
322
- hostingEnvKeys: string[];
323
- }
324
- /**
325
- * An environment, described structurally so it needs no ambient types.
326
- *
327
- * `@types/node` is not a dependency of this package, so nothing in its public surface may name
328
- * `NodeJS.ProcessEnv`: a consumer without those types cannot compile against a signature that does.
329
- * `process.env` satisfies this, and so does a plain object.
330
- */
331
- type EnvLike = Record<string, string | undefined>;
332
- /**
333
- * Return the sorted NAMES of hosting-related environment variables present in
334
- * `env`. Only names are surfaced — never values — so a build fingerprint can
335
- * distinguish "deployed on Cloudflare" from "deployed on Vercel" without ever
336
- * disclosing a secret.
337
- */
338
- declare function collectHostingEnvKeys(env?: EnvLike): string[];
339
- /**
340
- * Derive a {@link StackDescriptor} from the wire packages and the build
341
- * environment. Never throws: an unrecognised stack just yields nulls.
342
- */
343
- declare function detectStack(packages: readonly WirePackage[], env?: EnvLike): StackDescriptor;
344
- /** True when the descriptor carries no useful signal (nothing worth injecting). */
345
- declare function isEmptyStack(stack: StackDescriptor): boolean;
346
-
347
360
  declare const WIDGET_SCRIPT_URL = "https://cdn.patchstack.com/patchstack-widget.js";
348
361
  /** Attribute that tags the connector-managed widget tag so re-runs update it. */
349
362
  declare const WIDGET_MARKER_ATTR = "data-patchstack-connect-widget";
package/dist/index.js CHANGED
@@ -12,6 +12,12 @@ var PatchstackError = class extends Error {
12
12
  }
13
13
  code;
14
14
  cause;
15
+ /**
16
+ * For a `VALIDATION_ERROR`, the request fields the server refused, as it named them. Read from the
17
+ * structured `errors` object a validation response carries, so a caller deciding what to do about a
18
+ * refusal keys on the field rather than on the wording of a sentence.
19
+ */
20
+ fields = [];
15
21
  };
16
22
 
17
23
  // src/parsers/consistency.ts
@@ -49,8 +55,8 @@ async function readDeclaredDependencyNames(cwd) {
49
55
  return [...names];
50
56
  }
51
57
  function missingDependencies(declared, packages) {
52
- const present = new Set(packages.map((entry) => entry.name));
53
- return declared.filter((name) => !present.has(name));
58
+ const present2 = new Set(packages.map((entry) => entry.name));
59
+ return declared.filter((name) => !present2.has(name));
54
60
  }
55
61
  function disagreements(packages, other) {
56
62
  const otherVersions = /* @__PURE__ */ new Map();
@@ -1185,12 +1191,40 @@ function compareSegments(a, b) {
1185
1191
  return 0;
1186
1192
  }
1187
1193
 
1194
+ // src/hosting.ts
1195
+ var RULES = [
1196
+ { platform: "netlify", any: ["NETLIFY", "NETLIFY_BUILD_BASE", "DEPLOY_PRIME_URL"] },
1197
+ { platform: "vercel", any: ["VERCEL", "VERCEL_ENV", "VERCEL_URL"] },
1198
+ { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "CLOUDFLARE_ACCOUNT_ID"] },
1199
+ { platform: "aws", any: ["AWS_APP_ID", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
1200
+ { platform: "render", any: ["RENDER", "RENDER_SERVICE_ID", "RENDER_EXTERNAL_URL"] },
1201
+ { platform: "railway", any: ["RAILWAY_ENVIRONMENT", "RAILWAY_ENVIRONMENT_NAME", "RAILWAY_PROJECT_ID"] },
1202
+ { platform: "fly", any: ["FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"] },
1203
+ { platform: "heroku", any: ["DYNO", "HEROKU_APP_NAME"] },
1204
+ { platform: "google-cloud", any: ["K_SERVICE", "GAE_APPLICATION", "GAE_SERVICE"] },
1205
+ { platform: "deno-deploy", any: ["DENO_DEPLOYMENT_ID"] },
1206
+ { platform: "azure", any: ["WEBSITE_SITE_NAME", "WEBSITE_INSTANCE_ID"] }
1207
+ ];
1208
+ var present = (env, name) => {
1209
+ const value = env[name];
1210
+ return value !== void 0 && value !== "";
1211
+ };
1212
+ function detectHostingPlatform(env = process.env) {
1213
+ for (const rule of RULES) {
1214
+ const evidence = rule.any.filter((name) => present(env, name));
1215
+ if (evidence.length > 0) {
1216
+ return { platform: rule.platform, evidence };
1217
+ }
1218
+ }
1219
+ return { platform: null, evidence: [] };
1220
+ }
1221
+
1188
1222
  // src/pulse-token.ts
1189
1223
  var TOKEN_SKEW_MS = 6e4;
1190
1224
  function buildTokenUrl(manifestEndpoint) {
1191
1225
  const url = new URL(manifestEndpoint);
1192
- const path9 = url.pathname.replace(/\/$/, "");
1193
- url.pathname = path9.endsWith("/manifest") ? `${path9.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
1226
+ const path10 = url.pathname.replace(/\/$/, "");
1227
+ url.pathname = path10.endsWith("/manifest") ? `${path10.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
1194
1228
  url.search = "";
1195
1229
  url.hash = "";
1196
1230
  return url.toString();
@@ -1295,14 +1329,22 @@ function buildClaimUrl(endpoint, siteUuid) {
1295
1329
  const origin = new URL(endpoint).origin;
1296
1330
  return `${origin}/monitor/claim?site=${encodeURIComponent(siteUuid)}`;
1297
1331
  }
1298
- function buildManifestBody(config, payload) {
1332
+ function buildManifestBody(config, payload, env = process.env) {
1333
+ const hosting = config.environment === "local" ? null : detectHostingPlatform(env);
1299
1334
  return {
1300
1335
  ...payload,
1301
1336
  environment: config.environment,
1302
1337
  ...typeof config.siteUrl === "string" && config.siteUrl !== "" ? { url: config.siteUrl } : {},
1303
- ...typeof config.siteName === "string" && config.siteName !== "" ? { name: config.siteName } : {}
1338
+ ...typeof config.siteName === "string" && config.siteName !== "" ? { name: config.siteName } : {},
1339
+ ...hosting !== null && hosting.platform !== null ? { hosting } : {}
1304
1340
  };
1305
1341
  }
1342
+ function validationFields(body) {
1343
+ if (typeof body !== "object" || body === null) return [];
1344
+ const errors = body.errors;
1345
+ if (typeof errors !== "object" || errors === null || Array.isArray(errors)) return [];
1346
+ return Object.keys(errors);
1347
+ }
1306
1348
  async function postManifest(config, payload) {
1307
1349
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
1308
1350
  const timeoutMs = config.timeoutMs;
@@ -1347,10 +1389,12 @@ async function postManifest(config, payload) {
1347
1389
  );
1348
1390
  }
1349
1391
  if (response.status === 422) {
1350
- throw new PatchstackError(
1392
+ const refused2 = new PatchstackError(
1351
1393
  body?.message ?? "Patchstack rejected the manifest payload (validation failed).",
1352
1394
  "VALIDATION_ERROR"
1353
1395
  );
1396
+ refused2.fields = validationFields(body);
1397
+ throw refused2;
1354
1398
  }
1355
1399
  const refused = authFailureMessage(response.status, config);
1356
1400
  if (refused !== null) {
@@ -1376,7 +1420,80 @@ function isTimeoutError(cause) {
1376
1420
 
1377
1421
  // src/config.ts
1378
1422
  import { readFile as readFile8, writeFile, chmod } from "fs/promises";
1379
- import path7 from "path";
1423
+ import path8 from "path";
1424
+
1425
+ // src/environment.ts
1426
+ import { readFileSync } from "fs";
1427
+ import path6 from "path";
1428
+ var set = (value) => value !== void 0 && value !== "";
1429
+ var DISCRIMINATORS = [
1430
+ {
1431
+ platform: "vercel",
1432
+ read: (env) => {
1433
+ if (!set(env.VERCEL) || !set(env.VERCEL_ENV)) return null;
1434
+ return env.VERCEL_ENV === "production" ? { environment: "production", evidence: "VERCEL_ENV=production" } : { environment: "sandbox", evidence: `VERCEL_ENV=${env.VERCEL_ENV}` };
1435
+ }
1436
+ },
1437
+ {
1438
+ platform: "netlify",
1439
+ read: (env) => {
1440
+ if (env.NETLIFY !== "true" || !set(env.CONTEXT)) return null;
1441
+ return env.CONTEXT === "production" ? { environment: "production", evidence: "CONTEXT=production" } : { environment: "sandbox", evidence: `CONTEXT=${env.CONTEXT}` };
1442
+ }
1443
+ },
1444
+ {
1445
+ platform: "render",
1446
+ read: (env) => {
1447
+ if (env.RENDER !== "true") return null;
1448
+ return env.IS_PULL_REQUEST === "true" ? { environment: "sandbox", evidence: "IS_PULL_REQUEST=true" } : { environment: "production", evidence: "RENDER=true, not a pull request" };
1449
+ }
1450
+ },
1451
+ {
1452
+ platform: "railway",
1453
+ read: (env) => {
1454
+ if (!set(env.RAILWAY_ENVIRONMENT_NAME)) return null;
1455
+ return env.RAILWAY_ENVIRONMENT_NAME === "production" ? { environment: "production", evidence: "RAILWAY_ENVIRONMENT_NAME=production" } : { environment: "sandbox", evidence: `RAILWAY_ENVIRONMENT_NAME=${env.RAILWAY_ENVIRONMENT_NAME}` };
1456
+ }
1457
+ }
1458
+ ];
1459
+ var HOSTED_BUILDER_PACKAGES = [
1460
+ // `lovable-tagger` covers projects from before ~Aug 2026; current templates ship the
1461
+ // `@lovable.dev/*` scoped tooling instead.
1462
+ { builder: "lovable", exact: ["lovable-tagger"], prefixes: ["@lovable.dev/"] },
1463
+ { builder: "replit", exact: [], prefixes: ["@replit/"] }
1464
+ ];
1465
+ function detectHostedBuilder(cwd) {
1466
+ let declared;
1467
+ try {
1468
+ const pkg = JSON.parse(readFileSync(path6.join(cwd, "package.json"), "utf8"));
1469
+ declared = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1470
+ } catch {
1471
+ return null;
1472
+ }
1473
+ const names = Object.keys(declared);
1474
+ for (const rule of HOSTED_BUILDER_PACKAGES) {
1475
+ const hit = names.some(
1476
+ (name) => rule.exact.includes(name) || rule.prefixes.some((prefix) => name.startsWith(prefix))
1477
+ );
1478
+ if (hit) return rule.builder;
1479
+ }
1480
+ return null;
1481
+ }
1482
+ function inferEnvironment(env = process.env, builder = null) {
1483
+ for (const discriminator of DISCRIMINATORS) {
1484
+ const verdict = discriminator.read(env);
1485
+ if (verdict !== null) {
1486
+ return { environment: verdict.environment, evidence: [`${discriminator.platform}: ${verdict.evidence}`] };
1487
+ }
1488
+ }
1489
+ if (builder !== null) {
1490
+ return {
1491
+ environment: "production",
1492
+ evidence: [`${builder}: a build in a ${builder} project is its publish step`]
1493
+ };
1494
+ }
1495
+ return { environment: "local", evidence: [] };
1496
+ }
1380
1497
 
1381
1498
  // src/site-url.ts
1382
1499
  var RESERVED_SUFFIXES = [
@@ -1447,7 +1564,7 @@ function detectSiteUrl(env = process.env) {
1447
1564
 
1448
1565
  // src/site-name.ts
1449
1566
  import { readFile as readFile7 } from "fs/promises";
1450
- import path6 from "path";
1567
+ import path7 from "path";
1451
1568
  var NAME_MAX_LENGTH = 191;
1452
1569
  var TEMPLATE_PACKAGE_NAMES = /* @__PURE__ */ new Set([
1453
1570
  // Lovable
@@ -1524,13 +1641,13 @@ async function readIfPresent(file) {
1524
1641
  }
1525
1642
  }
1526
1643
  async function detectSiteName(cwd) {
1527
- for (const shell of ["index.html", path6.join("public", "index.html")]) {
1528
- const html = await readIfPresent(path6.join(cwd, shell));
1644
+ for (const shell of ["index.html", path7.join("public", "index.html")]) {
1645
+ const html = await readIfPresent(path7.join(cwd, shell));
1529
1646
  if (html === null) continue;
1530
1647
  const name = nameFromHtmlTitle(html);
1531
1648
  if (name !== null) return { name, source: "index.html" };
1532
1649
  }
1533
- const manifest = await readIfPresent(path6.join(cwd, "package.json"));
1650
+ const manifest = await readIfPresent(path7.join(cwd, "package.json"));
1534
1651
  if (manifest !== null) {
1535
1652
  try {
1536
1653
  const parsed = JSON.parse(manifest);
@@ -1547,7 +1664,6 @@ async function detectSiteName(cwd) {
1547
1664
  // src/config.ts
1548
1665
  var CONFIG_FILENAME = ".patchstackrc.json";
1549
1666
  var SECRET_FILENAME = ".patchstackrc.local.json";
1550
- var DEFAULT_ENVIRONMENT = "production";
1551
1667
  async function resolveSiteIdentity(cwd, fromEnv, fromFile) {
1552
1668
  const configuredUrl = stated(fromEnv.url) ?? stated(fromFile.url);
1553
1669
  const configuredName = stated(fromEnv.name) ?? stated(fromFile.name);
@@ -1580,11 +1696,13 @@ async function resolveConfig(options) {
1580
1696
  const environmentRaw = fromEnv.environment ?? fromFile.environment;
1581
1697
  if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
1582
1698
  throw new PatchstackError(
1583
- `Environment must be "production" or "sandbox"; got "${environmentRaw}".`,
1699
+ `Environment must be "production", "sandbox" or "local"; got "${environmentRaw}".`,
1584
1700
  "CONFIG_INVALID"
1585
1701
  );
1586
1702
  }
1587
- const environment = environmentRaw ?? DEFAULT_ENVIRONMENT;
1703
+ const inferred = environmentRaw === void 0 ? inferEnvironment(process.env, detectHostedBuilder(options.cwd)) : null;
1704
+ const environment = environmentRaw ?? inferred.environment;
1705
+ const environmentEvidence = inferred?.evidence ?? [];
1588
1706
  if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
1589
1707
  throw new PatchstackError(
1590
1708
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
@@ -1610,18 +1728,19 @@ async function resolveConfig(options) {
1610
1728
  endpoint,
1611
1729
  timeoutMs,
1612
1730
  environment,
1731
+ environmentEvidence,
1613
1732
  widget: fromFile.widget !== false,
1614
1733
  claimToken
1615
1734
  };
1616
1735
  }
1617
1736
  async function writeConfigFile(cwd, config) {
1618
- const target = path7.join(cwd, CONFIG_FILENAME);
1737
+ const target = path8.join(cwd, CONFIG_FILENAME);
1619
1738
  const content = JSON.stringify(config, null, 2) + "\n";
1620
1739
  await writeFile(target, content, "utf8");
1621
1740
  return target;
1622
1741
  }
1623
1742
  async function writeSecretFile(cwd, secrets) {
1624
- const target = path7.join(cwd, SECRET_FILENAME);
1743
+ const target = path8.join(cwd, SECRET_FILENAME);
1625
1744
  await writeFile(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
1626
1745
  try {
1627
1746
  await chmod(target, 384);
@@ -1631,7 +1750,7 @@ async function writeSecretFile(cwd, secrets) {
1631
1750
  return { path: target, ...ignore };
1632
1751
  }
1633
1752
  async function ensureIgnored(cwd, entry) {
1634
- const target = path7.join(cwd, ".gitignore");
1753
+ const target = path8.join(cwd, ".gitignore");
1635
1754
  let existing = "";
1636
1755
  try {
1637
1756
  existing = await readFile8(target, "utf8");
@@ -1698,7 +1817,7 @@ async function readConfigFile(cwd) {
1698
1817
  return readJsonFile(cwd, CONFIG_FILENAME);
1699
1818
  }
1700
1819
  async function readJsonFile(cwd, filename) {
1701
- const target = path7.join(cwd, filename);
1820
+ const target = path8.join(cwd, filename);
1702
1821
  let raw;
1703
1822
  try {
1704
1823
  raw = await readFile8(target, "utf8");
@@ -1751,7 +1870,7 @@ function isUuid(value) {
1751
1870
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1752
1871
  }
1753
1872
  function isEnvironment(value) {
1754
- return value === "production" || value === "sandbox";
1873
+ return value === "production" || value === "sandbox" || value === "local";
1755
1874
  }
1756
1875
 
1757
1876
  // src/stack.ts
@@ -1768,6 +1887,9 @@ var STACK_RULES = [
1768
1887
  { category: "framework", pkg: "@sveltejs/kit", label: "sveltekit" },
1769
1888
  { category: "framework", pkg: "@builder.io/qwik-city", label: "qwik-city" },
1770
1889
  { category: "framework", pkg: "gatsby", label: "gatsby" },
1890
+ { category: "framework", pkg: "@11ty/eleventy", label: "eleventy" },
1891
+ { category: "framework", pkg: "@docusaurus/core", label: "docusaurus" },
1892
+ { category: "framework", pkg: "vitepress", label: "vitepress" },
1771
1893
  { category: "framework", pkg: "express", label: "express" },
1772
1894
  { category: "framework", pkg: "fastify", label: "fastify" },
1773
1895
  // UI runtimes.
@@ -1817,10 +1939,10 @@ function collectHostingEnvKeys(env = process.env) {
1817
1939
  return Object.keys(env).filter((key) => HOSTING_ENV_PATTERNS.some((pattern) => pattern.test(key))).sort();
1818
1940
  }
1819
1941
  function detectStack(packages, env = process.env) {
1820
- const present = new Set(packages.map((pkg) => pkg.name));
1942
+ const present2 = new Set(packages.map((pkg) => pkg.name));
1821
1943
  const firstMatch = (category) => {
1822
1944
  for (const rule of STACK_RULES) {
1823
- if (rule.category === category && present.has(rule.pkg)) {
1945
+ if (rule.category === category && present2.has(rule.pkg)) {
1824
1946
  return rule.label;
1825
1947
  }
1826
1948
  }
@@ -1841,8 +1963,8 @@ function isEmptyStack(stack) {
1841
1963
  }
1842
1964
 
1843
1965
  // src/widget.ts
1844
- import { existsSync, readFileSync, writeFileSync } from "fs";
1845
- import path8 from "path";
1966
+ import { existsSync, readFileSync as readFileSync2, writeFileSync } from "fs";
1967
+ import path9 from "path";
1846
1968
  var WIDGET_SCRIPT_URL = "https://cdn.patchstack.com/patchstack-widget.js";
1847
1969
  var WIDGET_MARKER_ATTR = "data-patchstack-connect-widget";
1848
1970
  var WIDGET_NEEDLE = "patchstack-widget";
@@ -1880,7 +2002,7 @@ ${indent}</body>`;
1880
2002
  }
1881
2003
  function findSourceShell(cwd) {
1882
2004
  for (const candidate of SOURCE_SHELL_CANDIDATES) {
1883
- if (existsSync(path8.join(cwd, candidate))) {
2005
+ if (existsSync(path9.join(cwd, candidate))) {
1884
2006
  return candidate;
1885
2007
  }
1886
2008
  }
@@ -1891,8 +2013,8 @@ function ensureSourceWidget(cwd, siteUuid) {
1891
2013
  if (shell === null) {
1892
2014
  return { shell: null, action: "no-shell" };
1893
2015
  }
1894
- const file = path8.join(cwd, shell);
1895
- const before = readFileSync(file, "utf8");
2016
+ const file = path9.join(cwd, shell);
2017
+ const before = readFileSync2(file, "utf8");
1896
2018
  const { html, action } = ensureWidgetInHtml(before, siteUuid);
1897
2019
  if (html !== before) {
1898
2020
  writeFileSync(file, html);