@patchstack/connect 0.2.6 → 0.2.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/README.md CHANGED
@@ -17,9 +17,11 @@ npm install --save-dev @patchstack/connect
17
17
  npx @patchstack/connect scan
18
18
  ```
19
19
 
20
+ > **Use your project's own package manager.** On bun-managed projects (Lovable, Bolt, most vibe-coding platforms) install with `bun add -d @patchstack/connect` instead — running `npm install` there plants a `package-lock.json` that the platform's native dependency flow never updates again, leaving a stale lockfile next to the live one. The connector detects and works around that (see *Stale lockfiles* below), but not creating the fossil is better.
21
+
20
22
  That's it. The first `scan`:
21
23
 
22
- 1. Reads your `package-lock.json`.
24
+ 1. Reads your lockfile (see *Supported lockfiles*).
23
25
  2. POSTs the package list to Patchstack with **no** UUID.
24
26
  3. Patchstack provisions a fresh site and returns its UUID.
25
27
  4. The connector writes the UUID to `.patchstackrc.json` so the next `scan` targets the same site.
@@ -123,6 +125,10 @@ That's the entire payload. No source code, no environment variables, no file pat
123
125
 
124
126
  If both a Bun lockfile and `node_modules/` are present, the connector walks `node_modules/` to enumerate the installed packages. Run `bun install` (or `npm install`) before scanning so the directory is populated.
125
127
 
128
+ ### Stale lockfiles
129
+
130
+ Every scanned source is validated against `package.json`: if the chosen lockfile is missing dependencies that `package.json` declares, it is treated as a fossil (e.g. a `package-lock.json` created by a one-off `npm install` in a bun-managed project) and the connector falls through to the next source — ultimately walking `node_modules/`, the installed truth — and prints a warning naming the stale file. Delete the stale lockfile to silence the warning. Without this, the manifest and the build fingerprint would silently freeze while the real dependency set drifts.
131
+
126
132
  ## Development
127
133
 
128
134
  ```bash
package/dist/cli.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import { readFileSync, writeFileSync } from "fs";
5
+
3
6
  // src/parsers/index.ts
4
7
  import { access } from "fs/promises";
5
- import path4 from "path";
8
+ import path5 from "path";
6
9
 
7
10
  // src/types.ts
