@patchstack/connect 0.1.0 → 0.2.4

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
@@ -6,23 +6,51 @@ Connect a JavaScript / Node.js application to [Patchstack](https://patchstack.co
6
6
 
7
7
  Copy and paste this prompt into any AI coding assistant (Cursor, v0, Bolt, Lovable, Claude Code, etc.):
8
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.
9
+ > Add `@patchstack/connect` to this project as a dev dependency, then add `"prebuild": "patchstack-connect scan"` to `package.json` `scripts`. The first time the script runs, Patchstack provisions a site automatically from the lockfile and saves the new site UUID to `.patchstackrc.json` — no dashboard signup or manual UUID required. Commit `.patchstackrc.json` so every developer / CI run targets the same site.
10
10
 
11
- ## Quick start
11
+ ## Quick start (zero configuration)
12
12
 
13
13
  ```bash
14
14
  npm install --save-dev @patchstack/connect
15
- npx @patchstack/connect init <your-site-uuid>
16
15
  npx @patchstack/connect scan
17
16
  ```
18
17
 
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.
18
+ That's it. The first `scan`:
19
+
20
+ 1. Reads your `package-lock.json`.
21
+ 2. POSTs the package list to Patchstack with **no** UUID.
22
+ 3. Patchstack provisions a fresh site and returns its UUID.
23
+ 4. The connector writes the UUID to `.patchstackrc.json` so the next `scan` targets the same site.
24
+
25
+ Then wire it into builds:
26
+
27
+ ```jsonc
28
+ // package.json
29
+ {
30
+ "scripts": {
31
+ "prebuild": "patchstack-connect scan"
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## Quick start (existing site)
37
+
38
+ If you already created an "Application" site in the Patchstack dashboard, pre-seed the UUID:
39
+
40
+ ```bash
41
+ npm install --save-dev @patchstack/connect
42
+ npx @patchstack/connect init <your-site-uuid>
43
+ npx @patchstack/connect scan
44
+ ```
20
45
 
21
46
  ## CLI
22
47
 
23
48
  ```
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
49
+ patchstack-connect scan [options] Scan the lockfile and POST to Patchstack.
50
+ If no UUID is configured the server provisions
51
+ one and the connector persists it.
52
+ patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json with
53
+ an existing site UUID
26
54
  patchstack-connect status [options] Show current configuration
27
55
  patchstack-connect help Print help
28
56
 
@@ -43,7 +71,7 @@ Precedence (highest wins):
43
71
  Environment variables:
44
72
 
45
73
  - `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`)
74
+ - `PATCHSTACK_ENDPOINT` — override the API endpoint (default `http://api.patchstack.com/monitor/pulse/manifest`)
47
75
  - `PATCHSTACK_TIMEOUT_MS` — request timeout in milliseconds (default `30000`)
48
76
 
49
77
  `.patchstackrc.json` example:
@@ -84,10 +112,14 @@ That's the entire payload. No source code, no environment variables, no file pat
84
112
 
85
113
  ## Supported lockfiles
86
114
 
87
- - ✅ `package-lock.json` (npm v6 / v2 / v3)
115
+ - ✅ `package-lock.json` (npm v6 / v2 / v3) — parsed directly
116
+ - ✅ `bun.lockb` (binary) — package list resolved by walking `node_modules/`
117
+ - ✅ `bun.lock` (text) — same fallback; direct parsing coming
88
118
  - ❌ `yarn.lock` — coming soon
89
119
  - ❌ `pnpm-lock.yaml` — coming soon
90
120
 
121
+ 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.
122
+
91
123
  ## Development
92
124
 
93
125
  ```bash
@@ -97,6 +129,34 @@ npm test
97
129
  npm run build
98
130
  ```
99
131
 
