@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 +7 -1
- package/dist/cli.js +272 -162
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +170 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +163 -71
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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/
|
|
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
|
|
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" +
|
|
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
|
|
107
|
-
import
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
255
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
214
256
|
async function parsePnpmLockfile(lockfilePath) {
|
|
215
257
|
let raw;
|
|
216
258
|
try {
|
|
217
|
-
raw = await
|
|
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
|
|
466
|
-
import
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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:
|
|
698
|
-
filename:
|
|
699
|
-
strategy:
|
|
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:
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
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/
|
|
836
|
-
import {
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
912
|
-
|
|
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
|
|
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
|
|
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 [
|
|
1091
|
-
|
|
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
|
|
1103
|
-
out
|
|
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:
|
|
1233
|
-
console.log(`Endpoint:
|
|
1234
|
-
console.log(`Timeout:
|
|
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
|
|
1242
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
1249
|
-
|
|
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
|
|
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
|
-
`
|
|
1364
|
+
`mark-build: marked ${marked} HTML file(s) in ${dir}${checksum !== null ? ` (build ${checksum})` : ""}.`
|
|
1255
1365
|
);
|
|
1256
1366
|
return 0;
|
|
1257
1367
|
}
|