@patchstack/connect 0.5.5 → 0.5.7

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.cjs CHANGED
@@ -1278,6 +1278,128 @@ function detectHostingPlatform(env = process.env) {
1278
1278
  return { platform: null, evidence: [] };
1279
1279
  }
1280
1280
 
1281
+ // src/protect/safe-origin.js
1282
+ function isSafeOrigin(value) {
1283
+ try {
1284
+ const u = new URL(value);
1285
+ if (u.protocol === "https:") return true;
1286
+ return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
1287
+ } catch {
1288
+ return false;
1289
+ }
1290
+ }
1291
+
1292
+ // src/endpoint-policy.ts
1293
+ function assertConnectableEndpoint(config, target = config.endpoint) {
1294
+ if (config.endpointTrusted === false) {
1295
+ throw new PatchstackError(
1296
+ "The custom endpoint in .patchstackrc.json must be confirmed with --endpoint or PATCHSTACK_ENDPOINT before it can be used.",
1297
+ "CONFIG_INVALID"
1298
+ );
1299
+ }
1300
+ if (safeRemoteUrl(config.endpoint) === null) {
1301
+ throw new PatchstackError(
1302
+ "Patchstack endpoints must use HTTPS, except for localhost development endpoints.",
1303
+ "CONFIG_INVALID"
1304
+ );
1305
+ }
1306
+ let endpointOrigin;
1307
+ let targetOrigin;
1308
+ try {
1309
+ endpointOrigin = new URL(config.endpoint).origin;
1310
+ targetOrigin = new URL(target).origin;
1311
+ } catch (cause) {
1312
+ throw new PatchstackError("The configured Patchstack endpoint is not a valid URL.", "CONFIG_INVALID", cause);
1313
+ }
1314
+ if (safeRemoteUrl(target) === null || targetOrigin !== endpointOrigin) {
1315
+ throw new PatchstackError(
1316
+ "A Patchstack request resolved outside the configured endpoint origin.",
1317
+ "CONFIG_INVALID"
1318
+ );
1319
+ }
1320
+ }
1321
+ function safeRemoteUrl(value) {
1322
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
1323
+ try {
1324
+ const parsed = new URL(value);
1325
+ if (!isSafeOrigin(parsed.toString()) || parsed.username !== "" || parsed.password !== "") return null;
1326
+ return parsed.toString();
1327
+ } catch {
1328
+ return null;
1329
+ }
1330
+ }
1331
+ function safeDisplayString(value, maxLength = 512) {
1332
+ if (typeof value !== "string") return null;
1333
+ const clean = value.replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
1334
+ if (clean.length === 0) return null;
1335
+ return clean.slice(0, maxLength);
1336
+ }
1337
+ function isCanonicalUuid(value) {
1338
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1339
+ }
1340
+ function isCredential(value) {
1341
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) return false;
1342
+ for (let i = 0; i < value.length; i += 1) {
1343
+ const code = value.charCodeAt(i);
1344
+ if (code < 33 || code > 126) return false;
1345
+ }
1346
+ const separator = value.lastIndexOf("-");
1347
+ return separator > 0 && separator < value.length - 1 && /^\d+$/.test(value.slice(separator + 1));
1348
+ }
1349
+ function isHeaderValue(value, maxLength = 8192) {
1350
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return false;
1351
+ for (let i = 0; i < value.length; i += 1) {
1352
+ const code = value.charCodeAt(i);
1353
+ if (code < 33 || code > 126) return false;
1354
+ }
1355
+ return true;
1356
+ }
1357
+
1358
+ // src/bounded-response.ts
1359
+ var MAX_CONTROL_RESPONSE_BYTES = 256 * 1024;
1360
+ async function readBoundedText(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
1361
+ const stated2 = Number(response.headers?.get?.("content-length"));
1362
+ if (Number.isFinite(stated2) && stated2 > maxBytes) {
1363
+ void response.body?.cancel().catch(() => {
1364
+ });
1365
+ throw new Error(`response exceeds ${maxBytes} bytes`);
1366
+ }
1367
+ const stream = response.body;
1368
+ if (stream === null || typeof stream.getReader !== "function") {
1369
+ if (stated2 === 0) return "";
1370
+ throw new Error("response body cannot be read with a size bound");
1371
+ }
1372
+ const reader = stream.getReader();
1373
+ const chunks = [];
1374
+ let total = 0;
1375
+ try {
1376
+ for (; ; ) {
1377
+ const { done, value } = await reader.read();
1378
+ if (done) break;
1379
+ total += value.byteLength;
1380
+ if (total > maxBytes) {
1381
+ void reader.cancel().catch(() => {
1382
+ });
1383
+ throw new Error(`response exceeds ${maxBytes} bytes`);
1384
+ }
1385
+ chunks.push(value);
1386
+ }
1387
+ } finally {
1388
+ reader.releaseLock();
1389
+ }
1390
+ const bytes = new Uint8Array(total);
1391
+ let offset = 0;
1392
+ for (const chunk of chunks) {
1393
+ bytes.set(chunk, offset);
1394
+ offset += chunk.byteLength;
1395
+ }
1396
+ return new TextDecoder().decode(bytes);
1397
+ }
1398
+ async function readBoundedJson(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
1399
+ const text = await readBoundedText(response, maxBytes);
1400
+ return JSON.parse(text);
1401
+ }
1402
+
1281
1403
  // src/pulse-token.ts