8
11
  var PatchstackError = class extends Error {
@@ -16,13 +19,52 @@ var PatchstackError = class extends Error {
16
19
  cause;
17
20
  };
18
21
 
19
- // src/parsers/npm.ts
22
+ // src/parsers/consistency.ts
20
23
  import { readFile } from "fs/promises";
21
24
  import path from "path";
25
+ async function readDeclaredDependencyNames(cwd) {
26
+ let raw;
27
+ try {
28
+ raw = await readFile(path.join(cwd, "package.json"), "utf8");
29
+ } catch {
30
+ return [];
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ } catch {
36
+ return [];
37
+ }
38
+ if (typeof parsed !== "object" || parsed === null) {
39
+ return [];
40
+ }
41
+ const names = /* @__PURE__ */ new Set();
42
+ for (const group of ["dependencies", "devDependencies"]) {
43
+ const deps = parsed[group];
44
+ if (typeof deps !== "object" || deps === null) {
45
+ continue;
46
+ }
47
+ for (const [name, spec] of Object.entries(deps)) {
48
+ if (typeof spec === "string" && /^(file|link|workspace|portal):/.test(spec)) {
49
+ continue;
50
+ }
51
+ names.add(name);
52
+ }
53
+ }
54
+ return [...names];
55
+ }
56
+ function missingDependencies(declared, packages) {
57
+ const present = new Set(packages.map((entry) => entry.name));
58
+ return declared.filter((name) => !present.has(name));
59
+ }
60
+
61
+ // src/parsers/npm.ts
62
+ import { readFile as readFile2 } from "fs/promises";
63
+ import path2 from "path";
22
64
  async function parseNpmLockfile(lockfilePath) {
23
65
  let raw;
24
66
  try {
25
- raw = await readFile(lockfilePath, "utf8");
67
+ raw = await readFile2(lockfilePath, "utf8");
26
68
  } catch (cause) {
27
69
  throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
28
70
  }
@@ -80,7 +122,7 @@ function extractFromV1(deps, acc = [], depth = 0) {
80
122
  return acc;
81
123
  }
82
124
  function extractNameFromPath(pkgPath) {
83
- const segments = pkgPath.split("node_modules" + path.sep === pkgPath ? path.sep : "/");
125
+ const segments = pkgPath.split("node_modules" + path2.sep === pkgPath ? path2.sep : "/");
84
126
  const parts = pkgPath.split("/");
85
127
  const nmIndex = parts.lastIndexOf("node_modules");
86
128
  if (nmIndex === -1 || nmIndex >= parts.length - 1) {
@@ -103,10 +145,10 @@ function isDirectV2(pkgPath) {
103
145
  }
104
146
 
105
147
  // src/parsers/node_modules.ts
106
- import { lstat, readFile as readFile2, readdir, stat } from "fs/promises";
107
- import path2 from "path";
148
+ import { lstat, readFile as readFile3, readdir, stat } from "fs/promises";
149
+ import path3 from "path";
108
150
  async function walkNodeModules(cwd) {
109
- const root = path2.join(cwd, "node_modules");
151
+ const root = path3.join(cwd, "node_modules");
110
152
  try {
111
153
  const info = await stat(root);
112
154
  if (!info.isDirectory()) {
@@ -140,7 +182,7 @@ async function walk(dir, acc, depth) {
140
182
  if (name.startsWith(".")) {
141
183
  continue;
142
184
  }
143
- const fullPath = path2.join(dir, name);
185
+ const fullPath = path3.join(dir, name);
144
186
  if (!await isPlainDirectory(fullPath)) {
145
187
  continue;
146
188
  }
@@ -155,7 +197,7 @@ async function walk(dir, acc, depth) {
155
197
  if (sub.startsWith(".")) {
156
198
  continue;
157
199
  }
158
- const scopedDir = path2.join(fullPath, sub);
200
+ const scopedDir = path3.join(fullPath, sub);
159
201
  if (!await isPlainDirectory(scopedDir)) {
160
202
  continue;
161
203
  }
@@ -171,7 +213,7 @@ async function walk(dir, acc, depth) {
171
213
  async function readPackage(pkgDir, depth, acc) {
172
214
  let raw;
173
215
  try {
174
- raw = await readFile2(path2.join(pkgDir, "package.json"), "utf8");
216
+ raw = await readFile3(path3.join(pkgDir, "package.json"), "utf8");
175
217
  } catch {
176
218
  return;
177
219
  }
@@ -194,7 +236,7 @@ async function readPackage(pkgDir, depth, acc) {
194
236
  });
195
237
  }
196
238
  async function walkNested(pkgDir, acc, depth) {
197
- const nested = path2.join(pkgDir, "node_modules");
239
+ const nested = path3.join(pkgDir, "node_modules");
198
240
  if (!await isPlainDirectory(nested)) {
199
241
  return;
200
242
  }
@@ -210,11 +252,11 @@ async function isPlainDirectory(dir) {
210
252
  }
211
253
 
212
254
  // src/parsers/pnpm.ts
213
- import { readFile as readFile3 } from "fs/promises";
255
+ import { readFile as readFile4 } from "fs/promises";
214
256
  async function parsePnpmLockfile(lockfilePath) {
215
257
  let raw;
216
258
  try {
217
- raw = await readFile3(lockfilePath, "utf8");
259
+ raw = await readFile4(lockfilePath, "utf8");
218
260
  } catch (cause) {
219
261
  throw new PatchstackError(
220
262
  `Could not read lockfile at ${lockfilePath}`,
@@ -462,12 +504,12 @@ function extractLeafName(trimmed) {
462
504
  }
463
505
 
464
506
  // src/parsers/yarn.ts
465
- import { readFile as readFile4 } from "fs/promises";
466
- import path3 from "path";
507
+ import { readFile as readFile5 } from "fs/promises";
508
+ import path4 from "path";
467
509
  async function parseYarnLockfile(lockfilePath) {
468
510
  let raw;
469
511
  try {
470
- raw = await readFile4(lockfilePath, "utf8");
512
+ raw = await readFile5(lockfilePath, "utf8");
471
513
  } catch (cause) {
472
514
  throw new PatchstackError(
473
515
  `Could not read lockfile at ${lockfilePath}`,
@@ -482,7 +524,7 @@ async function parseYarnLockfile(lockfilePath) {
482
524
  "LOCKFILE_PARSE_ERROR"
483
525
  );
484
526
  }
485
- const directNames = await readDirectDepNames(path3.dirname(lockfilePath));
527
+ const directNames = await readDirectDepNames(path4.dirname(lockfilePath));
486
528
  const entries = [];
487
529
  const seen = /* @__PURE__ */ new Set();
488
530
  for (const block of blocks) {
@@ -626,7 +668,7 @@ async function readDirectDepNames(cwd) {
626
668
  const names = /* @__PURE__ */ new Set();
627
669
  let raw;
628
670
  try {
629
- raw = await readFile4(path3.join(cwd, "package.json"), "utf8");
671
+ raw = await readFile5(path4.join(cwd, "package.json"), "utf8");
630
672
  } catch {
631
673
  return names;
632
674
  }
@@ -653,61 +695,99 @@ async function readDirectDepNames(cwd) {
653
695
  }
654
696
 
655
697
  // src/parsers/index.ts
698
+ var LOCKFILE_CANDIDATES = [
699
+ { filename: "package-lock.json", strategy: "npm-lockfile" },
700
+ { filename: "bun.lock", strategy: "node-modules-walk" },
701
+ { filename: "bun.lockb", strategy: "node-modules-walk" },
702
+ { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
703
+ { filename: "yarn.lock", strategy: "yarn-lockfile" }
704
+ ];
656
705
  async function detectLockfile(cwd) {
657
- const npmLock = path4.join(cwd, "package-lock.json");
658
- if (await exists(npmLock)) {
659
- return {
660
- ecosystem: "npm",
661
- filePath: npmLock,
662
- filename: "package-lock.json",
663
- strategy: "npm-lockfile"
664
- };
665
- }
666
- const bunLock = path4.join(cwd, "bun.lock");
667
- if (await exists(bunLock)) {
668
- return {
669
- ecosystem: "npm",
670
- filePath: bunLock,
671
- filename: "bun.lock",
672
- strategy: "node-modules-walk"
673
- };
674
- }
675
- const bunLockB = path4.join(cwd, "bun.lockb");
676
- if (await exists(bunLockB)) {
677
- return {
678
- ecosystem: "npm",
679
- filePath: bunLockB,
680
- filename: "bun.lockb",
681
- strategy: "node-modules-walk"
682
- };
683
- }
684
- const pnpmLock = path4.join(cwd, "pnpm-lock.yaml");
685
- if (await exists(pnpmLock)) {
686
- return {
687
- ecosystem: "npm",
688
- filePath: pnpmLock,
689
- filename: "pnpm-lock.yaml",
690
- strategy: "pnpm-lockfile"
691
- };
692
- }
693
- const yarnLock = path4.join(cwd, "yarn.lock");
694
- if (await exists(yarnLock)) {
706
+ const probed = await Promise.all(
707
+ LOCKFILE_CANDIDATES.map(async (candidate) => {
708
+ const filePath = path5.join(cwd, candidate.filename);
709
+ return { ...candidate, filePath, present: await exists(filePath) };
710
+ })
711
+ );
712
+ const match = probed.find((candidate) => candidate.present);
713
+ if (match) {
695
714
  return {
696
715
  ecosystem: "npm",
697
- filePath: yarnLock,
698
- filename: "yarn.lock",
699
- strategy: "yarn-lockfile"
716
+ filePath: match.filePath,
717
+ filename: match.filename,
718
+ strategy: match.strategy
700
719
  };
701
720
  }
721
+ const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
702
722
  throw new PatchstackError(
703
- `No lockfile found in ${cwd}. Expected one of: package-lock.json, bun.lock, bun.lockb, yarn.lock, pnpm-lock.yaml.`,
723
+ `No lockfile found in ${cwd}. Expected one of: ${expected}.`,
704
724
  "LOCKFILE_NOT_FOUND"
705
725
  );
706
726
  }
707
727
  async function scanLockfile(cwd) {
708
- const detected = await detectLockfile(cwd);
709
- const packages = await runStrategy(detected, cwd);
710
- return { ecosystem: detected.ecosystem, packages };
728
+ const candidates = await presentLockfiles(cwd);
729
+ if (candidates.length === 0) {
730
+ await detectLockfile(cwd);
731
+ }
732
+ const declared = await readDeclaredDependencyNames(cwd);
733
+ const warnings = [];
734
+ let firstParsed = null;
735
+ let walkTried = false;
736
+ for (const candidate of candidates) {
737
+ let packages;
738
+ try {
739
+ packages = await runStrategy(candidate, cwd);
740
+ } catch {
741
+ continue;
742
+ }
743
+ walkTried ||= candidate.strategy === "node-modules-walk";
744
+ firstParsed ??= { packages, filename: candidate.filename };
745
+ const missing = missingDependencies(declared, packages);
746
+ if (missing.length === 0) {
747
+ return manifestWith(packages, warnings);
748
+ }
749
+ warnings.push(staleWarning(candidate.filename, missing));
750
+ }
751
+ if (!walkTried) {
752
+ try {
753
+ const packages = await walkNodeModules(cwd);
754
+ const missing = missingDependencies(declared, packages);
755
+ if (missing.length > 0) {
756
+ warnings.push(staleWarning("node_modules/", missing));
757
+ }
758
+ warnings.push("Scanned node_modules/ instead. Delete the stale lockfile to silence this warning.");
759
+ return manifestWith(packages, warnings);
760
+ } catch {
761
+ }
762
+ }
763
+ if (firstParsed === null) {
764
+ const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
765
+ throw new PatchstackError(
766
+ `No readable lockfile found in ${cwd}. Expected one of: ${expected}.`,
767
+ "LOCKFILE_NOT_FOUND"
768
+ );
769
+ }
770
+ warnings.push(
771
+ `No fully-consistent source found; reporting ${firstParsed.filename}. The manifest may understate the real dependency set.`
772
+ );
773
+ return manifestWith(firstParsed.packages, warnings);
774
+ }
775
+ function manifestWith(packages, warnings) {
776
+ return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
777
+ }
778
+ function staleWarning(source, missing) {
779
+ const sample = missing.slice(0, 3).join(", ");
780
+ const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
781
+ return `${source} looks stale: package.json declares ${missing.length} dependenc${missing.length === 1 ? "y" : "ies"} it does not contain (${sample}${suffix}).`;
782
+ }
783
+ async function presentLockfiles(cwd) {
784
+ const probed = await Promise.all(
785
+ LOCKFILE_CANDIDATES.map(async (candidate) => {
786
+ const filePath = path5.join(cwd, candidate.filename);
787
+ return { ...candidate, filePath, present: await exists(filePath) };
788
+ })
789
+ );
790
+ return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
711
791
  }
712
792
  async function runStrategy(detected, cwd) {
713
793
  switch (detected.strategy) {
@@ -832,86 +912,19 @@ function compareSegments(a, b) {
832
912
  return 0;
833
913
  }
834
914
 
835
- // src/buildFlag.ts
836
- import { promises as fs } from "fs";
837
- import path5 from "path";
838
- var FLAG_MARKER = "__PATCHSTACK_PROD__";
839
- var SNIPPET = "<script>window.__PATCHSTACK_PROD__=true;/*patchstack:production*/</script>";
840
- var DEFAULT_DIRS = ["dist", "build", "out", ".output/public"];
841
- async function pathExists(target) {
842
- try {
843
- await fs.access(target);
844
- return true;
845
- } catch {
846
- return false;
847
- }
848
- }
849
- async function resolveBuildDir(cwd, override) {
850
- if (override !== void 0 && override.length > 0) {
851
- const dir = path5.resolve(cwd, override);
852
- return await pathExists(dir) ? dir : null;
853
- }
854
- for (const candidate of DEFAULT_DIRS) {
855
- const dir = path5.resolve(cwd, candidate);
856
- if (await pathExists(dir)) {
857
- return dir;
915
+ // src/checksum.ts
916
+ import { createHash } from "crypto";
917
+ function computeManifestChecksum(packages) {
918
+ const canonical = packages.map((pkg) => ({ name: pkg.name, version: pkg.version })).sort((a, b) => {
919
+ if (a.name !== b.name) {
920
+ return a.name < b.name ? -1 : 1;
858
921
  }
859
- }
860
- return null;
861
- }
862
- async function findHtmlFiles(dir, depth = 3) {
863
- const found = [];
864
- let entries;
865
- try {
866
- entries = await fs.readdir(dir, { withFileTypes: true });
867
- } catch {
868
- return found;
869
- }
870
- for (const entry of entries) {
871
- const full = path5.join(dir, entry.name);
872
- if (entry.isDirectory()) {
873
- if (depth > 0) {
874
- found.push(...await findHtmlFiles(full, depth - 1));
875
- }
876
- } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) {
877
- found.push(full);
878
- }
879
- }
880
- return found;
881
- }
882
- function injectFlag(html) {
883
- if (html.includes(FLAG_MARKER)) {
884
- return null;
885
- }
886
- const headOpen = /<head[^>]*>/i;
887
- if (headOpen.test(html)) {
888
- return html.replace(headOpen, (match) => `${match}${SNIPPET}`);
889
- }
890
- const bodyOpen = /<body[^>]*>/i;
891
- if (bodyOpen.test(html)) {
892
- return html.replace(bodyOpen, (match) => `${match}${SNIPPET}`);
893
- }
894
- return `${SNIPPET}${html}`;
895
- }
896
- async function markProductionBuild(cwd, override) {
897
- const dir = await resolveBuildDir(cwd, override);
898
- if (dir === null) {
899
- return { dir: null, patched: [], skipped: [] };
900
- }
901
- const files = await findHtmlFiles(dir);
902
- const patched = [];
903
- const skipped = [];
904
- for (const file of files) {
905
- const html = await fs.readFile(file, "utf8");
906
- const next = injectFlag(html);
907
- if (next === null) {
908
- skipped.push(file);
909
- continue;
922
+ if (a.version !== b.version) {
923
+ return a.version < b.version ? -1 : 1;
910
924
  }
911
- await fs.writeFile(file, next, "utf8");
912
- patched.push(file);
913
- }
914
- return { dir, patched, skipped };
925
+ return 0;
926
+ });
927
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 12);
915
928
  }
916
929
 
917
930
  // src/client.ts
@@ -937,7 +950,7 @@ async function postManifest(config, payload) {
937
950
  Accept: "application/json",
938
951
  "User-Agent": "@patchstack/connect"
939
952
  },
940
- body: JSON.stringify(payload),
953
+ body: JSON.stringify({ ...payload, environment: config.environment }),
941
954
  signal: AbortSignal.timeout(timeoutMs)
942
955
  });
943
956
  } catch (cause) {
@@ -992,15 +1005,24 @@ function isTimeoutError(cause) {
992
1005
  }
993
1006
 
994
1007
  // src/config.ts
995
- import { readFile as readFile5, writeFile } from "fs/promises";
1008
+ import { readFile as readFile6, writeFile } from "fs/promises";
996
1009
  import path6 from "path";
997
1010
  var CONFIG_FILENAME = ".patchstackrc.json";
1011
+ var DEFAULT_ENVIRONMENT = "production";
998
1012
  async function resolveConfig(options) {
999
1013
  const fromFile = await readConfigFile(options.cwd);
1000
1014
  const fromEnv = readEnv();
1001
1015
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1002
1016
  const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
1003
1017
  const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1018
+ const environmentRaw = fromEnv.environment ?? fromFile.environment;
1019
+ if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
1020
+ throw new PatchstackError(
1021
+ `Environment must be "production" or "sandbox"; got "${environmentRaw}".`,
1022
+ "CONFIG_INVALID"
1023
+ );
1024
+ }
1025
+ const environment = environmentRaw ?? DEFAULT_ENVIRONMENT;
1004
1026
  if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
1005
1027
  throw new PatchstackError(
1006
1028
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
@@ -1016,7 +1038,8 @@ async function resolveConfig(options) {
1016
1038
  return {
1017
1039
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
1018
1040
  endpoint,
1019
- timeoutMs
1041
+ timeoutMs,
1042
+ environment
1020
1043
  };
1021
1044
  }
1022
1045
  async function writeConfigFile(cwd, config) {
@@ -1033,7 +1056,7 @@ async function readConfigFile(cwd) {
1033
1056
  const target = path6.join(cwd, CONFIG_FILENAME);
1034
1057
  let raw;
1035
1058
  try {
1036
- raw = await readFile5(target, "utf8");
1059
+ raw = await readFile6(target, "utf8");
1037
1060
  } catch (err) {
1038
1061
  if (err.code === "ENOENT") {
1039
1062
  return {};
@@ -1067,15 +1090,74 @@ function readEnv() {
1067
1090
  }
1068
1091
  timeoutMs = parsed;
1069
1092
  }
1093
+ const environmentRaw = process.env.PATCHSTACK_ENVIRONMENT;
1070
1094
  return {
1071
1095
  siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
1072
1096
  endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
1073
- timeoutMs
1097
+ timeoutMs,
1098
+ environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
1074
1099
  };
1075
1100
  }
1076
1101
  function isUuid(value) {
1077
1102
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1078
1103
  }
1104
+ function isEnvironment(value) {
1105
+ return value === "production" || value === "sandbox";
1106
+ }
1107
+
1108
+ // src/mark-build.ts
1109
+ import { existsSync, readdirSync, statSync } from "fs";
1110
+ import path7 from "path";
1111
+ var MARKER_ATTR = "data-patchstack-build";
1112
+ var BUILD_DIR_CANDIDATES = ["dist", "build", "out", ".output/public"];
1113
+ function resolveBuildDir(cwd, override) {
1114
+ if (override !== void 0 && override !== "") {
1115
+ const abs = path7.resolve(cwd, override);
1116
+ return existsSync(abs) && statSync(abs).isDirectory() ? abs : null;
1117
+ }
1118
+ for (const candidate of BUILD_DIR_CANDIDATES) {
1119
+ const abs = path7.resolve(cwd, candidate);
1120
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
1121
+ return abs;
1122
+ }
1123
+ }
1124
+ return null;
1125
+ }
1126
+ function findHtmlFiles(dir) {
1127
+ const out = [];
1128
+ const walk2 = (current) => {
1129
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
1130
+ const full = path7.join(current, entry.name);
1131
+ if (entry.isDirectory()) {
1132
+ walk2(full);
1133
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) {
1134
+ out.push(full);
1135
+ }
1136
+ }
1137
+ };
1138
+ walk2(dir);
1139
+ return out;
1140
+ }
1141
+ function buildInjectionSnippet(checksum) {
1142
+ const statements = ["window.__PATCHSTACK_PROD__=true;"];
1143
+ if (checksum !== null && checksum !== "") {
1144
+ statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`);
1145
+ }
1146
+ return `<script ${MARKER_ATTR}>${statements.join("")}</script>`;
1147
+ }
1148
+ function injectMarker(html, snippet) {
1149
+ const stripped = html.replace(
1150
+ new RegExp(`\\s*<script ${MARKER_ATTR}[^>]*>[\\s\\S]*?</script>`, "gi"),
1151
+ ""
1152
+ );
1153
+ if (/<\/head>/i.test(stripped)) {
1154
+ return stripped.replace(/<\/head>/i, `${snippet}</head>`);
1155
+ }
1156
+ if (/<\/body>/i.test(stripped)) {
1157
+ return stripped.replace(/<\/body>/i, `${snippet}</body>`);
1158
+ }
1159
+ return stripped + snippet;
1160
+ }
1079
1161
 
1080
1162
  // src/cli.ts
1081
1163
  var HELP = `@patchstack/connect \u2014 scan your lockfile and report packages to Patchstack.
@@ -1087,10 +1169,8 @@ Usage:
1087
1169
  patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json
1088
1170
  with an existing site UUID
1089
1171
  patchstack-connect status [options] Show current configuration
1090
- patchstack-connect mark-build [--dir <path>] Inject the production flag into
1091
- the built HTML (run as a postbuild
1092
- step). Tells the widget it's live so
1093
- it hides the claim flow.
1172
+ patchstack-connect mark-build [options] Stamp built HTML with a production flag +
1173
+ build fingerprint (run as a postbuild step)
1094
1174
  patchstack-connect help Print this message
1095
1175
 
1096
1176
  Options (for scan and status):
@@ -1099,13 +1179,14 @@ Options (for scan and status):
1099
1179
  --dry-run (scan only) Show the payload without posting
1100
1180
 
1101
1181
  Options (for mark-build):
1102
- --dir <path> Build output dir (default: auto-detect dist/, build/,
1103
- out/, .output/public)
1182
+ --dir <path> Build output directory (default: auto-detect
1183
+ dist/ build/ out/ .output/public)
1104
1184
 
1105
1185
  Environment:
1106
1186
  PATCHSTACK_SITE_UUID Site UUID
1107
1187
  PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest)
1108
1188
  PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000)
1189
+ PATCHSTACK_ENVIRONMENT Manifest environment: production | sandbox (default: production)
1109
1190
 
1110
1191
  Precedence: CLI flag > environment variable > .patchstackrc.json.
1111
1192
 
@@ -1175,10 +1256,14 @@ async function runScan(args) {
1175
1256
  cliEndpoint: getStringFlag(args.flags, "endpoint")
1176
1257
  });
1177
1258
  const manifest = await scanLockfile(process.cwd());
1259
+ for (const warning of manifest.warnings ?? []) {
1260
+ console.warn(`patchstack: ${warning}`);
1261
+ }
1178
1262
  const { payload, stats } = buildWirePayload(manifest);
1179
1263
  console.log(
1180
1264
  `Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names in ${manifest.ecosystem} lockfile.`
1181
1265
  );
1266
+ console.log(`Reporting under the ${config.environment} environment.`);
1182
1267
  if (stats.duplicateNames.length > 0) {
1183
1268
  console.log(`${stats.duplicateNames.length} package(s) appear at multiple versions:`);
1184
1269
  if (stats.duplicateNames.length <= 10) {
@@ -1229,29 +1314,54 @@ async function runStatus(args) {
1229
1314
  cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
1230
1315
  cliEndpoint: getStringFlag(args.flags, "endpoint")
1231
1316
  });
1232
- console.log(`Site UUID: ${config.siteUuid ?? "(none yet \u2014 the next `scan` will provision one)"}`);
1233
- console.log(`Endpoint: ${config.endpoint}`);
1234
- console.log(`Timeout: ${config.timeoutMs}ms`);
1317
+ console.log(`Site UUID: ${config.siteUuid ?? "(none yet \u2014 the next `scan` will provision one)"}`);
1318
+ console.log(`Endpoint: ${config.endpoint}`);
1319
+ console.log(`Timeout: ${config.timeoutMs}ms`);
1320
+ console.log(`Environment: ${config.environment}`);
1235
1321
  if (config.siteUuid !== null) {
1236
1322
  console.log(`Claim URL: ${buildClaimUrl(config.endpoint, config.siteUuid)}`);
1237
1323
  }
1238
1324
  return 0;
1239
1325
  }
1240
1326
  async function runMarkBuild(args) {
1241
- const result = await markProductionBuild(process.cwd(), getStringFlag(args.flags, "dir"));
1242
- if (result.dir === null) {
1327
+ const cwd = process.cwd();
1328
+ let checksum = null;
1329
+ try {
1330
+ const manifest = await scanLockfile(cwd);
1331
+ for (const warning of manifest.warnings ?? []) {
1332
+ console.warn(`mark-build: ${warning}`);
1333
+ }
1334
+ const { payload } = buildWirePayload(manifest);
1335
+ checksum = computeManifestChecksum(payload.packages);
1336
+ } catch (err) {
1337
+ console.warn(
1338
+ `mark-build: could not compute the build fingerprint (${err.message}). Stamping the production flag only.`
1339
+ );
1340
+ }
1341
+ const dir = resolveBuildDir(cwd, getStringFlag(args.flags, "dir"));
1342
+ if (dir === null) {
1243
1343
  console.warn(
1244
- "patchstack: no build output found (looked for dist/, build/, out/, .output/public). Pass --dir <path> if your build outputs elsewhere. Skipping production flag."
1344
+ "mark-build: no build output directory found (looked for dist/, build/, out/, .output/public). Pass --dir <path> if it is elsewhere. Nothing to mark."
1245
1345
  );
1246
1346
  return 0;
1247
1347
  }
1248
- if (result.patched.length === 0 && result.skipped.length === 0) {
1249
- console.warn(`patchstack: no HTML files found under ${result.dir}; skipping production flag.`);
1348
+ const files = findHtmlFiles(dir);
1349
+ if (files.length === 0) {
1350
+ console.warn(`mark-build: no HTML files found under ${dir}. Nothing to mark.`);
1250
1351
  return 0;
1251
1352
  }
1252
- const already = result.skipped.length > 0 ? ` (${result.skipped.length} already marked)` : "";
1353
+ const snippet = buildInjectionSnippet(checksum);
1354
+ let marked = 0;
1355
+ for (const file of files) {
1356
+ const before = readFileSync(file, "utf8");
1357
+ const after = injectMarker(before, snippet);
1358
+ if (after !== before) {
1359
+ writeFileSync(file, after);
1360
+ marked += 1;
1361
+ }
1362
+ }
1253
1363
  console.log(
1254
- `patchstack: marked ${result.patched.length} HTML file(s) as a production build in ${result.dir}${already}.`
1364
+ `mark-build: marked ${marked} HTML file(s) in ${dir}${checksum !== null ? ` (build ${checksum})` : ""}.`
1255
1365
  );
1256
1366
  return 0;
1257
1367
  }