@humaan/patch-patrol 0.2.2

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 ADDED
@@ -0,0 +1,204 @@
1
+ # Patch Patrol
2
+
3
+ Patch Patrol is an interactive CLI for rolling out advisory-driven package bumps across many local repositories.
4
+
5
+ It scans a repo or folder of repos, shows which projects are affected, then lets you choose exactly which projects should get update branches and pull requests.
6
+
7
+ ## Install
8
+
9
+ Run directly:
10
+
11
+ ```sh
12
+ pnpx @humaan/patch-patrol
13
+ ```
14
+
15
+ `npx @humaan/patch-patrol` works too if you use npm instead of pnpm.
16
+
17
+ Or install globally:
18
+
19
+ ```sh
20
+ npm install --global @humaan/patch-patrol
21
+ patch-patrol
22
+ ```
23
+
24
+ ## Requirements
25
+
26
+ - Node.js 20+
27
+ - GitHub CLI authenticated with `gh auth login`
28
+ - The package manager used by affected repos, such as `npm`, `pnpm`, `yarn`, or `bun`
29
+
30
+ ## Interactive Workflow
31
+
32
+ Start Patch Patrol with no flags:
33
+
34
+ ```sh
35
+ patch-patrol
36
+ ```
37
+
38
+ The CLI guides you through two phases.
39
+
40
+ **1. Scan**
41
+
42
+ - Enter a single repo path or a parent folder containing repos.
43
+ - Choose an advisory rule from the list.
44
+ - Choose which bump levels to include: major, minor, patch, or any combination.
45
+ - Review the affected-project summary.
46
+
47
+ **2. Action**
48
+
49
+ - Select affected projects from a multiselect list.
50
+ - Confirm the selected projects.
51
+ - Patch Patrol creates update branches, refreshes lockfiles, commits the package bumps, pushes to GitHub, and opens pull requests.
52
+
53
+ Use arrow keys to move through selections, spacebar to toggle multiselect items, and enter to continue. Selecting no affected projects exits without changing anything.
54
+
55
+ ## What It Does
56
+
57
+ For each selected project, Patch Patrol:
58
+
59
+ - Checks the worktree is clean before editing.
60
+ - Checks out and fast-forwards the default branch.
61
+ - Creates an advisory-specific branch under `patch-patrol/`.
62
+ - Updates affected packages in `package.json`.
63
+ - Preserves existing `^` or `~` range prefixes.
64
+ - Refreshes the lockfile with the repo's package manager.
65
+ - Commits the change.
66
+ - Pushes the branch and opens a GitHub pull request.
67
+
68
+ If a selected repo does not already have a GitHub remote, Patch Patrol prompts for one before pushing. For Bitbucket Humaan remotes, it suggests the GitHub equivalent automatically:
69
+
70
+ ```txt
71
+ bitbucket.org:humaanco/example.git
72
+ ```
73
+
74
+ becomes:
75
+
76
+ ```txt
77
+ git@github.com:humaan/example.git
78
+ ```
79
+
80
+ ## Included Advisories
81
+
82
+ ### `next-may-2026`
83
+
84
+ Implements the [Next.js May 2026 security release](https://vercel.com/changelog/next-js-may-2026-security-release#affected-versions).
85
+
86
+ | Package | Affected | Upgrade to |
87
+ | --- | --- | --- |
88
+ | `next` 13.x | all versions | `15.5.18` |
89
+ | `next` 14.x | all versions | `15.5.18` |
90
+ | `next` 15.x | `<=15.5.17` | `15.5.18` |
91
+ | `next` 16.x | `<=16.2.5` | `16.2.6` |
92
+ | `react-server-dom-*` 19.0.x | `<=19.0.5` | `19.0.6` |
93
+ | `react-server-dom-*` 19.1.x | `<=19.1.6` | `19.1.7` |
94
+ | `react-server-dom-*` 19.2.x | `<=19.2.5` | `19.2.6` |
95
+
96
+ ## Advisory Files
97
+
98
+ Advisories are JSON files in `advisories/*.json`. They describe package names, affected version constraints, target versions, and PR metadata.
99
+
100
+ ```json
101
+ {
102
+ "id": "example-advisory",
103
+ "title": "Example package security release",
104
+ "branchName": "example-security-release",
105
+ "commitMessage": "Update example security dependencies",
106
+ "prTitle": "Update example security dependencies",
107
+ "prBody": "Updates packages affected by the example advisory.\n\nAdvisory: https://example.com/advisory",
108
+ "packages": [
109
+ {
110
+ "name": "example-package",
111
+ "affected": [
112
+ {
113
+ "major": 2,
114
+ "maxVersion": "2.4.3",
115
+ "upgradeTo": "2.4.4",
116
+ "reason": "example-package 2.x <= 2.4.3 is affected."
117
+ }
118
+ ]
119
+ }
120
+ ]
121
+ }
122
+ ```
123
+
124
+ Supported package matching:
125
+
126
+ - Exact names, such as `next`
127
+ - Wildcards, such as `react-server-dom-*`
128
+
129
+ Supported version constraints:
130
+
131
+ - `major`
132
+ - `minor`
133
+ - `patch`
134
+ - `minVersion`
135
+ - `maxVersion`
136
+
137
+ Patch Patrol currently inspects `dependencies`, `devDependencies`, and `optionalDependencies`.
138
+
139
+ ## Advanced CLI Usage
140
+
141
+ Use flags when you want a repeatable scan or automated run instead of the guided workflow.
142
+
143
+ List available advisory rules:
144
+
145
+ ```sh
146
+ patch-patrol --list-rules
147
+ ```
148
+
149
+ Scan without changing files:
150
+
151
+ ```sh
152
+ patch-patrol --root /Users/sam/repos --rule next-may-2026 --dry-run
153
+ ```
154
+
155
+ Filter by bump level:
156
+
157
+ ```sh
158
+ patch-patrol --root /Users/sam/repos --rule next-may-2026 --dry-run --patch
159
+ patch-patrol --root /Users/sam/repos --rule next-may-2026 --dry-run --minor
160
+ patch-patrol --root /Users/sam/repos --rule next-may-2026 --dry-run --major
161
+ patch-patrol --root /Users/sam/repos --rule next-may-2026 --dry-run --bump minor,patch
162
+ ```
163
+
164
+ Bump filters compare the installed version in `package.json` with the advisory target version. For example, `15.5.7 -> 15.5.18` is a patch bump, `16.1.6 -> 16.2.6` is a minor bump, and `14.2.20 -> 15.5.18` is a major bump.
165
+
166
+ Update all affected repos without selection prompts:
167
+
168
+ ```sh
169
+ patch-patrol --root /Users/sam/repos --rule next-may-2026 --yes
170
+ ```
171
+
172
+ Update locally without pushing or opening pull requests:
173
+
174
+ ```sh
175
+ patch-patrol --root /Users/sam/repos --rule next-may-2026 --no-pr
176
+ ```
177
+
178
+ Use an external advisory directory:
179
+
180
+ ```sh
181
+ patch-patrol --root /Users/sam/repos --advisory-dir ./company-advisories --rule some-advisory
182
+ ```
183
+
184
+ ## Development
185
+
186
+ Patch Patrol is written in TypeScript. Source files live in `src/`, and the published CLI runs compiled JavaScript from `dist/` through the thin executable shim in `bin/`.
187
+
188
+ ```sh
189
+ pnpm install
190
+ pnpm run check
191
+ ```
192
+
193
+ Build only:
194
+
195
+ ```sh
196
+ pnpm run build
197
+ ```
198
+
199
+ ## Safety
200
+
201
+ - Repos with uncommitted changes are skipped before edits are made.
202
+ - Scan-only runs do not touch files.
203
+ - Action mode only modifies projects you select, unless `--yes` is used.
204
+ - Pull requests are opened only after a branch and commit are created successfully.
@@ -0,0 +1,63 @@
1
+ {
2
+ "id": "next-may-2026",
3
+ "title": "Next.js May 2026 security release",
4
+ "branchName": "nextjs-may-2026-security-release",
5
+ "commitMessage": "Update Next.js security release dependencies",
6
+ "prTitle": "Update Next.js security release dependencies",
7
+ "prBody": "Updates packages affected by the Next.js May 2026 security release.\n\nAdvisory: https://vercel.com/changelog/next-js-may-2026-security-release#affected-versions",
8
+ "packages": [
9
+ {
10
+ "name": "next",
11
+ "affected": [
12
+ {
13
+ "major": 13,
14
+ "upgradeTo": "15.5.18",
15
+ "reason": "Next.js 13.x is affected; upgrade to 15.5.18 or 16.2.6."
16
+ },
17
+ {
18
+ "major": 14,
19
+ "upgradeTo": "15.5.18",
20
+ "reason": "Next.js 14.x is affected; upgrade to 15.5.18 or 16.2.6."
21
+ },
22
+ {
23
+ "major": 15,
24
+ "maxVersion": "15.5.17",
25
+ "upgradeTo": "15.5.18",
26
+ "reason": "Next.js 15.x <= 15.5.17 is affected."
27
+ },
28
+ {
29
+ "major": 16,
30
+ "maxVersion": "16.2.5",
31
+ "upgradeTo": "16.2.6",
32
+ "reason": "Next.js 16.x <= 16.2.5 is affected."
33
+ }
34
+ ]
35
+ },
36
+ {
37
+ "name": "react-server-dom-*",
38
+ "affected": [
39
+ {
40
+ "major": 19,
41
+ "minor": 0,
42
+ "maxVersion": "19.0.5",
43
+ "upgradeTo": "19.0.6",
44
+ "reason": "react-server-dom-* 19.0.x <= 19.0.5 is affected."
45
+ },
46
+ {
47
+ "major": 19,
48
+ "minor": 1,
49
+ "maxVersion": "19.1.6",
50
+ "upgradeTo": "19.1.7",
51
+ "reason": "react-server-dom-* 19.1.x <= 19.1.6 is affected."
52
+ },
53
+ {
54
+ "major": 19,
55
+ "minor": 2,
56
+ "maxVersion": "19.2.5",
57
+ "upgradeTo": "19.2.6",
58
+ "reason": "react-server-dom-* 19.2.x <= 19.2.5 is affected."
59
+ }
60
+ ]
61
+ }
62
+ ]
63
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../dist/cli.js';
4
+
5
+ runCli().catch((error) => {
6
+ console.error(error.message);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { DEPENDENCY_SECTIONS } from './constants.js';
4
+ export async function loadRules(advisoryDir) {
5
+ const files = (await fs.readdir(advisoryDir))
6
+ .filter((file) => file.endsWith('.json'))
7
+ .sort((a, b) => a.localeCompare(b));
8
+ const rules = new Map();
9
+ for (const file of files) {
10
+ const filePath = path.join(advisoryDir, file);
11
+ const rule = JSON.parse(await fs.readFile(filePath, 'utf8'));
12
+ validateRule(rule, filePath);
13
+ rules.set(rule.id, rule);
14
+ }
15
+ return rules;
16
+ }
17
+ export function getUpdates(packageJson, rule, bumpLevels) {
18
+ const updates = [];
19
+ for (const section of DEPENDENCY_SECTIONS) {
20
+ const dependencies = packageJson[section];
21
+ if (!dependencies)
22
+ continue;
23
+ for (const [name, range] of Object.entries(dependencies)) {
24
+ const update = evaluateRule(rule, name, range);
25
+ if (update && bumpLevels.has(update.bumpLevel))
26
+ updates.push({ section, name, from: range, ...update });
27
+ }
28
+ }
29
+ return updates;
30
+ }
31
+ function validateRule(rule, filePath) {
32
+ const requiredStrings = ['id', 'title', 'branchName', 'commitMessage', 'prTitle', 'prBody'];
33
+ for (const key of requiredStrings) {
34
+ if (typeof rule[key] !== 'string' || !rule[key].trim()) {
35
+ throw new Error(`${filePath} must define a non-empty string: ${key}`);
36
+ }
37
+ }
38
+ if (!Array.isArray(rule.packages) || rule.packages.length === 0) {
39
+ throw new Error(`${filePath} must define at least one package rule.`);
40
+ }
41
+ for (const packageRule of rule.packages) {
42
+ if (typeof packageRule.name !== 'string' || !packageRule.name.trim()) {
43
+ throw new Error(`${filePath} has a package rule without a name.`);
44
+ }
45
+ if (!Array.isArray(packageRule.affected) || packageRule.affected.length === 0) {
46
+ throw new Error(`${filePath} package ${packageRule.name} must define affected ranges.`);
47
+ }
48
+ for (const affected of packageRule.affected) {
49
+ if (typeof affected.upgradeTo !== 'string' || !affected.upgradeTo.trim()) {
50
+ throw new Error(`${filePath} package ${packageRule.name} has an affected range without upgradeTo.`);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ function evaluateRule(rule, name, range) {
56
+ const fromVersion = parseVersionFromRange(range);
57
+ if (!fromVersion)
58
+ return null;
59
+ for (const packageRule of rule.packages) {
60
+ if (!matchesPackageName(packageRule.name, name))
61
+ continue;
62
+ for (const affected of packageRule.affected) {
63
+ if (!matchesAffectedRange(fromVersion, affected))
64
+ continue;
65
+ const toVersion = parseVersion(affected.upgradeTo);
66
+ const bumpLevel = classifyBump(fromVersion, toVersion);
67
+ return makeUpdate(range, affected.upgradeTo, bumpLevel, affected.reason ?? `${name} ${range} is affected by ${rule.title}.`);
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ function matchesPackageName(pattern, name) {
73
+ if (!pattern.includes('*'))
74
+ return pattern === name;
75
+ const escaped = pattern
76
+ .split('*')
77
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
78
+ .join('.*');
79
+ return new RegExp(`^${escaped}$`).test(name);
80
+ }
81
+ function matchesAffectedRange(version, affected) {
82
+ if (affected.major !== undefined && version.major !== affected.major)
83
+ return false;
84
+ if (affected.minor !== undefined && version.minor !== affected.minor)
85
+ return false;
86
+ if (affected.patch !== undefined && version.patch !== affected.patch)
87
+ return false;
88
+ if (affected.minVersion && compareVersions(version, parseVersion(affected.minVersion)) < 0)
89
+ return false;
90
+ if (affected.maxVersion && compareVersions(version, parseVersion(affected.maxVersion)) > 0)
91
+ return false;
92
+ return true;
93
+ }
94
+ function classifyBump(fromVersion, toVersion) {
95
+ if (toVersion.major !== fromVersion.major)
96
+ return 'major';
97
+ if (toVersion.minor !== fromVersion.minor)
98
+ return 'minor';
99
+ return 'patch';
100
+ }
101
+ function makeUpdate(fromRange, version, bumpLevel, reason) {
102
+ const prefix = fromRange.match(/^[~^]/)?.[0] ?? '';
103
+ return { to: `${prefix}${version}`, bumpLevel, reason };
104
+ }
105
+ function parseVersionFromRange(range) {
106
+ if (typeof range !== 'string')
107
+ return null;
108
+ if (/^(workspace|catalog|npm|file|link|portal|git|https?):/.test(range))
109
+ return null;
110
+ if (!/\d+\.\d+\.\d+/.test(range))
111
+ return null;
112
+ return parseVersion(range);
113
+ }
114
+ function parseVersion(version) {
115
+ const match = version.match(/(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/);
116
+ if (!match)
117
+ throw new Error(`Invalid version: ${version}`);
118
+ return {
119
+ major: Number(match.groups?.major),
120
+ minor: Number(match.groups?.minor),
121
+ patch: Number(match.groups?.patch),
122
+ };
123
+ }
124
+ function compareVersions(a, b) {
125
+ for (const key of ['major', 'minor', 'patch']) {
126
+ if (a[key] > b[key])
127
+ return 1;
128
+ if (a[key] < b[key])
129
+ return -1;
130
+ }
131
+ return 0;
132
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+ import { intro, outro } from '@clack/prompts';
3
+ import { loadRules } from './advisories.js';
4
+ import { actionRepo } from './repo-actions.js';
5
+ import { finish, printRules } from './output.js';
6
+ import { findRepos, runScan } from './scanner.js';
7
+ import { getRuleOrThrow, parseArgs } from './options.js';
8
+ import { printAffectedRepos } from './format.js';
9
+ import { promptForAffectedRepos, promptForScanOptions } from './prompts.js';
10
+ export async function runCli(argv = process.argv.slice(2)) {
11
+ const options = parseArgs(argv);
12
+ const rules = await loadRules(options.advisoryDir);
13
+ if (options.listRules) {
14
+ printRules(rules);
15
+ return;
16
+ }
17
+ if (options.interactive) {
18
+ intro('Patch Patrol');
19
+ await promptForScanOptions(options, rules);
20
+ }
21
+ const rule = getRuleOrThrow(rules, options.rule);
22
+ const root = path.resolve(options.root);
23
+ const repos = await findRepos(root);
24
+ const affectedRepos = await runScan(repos, root, rule, options);
25
+ printAffectedRepos(affectedRepos, options);
26
+ if (affectedRepos.length === 0 || options.dryRun) {
27
+ if (options.interactive)
28
+ outro('Scan complete.');
29
+ return;
30
+ }
31
+ const selectedRepos = options.yes ? affectedRepos : await promptForAffectedRepos(affectedRepos, options);
32
+ if (selectedRepos.length === 0) {
33
+ finish(options, 'No projects selected. Nothing changed.');
34
+ return;
35
+ }
36
+ for (const affectedRepo of selectedRepos) {
37
+ await actionRepo(affectedRepo, rule, options);
38
+ }
39
+ if (options.interactive)
40
+ outro('Selected projects processed.');
41
+ }
@@ -0,0 +1,9 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ const SRC_DIR = path.dirname(fileURLToPath(import.meta.url));
4
+ export const PACKAGE_ROOT = path.resolve(SRC_DIR, '..');
5
+ export const DEFAULT_ADVISORY_DIR = path.join(PACKAGE_ROOT, 'advisories');
6
+ export const DEFAULT_RULE_ID = 'next-may-2026';
7
+ export const DEFAULT_BRANCH_PREFIX = 'patch-patrol';
8
+ export const BUMP_LEVELS = ['major', 'minor', 'patch'];
9
+ export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
package/dist/files.js ADDED
@@ -0,0 +1,14 @@
1
+ import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
3
+ export async function exists(filePath) {
4
+ try {
5
+ await fs.access(filePath);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export function fsSyncExists(filePath) {
13
+ return fsSync.existsSync(filePath);
14
+ }
package/dist/format.js ADDED
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import { log, note } from '@clack/prompts';
3
+ export function printAffectedRepos(affectedRepos, options) {
4
+ if (affectedRepos.length === 0) {
5
+ if (options.interactive)
6
+ log.success('No affected repos found.');
7
+ else
8
+ console.log('No affected repos found.');
9
+ return;
10
+ }
11
+ const summary = formatAffectedRepos(affectedRepos);
12
+ if (options.interactive)
13
+ note(summary, `Affected repos (${affectedRepos.length})`);
14
+ else
15
+ console.log(`\nAffected repos (${affectedRepos.length}):\n${summary}`);
16
+ }
17
+ export function formatAffectedRepos(affectedRepos) {
18
+ return affectedRepos
19
+ .map((affectedRepo, index) => {
20
+ const updates = affectedRepo.updates
21
+ .map((update) => ` [${update.bumpLevel}] ${update.section}.${update.name}: ${update.from} -> ${update.to}\n ${update.reason}`)
22
+ .join('\n');
23
+ return `${index + 1}. ${path.basename(affectedRepo.repo)}\n${updates}`;
24
+ })
25
+ .join('\n\n');
26
+ }
27
+ export function summarizeUpdates(updates) {
28
+ return updates.map((update) => `${update.name} ${update.from} -> ${update.to} (${update.bumpLevel})`).join(', ');
29
+ }
package/dist/git.js ADDED
@@ -0,0 +1,91 @@
1
+ import path from 'node:path';
2
+ import { promptForGithubRemote } from './prompts.js';
3
+ import { run } from './shell.js';
4
+ export function ensureClean(repo) {
5
+ const status = run('git', ['status', '--porcelain'], repo, { capture: true });
6
+ if (status.stdout.trim())
7
+ throw new Error(`${repo} has uncommitted changes; skipping to avoid overwriting local work.`);
8
+ }
9
+ export function checkoutFreshBranch(repo, rule, options) {
10
+ const defaultBranch = getDefaultBranch(repo);
11
+ run('git', ['checkout', defaultBranch], repo);
12
+ run('git', ['pull', '--ff-only'], repo);
13
+ run('git', ['checkout', '-B', getBranchName(rule, options)], repo);
14
+ }
15
+ export function commitChanges(repo, rule) {
16
+ run('git', ['add', ...getChangedFiles(repo)], repo);
17
+ run('git', ['commit', '-m', rule.commitMessage], repo);
18
+ }
19
+ export async function pushAndCreatePr(repo, rule, options) {
20
+ const branchName = getBranchName(rule, options);
21
+ const githubRemote = await ensureGithubRemote(repo);
22
+ run('git', ['push', '-u', githubRemote.name, branchName], repo);
23
+ if (!githubRemote.ownerRepo)
24
+ throw new Error(`Could not determine GitHub repo for remote ${githubRemote.name}.`);
25
+ const prArgs = ['pr', 'create', '--repo', githubRemote.ownerRepo, '--head', branchName, '--title', rule.prTitle, '--body', rule.prBody];
26
+ if (options.base)
27
+ prArgs.push('--base', options.base);
28
+ run('gh', prArgs, repo);
29
+ }
30
+ function getDefaultBranch(repo) {
31
+ const symbolic = run('git', ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], repo, { capture: true, allowFailure: true });
32
+ if (symbolic?.stdout.trim())
33
+ return symbolic.stdout.trim().replace(/^origin\//, '');
34
+ const current = run('git', ['branch', '--show-current'], repo, { capture: true });
35
+ return current.stdout.trim() || 'main';
36
+ }
37
+ function getBranchName(rule, options) {
38
+ return `${options.branchPrefix}/${rule.branchName}`;
39
+ }
40
+ function hasChanges(repo) {
41
+ return Boolean(run('git', ['status', '--porcelain'], repo, { capture: true }).stdout.trim());
42
+ }
43
+ export function getChangedFiles(repo) {
44
+ return run('git', ['status', '--porcelain'], repo, { capture: true }).stdout
45
+ .split('\n')
46
+ .filter(Boolean)
47
+ .map((line) => line.slice(3));
48
+ }
49
+ export { hasChanges };
50
+ async function ensureGithubRemote(repo) {
51
+ const remotes = listRemotes(repo);
52
+ const github = remotes.find((remote) => remote.github);
53
+ if (github)
54
+ return github;
55
+ const origin = remotes.find((remote) => remote.name === 'origin') ?? remotes[0];
56
+ const suggestion = origin ? convertOriginToGithub(origin.url) : '';
57
+ const url = await promptForGithubRemote(path.basename(repo), suggestion, parseRemote);
58
+ const name = remotes.some((remote) => remote.name === 'github') ? 'github-pr' : 'github';
59
+ run('git', ['remote', 'add', name, url], repo);
60
+ return parseRemote(name, url);
61
+ }
62
+ function listRemotes(repo) {
63
+ const result = run('git', ['remote', '-v'], repo, { capture: true });
64
+ const remotes = new Map();
65
+ for (const line of result.stdout.trim().split('\n').filter(Boolean)) {
66
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
67
+ if (match)
68
+ remotes.set(match[1], parseRemote(match[1], match[2]));
69
+ }
70
+ return [...remotes.values()];
71
+ }
72
+ function parseRemote(name, url) {
73
+ const normalized = url
74
+ .replace(/^git@github\.com:/, 'https://github.com/')
75
+ .replace(/^ssh:\/\/git@github\.com\//, 'https://github.com/')
76
+ .replace(/\.git$/, '');
77
+ const match = normalized.match(/github\.com[/:](?<owner>[^/]+)\/(?<repo>[^/]+)$/);
78
+ return {
79
+ name,
80
+ url,
81
+ github: Boolean(match),
82
+ ownerRepo: match ? `${match.groups?.owner}/${match.groups?.repo}` : undefined,
83
+ };
84
+ }
85
+ function convertOriginToGithub(url) {
86
+ const match = url.match(/bitbucket\.org[:/](?<workspace>[^/]+)\/(?<repo>[^/.]+)(?:\.git)?$/i);
87
+ if (!match)
88
+ return '';
89
+ const owner = match.groups?.workspace === 'humaanco' ? 'humaan' : match.groups?.workspace;
90
+ return `git@github.com:${owner}/${match.groups?.repo}.git`;
91
+ }
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { BUMP_LEVELS, DEFAULT_ADVISORY_DIR, DEFAULT_BRANCH_PREFIX, DEFAULT_RULE_ID } from './constants.js';
4
+ export function parseArgs(args) {
5
+ const options = {
6
+ root: process.cwd(),
7
+ rule: DEFAULT_RULE_ID,
8
+ base: undefined,
9
+ branchPrefix: DEFAULT_BRANCH_PREFIX,
10
+ advisoryDir: DEFAULT_ADVISORY_DIR,
11
+ bumpLevels: new Set(BUMP_LEVELS),
12
+ dryRun: false,
13
+ yes: false,
14
+ createPr: true,
15
+ skipInstall: false,
16
+ limit: 0,
17
+ listRules: false,
18
+ interactive: args.length === 0,
19
+ };
20
+ let hasExplicitBumpFilter = false;
21
+ for (let index = 0; index < args.length; index += 1) {
22
+ const arg = args[index];
23
+ const readValue = () => {
24
+ const value = args[index + 1];
25
+ if (!value || value.startsWith('--'))
26
+ throw new Error(`Missing value for ${arg}`);
27
+ index += 1;
28
+ return value;
29
+ };
30
+ const addBumpFilter = (levels) => {
31
+ if (!hasExplicitBumpFilter) {
32
+ options.bumpLevels.clear();
33
+ hasExplicitBumpFilter = true;
34
+ }
35
+ for (const level of levels) {
36
+ if (!BUMP_LEVELS.includes(level))
37
+ throw new Error(`Invalid bump level: ${level}`);
38
+ options.bumpLevels.add(level);
39
+ }
40
+ };
41
+ if (arg === '--root')
42
+ options.root = readValue();
43
+ else if (arg === '--rule')
44
+ options.rule = readValue();
45
+ else if (arg === '--advisory-dir')
46
+ options.advisoryDir = path.resolve(readValue());
47
+ else if (arg === '--base')
48
+ options.base = readValue();
49
+ else if (arg === '--branch-prefix')
50
+ options.branchPrefix = readValue();
51
+ else if (arg === '--limit')
52
+ options.limit = Number(readValue());
53
+ else if (arg === '--bump')
54
+ addBumpFilter(readValue().split(',').map((level) => level.trim()).filter(Boolean));
55
+ else if (arg === '--major')
56
+ addBumpFilter(['major']);
57
+ else if (arg === '--minor')
58
+ addBumpFilter(['minor']);
59
+ else if (arg === '--patch')
60
+ addBumpFilter(['patch']);
61
+ else if (arg === '--dry-run')
62
+ options.dryRun = true;
63
+ else if (arg === '--interactive')
64
+ options.interactive = true;
65
+ else if (arg === '--list-rules')
66
+ options.listRules = true;
67
+ else if (arg === '--yes' || arg === '-y')
68
+ options.yes = true;
69
+ else if (arg === '--no-pr')
70
+ options.createPr = false;
71
+ else if (arg === '--skip-install')
72
+ options.skipInstall = true;
73
+ else if (arg === '--help' || arg === '-h') {
74
+ printHelp();
75
+ process.exit(0);
76
+ }
77
+ else {
78
+ throw new Error(`Unknown option: ${arg}`);
79
+ }
80
+ }
81
+ if (options.bumpLevels.size === 0)
82
+ throw new Error('Select at least one bump level.');
83
+ return options;
84
+ }
85
+ export function getRuleOrThrow(rules, ruleId) {
86
+ const rule = rules.get(ruleId);
87
+ if (!rule)
88
+ throw new Error(`Unknown rule "${ruleId}". Available rules: ${[...rules.keys()].join(', ')}`);
89
+ return rule;
90
+ }
91
+ function printHelp() {
92
+ console.log(`patch-patrol
93
+
94
+ Usage:
95
+ patch-patrol
96
+ patch-patrol --root /path/to/repos [options]
97
+
98
+ Options:
99
+ --rule <name> Rule to apply. Default: ${DEFAULT_RULE_ID}
100
+ --advisory-dir <path> Directory containing advisory JSON files.
101
+ --base <branch> Base branch for PRs. Defaults to GitHub default branch.
102
+ --branch-prefix <name> Branch namespace. Default: ${DEFAULT_BRANCH_PREFIX}
103
+ --limit <number> Stop after processing this many affected repos.
104
+ --bump <levels> Only include bump levels: major, minor, patch. Comma-separated.
105
+ --major Only include major version bumps. Can combine with --minor/--patch.
106
+ --minor Only include minor version bumps. Can combine with --major/--patch.
107
+ --patch Only include patch version bumps. Can combine with --major/--minor.
108
+ --dry-run Scan and report affected repos without editing files.
109
+ --interactive Prompt for scan options and project selection.
110
+ --list-rules List available advisory rules.
111
+ --no-pr Update locally without pushing or opening PRs.
112
+ --skip-install Do not refresh lockfiles.
113
+ --yes, -y Do not prompt before updating affected repos.
114
+ --help, -h Show this help.
115
+ `);
116
+ }
package/dist/output.js ADDED
@@ -0,0 +1,12 @@
1
+ import { outro } from '@clack/prompts';
2
+ export function printRules(rules) {
3
+ for (const rule of rules.values()) {
4
+ console.log(`${rule.id}: ${rule.title}`);
5
+ }
6
+ }
7
+ export function finish(options, message) {
8
+ if (options.interactive)
9
+ outro(message);
10
+ else
11
+ console.log(message);
12
+ }
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+ import { fsSyncExists } from './files.js';
3
+ import { run } from './shell.js';
4
+ export function refreshLockfile(repo) {
5
+ const manager = detectPackageManager(repo);
6
+ if (manager === 'pnpm')
7
+ run('pnpm', ['install', '--lockfile-only'], repo);
8
+ else if (manager === 'yarn')
9
+ run('yarn', ['install', '--mode=update-lockfile'], repo, { allowFailure: true }) || run('yarn', ['install'], repo);
10
+ else if (manager === 'bun')
11
+ run('bun', ['install', '--lockfile-only'], repo);
12
+ else
13
+ run('npm', ['install', '--package-lock-only'], repo);
14
+ }
15
+ function detectPackageManager(repo) {
16
+ if (fsSyncExists(path.join(repo, 'pnpm-lock.yaml')))
17
+ return 'pnpm';
18
+ if (fsSyncExists(path.join(repo, 'yarn.lock')))
19
+ return 'yarn';
20
+ if (fsSyncExists(path.join(repo, 'bun.lockb')) || fsSyncExists(path.join(repo, 'bun.lock')))
21
+ return 'bun';
22
+ return 'npm';
23
+ }
@@ -0,0 +1,118 @@
1
+ import fsSync from 'node:fs';
2
+ import path from 'node:path';
3
+ import { cancel, confirm, isCancel, multiselect, select, text } from '@clack/prompts';
4
+ import { parseSelection } from './selection.js';
5
+ import { summarizeUpdates } from './format.js';
6
+ export async function promptForScanOptions(options, rules) {
7
+ options.root = await promptForRoot(options.root);
8
+ options.rule = await promptForRule(rules, options.rule);
9
+ options.bumpLevels = new Set(await promptForBumpLevels(options.bumpLevels));
10
+ }
11
+ export async function promptForRoot(defaultRoot) {
12
+ const root = await text({
13
+ message: 'Repo or folder of repos',
14
+ placeholder: defaultRoot,
15
+ initialValue: defaultRoot,
16
+ validate(value) {
17
+ if (!value.trim())
18
+ return 'Enter a repo path or a folder containing repos.';
19
+ if (!fsSync.existsSync(path.resolve(value)))
20
+ return 'That path does not exist.';
21
+ return undefined;
22
+ },
23
+ });
24
+ handleCancel(root);
25
+ return path.resolve(root);
26
+ }
27
+ export async function promptForRule(rules, defaultRuleId) {
28
+ const selectedRule = await select({
29
+ message: 'Select an advisory rule',
30
+ initialValue: defaultRuleId,
31
+ options: [...rules.values()].map((rule) => ({
32
+ value: rule.id,
33
+ label: rule.id,
34
+ hint: rule.title,
35
+ })),
36
+ });
37
+ handleCancel(selectedRule);
38
+ return selectedRule;
39
+ }
40
+ export async function promptForBumpLevels(defaultLevels) {
41
+ const selectedLevels = await multiselect({
42
+ message: 'Filter by version bump level',
43
+ required: true,
44
+ initialValues: [...defaultLevels],
45
+ options: [
46
+ { value: 'major', label: 'Major', hint: 'Example: 14.2.20 -> 15.5.18' },
47
+ { value: 'minor', label: 'Minor', hint: 'Example: 16.1.6 -> 16.2.6' },
48
+ { value: 'patch', label: 'Patch', hint: 'Example: 15.5.7 -> 15.5.18' },
49
+ ],
50
+ });
51
+ handleCancel(selectedLevels);
52
+ return selectedLevels;
53
+ }
54
+ export async function promptForAffectedRepos(affectedRepos, options) {
55
+ if (!options.interactive)
56
+ return promptForAffectedReposText(affectedRepos);
57
+ const selectedRepos = await multiselect({
58
+ message: 'Select projects to update and open PRs for',
59
+ required: false,
60
+ options: affectedRepos.map((affectedRepo) => ({
61
+ value: affectedRepo.repo,
62
+ label: path.basename(affectedRepo.repo),
63
+ hint: summarizeUpdates(affectedRepo.updates),
64
+ })),
65
+ });
66
+ handleCancel(selectedRepos);
67
+ if (selectedRepos.length === 0)
68
+ return [];
69
+ const proceed = await confirm({
70
+ message: `Update ${selectedRepos.length} project${selectedRepos.length === 1 ? '' : 's'} and open PR${selectedRepos.length === 1 ? '' : 's'}?`,
71
+ initialValue: true,
72
+ });
73
+ handleCancel(proceed);
74
+ if (!proceed)
75
+ return [];
76
+ return affectedRepos.filter((affectedRepo) => selectedRepos.includes(affectedRepo.repo));
77
+ }
78
+ export async function promptForGithubRemote(repoName, suggestion, parseRemote) {
79
+ const answer = await text({
80
+ message: `GitHub remote URL for ${repoName}`,
81
+ placeholder: suggestion || 'git@github.com:owner/repo.git',
82
+ initialValue: suggestion,
83
+ validate(value) {
84
+ if (!value.trim())
85
+ return 'Enter a GitHub remote URL.';
86
+ if (!parseRemote('github', value.trim()).github)
87
+ return 'Enter a GitHub remote URL.';
88
+ return undefined;
89
+ },
90
+ });
91
+ handleCancel(answer);
92
+ return answer.trim();
93
+ }
94
+ async function promptForAffectedReposText(affectedRepos) {
95
+ const answer = await text({
96
+ message: 'Projects to update and open PRs for',
97
+ placeholder: '1,3-5, all, or empty to skip',
98
+ validate(value) {
99
+ const selection = value.trim().toLowerCase();
100
+ if (!selection || selection === 'none' || selection === 'n' || selection === 'all' || selection === 'a')
101
+ return undefined;
102
+ return parseSelection(selection, affectedRepos.length).length > 0 ? undefined : 'Enter a valid selection, such as 1,3-5, all, or none.';
103
+ },
104
+ });
105
+ handleCancel(answer);
106
+ const selection = answer.trim().toLowerCase();
107
+ if (!selection || selection === 'none' || selection === 'n')
108
+ return [];
109
+ if (selection === 'all' || selection === 'a')
110
+ return affectedRepos;
111
+ return parseSelection(selection, affectedRepos.length).map((index) => affectedRepos[index]);
112
+ }
113
+ export function handleCancel(value) {
114
+ if (!isCancel(value))
115
+ return;
116
+ cancel('Cancelled.');
117
+ process.exit(0);
118
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getUpdates } from './advisories.js';
4
+ import { checkoutFreshBranch, commitChanges, ensureClean, hasChanges, pushAndCreatePr } from './git.js';
5
+ import { refreshLockfile } from './package-manager.js';
6
+ export async function actionRepo(affectedRepo, rule, options) {
7
+ const { repo, packageJsonPath, updates } = affectedRepo;
8
+ console.log(`\n${path.basename(repo)}`);
9
+ for (const update of updates) {
10
+ console.log(` [${update.bumpLevel}] ${update.section}.${update.name}: ${update.from} -> ${update.to} (${update.reason})`);
11
+ }
12
+ ensureClean(repo);
13
+ checkoutFreshBranch(repo, rule, options);
14
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
15
+ const freshUpdates = getUpdates(packageJson, rule, options.bumpLevels);
16
+ if (freshUpdates.length === 0) {
17
+ console.log(' No affected dependencies after updating the base branch; skipping.');
18
+ return;
19
+ }
20
+ applyUpdates(packageJson, freshUpdates);
21
+ await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
22
+ if (!options.skipInstall)
23
+ refreshLockfile(repo);
24
+ if (!hasChanges(repo)) {
25
+ console.log(' No changes after update; skipping.');
26
+ return;
27
+ }
28
+ commitChanges(repo, rule);
29
+ if (!options.createPr) {
30
+ console.log(' Updated locally; PR creation disabled.');
31
+ return;
32
+ }
33
+ await pushAndCreatePr(repo, rule, options);
34
+ }
35
+ function applyUpdates(packageJson, updates) {
36
+ for (const update of updates) {
37
+ const dependencies = packageJson[update.section];
38
+ if (dependencies)
39
+ dependencies[update.name] = update.to;
40
+ }
41
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spinner } from '@clack/prompts';
4
+ import { getUpdates } from './advisories.js';
5
+ import { exists } from './files.js';
6
+ export async function runScan(repos, root, rule, options) {
7
+ if (!options.interactive) {
8
+ console.log(`Scanning ${repos.length} repos in ${root} using rule: ${rule.title}`);
9
+ return scanRepos(repos, rule, options);
10
+ }
11
+ const scanSpinner = spinner();
12
+ scanSpinner.start(`Scanning ${repos.length} repos in ${root}`);
13
+ const affectedRepos = await scanRepos(repos, rule, options);
14
+ scanSpinner.stop(`Found ${affectedRepos.length} affected repo${affectedRepos.length === 1 ? '' : 's'}.`);
15
+ return affectedRepos;
16
+ }
17
+ export async function findRepos(root) {
18
+ if (await isNodeRepo(root))
19
+ return [root];
20
+ const entries = await fs.readdir(root, { withFileTypes: true });
21
+ const repos = [];
22
+ for (const entry of entries) {
23
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
24
+ continue;
25
+ const repoPath = path.join(root, entry.name);
26
+ if (await isNodeRepo(repoPath))
27
+ repos.push(repoPath);
28
+ }
29
+ return repos.sort((a, b) => a.localeCompare(b));
30
+ }
31
+ async function isNodeRepo(repoPath) {
32
+ return await exists(path.join(repoPath, '.git')) && await exists(path.join(repoPath, 'package.json'));
33
+ }
34
+ async function scanRepos(repos, rule, options) {
35
+ const affectedRepos = [];
36
+ for (const repo of repos) {
37
+ const affectedRepo = await scanRepo(repo, rule, options);
38
+ if (!affectedRepo)
39
+ continue;
40
+ affectedRepos.push(affectedRepo);
41
+ if (options.limit > 0 && affectedRepos.length >= options.limit)
42
+ break;
43
+ }
44
+ return affectedRepos;
45
+ }
46
+ async function scanRepo(repo, rule, options) {
47
+ const packageJsonPath = path.join(repo, 'package.json');
48
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
49
+ const updates = getUpdates(packageJson, rule, options.bumpLevels);
50
+ if (updates.length === 0)
51
+ return null;
52
+ return { repo, packageJsonPath, updates };
53
+ }
@@ -0,0 +1,23 @@
1
+ export function parseSelection(selection, max) {
2
+ const indexes = new Set();
3
+ for (const part of selection.split(',').map((item) => item.trim()).filter(Boolean)) {
4
+ const range = part.match(/^(\d+)\s*-\s*(\d+)$/);
5
+ if (range) {
6
+ const start = Number(range[1]);
7
+ const end = Number(range[2]);
8
+ if (!isValidSelectionNumber(start, max) || !isValidSelectionNumber(end, max) || start > end)
9
+ return [];
10
+ for (let value = start; value <= end; value += 1)
11
+ indexes.add(value - 1);
12
+ continue;
13
+ }
14
+ const value = Number(part);
15
+ if (!isValidSelectionNumber(value, max))
16
+ return [];
17
+ indexes.add(value - 1);
18
+ }
19
+ return [...indexes].sort((a, b) => a - b);
20
+ }
21
+ function isValidSelectionNumber(value, max) {
22
+ return Number.isInteger(value) && value >= 1 && value <= max;
23
+ }
package/dist/shell.js ADDED
@@ -0,0 +1,15 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ export function run(command, args, cwd, options = {}) {
3
+ const result = spawnSync(command, args, {
4
+ cwd,
5
+ encoding: 'utf8',
6
+ stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
7
+ });
8
+ if (result.status !== 0) {
9
+ if (options.allowFailure)
10
+ return null;
11
+ const stderr = result.stderr ? `\n${result.stderr}` : '';
12
+ throw new Error(`${command} ${args.join(' ')} failed in ${cwd}.${stderr}`);
13
+ }
14
+ return result;
15
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@humaan/patch-patrol",
3
+ "version": "0.2.2",
4
+ "description": "Scan local repositories for advisory-driven package bumps, update them, and open pull requests.",
5
+ "type": "module",
6
+ "bin": {
7
+ "patch-patrol": "./bin/patch-patrol.js"
8
+ },
9
+ "files": [
10
+ "advisories/",
11
+ "bin/",
12
+ "dist/",
13
+ "README.md"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+ssh://git@github.com/humaan/patch-patrol.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/humaan/patch-patrol/issues"
21
+ },
22
+ "homepage": "https://github.com/humaan/patch-patrol#readme",
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org/"
26
+ },
27
+ "dependencies": {
28
+ "@clack/prompts": "^0.11.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.15.3",
32
+ "typescript": "^5.9.3"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "license": "MIT",
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "typecheck": "tsc --noEmit",
41
+ "check": "pnpm run typecheck && pnpm run build && node --check bin/patch-patrol.js && node --check dist/*.js",
42
+ "scan:dry": "pnpm run build && node bin/patch-patrol.js --root .. --dry-run --no-pr"
43
+ }
44
+ }