132
+ ## Release process
133
+
134
+ Pull requests run typecheck, tests, build, package verification, and a production dependency audit in GitHub Actions.
135
+
136
+ Publishing runs when a GitHub Release is published. The release tag must match the package version in `package.json` with a leading `v`. For example, `package.json` version `0.2.0` must be released with tag `v0.2.0`; otherwise the workflow fails before publishing.
137
+
138
+ To publish a release:
139
+
140
+ 1. Bump the package version, for example `npm version 0.2.0 --no-git-tag-version`.
141
+ 2. Commit `package.json` and `package-lock.json`.
142
+ 3. Merge the version bump to `main`.
143
+ 4. Create and publish a GitHub Release tagged `v0.2.0`.
144
+ 5. The `Publish` workflow verifies the package, then runs `npm publish --provenance --access public`.
145
+
146
+ Before the first release, configure npm trusted publishing for this package:
147
+
148
+ 1. Merge `.github/workflows/publish.yml` to `main`.
149
+ 2. Open the `@patchstack/connect` package settings on npmjs.com.
150
+ 3. In **Trusted publishing**, choose **GitHub Actions**.
151
+ 4. Configure:
152
+ - Organization/user: `patchstack`
153
+ - Repository: `connect`
154
+ - Workflow filename: `publish.yml`
155
+ - Environment name: `npm`
156
+ 5. In GitHub repository settings, create an `npm` environment. Optional but recommended: require reviewer approval for that environment.
157
+
158
+ Do not add an npm publish token to GitHub secrets for this workflow. Trusted publishing uses GitHub OIDC short-lived credentials. After the first trusted publish succeeds, npm recommends setting package publishing access to require two-factor authentication and disallow tokens.
159
+
100
160
  ## License
101
161
 
102
162
  MIT
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/parsers/index.ts
4
4
  import { access } from "fs/promises";
5
- import path2 from "path";
5
+ import path3 from "path";
6
6
 
7
7
  // src/types.ts
