@patchstack/connect 0.5.6 → 0.5.8
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/AGENT-INSTALL.md +2 -2
- package/README.md +4 -4
- package/dist/chunk-4OHAYSV2.js +295 -0
- package/dist/chunk-4OHAYSV2.js.map +1 -0
- package/dist/cli.js +1089 -470
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +450 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +461 -39
- package/dist/index.js.map +1 -1
- package/dist/protect/templates/demo-rules.json +1 -1
- package/dist/protect.cjs +1226 -244
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.cts +7 -2
- package/dist/protect.d.ts +7 -2
- package/dist/protect.edge.js +992 -179
- package/dist/protect.edge.js.map +3 -3
- package/dist/protect.js +846 -192
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-2ALIJGZ3.js → refresh-manifest-IE6SPHG6.js} +168 -41
- package/dist/refresh-manifest-IE6SPHG6.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-IOODP4ZB.js +0 -104
- package/dist/chunk-IOODP4ZB.js.map +0 -1
- package/dist/refresh-manifest-2ALIJGZ3.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1254,8 +1254,8 @@ function compareSegments(a, b) {
|
|
|
1254
1254
|
var RULES = [
|
|
1255
1255
|
{ platform: "netlify", any: ["NETLIFY", "NETLIFY_BUILD_BASE", "DEPLOY_PRIME_URL"] },
|
|
1256
1256
|
{ platform: "vercel", any: ["VERCEL", "VERCEL_ENV", "VERCEL_URL"] },
|
|
1257
|
-
{ platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "CLOUDFLARE_ACCOUNT_ID"] },
|
|
1258
|
-
{ platform: "aws", any: ["AWS_APP_ID", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
|
|
1257
|
+
{ platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "WORKERS_CI", "WORKERS_CI_BRANCH", "CLOUDFLARE_ACCOUNT_ID"] },
|
|
1258
|
+
{ platform: "aws", any: ["AWS_APP_ID", "AWS_BRANCH", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
|
|
1259
1259
|
{ platform: "render", any: ["RENDER", "RENDER_SERVICE_ID", "RENDER_EXTERNAL_URL"] },
|
|
1260
1260
|
{ platform: "railway", any: ["RAILWAY_ENVIRONMENT", "RAILWAY_ENVIRONMENT_NAME", "RAILWAY_PROJECT_ID"] },
|
|
1261
1261
|
{ platform: "fly", any: ["FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"] },
|
|
@@ -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 =
|
|
1299
|
-
var inflight =
|
|
1300
|
-
function
|
|
1301
|
-
|
|
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 (
|
|
1306
|
-
|
|
1441
|
+
if (config.endpointTrusted === false) return null;
|
|
1442
|
+
let tokenUrl;
|
|
1443
|
+
try {
|
|
1444
|
+
tokenUrl = buildTokenUrl(config.endpoint);
|
|
1445
|
+
} catch {
|
|
1446
|
+
return null;
|
|
1307
1447
|
}
|
|
1308
|
-
if (
|
|
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;
|
|
1453
|
+
}
|
|
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
|
-
|
|
1458
|
+
let exchange;
|
|
1459
|
+
exchange = (async () => {
|
|
1312
1460
|
try {
|
|
1313
|
-
const response = await fetchImpl(
|
|
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
|
|
1329
|
-
if (
|
|
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
|
|
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
|
|
1485
|
+
if (inflight.get(key) === exchange) inflight.delete(key);
|
|
1338
1486
|
}
|
|
1339
1487
|
})();
|
|
1340
|
-
|
|
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
|
|
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
|
-
|
|
1438
|
-
let
|
|
1624
|
+
let text = "";
|
|
1625
|
+
let parsed = null;
|
|
1439
1626
|
try {
|
|
1440
|
-
|
|
1627
|
+
text = await readBoundedText(response);
|
|
1628
|
+
parsed = text.length > 0 ? JSON.parse(text) : null;
|
|
1441
1629
|
} catch {
|
|
1442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1641
|
+
safeDisplayString(parsed?.message) ?? "Patchstack rejected the manifest payload (validation failed).",
|
|
1453
1642
|
"VALIDATION_ERROR"
|
|
1454
1643
|
);
|
|
1455
|
-
refused2.fields = validationFields(
|
|
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
|
|
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
|
|
1658
|
+
throw new PatchstackError("Patchstack returned an unexpected response.", "SERVER_ERROR");
|
|
1470
1659
|
}
|
|
1471
1660
|
return body;
|
|
1472
1661
|
}
|
|
@@ -1480,11 +1669,23 @@ 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");
|
|
1486
1676
|
var import_node_path6 = __toESM(require("path"), 1);
|
|
1487
1677
|
var set = (value) => value !== void 0 && value !== "";
|
|
1678
|
+
var PRODUCTION_BRANCHES = /* @__PURE__ */ new Set([
|
|
1679
|
+
"main",
|
|
1680
|
+
"master",
|
|
1681
|
+
"production",
|
|
1682
|
+
"prod",
|
|
1683
|
+
"release",
|
|
1684
|
+
"live"
|
|
1685
|
+
]);
|
|
1686
|
+
function environmentFromBranch(variable, branch) {
|
|
1687
|
+
return PRODUCTION_BRANCHES.has(branch.toLowerCase()) ? { environment: "production", evidence: `${variable}=${branch} (a production branch by name)` } : { environment: "sandbox", evidence: `${variable}=${branch} (not a production branch by name)` };
|
|
1688
|
+
}
|
|
1488
1689
|
var DISCRIMINATORS = [
|
|
1489
1690
|
{
|
|
1490
1691
|
platform: "vercel",
|
|
@@ -1513,6 +1714,79 @@ var DISCRIMINATORS = [
|
|
|
1513
1714
|
if (!set(env.RAILWAY_ENVIRONMENT_NAME)) return null;
|
|
1514
1715
|
return env.RAILWAY_ENVIRONMENT_NAME === "production" ? { environment: "production", evidence: "RAILWAY_ENVIRONMENT_NAME=production" } : { environment: "sandbox", evidence: `RAILWAY_ENVIRONMENT_NAME=${env.RAILWAY_ENVIRONMENT_NAME}` };
|
|
1515
1716
|
}
|
|
1717
|
+
},
|
|
1718
|
+
{
|
|
1719
|
+
// Pages and Workers Builds both name the branch and neither names the tier.
|
|
1720
|
+
platform: "cloudflare",
|
|
1721
|
+
read: (env) => {
|
|
1722
|
+
if (set(env.CF_PAGES) && set(env.CF_PAGES_BRANCH)) {
|
|
1723
|
+
return environmentFromBranch("CF_PAGES_BRANCH", env.CF_PAGES_BRANCH);
|
|
1724
|
+
}
|
|
1725
|
+
if (set(env.WORKERS_CI) && set(env.WORKERS_CI_BRANCH)) {
|
|
1726
|
+
return environmentFromBranch("WORKERS_CI_BRANCH", env.WORKERS_CI_BRANCH);
|
|
1727
|
+
}
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
// Amplify names the branch, and separately whether the build is a pull-request preview.
|
|
1733
|
+
platform: "aws",
|
|
1734
|
+
read: (env) => {
|
|
1735
|
+
if (!set(env.AWS_APP_ID) || !set(env.AWS_BRANCH)) return null;
|
|
1736
|
+
return set(env.AWS_PULL_REQUEST_ID) ? { environment: "sandbox", evidence: "AWS_PULL_REQUEST_ID set (a pull-request preview)" } : environmentFromBranch("AWS_BRANCH", env.AWS_BRANCH);
|
|
1737
|
+
}
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
// A Deployment build carries `REPLIT_DEPLOYMENT`; the workspace carries `REPL_ID` alone. Read here,
|
|
1741
|
+
// before the hosted-builder rule, so a workspace build in a Replit project is not its publish step.
|
|
1742
|
+
platform: "replit",
|
|
1743
|
+
read: (env) => {
|
|
1744
|
+
if (set(env.REPLIT_DEPLOYMENT)) {
|
|
1745
|
+
return { environment: "production", evidence: "REPLIT_DEPLOYMENT set (a Replit Deployment build)" };
|
|
1746
|
+
}
|
|
1747
|
+
if (set(env.REPL_ID)) {
|
|
1748
|
+
return { environment: "sandbox", evidence: "REPL_ID set without REPLIT_DEPLOYMENT (the workspace)" };
|
|
1749
|
+
}
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
// GitHub Pages deploys build here. A pull request checks out a merge ref whose name says nothing, so
|
|
1755
|
+
// the event is read first; a tag is a release; otherwise the branch name decides.
|
|
1756
|
+
platform: "github-actions",
|
|
1757
|
+
read: (env) => {
|
|
1758
|
+
if (env.GITHUB_ACTIONS !== "true") return null;
|
|
1759
|
+
if (set(env.GITHUB_EVENT_NAME) && env.GITHUB_EVENT_NAME.startsWith("pull_request")) {
|
|
1760
|
+
return { environment: "sandbox", evidence: `GITHUB_EVENT_NAME=${env.GITHUB_EVENT_NAME} (a pull-request build)` };
|
|
1761
|
+
}
|
|
1762
|
+
if (env.GITHUB_REF_TYPE === "tag") {
|
|
1763
|
+
return { environment: "production", evidence: "GITHUB_REF_TYPE=tag (a tag build)" };
|
|
1764
|
+
}
|
|
1765
|
+
if (!set(env.GITHUB_REF_NAME)) return null;
|
|
1766
|
+
return environmentFromBranch("GITHUB_REF_NAME", env.GITHUB_REF_NAME);
|
|
1767
|
+
}
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
// The tier, when a job declares one, is GitLab's own word for it. Without one: a merge request is a
|
|
1771
|
+
// check, a tag is a release, the default branch goes live, and any other branch name decides.
|
|
1772
|
+
platform: "gitlab-ci",
|
|
1773
|
+
read: (env) => {
|
|
1774
|
+
if (env.GITLAB_CI !== "true") return null;
|
|
1775
|
+
if (set(env.CI_ENVIRONMENT_TIER)) {
|
|
1776
|
+
return env.CI_ENVIRONMENT_TIER === "production" ? { environment: "production", evidence: "CI_ENVIRONMENT_TIER=production" } : { environment: "sandbox", evidence: `CI_ENVIRONMENT_TIER=${env.CI_ENVIRONMENT_TIER}` };
|
|
1777
|
+
}
|
|
1778
|
+
if (set(env.CI_MERGE_REQUEST_IID)) {
|
|
1779
|
+
return { environment: "sandbox", evidence: "CI_MERGE_REQUEST_IID set (a merge-request build)" };
|
|
1780
|
+
}
|
|
1781
|
+
if (set(env.CI_COMMIT_TAG)) {
|
|
1782
|
+
return { environment: "production", evidence: "CI_COMMIT_TAG set (a tag build)" };
|
|
1783
|
+
}
|
|
1784
|
+
if (!set(env.CI_COMMIT_BRANCH)) return null;
|
|
1785
|
+
if (set(env.CI_DEFAULT_BRANCH) && env.CI_COMMIT_BRANCH === env.CI_DEFAULT_BRANCH) {
|
|
1786
|
+
return { environment: "production", evidence: `CI_COMMIT_BRANCH=${env.CI_COMMIT_BRANCH} (the default branch)` };
|
|
1787
|
+
}
|
|
1788
|
+
return environmentFromBranch("CI_COMMIT_BRANCH", env.CI_COMMIT_BRANCH);
|
|
1789
|
+
}
|
|
1516
1790
|
}
|
|
1517
1791
|
];
|
|
1518
1792
|
var HOSTED_BUILDER_PACKAGES = [
|
|
@@ -1750,7 +2024,15 @@ async function resolveConfig(options) {
|
|
|
1750
2024
|
const fromSecretFile = await readSecretFile(options.cwd);
|
|
1751
2025
|
const fromEnv = readEnv();
|
|
1752
2026
|
const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
|
|
1753
|
-
const
|
|
2027
|
+
const endpointRaw = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
|
|
2028
|
+
const endpointTrusted = endpointRaw === DEFAULT_ENDPOINT || options.cliEndpoint !== void 0 || fromEnv.endpoint !== void 0 || fromFile.endpoint === void 0;
|
|
2029
|
+
const endpoint = safeRemoteUrl(endpointRaw);
|
|
2030
|
+
if (endpoint === null) {
|
|
2031
|
+
throw new PatchstackError(
|
|
2032
|
+
"Patchstack endpoints must be valid HTTPS URLs, except for localhost development endpoints.",
|
|
2033
|
+
"CONFIG_INVALID"
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
1754
2036
|
const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1755
2037
|
const environmentRaw = fromEnv.environment ?? fromFile.environment;
|
|
1756
2038
|
if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
|
|
@@ -1762,7 +2044,7 @@ async function resolveConfig(options) {
|
|
|
1762
2044
|
const inferred = environmentRaw === void 0 ? inferEnvironment(process.env, detectHostedBuilder(options.cwd)) : null;
|
|
1763
2045
|
const environment = environmentRaw ?? inferred.environment;
|
|
1764
2046
|
const environmentEvidence = inferred?.evidence ?? [];
|
|
1765
|
-
if (siteUuid !== null && siteUuid.length > 0 && !
|
|
2047
|
+
if (siteUuid !== null && siteUuid.length > 0 && !isCanonicalUuid(siteUuid)) {
|
|
1766
2048
|
throw new PatchstackError(
|
|
1767
2049
|
`Site UUID "${siteUuid}" does not look like a valid UUID.`,
|
|
1768
2050
|
"CONFIG_INVALID"
|
|
@@ -1785,6 +2067,7 @@ async function resolveConfig(options) {
|
|
|
1785
2067
|
siteUrl: identity.url,
|
|
1786
2068
|
siteName: identity.name,
|
|
1787
2069
|
endpoint,
|
|
2070
|
+
endpointTrusted,
|
|
1788
2071
|
timeoutMs,
|
|
1789
2072
|
environment,
|
|
1790
2073
|
environmentEvidence,
|
|
@@ -1795,12 +2078,12 @@ async function resolveConfig(options) {
|
|
|
1795
2078
|
async function writeConfigFile(cwd, config) {
|
|
1796
2079
|
const target = import_node_path8.default.join(cwd, CONFIG_FILENAME);
|
|
1797
2080
|
const content = JSON.stringify(config, null, 2) + "\n";
|
|
1798
|
-
await (
|
|
2081
|
+
await writeAtomicFile(target, content, 420);
|
|
1799
2082
|
return target;
|
|
1800
2083
|
}
|
|
1801
2084
|
async function writeSecretFile(cwd, secrets) {
|
|
1802
2085
|
const target = import_node_path8.default.join(cwd, SECRET_FILENAME);
|
|
1803
|
-
await (
|
|
2086
|
+
await writeAtomicFile(target, JSON.stringify(secrets, null, 2) + "\n", 384);
|
|
1804
2087
|
try {
|
|
1805
2088
|
await (0, import_promises9.chmod)(target, 384);
|
|
1806
2089
|
} catch {
|
|
@@ -1808,6 +2091,39 @@ async function writeSecretFile(cwd, secrets) {
|
|
|
1808
2091
|
const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
|
|
1809
2092
|
return { path: target, ...ignore };
|
|
1810
2093
|
}
|
|
2094
|
+
async function writeAtomicFile(target, content, mode) {
|
|
2095
|
+
const temporary = `${target}.${process.pid}.${(0, import_node_crypto.randomUUID)()}.tmp`;
|
|
2096
|
+
let handle;
|
|
2097
|
+
try {
|
|
2098
|
+
handle = await (0, import_promises9.open)(temporary, "wx", mode);
|
|
2099
|
+
await handle.writeFile(content, "utf8");
|
|
2100
|
+
await handle.sync();
|
|
2101
|
+
await handle.close();
|
|
2102
|
+
handle = void 0;
|
|
2103
|
+
try {
|
|
2104
|
+
await (0, import_promises9.rename)(temporary, target);
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
const code = error.code;
|
|
2107
|
+
if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
|
|
2108
|
+
try {
|
|
2109
|
+
await (0, import_promises9.unlink)(target);
|
|
2110
|
+
} catch (unlinkError) {
|
|
2111
|
+
if (unlinkError.code !== "ENOENT") throw unlinkError;
|
|
2112
|
+
}
|
|
2113
|
+
await (0, import_promises9.rename)(temporary, target);
|
|
2114
|
+
}
|
|
2115
|
+
} catch (error) {
|
|
2116
|
+
try {
|
|
2117
|
+
await handle?.close();
|
|
2118
|
+
} catch {
|
|
2119
|
+
}
|
|
2120
|
+
try {
|
|
2121
|
+
await (0, import_promises9.unlink)(temporary);
|
|
2122
|
+
} catch {
|
|
2123
|
+
}
|
|
2124
|
+
throw error;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
1811
2127
|
async function ensureIgnored(cwd, entry) {
|
|
1812
2128
|
const target = import_node_path8.default.join(cwd, ".gitignore");
|
|
1813
2129
|
let existing = "";
|
|
@@ -1826,7 +2142,7 @@ async function ensureIgnored(cwd, entry) {
|
|
|
1826
2142
|
${entry}
|
|
1827
2143
|
`;
|
|
1828
2144
|
try {
|
|
1829
|
-
await (
|
|
2145
|
+
await writeAtomicFile(target, existing + block, 420);
|
|
1830
2146
|
} catch (err) {
|
|
1831
2147
|
const code = err.code;
|
|
1832
2148
|
return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
|
|
@@ -1879,8 +2195,15 @@ async function readJsonFile(cwd, filename) {
|
|
|
1879
2195
|
const target = import_node_path8.default.join(cwd, filename);
|
|
1880
2196
|
let raw;
|
|
1881
2197
|
try {
|
|
2198
|
+
if ((await (0, import_promises9.lstat)(target)).isSymbolicLink()) {
|
|
2199
|
+
throw new PatchstackError(
|
|
2200
|
+
`Could not read ${target}: configuration files must not be symbolic links.`,
|
|
2201
|
+
"CONFIG_INVALID"
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
1882
2204
|
raw = await (0, import_promises9.readFile)(target, "utf8");
|
|
1883
2205
|
} catch (err) {
|
|
2206
|
+
if (err instanceof PatchstackError) throw err;
|
|
1884
2207
|
if (err.code === "ENOENT") {
|
|
1885
2208
|
return {};
|
|
1886
2209
|
}
|
|
@@ -1925,9 +2248,6 @@ function readEnv() {
|
|
|
1925
2248
|
environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
|
|
1926
2249
|
};
|
|
1927
2250
|
}
|
|
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
2251
|
function isEnvironment(value) {
|
|
1932
2252
|
return value === "production" || value === "sandbox" || value === "local";
|
|
1933
2253
|
}
|
|
@@ -2022,8 +2342,97 @@ function isEmptyStack(stack) {
|
|
|
2022
2342
|
}
|
|
2023
2343
|
|
|
2024
2344
|
// src/widget.ts
|
|
2345
|
+
var import_node_fs3 = require("fs");
|
|
2346
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
2347
|
+
|
|
2348
|
+
// src/safe-file.ts
|
|
2025
2349
|
var import_node_fs2 = require("fs");
|
|
2026
|
-
var
|
|
2350
|
+
var import_node_crypto2 = require("crypto");
|
|
2351
|
+
var import_node_path9 = require("path");
|
|
2352
|
+
function pathError(message) {
|
|
2353
|
+
return Object.assign(new Error(message), { code: "EINVAL" });
|
|
2354
|
+
}
|
|
2355
|
+
function existingEntry(path10) {
|
|
2356
|
+
try {
|
|
2357
|
+
return (0, import_node_fs2.lstatSync)(path10);
|
|
2358
|
+
} catch (error) {
|
|
2359
|
+
if (error.code === "ENOENT") return null;
|
|
2360
|
+
throw error;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
function assertWritableTarget(target) {
|
|
2364
|
+
const entry = existingEntry(target);
|
|
2365
|
+
if (entry?.isSymbolicLink()) throw pathError(`refusing to replace symbolic link: ${target}`);
|
|
2366
|
+
if (entry && !entry.isFile()) throw pathError(`refusing to replace non-file path: ${target}`);
|
|
2367
|
+
return entry;
|
|
2368
|
+
}
|
|
2369
|
+
function atomicWriteFileSync(target, content, options = {}) {
|
|
2370
|
+
const current = assertWritableTarget(target);
|
|
2371
|
+
const preserveMode = options.preserveMode !== false;
|
|
2372
|
+
const exactMode = options.mode ?? (preserveMode && current ? current.mode & 4095 : options.createMode);
|
|
2373
|
+
const temporary = (0, import_node_path9.join)((0, import_node_path9.dirname)(target), `.${(0, import_node_crypto2.randomUUID)()}.patchstack-tmp`);
|
|
2374
|
+
let descriptor = null;
|
|
2375
|
+
try {
|
|
2376
|
+
descriptor = (0, import_node_fs2.openSync)(temporary, "wx", exactMode ?? 438);
|
|
2377
|
+
(0, import_node_fs2.writeFileSync)(descriptor, content, options.encoding);
|
|
2378
|
+
(0, import_node_fs2.fsyncSync)(descriptor);
|
|
2379
|
+
(0, import_node_fs2.closeSync)(descriptor);
|
|
2380
|
+
descriptor = null;
|
|
2381
|
+
if (exactMode !== void 0) (0, import_node_fs2.chmodSync)(temporary, exactMode);
|
|
2382
|
+
try {
|
|
2383
|
+
(0, import_node_fs2.renameSync)(temporary, target);
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
const code = error.code;
|
|
2386
|
+
if (process.platform !== "win32" || code !== "EEXIST" && code !== "EPERM") throw error;
|
|
2387
|
+
try {
|
|
2388
|
+
(0, import_node_fs2.unlinkSync)(target);
|
|
2389
|
+
} catch (unlinkError) {
|
|
2390
|
+
if (unlinkError.code !== "ENOENT") throw unlinkError;
|
|
2391
|
+
}
|
|
2392
|
+
(0, import_node_fs2.renameSync)(temporary, target);
|
|
2393
|
+
}
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
if (descriptor !== null) {
|
|
2396
|
+
try {
|
|
2397
|
+
(0, import_node_fs2.closeSync)(descriptor);
|
|
2398
|
+
} catch {
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
try {
|
|
2402
|
+
(0, import_node_fs2.unlinkSync)(temporary);
|
|
2403
|
+
} catch {
|
|
2404
|
+
}
|
|
2405
|
+
throw error;
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
function projectTarget(cwd, target, allowRoot = false) {
|
|
2409
|
+
const logicalRoot = (0, import_node_path9.resolve)(cwd);
|
|
2410
|
+
const logicalTarget = (0, import_node_path9.isAbsolute)(target) ? (0, import_node_path9.resolve)(target) : (0, import_node_path9.resolve)(logicalRoot, target);
|
|
2411
|
+
const rel = (0, import_node_path9.relative)(logicalRoot, logicalTarget);
|
|
2412
|
+
if (!allowRoot && rel === "" || rel === ".." || rel.startsWith(`..${import_node_path9.sep}`) || (0, import_node_path9.isAbsolute)(rel)) {
|
|
2413
|
+
throw pathError(`path is outside the project: ${target}`);
|
|
2414
|
+
}
|
|
2415
|
+
const root = (0, import_node_fs2.realpathSync)(logicalRoot);
|
|
2416
|
+
return { root, path: rel === "" ? root : (0, import_node_path9.join)(root, rel), parts: rel === "" ? [] : rel.split(import_node_path9.sep) };
|
|
2417
|
+
}
|
|
2418
|
+
function assertProjectParents(root, parts) {
|
|
2419
|
+
let current = root;
|
|
2420
|
+
for (const part of parts.slice(0, -1)) {
|
|
2421
|
+
current = (0, import_node_path9.join)(current, part);
|
|
2422
|
+
const entry = existingEntry(current);
|
|
2423
|
+
if (entry === null) throw Object.assign(new Error(`project directory does not exist: ${current}`), { code: "ENOENT" });
|
|
2424
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
2425
|
+
throw pathError(`project path contains a non-directory or symbolic link: ${current}`);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
function writeProjectFileSync(cwd, target, content, options = {}) {
|
|
2430
|
+
const resolved = projectTarget(cwd, target);
|
|
2431
|
+
assertProjectParents(resolved.root, resolved.parts);
|
|
2432
|
+
atomicWriteFileSync(resolved.path, content, options);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// src/widget.ts
|
|
2027
2436
|
var WIDGET_SCRIPT_URL = "https://cdn.patchstack.com/patchstack-widget.js";
|
|
2028
2437
|
var WIDGET_MARKER_ATTR = "data-patchstack-connect-widget";
|
|
2029
2438
|
var WIDGET_NEEDLE = "patchstack-widget";
|
|
@@ -2061,7 +2470,7 @@ ${indent}</body>`;
|
|
|
2061
2470
|
}
|
|
2062
2471
|
function findSourceShell(cwd) {
|
|
2063
2472
|
for (const candidate of SOURCE_SHELL_CANDIDATES) {
|
|
2064
|
-
if ((0,
|
|
2473
|
+
if ((0, import_node_fs3.existsSync)(import_node_path10.default.join(cwd, candidate))) {
|
|
2065
2474
|
return candidate;
|
|
2066
2475
|
}
|
|
2067
2476
|
}
|
|
@@ -2072,14 +2481,14 @@ function ensureSourceWidget(cwd, siteUuid, jsxShell = null) {
|
|
|
2072
2481
|
if (shell === null) {
|
|
2073
2482
|
return { shell: null, action: "no-shell" };
|
|
2074
2483
|
}
|
|
2075
|
-
const file =
|
|
2076
|
-
if (!(0,
|
|
2484
|
+
const file = import_node_path10.default.join(cwd, shell);
|
|
2485
|
+
if (!(0, import_node_fs3.existsSync)(file)) {
|
|
2077
2486
|
return { shell: null, action: "no-shell" };
|
|
2078
2487
|
}
|
|
2079
|
-
const before = (0,
|
|
2488
|
+
const before = (0, import_node_fs3.readFileSync)(file, "utf8");
|
|
2080
2489
|
const { html, action } = ensureWidgetInHtml(before, siteUuid);
|
|
2081
2490
|
if (html !== before) {
|
|
2082
|
-
(
|
|
2491
|
+
writeProjectFileSync(cwd, file, html, { encoding: "utf8" });
|
|
2083
2492
|
}
|
|
2084
2493
|
return { shell, action };
|
|
2085
2494
|
}
|