1282
1404
  var TOKEN_SKEW_MS = 6e4;
1283
1405
  function buildTokenUrl(manifestEndpoint) {
@@ -1295,22 +1417,48 @@ function parsePulseAuth(credential) {
1295
1417
  if (!/^\d+$/.test(clientId)) return null;
1296
1418
  return { clientId, clientSecret: credential.slice(0, index) };
1297
1419
  }
1298
- var cached = null;
1299
- var inflight = null;
1300
- function clearPulseToken() {
1301
- cached = null;
1420
+ var cached = /* @__PURE__ */ new Map();
1421
+ var inflight = /* @__PURE__ */ new Map();
1422
+ function cacheKey(tokenUrl, credential) {
1423
+ return `${tokenUrl}
1424
+ ${credential}`;
1425
+ }
1426
+ function clearPulseToken(config) {
1427
+ if (!config || typeof config.pulseAuth !== "string") {
1428
+ cached.clear();
1429
+ inflight.clear();
1430
+ return;
1431
+ }
1432
+ try {
1433
+ const key = cacheKey(buildTokenUrl(config.endpoint), config.pulseAuth);
1434
+ cached.delete(key);
1435
+ inflight.delete(key);
1436
+ } catch {
1437
+ }
1302
1438
  }
1303
1439
  async function getPulseToken(config, fetchImpl = fetch) {
1304
1440
  if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
1305
- if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
1306
- return cached.token;
1441
+ if (config.endpointTrusted === false) return null;
1442
+ let tokenUrl;
1443
+ try {
1444
+ tokenUrl = buildTokenUrl(config.endpoint);
1445
+ } catch {
1446
+ return null;
1447
+ }
1448
+ if (!isSafeOrigin(tokenUrl)) return null;
1449
+ const key = cacheKey(tokenUrl, config.pulseAuth);
1450
+ const existing = cached.get(key);
1451
+ if (existing !== void 0 && Date.now() < existing.expiresAt - TOKEN_SKEW_MS) {
1452
+ return existing.token;
1307
1453
  }
1308
- if (inflight !== null) return inflight;
1454
+ const pending = inflight.get(key);
1455
+ if (pending !== void 0) return pending;
1309
1456
  const credentials = parsePulseAuth(config.pulseAuth);
1310
1457
  if (credentials === null) return null;
1311
- inflight = (async () => {
1458
+ let exchange;
1459
+ exchange = (async () => {
1312
1460
  try {
1313
- const response = await fetchImpl(buildTokenUrl(config.endpoint), {
1461
+ const response = await fetchImpl(tokenUrl, {
1314
1462
  method: "POST",
1315
1463
  headers: {
1316
1464
  "Content-Type": "application/json",
@@ -1325,25 +1473,27 @@ async function getPulseToken(config, fetchImpl = fetch) {
1325
1473
  signal: AbortSignal.timeout(config.timeoutMs)
1326
1474
  });
1327
1475
  if (!response.ok) return null;
1328
- const body = await response.json();
1329
- if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
1476
+ const body = await readBoundedJson(response);
1477
+ if (!isHeaderValue(body.access_token)) return null;
1330
1478
  const expiresIn = Number(body.expires_in);
1331
1479
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
1332
- cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
1480
+ cached.set(key, { token: body.access_token, expiresAt: Date.now() + ttlMs });
1333
1481
  return body.access_token;
1334
1482
  } catch {
1335
1483
  return null;
1336
1484
  } finally {
1337
- inflight = null;
1485
+ if (inflight.get(key) === exchange) inflight.delete(key);
1338
1486
  }
1339
1487
  })();
1340
- return inflight;
1488
+ inflight.set(key, exchange);
1489
+ return exchange;
1341
1490
  }
1342
1491
  async function pulseAuthHeader(config, fetchImpl = fetch) {
1343
1492
  const token = await getPulseToken(config, fetchImpl);
1344
1493
  return token === null ? {} : { Authorization: `Bearer ${token}` };
1345
1494
  }
1346
1495
  async function pulseFetch(config, url, init, fetchImpl = fetch) {
1496
+ assertConnectableEndpoint(config, url);
1347
1497
  const send = async () => {
1348
1498
  const auth = await pulseAuthHeader(config, fetchImpl);
1349
1499
  const response = await fetchImpl(url, {
@@ -1354,7 +1504,7 @@ async function pulseFetch(config, url, init, fetchImpl = fetch) {
1354
1504
  };
1355
1505
  const first = await send();
1356
1506
  if (first.response.status === 401 && first.authenticated) {
1357
- clearPulseToken();
1507
+ clearPulseToken(config);
1358
1508
  return (await send()).response;
1359
1509
  }
1360
1510
  return first.response;
@@ -1365,7 +1515,7 @@ var DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
1365
1515
  var DEFAULT_TIMEOUT_MS = 3e4;
1366
1516
  var CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
1367
1517
  function claimTokenHeader(config) {
1368
- return typeof config.claimToken === "string" && config.claimToken !== "" ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
1518
+ return config.endpointTrusted !== false && isHeaderValue(config.claimToken) ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
1369
1519
  }
1370
1520
  function buildEndpointUrl(base, siteUuid) {
1371
1521
  const trimmed = base.replace(/\/$/, "");
@@ -1404,9 +1554,46 @@ function validationFields(body) {
1404
1554
  if (typeof errors !== "object" || errors === null || Array.isArray(errors)) return [];
1405
1555
  return Object.keys(errors);
1406
1556
  }
1557
+ function manifestResponse(body, provisioning) {
1558
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
1559
+ const value = body;
1560
+ if (typeof value.stored !== "boolean") return null;
1561
+ const uuid = value.uuid === void 0 ? void 0 : isCanonicalUuid(value.uuid) ? value.uuid : null;
1562
+ if (uuid === null || provisioning && uuid === void 0) return null;
1563
+ if (value.api_key !== void 0 && !isCredential(value.api_key)) return null;
1564
+ if (value.manifest_id !== void 0 && (!Number.isInteger(value.manifest_id) || Number(value.manifest_id) <= 0)) return null;
1565
+ const claimValue = value.claim;
1566
+ let claim2;
1567
+ if (claimValue !== void 0) {
1568
+ if (typeof claimValue !== "object" || claimValue === null || Array.isArray(claimValue)) return null;
1569
+ const raw = claimValue;
1570
+ if (!["claimed", "owned-by-you", "owned-by-other", "rejected"].includes(String(raw.state))) return null;
1571
+ const dashboardUrl = raw.dashboard_url === void 0 || raw.dashboard_url === null ? null : safeRemoteUrl(raw.dashboard_url);
1572
+ if (raw.dashboard_url !== void 0 && raw.dashboard_url !== null && dashboardUrl === null) return null;
1573
+ claim2 = {
1574
+ state: raw.state,
1575
+ ...Number.isInteger(raw.site_id) ? { site_id: Number(raw.site_id) } : {},
1576
+ ...dashboardUrl !== null ? { dashboard_url: dashboardUrl } : {},
1577
+ ...raw.reason === "expired" || raw.reason === "invalid" ? { reason: raw.reason } : {}
1578
+ };
1579
+ }
1580
+ const result = {
1581
+ stored: value.stored,
1582
+ ...uuid !== void 0 ? { uuid } : {},
1583
+ ...typeof value.api_key === "string" ? { api_key: value.api_key } : {},
1584
+ ...Number.isInteger(value.manifest_id) ? { manifest_id: Number(value.manifest_id) } : {},
1585
+ ...safeDisplayString(value.checksum, 256) !== null ? { checksum: safeDisplayString(value.checksum, 256) } : {},
1586
+ ...safeDisplayString(value.reason) !== null ? { reason: safeDisplayString(value.reason) } : {},
1587
+ ...safeDisplayString(value.message) !== null ? { message: safeDisplayString(value.message) } : {},
1588
+ ...safeDisplayString(value.error) !== null ? { error: safeDisplayString(value.error) } : {},
1589
+ ...claim2 !== void 0 ? { claim: claim2 } : {}
1590
+ };
1591
+ return result;
1592
+ }
1407
1593
  async function postManifest(config, payload) {
1408
1594
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
1409
1595
  const timeoutMs = config.timeoutMs;
1596
+ assertConnectableEndpoint(config, url);
1410
1597
  let response;
1411
1598
  try {
1412
1599
  response = await pulseFetch(config, url, {
@@ -1434,25 +1621,27 @@ async function postManifest(config, payload) {
1434
1621
  cause
1435
1622
  );
1436
1623
  }
1437
- const text = await response.text();
1438
- let body = null;
1624
+ let text = "";
1625
+ let parsed = null;
1439
1626
  try {
1440
- body = text.length > 0 ? JSON.parse(text) : null;
1627
+ text = await readBoundedText(response);
1628
+ parsed = text.length > 0 ? JSON.parse(text) : null;
1441
1629
  } catch {
1442
- body = null;
1630
+ parsed = null;
1443
1631
  }
1632
+ const body = manifestResponse(parsed, config.siteUuid === null);
1444
1633
  if (response.status === 404) {
1445
1634
  throw new PatchstackError(
1446
- body?.error ?? "Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.",
1635
+ safeDisplayString(parsed?.error) ?? "Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.",
1447
1636
  "SITE_NOT_FOUND"
1448
1637
  );
1449
1638
  }
1450
1639
  if (response.status === 422) {
1451
1640
  const refused2 = new PatchstackError(
1452
- body?.message ?? "Patchstack rejected the manifest payload (validation failed).",
1641
+ safeDisplayString(parsed?.message) ?? "Patchstack rejected the manifest payload (validation failed).",
1453
1642
  "VALIDATION_ERROR"
1454
1643
  );
1455
- refused2.fields = validationFields(body);
1644
+ refused2.fields = validationFields(parsed);
1456
1645
  throw refused2;
1457
1646
  }
1458
1647
  const refused = authFailureMessage(response.status, config);
@@ -1461,12 +1650,12 @@ async function postManifest(config, payload) {
1461
1650
  }
1462
1651
  if (response.status < 200 || response.status >= 300) {
1463
1652
  throw new PatchstackError(
1464
- `Patchstack returned ${response.status}: ${text.slice(0, 200)}`,
1653
+ `Patchstack returned ${response.status}: ${safeDisplayString(text, 200) ?? "no response details"}`,
1465
1654
  "SERVER_ERROR"
1466
1655
  );
1467
1656
  }
1468
1657
  if (body === null) {
1469
- throw new PatchstackError("Patchstack returned an empty response.", "SERVER_ERROR");
1658
+ throw new PatchstackError("Patchstack returned an unexpected response.", "SERVER_ERROR");
1470
1659
  }
1471
1660
  return body;
1472
1661
  }
@@ -1480,6 +1669,7 @@ function isTimeoutError(cause) {
1480
1669
  // src/config.ts
1481
1670
  var import_promises9 = require("fs/promises");
1482
1671
  var import_node_path8 = __toESM(require("path"), 1);
1672
+ var import_node_crypto = require("crypto");
1483
1673
 
1484
1674
  // src/environment.ts
1485
1675
  var import_node_fs = require("fs");
@@ -1750,7 +1940,15 @@ async function resolveConfig(options) {
1750
1940
  const fromSecretFile = await readSecretFile(options.cwd);
1751
1941
  const fromEnv = readEnv();
1752
1942
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1753
- const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
1943
+ const endpointRaw = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
1944
+ const endpointTrusted = endpointRaw === DEFAULT_ENDPOINT || options.cliEndpoint !== void 0 || fromEnv.endpoint !== void 0 || fromFile.endpoint === void 0;
1945
+ const endpoint = safeRemoteUrl(endpointRaw);
1946
+ if (endpoint === null) {
1947
+ throw new PatchstackError(
1948
+ "Patchstack endpoints must be valid HTTPS URLs, except for localhost development endpoints.",
1949
+ "CONFIG_INVALID"
1950
+ );
1951
+ }
1754
1952
  const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1755
1953
  const environmentRaw = fromEnv.environment ?? fromFile.environment;
1756
1954
  if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
@@ -1762,7 +1960,7 @@ async function resolveConfig(options) {
1762
1960
  const inferred = environmentRaw === void 0 ? inferEnvironment(process.env, detectHostedBuilder(options.cwd)) : null;
1763
1961
  const environment = environmentRaw ?? inferred.environment;
1764
1962
  const environmentEvidence = inferred?.evidence ?? [];
1765
- if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
1963
+ if (siteUuid !== null && siteUuid.length > 0 && !isCanonicalUuid(siteUuid)) {
1766
1964
  throw new PatchstackError(
1767
1965
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
1768
1966
  "CONFIG_INVALID"
@@ -1785,6 +1983,7 @@ async function resolveConfig(options) {
1785
1983
  siteUrl: identity.url,
1786
1984
  siteName: identity.name,
1787
1985
  endpoint,
1986
+ endpointTrusted,
1788
1987
  timeoutMs,
1789
1988
  environment,
1790
1989
  environmentEvidence,
@@ -1795,12 +1994,12 @@ async function resolveConfig(options) {
1795
1994
  async function writeConfigFile(cwd, config) {
1796
1995
  const target = import_node_path8.default.join(cwd, CONFIG_FILENAME);
1797
1996
  const content = JSON.stringify(config, null, 2) + "\n";
1798
- await (0, import_promises9.writeFile)(target, content, "utf8");
1997
+ await writeAtomicFile(target, content, 420);
1799
1998
  return target;
1800
1999
  }
1801
2000
  async function writeSecretFile(cwd, secrets) {
1802
2001
  const target = import_node_path8.default.join(cwd, SECRET_FILENAME);
1803
- await (0, import_promises9.writeFile)(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
2002
+ await writeAtomicFile(target, JSON.stringify(secrets, null, 2) + "\n", 384);
1804
2003
  try {
1805
2004
  await (0, import_promises9.chmod)(target, 384);
1806
2005
  } catch {
@@ -1808,6 +2007,39 @@ async function writeSecretFile(cwd, secrets) {
1808
2007
  const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
1809
2008
  return { path: target, ...ignore };
1810
2009
  }
2010
+ async function writeAtomicFile(target, content, mode) {
2011
+ const temporary = `${target}.${process.pid}.${(0, import_node_crypto.randomUUID)()}.tmp`;
2012
+ let handle;
2013
+ try {
2014
+ handle = await (0, import_promises9.open)(temporary, "wx", mode);
2015
+ await handle.writeFile(content, "utf8");
2016
+ await handle.sync();
2017
+ await handle.close();
2018
+ handle = void 0;
2019
+ try {
2020
+ await (0, import_promises9.rename)(temporary, target);
2021
+ } catch (error) {
2022
+ const code = error.code;
2023
+ if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
2024
+ try {
2025
+ await (0, import_promises9.unlink)(target);
2026
+ } catch (unlinkError) {
2027
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
2028
+ }
2029
+ await (0, import_promises9.rename)(temporary, target);
2030
+ }
2031
+ } catch (error) {
2032
+ try {
2033
+ await handle?.close();
2034
+ } catch {
2035
+ }
2036
+ try {
2037
+ await (0, import_promises9.unlink)(temporary);
2038
+ } catch {
2039
+ }
2040
+ throw error;
2041
+ }
2042
+ }
1811
2043
  async function ensureIgnored(cwd, entry) {
1812
2044
  const target = import_node_path8.default.join(cwd, ".gitignore");
1813
2045
  let existing = "";
@@ -1826,7 +2058,7 @@ async function ensureIgnored(cwd, entry) {
1826
2058
  ${entry}
1827
2059
  `;
1828
2060
  try {
1829
- await (0, import_promises9.writeFile)(target, existing + block, "utf8");
2061
+ await writeAtomicFile(target, existing + block, 420);
1830
2062
  } catch (err) {
1831
2063
  const code = err.code;
1832
2064
  return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
@@ -1879,8 +2111,15 @@ async function readJsonFile(cwd, filename) {
1879
2111
  const target = import_node_path8.default.join(cwd, filename);
1880
2112
  let raw;
1881
2113
  try {
2114
+ if ((await (0, import_promises9.lstat)(target)).isSymbolicLink()) {
2115
+ throw new PatchstackError(
2116
+ `Could not read ${target}: configuration files must not be symbolic links.`,
2117
+ "CONFIG_INVALID"
2118
+ );
2119
+ }
1882
2120
  raw = await (0, import_promises9.readFile)(target, "utf8");
1883
2121
  } catch (err) {
2122
+ if (err instanceof PatchstackError) throw err;
1884
2123
  if (err.code === "ENOENT") {
1885
2124
  return {};
1886
2125
  }
@@ -1925,9 +2164,6 @@ function readEnv() {
1925
2164
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
1926
2165
  };
1927
2166
  }
1928
- function isUuid(value) {
1929
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1930
- }
1931
2167
  function isEnvironment(value) {
1932
2168
  return value === "production" || value === "sandbox" || value === "local";
1933
2169
  }
@@ -2022,8 +2258,97 @@ function isEmptyStack(stack) {
2022
2258
  }
2023
2259
 
2024
2260
  // src/widget.ts
2261
+ var import_node_fs3 = require("fs");
2262
+ var import_node_path10 = __toESM(require("path"), 1);
2263
+
2264
+ // src/safe-file.ts
2025
2265
  var import_node_fs2 = require("fs");
2026
- var import_node_path9 = __toESM(require("path"), 1);
2266
+ var import_node_crypto2 = require("crypto");
2267
+ var import_node_path9 = require("path");
2268
+ function pathError(message) {
2269
+ return Object.assign(new Error(message), { code: "EINVAL" });
2270
+ }
2271
+ function existingEntry(path10) {
2272
+ try {
2273
+ return (0, import_node_fs2.lstatSync)(path10);
2274
+ } catch (error) {
2275
+ if (error.code === "ENOENT") return null;
2276
+ throw error;
2277
+ }
2278
+ }
2279
+ function assertWritableTarget(target) {
2280
+ const entry = existingEntry(target);
2281
+ if (entry?.isSymbolicLink()) throw pathError(`refusing to replace symbolic link: ${target}`);
2282
+ if (entry && !entry.isFile()) throw pathError(`refusing to replace non-file path: ${target}`);
2283
+ return entry;
2284
+ }
2285
+ function atomicWriteFileSync(target, content, options = {}) {
2286
+ const current = assertWritableTarget(target);
2287
+ const preserveMode = options.preserveMode !== false;
2288
+ const exactMode = options.mode ?? (preserveMode && current ? current.mode & 4095 : options.createMode);
2289
+ const temporary = (0, import_node_path9.join)((0, import_node_path9.dirname)(target), `.${(0, import_node_crypto2.randomUUID)()}.patchstack-tmp`);
2290
+ let descriptor = null;
2291
+ try {
2292
+ descriptor = (0, import_node_fs2.openSync)(temporary, "wx", exactMode ?? 438);
2293
+ (0, import_node_fs2.writeFileSync)(descriptor, content, options.encoding);
2294
+ (0, import_node_fs2.fsyncSync)(descriptor);
2295
+ (0, import_node_fs2.closeSync)(descriptor);
2296
+ descriptor = null;
2297
+ if (exactMode !== void 0) (0, import_node_fs2.chmodSync)(temporary, exactMode);
2298
+ try {
2299
+ (0, import_node_fs2.renameSync)(temporary, target);
2300
+ } catch (error) {
2301
+ const code = error.code;
2302
+ if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
2303
+ try {
2304
+ (0, import_node_fs2.unlinkSync)(target);
2305
+ } catch (unlinkError) {
2306
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
2307
+ }
2308
+ (0, import_node_fs2.renameSync)(temporary, target);
2309
+ }
2310
+ } catch (error) {
2311
+ if (descriptor !== null) {
2312
+ try {
2313
+ (0, import_node_fs2.closeSync)(descriptor);
2314
+ } catch {
2315
+ }
2316
+ }
2317
+ try {
2318
+ (0, import_node_fs2.unlinkSync)(temporary);
2319
+ } catch {
2320
+ }
2321
+ throw error;
2322
+ }
2323
+ }
2324
+ function projectTarget(cwd, target, allowRoot = false) {
2325
+ const logicalRoot = (0, import_node_path9.resolve)(cwd);
2326
+ const logicalTarget = (0, import_node_path9.isAbsolute)(target) ? (0, import_node_path9.resolve)(target) : (0, import_node_path9.resolve)(logicalRoot, target);
2327
+ const rel = (0, import_node_path9.relative)(logicalRoot, logicalTarget);
2328
+ if (!allowRoot && rel === "" || rel === ".." || rel.startsWith(`..${import_node_path9.sep}`) || (0, import_node_path9.isAbsolute)(rel)) {
2329
+ throw pathError(`path is outside the project: ${target}`);
2330
+ }
2331
+ const root = (0, import_node_fs2.realpathSync)(logicalRoot);
2332
+ return { root, path: rel === "" ? root : (0, import_node_path9.join)(root, rel), parts: rel === "" ? [] : rel.split(import_node_path9.sep) };
2333
+ }
2334
+ function assertProjectParents(root, parts) {
2335
+ let current = root;
2336
+ for (const part of parts.slice(0, -1)) {
2337
+ current = (0, import_node_path9.join)(current, part);
2338
+ const entry = existingEntry(current);
2339
+ if (entry === null) throw Object.assign(new Error(`project directory does not exist: ${current}`), { code: "ENOENT" });
2340
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
2341
+ throw pathError(`project path contains a non-directory or symbolic link: ${current}`);
2342
+ }
2343
+ }
2344
+ }
2345
+ function writeProjectFileSync(cwd, target, content, options = {}) {
2346
+ const resolved = projectTarget(cwd, target);
2347
+ assertProjectParents(resolved.root, resolved.parts);
2348
+ atomicWriteFileSync(resolved.path, content, options);
2349
+ }
2350
+
2351
+ // src/widget.ts
2027
2352
  var WIDGET_SCRIPT_URL = "https://cdn.patchstack.com/patchstack-widget.js";
2028
2353
  var WIDGET_MARKER_ATTR = "data-patchstack-connect-widget";
2029
2354
  var WIDGET_NEEDLE = "patchstack-widget";
@@ -2061,22 +2386,25 @@ ${indent}</body>`;
2061
2386
  }
2062
2387
  function findSourceShell(cwd) {
2063
2388
  for (const candidate of SOURCE_SHELL_CANDIDATES) {
2064
- if ((0, import_node_fs2.existsSync)(import_node_path9.default.join(cwd, candidate))) {
2389
+ if ((0, import_node_fs3.existsSync)(import_node_path10.default.join(cwd, candidate))) {
2065
2390
  return candidate;
2066
2391
  }
2067
2392
  }
2068
2393
  return null;
2069
2394
  }
2070
- function ensureSourceWidget(cwd, siteUuid) {
2071
- const shell = findSourceShell(cwd);
2395
+ function ensureSourceWidget(cwd, siteUuid, jsxShell = null) {
2396
+ const shell = findSourceShell(cwd) ?? jsxShell;
2072
2397
  if (shell === null) {
2073
2398
  return { shell: null, action: "no-shell" };
2074
2399
  }
2075
- const file = import_node_path9.default.join(cwd, shell);
2076
- const before = (0, import_node_fs2.readFileSync)(file, "utf8");
2400
+ const file = import_node_path10.default.join(cwd, shell);
2401
+ if (!(0, import_node_fs3.existsSync)(file)) {
2402
+ return { shell: null, action: "no-shell" };
2403
+ }
2404
+ const before = (0, import_node_fs3.readFileSync)(file, "utf8");
2077
2405
  const { html, action } = ensureWidgetInHtml(before, siteUuid);
2078
2406
  if (html !== before) {
2079
- (0, import_node_fs2.writeFileSync)(file, html);
2407
+ writeProjectFileSync(cwd, file, html, { encoding: "utf8" });
2080
2408
  }
2081
2409
  return { shell, action };
2082
2410
  }