8
8
  var PatchstackError = class extends Error {
@@ -102,20 +102,150 @@ function isDirectV2(pkgPath) {
102
102
  return nmCount === 1;
103
103
  }
104
104
 
105
+ // src/parsers/node_modules.ts
106
+ import { lstat, readFile as readFile2, readdir, stat } from "fs/promises";
107
+ import path2 from "path";
108
+ async function walkNodeModules(cwd) {
109
+ const root = path2.join(cwd, "node_modules");
110
+ try {
111
+ const info = await stat(root);
112
+ if (!info.isDirectory()) {
113
+ throw new PatchstackError(
114
+ `${root} exists but is not a directory.`,
115
+ "LOCKFILE_NOT_FOUND"
116
+ );
117
+ }
118
+ } catch (cause) {
119
+ if (cause instanceof PatchstackError) {
120
+ throw cause;
121
+ }
122
+ throw new PatchstackError(
123
+ `node_modules/ not found at ${cwd}. Install dependencies first (e.g. \`bun install\` or \`npm install\`).`,
124
+ "LOCKFILE_NOT_FOUND",
125
+ cause
126
+ );
127
+ }
128
+ const entries = [];
129
+ await walk(root, entries, 0);
130
+ return entries;
131
+ }
132
+ async function walk(dir, acc, depth) {
133
+ let names;
134
+ try {
135
+ names = await readdir(dir);
136
+ } catch {
137
+ return;
138
+ }
139
+ for (const name of names) {
140
+ if (name.startsWith(".")) {
141
+ continue;
142
+ }
143
+ const fullPath = path2.join(dir, name);
144
+ if (!await isPlainDirectory(fullPath)) {
145
+ continue;
146
+ }
147
+ if (name.startsWith("@")) {
148
+ let subNames;
149
+ try {
150
+ subNames = await readdir(fullPath);
151
+ } catch {
152
+ continue;
153
+ }
154
+ for (const sub of subNames) {
155
+ if (sub.startsWith(".")) {
156
+ continue;
157
+ }
158
+ const scopedDir = path2.join(fullPath, sub);
159
+ if (!await isPlainDirectory(scopedDir)) {
160
+ continue;
161
+ }
162
+ await readPackage(scopedDir, depth, acc);
163
+ await walkNested(scopedDir, acc, depth);
164
+ }
165
+ continue;
166
+ }
167
+ await readPackage(fullPath, depth, acc);
168
+ await walkNested(fullPath, acc, depth);
169
+ }
170
+ }
171
+ async function readPackage(pkgDir, depth, acc) {
172
+ let raw;
173
+ try {
174
+ raw = await readFile2(path2.join(pkgDir, "package.json"), "utf8");
175
+ } catch {
176
+ return;
177
+ }
178
+ let parsed;
179
+ try {
180
+ parsed = JSON.parse(raw);
181
+ } catch {
182
+ return;
183
+ }
184
+ if (typeof parsed.name !== "string" || parsed.name.length === 0) {
185
+ return;
186
+ }
187
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
188
+ return;
189
+ }
190
+ acc.push({
191
+ name: parsed.name,
192
+ version: parsed.version,
193
+ direct: depth === 0
194
+ });
195
+ }
196
+ async function walkNested(pkgDir, acc, depth) {
197
+ const nested = path2.join(pkgDir, "node_modules");
198
+ if (!await isPlainDirectory(nested)) {
199
+ return;
200
+ }
201
+ await walk(nested, acc, depth + 1);
202
+ }
203
+ async function isPlainDirectory(dir) {
204
+ try {
205
+ const info = await lstat(dir);
206
+ return info.isDirectory() && !info.isSymbolicLink();
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
105
212
  // src/parsers/index.ts
106
213
  async function detectLockfile(cwd) {
107
- const npmLock = path2.join(cwd, "package-lock.json");
214
+ const npmLock = path3.join(cwd, "package-lock.json");
108
215
  if (await exists(npmLock)) {
109
- return { ecosystem: "npm", filePath: npmLock, filename: "package-lock.json" };
110
- }
111
- const yarnLock = path2.join(cwd, "yarn.lock");
216
+ return {
217
+ ecosystem: "npm",
218
+ filePath: npmLock,
219
+ filename: "package-lock.json",
220
+ strategy: "npm-lockfile"
221
+ };
222
+ }
223
+ const bunLock = path3.join(cwd, "bun.lock");
224
+ if (await exists(bunLock)) {
225
+ return {
226
+ ecosystem: "npm",
227
+ filePath: bunLock,
228
+ filename: "bun.lock",
229
+ strategy: "node-modules-walk"
230
+ };
231
+ }
232
+ const bunLockB = path3.join(cwd, "bun.lockb");
233
+ if (await exists(bunLockB)) {
234
+ return {
235
+ ecosystem: "npm",
236
+ filePath: bunLockB,
237
+ filename: "bun.lockb",
238
+ strategy: "node-modules-walk"
239
+ };
240
+ }
241
+ const yarnLock = path3.join(cwd, "yarn.lock");
112
242
  if (await exists(yarnLock)) {
113
243
  throw new PatchstackError(
114
244
  "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
245
  "LOCKFILE_UNSUPPORTED"
116
246
  );
117
247
  }
118
- const pnpmLock = path2.join(cwd, "pnpm-lock.yaml");
248
+ const pnpmLock = path3.join(cwd, "pnpm-lock.yaml");
119
249
  if (await exists(pnpmLock)) {
120
250
  throw new PatchstackError(
121
251
  "pnpm-lock.yaml detected but not yet supported. Open an issue at github.com/patchstack/connect to request support.",
@@ -123,15 +253,23 @@ async function detectLockfile(cwd) {
123
253
  );
124
254
  }
125
255
  throw new PatchstackError(
126
- `No lockfile found in ${cwd}. Expected one of: package-lock.json, yarn.lock, pnpm-lock.yaml.`,
256
+ `No lockfile found in ${cwd}. Expected one of: package-lock.json, bun.lock, bun.lockb, yarn.lock, pnpm-lock.yaml.`,
127
257
  "LOCKFILE_NOT_FOUND"
128
258
  );
129
259
  }
130
260
  async function scanLockfile(cwd) {
131
261
  const detected = await detectLockfile(cwd);
132
- const packages = await parseNpmLockfile(detected.filePath);
262
+ const packages = await runStrategy(detected, cwd);
133
263
  return { ecosystem: detected.ecosystem, packages };
134
264
  }
265
+ async function runStrategy(detected, cwd) {
266
+ switch (detected.strategy) {
267
+ case "npm-lockfile":
268
+ return parseNpmLockfile(detected.filePath);
269
+ case "node-modules-walk":
270
+ return walkNodeModules(cwd);
271
+ }
272
+ }
135
273
  async function exists(filePath) {
136
274
  try {
137
275
  await access(filePath);
@@ -244,11 +382,11 @@ function compareSegments(a, b) {
244
382
  }
245
383
 
246
384
  // src/client.ts
247
- var DEFAULT_ENDPOINT = "https://app.patchstack.com/monitor/pulse/manifest";
385
+ var DEFAULT_ENDPOINT = "http://api.patchstack.com/monitor/pulse/manifest";
248
386
  var DEFAULT_TIMEOUT_MS = 3e4;
249
387
  function buildEndpointUrl(base, siteUuid) {
250
388
  const trimmed = base.replace(/\/$/, "");
251
- return `${trimmed}/${encodeURIComponent(siteUuid)}`;
389
+ return siteUuid !== void 0 && siteUuid !== null && siteUuid.length > 0 ? `${trimmed}/${encodeURIComponent(siteUuid)}` : trimmed;
252
390
  }
253
391
  async function postManifest(config, payload) {
254
392
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
@@ -317,8 +455,8 @@ function isTimeoutError(cause) {
317
455
  }
318
456
 
319
457
  // src/config.ts
320
- import { readFile as readFile2, writeFile } from "fs/promises";
321
- import path3 from "path";
458
+ import { readFile as readFile3, writeFile } from "fs/promises";
459
+ import path4 from "path";
322
460
  var CONFIG_FILENAME = ".patchstackrc.json";
323
461
  async function resolveConfig(options) {
324
462
  const fromFile = await readConfigFile(options.cwd);
@@ -326,31 +464,39 @@ async function resolveConfig(options) {
326
464
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
327
465
  const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
328
466
  const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
329
- if (siteUuid === null || siteUuid.length === 0) {
467
+ if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
330
468
  throw new PatchstackError(
331
- "No site UUID configured. Run `patchstack-connect init <site-uuid>` or set PATCHSTACK_SITE_UUID.",
332
- "CONFIG_MISSING"
469
+ `Site UUID "${siteUuid}" does not look like a valid UUID.`,
470
+ "CONFIG_INVALID"
333
471
  );
334
472
  }
335
- if (!isUuid(siteUuid)) {
473
+ if (options.requireSiteUuid && (siteUuid === null || siteUuid.length === 0)) {
336
474
  throw new PatchstackError(
337
- `Site UUID "${siteUuid}" does not look like a valid UUID.`,
338
- "CONFIG_INVALID"
475
+ "No site UUID configured. Run `patchstack-connect scan` to provision one, or set PATCHSTACK_SITE_UUID.",
476
+ "CONFIG_MISSING"
339
477
  );
340
478
  }
341
- return { siteUuid, endpoint, timeoutMs };
479
+ return {
480
+ siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
481
+ endpoint,
482
+ timeoutMs
483
+ };
342
484
  }
343
485
  async function writeConfigFile(cwd, config) {
344
- const target = path3.join(cwd, CONFIG_FILENAME);
486
+ const target = path4.join(cwd, CONFIG_FILENAME);
345
487
  const content = JSON.stringify(config, null, 2) + "\n";
346
488
  await writeFile(target, content, "utf8");
347
489
  return target;
348
490
  }
491
+ async function persistSiteUuid(cwd, siteUuid) {
492
+ const existing = await readConfigFile(cwd);
493
+ return writeConfigFile(cwd, { ...existing, siteUuid });
494
+ }
349
495
  async function readConfigFile(cwd) {
350
- const target = path3.join(cwd, CONFIG_FILENAME);
496
+ const target = path4.join(cwd, CONFIG_FILENAME);
351
497
  let raw;
352
498
  try {
353
- raw = await readFile2(target, "utf8");
499
+ raw = await readFile3(target, "utf8");
354
500
  } catch (err) {
355
501
  if (err.code === "ENOENT") {
356
502
  return {};
@@ -398,8 +544,11 @@ function isUuid(value) {
398
544
  var HELP = `@patchstack/connect \u2014 scan your lockfile and report packages to Patchstack.
399
545
 
400
546
  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
547
+ patchstack-connect scan [options] Scan lockfile and POST to Patchstack.
548
+ If no UUID is configured, the server
549
+ provisions one and we persist it.
550
+ patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json
551
+ with an existing site UUID
403
552
  patchstack-connect status [options] Show current configuration
404
553
  patchstack-connect help Print this message
405
554
 
@@ -410,15 +559,15 @@ Options (for scan and status):
410
559
 
411
560
  Environment:
412
561
  PATCHSTACK_SITE_UUID Site UUID
413
- PATCHSTACK_ENDPOINT API endpoint (default: https://app.patchstack.com/monitor/pulse/manifest)
562
+ PATCHSTACK_ENDPOINT API endpoint (default: http://api.patchstack.com/monitor/pulse/manifest)
414
563
  PATCHSTACK_TIMEOUT_MS Request timeout in ms (default: 30000)
415
564
 
416
565
  Precedence: CLI flag > environment variable > .patchstackrc.json.
417
566
 
418
567
  Examples:
419
- npx @patchstack/connect init 550e8400-e29b-41d4-a716-446655440000
420
568
  npx @patchstack/connect scan
421
569
  npx @patchstack/connect scan --dry-run
570
+ npx @patchstack/connect init 550e8400-e29b-41d4-a716-446655440000
422
571
  npx @patchstack/connect scan --site-uuid 550e8400-...-446655440000
423
572
  `;
424
573
  var VALUE_FLAGS = /* @__PURE__ */ new Set(["site-uuid", "endpoint"]);
@@ -493,7 +642,12 @@ async function runScan(args) {
493
642
  }
494
643
  if (dryRun) {
495
644
  console.log("");
496
- console.log("--dry-run: not posting to Patchstack. Payload preview:");
645
+ if (config.siteUuid === null) {
646
+ console.log("--dry-run: no site UUID configured. A real run would provision one.");
647
+ } else {
648
+ console.log(`--dry-run: not posting to Patchstack (site UUID ${config.siteUuid}).`);
649
+ }
650
+ console.log("Payload preview:");
497
651
  const preview = JSON.stringify(payload, null, 2).split("\n");
498
652
  console.log(preview.slice(0, Math.min(preview.length, 30)).join("\n"));
499
653
  if (preview.length > 30) {
@@ -501,7 +655,15 @@ async function runScan(args) {
501
655
  }
502
656
  return 0;
503
657
  }
658
+ const provisioning = config.siteUuid === null;
659
+ if (provisioning) {
660
+ console.log("No site UUID configured \u2014 provisioning a new Patchstack site from this manifest\u2026");
661
+ }
504
662
  const response = await postManifest(config, payload);
663
+ if (provisioning && response.uuid !== void 0 && response.uuid.length > 0) {
664
+ const target = await persistSiteUuid(process.cwd(), response.uuid);
665
+ console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`);
666
+ }
505
667
  if (response.stored) {
506
668
  console.log(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`);
507
669
  } else if (response.reason === "duplicate") {
@@ -512,23 +674,15 @@ async function runScan(args) {
512
674
  return 0;
513
675
  }
514
676
  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
- }
677
+ const config = await resolveConfig({
678
+ cwd: process.cwd(),
679
+ cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
680
+ cliEndpoint: getStringFlag(args.flags, "endpoint")
681
+ });
682
+ console.log(`Site UUID: ${config.siteUuid ?? "(none yet \u2014 the next `scan` will provision one)"}`);
683
+ console.log(`Endpoint: ${config.endpoint}`);
684
+ console.log(`Timeout: ${config.timeoutMs}ms`);
685
+ return 0;
532
686
  }
533
687
  async function main() {
534
688
  const args = parseArgs(process.argv);