@patchstack/connect 0.1.0
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/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/cli.js +561 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +470 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +423 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patchstack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @patchstack/connect
|
|
2
|
+
|
|
3
|
+
Connect a JavaScript / Node.js application to [Patchstack](https://patchstack.com) for continuous vulnerability monitoring. Scans your `package-lock.json` and reports installed packages so Patchstack can match them against its vulnerability database and notify you when something needs patching.
|
|
4
|
+
|
|
5
|
+
## Install prompt (for AI coding tools)
|
|
6
|
+
|
|
7
|
+
Copy and paste this prompt into any AI coding assistant (Cursor, v0, Bolt, Lovable, Claude Code, etc.):
|
|
8
|
+
|
|
9
|
+
> Add `@patchstack/connect` to this project as a dev dependency. Run `npx @patchstack/connect init <SITE_UUID>` once to save the site UUID, then add `"prebuild": "patchstack-connect scan"` to `package.json` `scripts` so dependencies are reported to Patchstack on every build. Replace `<SITE_UUID>` with the UUID from the Patchstack dashboard.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install --save-dev @patchstack/connect
|
|
15
|
+
npx @patchstack/connect init <your-site-uuid>
|
|
16
|
+
npx @patchstack/connect scan
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Get your site UUID from the Patchstack dashboard: create a new site with type **Application**, then copy the UUID shown on the site's settings page.
|
|
20
|
+
|
|
21
|
+
## CLI
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
patchstack-connect init <site-uuid> Save the site UUID to .patchstackrc.json
|
|
25
|
+
patchstack-connect scan [options] Scan the lockfile and POST to Patchstack
|
|
26
|
+
patchstack-connect status [options] Show current configuration
|
|
27
|
+
patchstack-connect help Print help
|
|
28
|
+
|
|
29
|
+
Options (for scan and status):
|
|
30
|
+
--site-uuid <uuid> Override the configured site UUID
|
|
31
|
+
--endpoint <url> Override the API endpoint
|
|
32
|
+
--dry-run (scan only) Print the payload without posting
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
Precedence (highest wins):
|
|
38
|
+
|
|
39
|
+
1. CLI flag (`--site-uuid`, `--endpoint`)
|
|
40
|
+
2. Environment variable
|
|
41
|
+
3. `.patchstackrc.json` in the current directory
|
|
42
|
+
|
|
43
|
+
Environment variables:
|
|
44
|
+
|
|
45
|
+
- `PATCHSTACK_SITE_UUID` — the site UUID from your Patchstack dashboard
|
|
46
|
+
- `PATCHSTACK_ENDPOINT` — override the API endpoint (default `https://app.patchstack.com/monitor/pulse/manifest`)
|
|
47
|
+
- `PATCHSTACK_TIMEOUT_MS` — request timeout in milliseconds (default `30000`)
|
|
48
|
+
|
|
49
|
+
`.patchstackrc.json` example:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"siteUuid": "550e8400-e29b-41d4-a716-446655440000"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The site UUID is the only credential. Possession of it grants the right to submit manifests for that site, so treat it like an API token: keep it out of public repos, and prefer the environment variable in CI.
|
|
58
|
+
|
|
59
|
+
## Programmatic API
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { scanAndReport } from '@patchstack/connect';
|
|
63
|
+
|
|
64
|
+
const result = await scanAndReport();
|
|
65
|
+
console.log(result.response.stored ? 'Reported' : 'Unchanged');
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postManifest`, `resolveConfig`.
|
|
69
|
+
|
|
70
|
+
## What gets sent
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"ecosystem": "npm",
|
|
75
|
+
"packages": [
|
|
76
|
+
{ "name": "axios", "version": "1.6.0" },
|
|
77
|
+
{ "name": "lodash", "version": "4.17.15" },
|
|
78
|
+
{ "name": "lodash", "version": "4.17.21" }
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That's the entire payload. No source code, no environment variables, no file paths — just the package names and versions from your lockfile. Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed.
|
|
84
|
+
|
|
85
|
+
## Supported lockfiles
|
|
86
|
+
|
|
87
|
+
- ✅ `package-lock.json` (npm v6 / v2 / v3)
|
|
88
|
+
- ❌ `yarn.lock` — coming soon
|
|
89
|
+
- ❌ `pnpm-lock.yaml` — coming soon
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm install
|
|
95
|
+
npm run typecheck
|
|
96
|
+
npm test
|
|
97
|
+
npm run build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/parsers/index.ts
|
|
4
|
+
import { access } from "fs/promises";
|
|
5
|
+
import path2 from "path";
|
|
6
|
+
|
|
7
|
+
// src/types.ts
|
|
8
|
+
var PatchstackError = class extends Error {
|
|
9
|
+
constructor(message, code, cause) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.cause = cause;
|
|
13
|
+
this.name = "PatchstackError";
|
|
14
|
+
}
|
|
15
|
+
code;
|
|
16
|
+
cause;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/parsers/npm.ts
|
|
20
|
+
import { readFile } from "fs/promises";
|
|
21
|
+
import path from "path";
|
|
22
|
+
async function parseNpmLockfile(lockfilePath) {
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = await readFile(lockfilePath, "utf8");
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
|
|
28
|
+
}
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(raw);
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
throw new PatchstackError(`Lockfile at ${lockfilePath} is not valid JSON`, "LOCKFILE_PARSE_ERROR", cause);
|
|
34
|
+
}
|
|
35
|
+
if (parsed.packages) {
|
|
36
|
+
return extractFromV2(parsed.packages);
|
|
37
|
+
}
|
|
38
|
+
if (parsed.dependencies) {
|
|
39
|
+
return extractFromV1(parsed.dependencies);
|
|
40
|
+
}
|
|
41
|
+
throw new PatchstackError(
|
|
42
|
+
`Lockfile at ${lockfilePath} has no "packages" or "dependencies" key`,
|
|
43
|
+
"LOCKFILE_PARSE_ERROR"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function extractFromV2(packages) {
|
|
47
|
+
const entries = [];
|
|
48
|
+
for (const [pkgPath, pkg] of Object.entries(packages)) {
|
|
49
|
+
if (pkgPath === "") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (pkg.link === true) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const name = pkg.name ?? extractNameFromPath(pkgPath);
|
|
59
|
+
if (name === null) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
entries.push({
|
|
63
|
+
name,
|
|
64
|
+
version: pkg.version,
|
|
65
|
+
path: pkgPath,
|
|
66
|
+
direct: isDirectV2(pkgPath)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
function extractFromV1(deps, acc = [], depth = 0) {
|
|
72
|
+
for (const [name, dep] of Object.entries(deps)) {
|
|
73
|
+
if (typeof dep.version === "string" && dep.version.length > 0) {
|
|
74
|
+
acc.push({ name, version: dep.version, direct: depth === 0 });
|
|
75
|
+
}
|
|
76
|
+
if (dep.dependencies) {
|
|
77
|
+
extractFromV1(dep.dependencies, acc, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return acc;
|
|
81
|
+
}
|
|
82
|
+
function extractNameFromPath(pkgPath) {
|
|
83
|
+
const segments = pkgPath.split("node_modules" + path.sep === pkgPath ? path.sep : "/");
|
|
84
|
+
const parts = pkgPath.split("/");
|
|
85
|
+
const nmIndex = parts.lastIndexOf("node_modules");
|
|
86
|
+
if (nmIndex === -1 || nmIndex >= parts.length - 1) {
|
|
87
|
+
return segments[segments.length - 1] ?? null;
|
|
88
|
+
}
|
|
89
|
+
const tail = parts.slice(nmIndex + 1);
|
|
90
|
+
if (tail.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const first = tail[0];
|
|
94
|
+
if (first !== void 0 && first.startsWith("@") && tail.length >= 2) {
|
|
95
|
+
return `${first}/${tail[1]}`;
|
|
96
|
+
}
|
|
97
|
+
return first ?? null;
|
|
98
|
+
}
|
|
99
|
+
function isDirectV2(pkgPath) {
|
|
100
|
+
const parts = pkgPath.split("/");
|
|
101
|
+
const nmCount = parts.filter((p) => p === "node_modules").length;
|
|
102
|
+
return nmCount === 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/parsers/index.ts
|
|
106
|
+
async function detectLockfile(cwd) {
|
|
107
|
+
const npmLock = path2.join(cwd, "package-lock.json");
|
|
108
|
+
if (await exists(npmLock)) {
|
|
109
|
+
return { ecosystem: "npm", filePath: npmLock, filename: "package-lock.json" };
|
|
110
|
+
}
|
|
111
|
+
const yarnLock = path2.join(cwd, "yarn.lock");
|
|
112
|
+
if (await exists(yarnLock)) {
|
|
113
|
+
throw new PatchstackError(
|
|
114
|
+
"yarn.lock detected but not yet supported. Run `npm install` to generate a package-lock.json, or open an issue at github.com/patchstack/connect.",
|
|
115
|
+
"LOCKFILE_UNSUPPORTED"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const pnpmLock = path2.join(cwd, "pnpm-lock.yaml");
|
|
119
|
+
if (await exists(pnpmLock)) {
|
|
120
|
+
throw new PatchstackError(
|
|
121
|
+
"pnpm-lock.yaml detected but not yet supported. Open an issue at github.com/patchstack/connect to request support.",
|
|
122
|
+
"LOCKFILE_UNSUPPORTED"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
throw new PatchstackError(
|
|
126
|
+
`No lockfile found in ${cwd}. Expected one of: package-lock.json, yarn.lock, pnpm-lock.yaml.`,
|
|
127
|
+
"LOCKFILE_NOT_FOUND"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
async function scanLockfile(cwd) {
|
|
131
|
+
const detected = await detectLockfile(cwd);
|
|
132
|
+
const packages = await parseNpmLockfile(detected.filePath);
|
|
133
|
+
return { ecosystem: detected.ecosystem, packages };
|
|
134
|
+
}
|
|
135
|
+
async function exists(filePath) {
|
|
136
|
+
try {
|
|
137
|
+
await access(filePath);
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/normalize.ts
|
|
145
|
+
function buildWirePayload(manifest) {
|
|
146
|
+
const seen = /* @__PURE__ */ new Map();
|
|
147
|
+
const wirePackages = [];
|
|
148
|
+
for (const entry of manifest.packages) {
|
|
149
|
+
const versions = seen.get(entry.name);
|
|
150
|
+
if (versions) {
|
|
151
|
+
if (versions.has(entry.version)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
versions.add(entry.version);
|
|
155
|
+
} else {
|
|
156
|
+
seen.set(entry.name, /* @__PURE__ */ new Set([entry.version]));
|
|
157
|
+
}
|
|
158
|
+
wirePackages.push({ name: entry.name, version: entry.version });
|
|
159
|
+
}
|
|
160
|
+
wirePackages.sort((a, b) => {
|
|
161
|
+
if (a.name === b.name) {
|
|
162
|
+
return compareVersions(a.version, b.version);
|
|
163
|
+
}
|
|
164
|
+
return a.name < b.name ? -1 : 1;
|
|
165
|
+
});
|
|
166
|
+
const duplicateNames = [];
|
|
167
|
+
for (const [name, versions] of seen) {
|
|
168
|
+
if (versions.size > 1) {
|
|
169
|
+
duplicateNames.push(name);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
payload: { ecosystem: manifest.ecosystem, packages: wirePackages },
|
|
174
|
+
stats: {
|
|
175
|
+
uniqueNames: seen.size,
|
|
176
|
+
duplicateNames,
|
|
177
|
+
totalEntries: manifest.packages.length
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function compareVersions(a, b) {
|
|
182
|
+
if (a === b) {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
const [aBase, aPre] = splitPrerelease(a);
|
|
186
|
+
const [bBase, bPre] = splitPrerelease(b);
|
|
187
|
+
const baseCmp = compareSegments(aBase.split("."), bBase.split("."));
|
|
188
|
+
if (baseCmp !== 0) {
|
|
189
|
+
return baseCmp;
|
|
190
|
+
}
|
|
191
|
+
if (aPre === null && bPre === null) {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
if (aPre === null) {
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
if (bPre === null) {
|
|
198
|
+
return -1;
|
|
199
|
+
}
|
|
200
|
+
return compareSegments(aPre.split("."), bPre.split("."));
|
|
201
|
+
}
|
|
202
|
+
function splitPrerelease(version) {
|
|
203
|
+
const cleaned = version.replace(/^[v=]+/, "").split("+")[0];
|
|
204
|
+
const dashIndex = cleaned.indexOf("-");
|
|
205
|
+
if (dashIndex === -1) {
|
|
206
|
+
return [cleaned, null];
|
|
207
|
+
}
|
|
208
|
+
return [cleaned.slice(0, dashIndex), cleaned.slice(dashIndex + 1)];
|
|
209
|
+
}
|
|
210
|
+
function compareSegments(a, b) {
|
|
211
|
+
const max = Math.max(a.length, b.length);
|
|
212
|
+
for (let i = 0; i < max; i++) {
|
|
213
|
+
const aPart = a[i];
|
|
214
|
+
const bPart = b[i];
|
|
215
|
+
if (aPart === void 0) {
|
|
216
|
+
return -1;
|
|
217
|
+
}
|
|
218
|
+
if (bPart === void 0) {
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
const aNum = /^\d+$/.test(aPart);
|
|
222
|
+
const bNum = /^\d+$/.test(bPart);
|
|
223
|
+
if (aNum && bNum) {
|
|
224
|
+
const diff = Number(aPart) - Number(bPart);
|
|
225
|
+
if (diff !== 0) {
|
|
226
|
+
return diff < 0 ? -1 : 1;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (aNum) {
|
|
231
|
+
return -1;
|
|
232
|
+
}
|
|
233
|
+
if (bNum) {
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
if (aPart < bPart) {
|
|
237
|
+
return -1;
|
|
238
|
+
}
|
|
239
|
+
if (aPart > bPart) {
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/client.ts
|
|
247
|
+
var DEFAULT_ENDPOINT = "https://app.patchstack.com/monitor/pulse/manifest";
|
|
248
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
249
|
+
function buildEndpointUrl(base, siteUuid) {
|
|
250
|
+
const trimmed = base.replace(/\/$/, "");
|
|
251
|
+
return `${trimmed}/${encodeURIComponent(siteUuid)}`;
|
|
252
|
+
}
|
|
253
|
+
async function postManifest(config, payload) {
|
|
254
|
+
const url = buildEndpointUrl(config.endpoint, config.siteUuid);
|
|
255
|
+
const timeoutMs = config.timeoutMs;
|
|
256
|
+
let response;
|
|
257
|
+
try {
|
|
258
|
+
response = await fetch(url, {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: {
|
|
261
|
+
"Content-Type": "application/json",
|
|
262
|
+
Accept: "application/json",
|
|
263
|
+
"User-Agent": "@patchstack/connect"
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify(payload),
|
|
266
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
267
|
+
});
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
if (isTimeoutError(cause)) {
|
|
270
|
+
throw new PatchstackError(
|
|
271
|
+
`Patchstack request to ${url} timed out after ${timeoutMs}ms. Override with PATCHSTACK_TIMEOUT_MS.`,
|
|
272
|
+
"NETWORK_TIMEOUT",
|
|
273
|
+
cause
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
throw new PatchstackError(
|
|
277
|
+
`Could not reach Patchstack at ${url}. Check your network connection.`,
|
|
278
|
+
"NETWORK_ERROR",
|
|
279
|
+
cause
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const text = await response.text();
|
|
283
|
+
let body = null;
|
|
284
|
+
try {
|
|
285
|
+
body = text.length > 0 ? JSON.parse(text) : null;
|
|
286
|
+
} catch {
|
|
287
|
+
body = null;
|
|
288
|
+
}
|
|
289
|
+
if (response.status === 404) {
|
|
290
|
+
throw new PatchstackError(
|
|
291
|
+
body?.error ?? "Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.",
|
|
292
|
+
"SITE_NOT_FOUND"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (response.status === 422) {
|
|
296
|
+
throw new PatchstackError(
|
|
297
|
+
body?.message ?? "Patchstack rejected the manifest payload (validation failed).",
|
|
298
|
+
"VALIDATION_ERROR"
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (response.status < 200 || response.status >= 300) {
|
|
302
|
+
throw new PatchstackError(
|
|
303
|
+
`Patchstack returned ${response.status}: ${text.slice(0, 200)}`,
|
|
304
|
+
"SERVER_ERROR"
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (body === null) {
|
|
308
|
+
throw new PatchstackError("Patchstack returned an empty response.", "SERVER_ERROR");
|
|
309
|
+
}
|
|
310
|
+
return body;
|
|
311
|
+
}
|
|
312
|
+
function isTimeoutError(cause) {
|
|
313
|
+
if (cause instanceof Error) {
|
|
314
|
+
return cause.name === "TimeoutError" || cause.name === "AbortError";
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/config.ts
|
|
320
|
+
import { readFile as readFile2, writeFile } from "fs/promises";
|
|
321
|
+
import path3 from "path";
|
|
322
|
+
var CONFIG_FILENAME = ".patchstackrc.json";
|
|
323
|
+
async function resolveConfig(options) {
|
|
324
|
+
const fromFile = await readConfigFile(options.cwd);
|
|
325
|
+
const fromEnv = readEnv();
|
|
326
|
+
const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
|
|
327
|
+
const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
|
|
328
|
+
const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
329
|
+
if (siteUuid === null || siteUuid.length === 0) {
|
|
330
|
+
throw new PatchstackError(
|
|
331
|
+
"No site UUID configured. Run `patchstack-connect init <site-uuid>` or set PATCHSTACK_SITE_UUID.",
|
|
332
|
+
"CONFIG_MISSING"
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (!isUuid(siteUuid)) {
|
|
336
|
+
throw new PatchstackError(
|
|
337
|
+
`Site UUID "${siteUuid}" does not look like a valid UUID.`,
|
|
338
|
+
"CONFIG_INVALID"
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return { siteUuid, endpoint, timeoutMs };
|
|
342
|
+
}
|
|
343
|
+
async function writeConfigFile(cwd, config) {
|
|
344
|
+
const target = path3.join(cwd, CONFIG_FILENAME);
|
|
345
|
+
const content = JSON.stringify(config, null, 2) + "\n";
|
|
346
|
+
await writeFile(target, content, "utf8");
|
|
347
|
+
return target;
|
|
348
|
+
}
|
|
349
|
+
async function readConfigFile(cwd) {
|
|
350
|
+
const target = path3.join(cwd, CONFIG_FILENAME);
|
|
351
|
+
let raw;
|
|
352
|
+
try {
|
|
353
|
+
raw = await readFile2(target, "utf8");
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (err.code === "ENOENT") {
|
|
356
|
+
return {};
|
|
357
|
+
}
|
|
358
|
+
throw new PatchstackError(
|
|
359
|
+
`Could not read ${target}: ${err.message}`,
|
|
360
|
+
"CONFIG_INVALID",
|
|
361
|
+
err
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
return JSON.parse(raw);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
throw new PatchstackError(
|
|
368
|
+
`Config file ${target} contains invalid JSON.`,
|
|
369
|
+
"CONFIG_INVALID",
|
|
370
|
+
err
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function readEnv() {
|
|
375
|
+
const timeoutRaw = process.env.PATCHSTACK_TIMEOUT_MS;
|
|
376
|
+
let timeoutMs;
|
|
377
|
+
if (timeoutRaw !== void 0 && timeoutRaw.length > 0) {
|
|
378
|
+
const parsed = Number(timeoutRaw);
|
|
379
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
380
|
+
throw new PatchstackError(
|
|
381
|
+
`PATCHSTACK_TIMEOUT_MS must be a positive number; got "${timeoutRaw}".`,
|
|
382
|
+
"CONFIG_INVALID"
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
timeoutMs = parsed;
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
|
|
389
|
+
endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
|
|
390
|
+
timeoutMs
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function isUuid(value) {
|
|
394
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/cli.ts
|
|
398
|
+
var HELP = `@patchstack/connect \u2014 scan your lockfile and report packages to Patchstack.
|
|
399
|
+
|
|
400
|
+
Usage:
|
|
401
|
+
patchstack-connect init <site-uuid> Save the site UUID to .patchstackrc.json
|
|
402
|
+
patchstack-connect scan [options] Scan lockfile and POST to Patchstack
|
|
403
|
+
patchstack-connect status [options] Show current configuration
|
|
404
|
+
patchstack-connect help Print this message
|
|
405
|
+
|
|
406
|
+
Options (for scan and status):
|
|
407
|
+
--site-uuid <uuid> Override the configured site UUID
|
|
408
|
+
--endpoint <url> Override the API endpoint
|
|
409
|
+
--dry-run (scan only) Show the payload without posting
|
|
410
|
+
|
|
411
|
+
Environment:
|
|
412
|
+
PATCHSTACK_SITE_UUID Site UUID
|
|
413
|
+
PATCHSTACK_ENDPOINT API endpoint (default: https://app.patchstack.com/monitor/pulse/manifest)
|
|
414
|
+
PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000)
|
|
415
|
+
|
|
416
|
+
Precedence: CLI flag > environment variable > .patchstackrc.json.
|
|
417
|
+
|
|
418
|
+
Examples:
|
|
419
|
+
npx @patchstack/connect init 550e8400-e29b-41d4-a716-446655440000
|
|
420
|
+
npx @patchstack/connect scan
|
|
421
|
+
npx @patchstack/connect scan --dry-run
|
|
422
|
+
npx @patchstack/connect scan --site-uuid 550e8400-...-446655440000
|
|
423
|
+
`;
|
|
424
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set(["site-uuid", "endpoint"]);
|
|
425
|
+
function parseArgs(argv) {
|
|
426
|
+
const args = argv.slice(2);
|
|
427
|
+
const positional = [];
|
|
428
|
+
const flags = /* @__PURE__ */ new Map();
|
|
429
|
+
for (let i = 0; i < args.length; i++) {
|
|
430
|
+
const arg = args[i];
|
|
431
|
+
if (!arg.startsWith("--")) {
|
|
432
|
+
positional.push(arg);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const stripped = arg.slice(2);
|
|
436
|
+
const eqIdx = stripped.indexOf("=");
|
|
437
|
+
if (eqIdx !== -1) {
|
|
438
|
+
flags.set(stripped.slice(0, eqIdx), stripped.slice(eqIdx + 1));
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const next = args[i + 1];
|
|
442
|
+
if (VALUE_FLAGS.has(stripped) && next !== void 0 && !next.startsWith("--")) {
|
|
443
|
+
flags.set(stripped, next);
|
|
444
|
+
i++;
|
|
445
|
+
} else {
|
|
446
|
+
flags.set(stripped, true);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
command: positional.shift() ?? null,
|
|
451
|
+
positional,
|
|
452
|
+
flags
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function getStringFlag(flags, name) {
|
|
456
|
+
const value = flags.get(name);
|
|
457
|
+
return typeof value === "string" ? value : void 0;
|
|
458
|
+
}
|
|
459
|
+
async function runInit(args) {
|
|
460
|
+
const uuid = args.positional[0];
|
|
461
|
+
if (!uuid) {
|
|
462
|
+
console.error("Error: site UUID is required.\n");
|
|
463
|
+
console.error("Usage: patchstack-connect init <site-uuid>");
|
|
464
|
+
return 1;
|
|
465
|
+
}
|
|
466
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) {
|
|
467
|
+
console.error(`Error: "${uuid}" does not look like a valid UUID.`);
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
const target = await writeConfigFile(process.cwd(), { siteUuid: uuid });
|
|
471
|
+
console.log(`Wrote ${target}`);
|
|
472
|
+
console.log("");
|
|
473
|
+
console.log("Next: run `npx @patchstack/connect scan` to send your first manifest.");
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
async function runScan(args) {
|
|
477
|
+
const dryRun = args.flags.get("dry-run") === true;
|
|
478
|
+
const config = await resolveConfig({
|
|
479
|
+
cwd: process.cwd(),
|
|
480
|
+
cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
|
|
481
|
+
cliEndpoint: getStringFlag(args.flags, "endpoint")
|
|
482
|
+
});
|
|
483
|
+
const manifest = await scanLockfile(process.cwd());
|
|
484
|
+
const { payload, stats } = buildWirePayload(manifest);
|
|
485
|
+
console.log(
|
|
486
|
+
`Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names in ${manifest.ecosystem} lockfile.`
|
|
487
|
+
);
|
|
488
|
+
if (stats.duplicateNames.length > 0) {
|
|
489
|
+
console.log(`${stats.duplicateNames.length} package(s) appear at multiple versions:`);
|
|
490
|
+
if (stats.duplicateNames.length <= 10) {
|
|
491
|
+
console.log(` ${stats.duplicateNames.join(", ")}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (dryRun) {
|
|
495
|
+
console.log("");
|
|
496
|
+
console.log("--dry-run: not posting to Patchstack. Payload preview:");
|
|
497
|
+
const preview = JSON.stringify(payload, null, 2).split("\n");
|
|
498
|
+
console.log(preview.slice(0, Math.min(preview.length, 30)).join("\n"));
|
|
499
|
+
if (preview.length > 30) {
|
|
500
|
+
console.log(` ... (${preview.length - 30} more lines)`);
|
|
501
|
+
}
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
const response = await postManifest(config, payload);
|
|
505
|
+
if (response.stored) {
|
|
506
|
+
console.log(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`);
|
|
507
|
+
} else if (response.reason === "duplicate") {
|
|
508
|
+
console.log("Manifest unchanged since last scan \u2014 nothing to store.");
|
|
509
|
+
} else {
|
|
510
|
+
console.log(`Server response: ${response.message ?? JSON.stringify(response)}`);
|
|
511
|
+
}
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
async function runStatus(args) {
|
|
515
|
+
try {
|
|
516
|
+
const config = await resolveConfig({
|
|
517
|
+
cwd: process.cwd(),
|
|
518
|
+
cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
|
|
519
|
+
cliEndpoint: getStringFlag(args.flags, "endpoint")
|
|
520
|
+
});
|
|
521
|
+
console.log(`Site UUID: ${config.siteUuid}`);
|
|
522
|
+
console.log(`Endpoint: ${config.endpoint}`);
|
|
523
|
+
console.log(`Timeout: ${config.timeoutMs}ms`);
|
|
524
|
+
return 0;
|
|
525
|
+
} catch (err) {
|
|
526
|
+
if (err instanceof PatchstackError && err.code === "CONFIG_MISSING") {
|
|
527
|
+
console.log("Not configured. Run `patchstack-connect init <site-uuid>` to get started.");
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async function main() {
|
|
534
|
+
const args = parseArgs(process.argv);
|
|
535
|
+
if (args.flags.has("help") || args.command === "help" || args.command === null) {
|
|
536
|
+
console.log(HELP);
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
switch (args.command) {
|
|
540
|
+
case "init":
|
|
541
|
+
return runInit(args);
|
|
542
|
+
case "scan":
|
|
543
|
+
return runScan(args);
|
|
544
|
+
case "status":
|
|
545
|
+
return runStatus(args);
|
|
546
|
+
default:
|
|
547
|
+
console.error(`Unknown command: ${args.command}
|
|
548
|
+
`);
|
|
549
|
+
console.error(HELP);
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
554
|
+
if (err instanceof PatchstackError) {
|
|
555
|
+
console.error(`Error (${err.code}): ${err.message}`);
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
console.error("Unexpected error:", err);
|
|
559
|
+
process.exit(2);
|
|
560
|
+
});
|
|
561
|
+
//# sourceMappingURL=cli.js.map
|