@in-the-loop-labs/pair-review 3.3.7 → 3.4.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/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/styles.css +14 -2
- package/public/index.html +1 -0
- package/public/js/components/UpdateBanner.js +143 -0
- package/public/js/modules/diff-renderer.js +3 -0
- package/public/local.html +2 -0
- package/public/pr.html +2 -0
- package/public/setup.html +1 -0
- package/src/config.js +17 -6
- package/src/main.js +33 -20
- package/src/mcp-stdio.js +7 -0
- package/src/routes/config.js +55 -1
- package/src/server.js +51 -9
- package/src/single-port.js +191 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@in-the-loop-labs/pair-review",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"glob": "^13.0.6",
|
|
69
69
|
"markdown-it": "^13.0.2",
|
|
70
70
|
"open": "^9.1.0",
|
|
71
|
+
"semver": "^7.7.4",
|
|
71
72
|
"simple-git": "^3.19.1",
|
|
72
73
|
"update-notifier": "^5.1.0",
|
|
73
74
|
"uuid": "^11.1.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
package/public/css/styles.css
CHANGED
|
@@ -31,7 +31,13 @@
|
|
|
31
31
|
--color-warning-bg: #fffae6;
|
|
32
32
|
--color-warning-border: #ffc552;
|
|
33
33
|
--color-warning-text: #7d4e00;
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
--color-info-bg: #eff6ff;
|
|
36
|
+
--color-info-border: #bfdbfe;
|
|
37
|
+
--color-info-accent: #3b82f6;
|
|
38
|
+
--color-info-text: #1e3a8a;
|
|
39
|
+
--color-info-text-muted: #3b5998;
|
|
40
|
+
|
|
35
41
|
--color-selection: #fff5b1;
|
|
36
42
|
--color-selection-num: #ffeb3b;
|
|
37
43
|
|
|
@@ -72,7 +78,13 @@
|
|
|
72
78
|
--color-warning-bg: #3d2e00;
|
|
73
79
|
--color-warning-border: #9e6a03;
|
|
74
80
|
--color-warning-text: #e3b341;
|
|
75
|
-
|
|
81
|
+
|
|
82
|
+
--color-info-bg: #152033;
|
|
83
|
+
--color-info-border: #2d4a7a;
|
|
84
|
+
--color-info-accent: #4a90e2;
|
|
85
|
+
--color-info-text: #cfe0f5;
|
|
86
|
+
--color-info-text-muted: #8aa9cf;
|
|
87
|
+
|
|
76
88
|
--color-selection: #3d3300;
|
|
77
89
|
--color-selection-num: #4d4000;
|
|
78
90
|
|
package/public/index.html
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* UpdateBanner Component
|
|
4
|
+
* Shows a persistent, dismissible corner-card notification when a newer
|
|
5
|
+
* version of pair-review is available. Single delivery path: on construction,
|
|
6
|
+
* fetch /api/config and show the banner if a pending_update exists.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DISMISS_KEY = 'update-banner-dismissed';
|
|
10
|
+
|
|
11
|
+
class UpdateBanner {
|
|
12
|
+
constructor() {
|
|
13
|
+
this._banner = null;
|
|
14
|
+
this._dismissBtn = null;
|
|
15
|
+
this._version = null;
|
|
16
|
+
|
|
17
|
+
// Single path: fetch current config at construction; show banner if a
|
|
18
|
+
// pending update exists. No event listener, no WebSocket coupling.
|
|
19
|
+
// `fetch()` returns a Promise; `.then()` chains async steps; the final
|
|
20
|
+
// `.catch()` swallows network errors because the banner is non-critical.
|
|
21
|
+
fetch('/api/config')
|
|
22
|
+
.then(r => (r.ok ? r.json() : null))
|
|
23
|
+
.then(config => {
|
|
24
|
+
if (config && config.pending_update) this.show(config.pending_update);
|
|
25
|
+
})
|
|
26
|
+
.catch(() => { /* non-critical */ });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Show the update banner for the given version.
|
|
31
|
+
* No-op if already showing or dismissed for this version.
|
|
32
|
+
* @param {string} version
|
|
33
|
+
*/
|
|
34
|
+
show(version) {
|
|
35
|
+
if (!version) return;
|
|
36
|
+
|
|
37
|
+
// Already dismissed for this version in this session
|
|
38
|
+
if (sessionStorage.getItem(DISMISS_KEY) === version) return;
|
|
39
|
+
|
|
40
|
+
// Already showing this version
|
|
41
|
+
if (this._banner && this._version === version) return;
|
|
42
|
+
|
|
43
|
+
// Remove any existing banner (e.g., for an older version)
|
|
44
|
+
this._remove();
|
|
45
|
+
|
|
46
|
+
this._version = version;
|
|
47
|
+
|
|
48
|
+
// Theme-aware colors come from CSS custom properties (set in styles.css
|
|
49
|
+
// under :root and [data-theme="dark"]). The inline `var(..., fallback)`
|
|
50
|
+
// form keeps the banner readable even if the stylesheet hasn't loaded
|
|
51
|
+
// yet. No MutationObserver needed — CSS handles the theme switch.
|
|
52
|
+
const banner = document.createElement('div');
|
|
53
|
+
banner.setAttribute('data-update-banner', '');
|
|
54
|
+
Object.assign(banner.style, {
|
|
55
|
+
position: 'fixed',
|
|
56
|
+
top: '16px',
|
|
57
|
+
left: '16px',
|
|
58
|
+
zIndex: '1000',
|
|
59
|
+
maxWidth: '360px',
|
|
60
|
+
background: 'var(--color-info-bg, #eff6ff)',
|
|
61
|
+
border: '1px solid var(--color-info-border, #bfdbfe)',
|
|
62
|
+
borderLeft: '4px solid var(--color-info-accent, #3b82f6)',
|
|
63
|
+
borderRadius: '8px',
|
|
64
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
|
65
|
+
padding: '12px 14px',
|
|
66
|
+
fontSize: '13px',
|
|
67
|
+
lineHeight: '1.4',
|
|
68
|
+
color: 'var(--color-info-text, #1e3a8a)',
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'flex-start',
|
|
71
|
+
gap: '10px'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Two-line layout: headline + restart instruction on its own line.
|
|
75
|
+
const text = document.createElement('div');
|
|
76
|
+
text.style.flex = '1';
|
|
77
|
+
|
|
78
|
+
const headline = document.createElement('div');
|
|
79
|
+
headline.textContent = `pair-review v${version} is available.`;
|
|
80
|
+
|
|
81
|
+
const instruction = document.createElement('div');
|
|
82
|
+
instruction.textContent = 'Restart the server to update.';
|
|
83
|
+
instruction.style.marginTop = '2px';
|
|
84
|
+
|
|
85
|
+
text.appendChild(headline);
|
|
86
|
+
text.appendChild(instruction);
|
|
87
|
+
|
|
88
|
+
const dismissBtn = document.createElement('button');
|
|
89
|
+
dismissBtn.textContent = '\u00d7';
|
|
90
|
+
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
|
91
|
+
Object.assign(dismissBtn.style, {
|
|
92
|
+
background: 'none',
|
|
93
|
+
border: 'none',
|
|
94
|
+
color: 'var(--color-info-text-muted, #3b5998)',
|
|
95
|
+
cursor: 'pointer',
|
|
96
|
+
fontSize: '18px',
|
|
97
|
+
padding: '0',
|
|
98
|
+
lineHeight: '1',
|
|
99
|
+
flexShrink: '0',
|
|
100
|
+
opacity: '0.8'
|
|
101
|
+
});
|
|
102
|
+
dismissBtn.addEventListener('click', () => this.dismiss());
|
|
103
|
+
|
|
104
|
+
banner.appendChild(text);
|
|
105
|
+
banner.appendChild(dismissBtn);
|
|
106
|
+
document.body.appendChild(banner);
|
|
107
|
+
this._banner = banner;
|
|
108
|
+
this._dismissBtn = dismissBtn;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Dismiss the banner and remember the choice for this session. */
|
|
112
|
+
dismiss() {
|
|
113
|
+
if (this._version) {
|
|
114
|
+
sessionStorage.setItem(DISMISS_KEY, this._version);
|
|
115
|
+
}
|
|
116
|
+
this._remove();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @private */
|
|
120
|
+
_remove() {
|
|
121
|
+
if (this._banner && this._banner.parentNode) {
|
|
122
|
+
this._banner.parentNode.removeChild(this._banner);
|
|
123
|
+
}
|
|
124
|
+
this._banner = null;
|
|
125
|
+
this._dismissBtn = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Singleton init (browser only)
|
|
130
|
+
if (typeof window !== 'undefined' && !window.updateBanner) {
|
|
131
|
+
if (document.readyState === 'loading') {
|
|
132
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
133
|
+
window.updateBanner = new UpdateBanner();
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
window.updateBanner = new UpdateBanner();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// CommonJS export for unit tests
|
|
141
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
142
|
+
module.exports = { UpdateBanner };
|
|
143
|
+
}
|
package/public/local.html
CHANGED
|
@@ -562,6 +562,7 @@
|
|
|
562
562
|
|
|
563
563
|
<!-- Highlight.js for syntax highlighting -->
|
|
564
564
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
565
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
|
|
565
566
|
|
|
566
567
|
<!-- Markdown-it Library for rendering markdown in comments -->
|
|
567
568
|
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
|
|
@@ -591,6 +592,7 @@
|
|
|
591
592
|
<!-- Components -->
|
|
592
593
|
<script src="/js/components/TabTitle.js"></script>
|
|
593
594
|
<script src="/js/components/Toast.js"></script>
|
|
595
|
+
<script src="/js/components/UpdateBanner.js"></script>
|
|
594
596
|
<script src="/js/components/ConfirmDialog.js"></script>
|
|
595
597
|
<script src="/js/components/TextInputDialog.js"></script>
|
|
596
598
|
<script src="/js/components/AnalysisConfigModal.js"></script>
|
package/public/pr.html
CHANGED
|
@@ -358,6 +358,7 @@
|
|
|
358
358
|
|
|
359
359
|
<!-- Highlight.js for syntax highlighting -->
|
|
360
360
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
361
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
|
|
361
362
|
|
|
362
363
|
<!-- Markdown-it Library for rendering markdown in comments -->
|
|
363
364
|
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
|
|
@@ -387,6 +388,7 @@
|
|
|
387
388
|
<!-- Components -->
|
|
388
389
|
<script src="/js/components/TabTitle.js"></script>
|
|
389
390
|
<script src="/js/components/Toast.js"></script>
|
|
391
|
+
<script src="/js/components/UpdateBanner.js"></script>
|
|
390
392
|
<script src="/js/components/ConfirmDialog.js"></script>
|
|
391
393
|
<script src="/js/components/TextInputDialog.js"></script>
|
|
392
394
|
<script src="/js/components/AnalysisConfigModal.js"></script>
|
package/public/setup.html
CHANGED
package/src/config.js
CHANGED
|
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
|
|
|
19
19
|
github_token: "",
|
|
20
20
|
github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
|
|
21
21
|
port: 7247,
|
|
22
|
+
single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
|
|
22
23
|
theme: "light",
|
|
23
24
|
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
24
25
|
default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
|
|
@@ -128,7 +129,7 @@ async function copyExampleConfig() {
|
|
|
128
129
|
try {
|
|
129
130
|
await fs.access(sourceExample);
|
|
130
131
|
await fs.copyFile(sourceExample, CONFIG_EXAMPLE_FILE);
|
|
131
|
-
|
|
132
|
+
logger.info(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
|
|
132
133
|
return true;
|
|
133
134
|
} catch (error) {
|
|
134
135
|
if (error.code === 'ENOENT') {
|
|
@@ -154,13 +155,13 @@ async function ensureConfigDir() {
|
|
|
154
155
|
if (error.code === 'ENOENT') {
|
|
155
156
|
try {
|
|
156
157
|
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
157
|
-
|
|
158
|
+
logger.info(`Created config directory: ${CONFIG_DIR}`);
|
|
158
159
|
// Copy example config to new directory
|
|
159
160
|
await copyExampleConfig();
|
|
160
161
|
return true; // Directory was newly created
|
|
161
162
|
} catch (mkdirError) {
|
|
162
163
|
if (mkdirError.code === 'EACCES' || mkdirError.code === 'EPERM') {
|
|
163
|
-
|
|
164
|
+
logger.error(`Cannot create configuration directory at ~/.pair-review/`);
|
|
164
165
|
process.exit(1);
|
|
165
166
|
}
|
|
166
167
|
throw mkdirError;
|
|
@@ -211,7 +212,7 @@ async function loadConfig() {
|
|
|
211
212
|
// Optional files or managed-config-present: skip silently
|
|
212
213
|
} else if (error instanceof SyntaxError) {
|
|
213
214
|
if (source.required) {
|
|
214
|
-
|
|
215
|
+
logger.error(`Invalid configuration file at ~/.pair-review/config.json`);
|
|
215
216
|
process.exit(1);
|
|
216
217
|
}
|
|
217
218
|
logger.warn(`Malformed config at ${source.label}, skipping`);
|
|
@@ -235,9 +236,19 @@ async function loadConfig() {
|
|
|
235
236
|
mergedConfig.repos = normalized;
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
// PORT env var overrides all config layers (used by Preview and similar harnesses)
|
|
240
|
+
if (process.env.PORT) {
|
|
241
|
+
const envPort = Number(process.env.PORT);
|
|
242
|
+
if (!validatePort(envPort)) {
|
|
243
|
+
logger.error(`Invalid PORT env var "${process.env.PORT}" (must be an integer between 1024 and 65535)`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
mergedConfig.port = envPort;
|
|
247
|
+
}
|
|
248
|
+
|
|
238
249
|
// Validate port
|
|
239
250
|
if (!validatePort(mergedConfig.port)) {
|
|
240
|
-
|
|
251
|
+
logger.error(`Invalid port number ${mergedConfig.port}`);
|
|
241
252
|
process.exit(1);
|
|
242
253
|
}
|
|
243
254
|
|
|
@@ -269,7 +280,7 @@ async function saveConfig(config) {
|
|
|
269
280
|
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
270
281
|
} catch (error) {
|
|
271
282
|
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
272
|
-
|
|
283
|
+
logger.error(`Cannot create configuration directory at ~/.pair-review/`);
|
|
273
284
|
process.exit(1);
|
|
274
285
|
}
|
|
275
286
|
throw error;
|
package/src/main.js
CHANGED
|
@@ -20,6 +20,7 @@ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./git/di
|
|
|
20
20
|
const { getEmoji: getCategoryEmoji } = require('./utils/category-emoji');
|
|
21
21
|
const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({default: open}) => open(...args));
|
|
22
22
|
const { registerProtocolHandler, unregisterProtocolHandler } = require('./protocol-handler');
|
|
23
|
+
const { attemptDelegation } = require('./single-port');
|
|
23
24
|
|
|
24
25
|
let db = null;
|
|
25
26
|
|
|
@@ -432,26 +433,8 @@ AI PROVIDERS:
|
|
|
432
433
|
showWelcomeMessage();
|
|
433
434
|
}
|
|
434
435
|
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
db = await initializeDatabase(resolveDbName(config));
|
|
438
|
-
|
|
439
|
-
// Migrate existing worktrees to database (if any)
|
|
440
|
-
const path = require('path');
|
|
441
|
-
const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
|
|
442
|
-
const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
|
|
443
|
-
if (migrationResult.migrated > 0) {
|
|
444
|
-
console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
|
|
445
|
-
}
|
|
446
|
-
if (migrationResult.errors.length > 0) {
|
|
447
|
-
console.warn('Some worktrees could not be migrated:', migrationResult.errors);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
|
|
451
|
-
const poolLifecycle = new WorktreePoolLifecycle(db, config);
|
|
452
|
-
await poolLifecycle.resetAndRehydrate();
|
|
453
|
-
|
|
454
|
-
// Parse command line arguments including flags
|
|
436
|
+
// Parse command line arguments including flags (before DB init so
|
|
437
|
+
// single-port delegation can skip DB entirely)
|
|
455
438
|
const { prArgs, flags } = parseArgs(args);
|
|
456
439
|
|
|
457
440
|
// Apply debug_stream from config if not already enabled by CLI flag
|
|
@@ -474,6 +457,36 @@ AI PROVIDERS:
|
|
|
474
457
|
// server, so we must also apply here.
|
|
475
458
|
applyConfigOverrides(config);
|
|
476
459
|
|
|
460
|
+
// Single-port delegation: if a pair-review server is already running on the
|
|
461
|
+
// configured port, delegate to it (open browser URL) and exit immediately.
|
|
462
|
+
// Skipped for: headless modes (no browser), single_port: false (dev mode).
|
|
463
|
+
if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
|
|
464
|
+
const delegated = await attemptDelegation(config, flags, prArgs);
|
|
465
|
+
if (delegated) {
|
|
466
|
+
process.exit(0);
|
|
467
|
+
}
|
|
468
|
+
// Not delegated — no server running, proceed to start one
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Initialize database
|
|
472
|
+
console.log('Initializing database...');
|
|
473
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
474
|
+
|
|
475
|
+
// Migrate existing worktrees to database (if any)
|
|
476
|
+
const path = require('path');
|
|
477
|
+
const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
|
|
478
|
+
const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
|
|
479
|
+
if (migrationResult.migrated > 0) {
|
|
480
|
+
console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
|
|
481
|
+
}
|
|
482
|
+
if (migrationResult.errors.length > 0) {
|
|
483
|
+
console.warn('Some worktrees could not be migrated:', migrationResult.errors);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
|
|
487
|
+
const poolLifecycle = new WorktreePoolLifecycle(db, config);
|
|
488
|
+
await poolLifecycle.resetAndRehydrate();
|
|
489
|
+
|
|
477
490
|
// Check for local mode (review uncommitted local changes)
|
|
478
491
|
if (flags.local) {
|
|
479
492
|
// Resolve localPath, defaulting to cwd if not provided
|
package/src/mcp-stdio.js
CHANGED
|
@@ -45,6 +45,13 @@ async function startMCPStdio() {
|
|
|
45
45
|
console.error(`[MCP] Warning: failed to load config, using defaults: ${err.message}`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// MCP mode needs its own Express server for stdio↔HTTP bridging and cannot
|
|
49
|
+
// delegate to a running pair-review instance (the stdio transport owns this
|
|
50
|
+
// process). Force auto-port selection to avoid EADDRINUSE when a regular
|
|
51
|
+
// pair-review server is already running on config.port.
|
|
52
|
+
// startServer (src/server.js) reads this env var and flips config.single_port.
|
|
53
|
+
process.env.PAIR_REVIEW_SINGLE_PORT = 'false';
|
|
54
|
+
|
|
48
55
|
const db = await initializeDatabase(resolveDbName(config));
|
|
49
56
|
const port = await startServer(db);
|
|
50
57
|
|
package/src/routes/config.js
CHANGED
|
@@ -22,11 +22,19 @@ const {
|
|
|
22
22
|
const { normalizeRepository } = require('../utils/paths');
|
|
23
23
|
const { isRunningViaNpx, getGitHubToken } = require('../config');
|
|
24
24
|
const { version } = require('../../package.json');
|
|
25
|
+
const semver = require('semver');
|
|
25
26
|
const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
|
|
26
27
|
const logger = require('../utils/logger');
|
|
27
28
|
|
|
28
29
|
const router = express.Router();
|
|
29
30
|
|
|
31
|
+
// Module-level state: the most recent version we've been told about that's
|
|
32
|
+
// newer than the running server. Plain string, not an object. `null` means
|
|
33
|
+
// nothing is pending. Reset on process restart — which is fine because a
|
|
34
|
+
// restart either IS the update (running version is now newer) or loses no
|
|
35
|
+
// information (the next notifier will re-populate it).
|
|
36
|
+
let pendingUpdateVersion = null;
|
|
37
|
+
|
|
30
38
|
/**
|
|
31
39
|
* Get user configuration (for frontend use)
|
|
32
40
|
* Returns safe-to-expose configuration values
|
|
@@ -71,10 +79,46 @@ router.get('/api/config', (req, res) => {
|
|
|
71
79
|
icon: config.share.icon || null,
|
|
72
80
|
label: config.share.label || null,
|
|
73
81
|
description: config.share.description || null
|
|
74
|
-
} : null
|
|
82
|
+
} : null,
|
|
83
|
+
pending_update: pendingUpdateVersion
|
|
75
84
|
});
|
|
76
85
|
});
|
|
77
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Notify the running server that a newer version is available.
|
|
89
|
+
* Called by a newer CLI invocation delegating to this server.
|
|
90
|
+
* Stores state so browser tabs can pick it up via GET /api/config.
|
|
91
|
+
*
|
|
92
|
+
* Suppression is version-based, not time-based: a POST is accepted only
|
|
93
|
+
* when the incoming version is strictly newer than both the running version
|
|
94
|
+
* and any currently-pending version. This means `pendingUpdateVersion`
|
|
95
|
+
* monotonically increases for the life of the process.
|
|
96
|
+
*/
|
|
97
|
+
router.post('/api/notify-update', (req, res) => {
|
|
98
|
+
const incomingVersion = req.body?.version;
|
|
99
|
+
if (!incomingVersion || !semver.valid(incomingVersion)) {
|
|
100
|
+
return res.status(400).json({ error: 'Invalid version' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!semver.gt(incomingVersion, version)) {
|
|
104
|
+
return res.json({ ok: true, notified: false, reason: 'not_newer' });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Suppress unless the incoming version is STRICTLY newer than what's
|
|
108
|
+
// already pending. Handles three cases at once:
|
|
109
|
+
// - incoming == pending → suppressed (nothing new)
|
|
110
|
+
// - incoming > pending → accepted (genuinely newer, falls through)
|
|
111
|
+
// - incoming < pending → suppressed (downgrade — user already knows)
|
|
112
|
+
if (pendingUpdateVersion && !semver.gt(incomingVersion, pendingUpdateVersion)) {
|
|
113
|
+
return res.json({ ok: true, notified: false, reason: 'not_newer_than_pending' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pendingUpdateVersion = incomingVersion;
|
|
117
|
+
logger.info(`New version available: ${incomingVersion} (running ${version})`);
|
|
118
|
+
|
|
119
|
+
res.json({ ok: true, notified: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
78
122
|
/**
|
|
79
123
|
* Get repository-specific settings
|
|
80
124
|
* Returns default_instructions, default_provider, and default_model for the repository
|
|
@@ -328,4 +372,14 @@ router.post('/api/providers/refresh-availability', async (req, res) => {
|
|
|
328
372
|
}
|
|
329
373
|
});
|
|
330
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Test-only helper: reset the in-memory pending-update state.
|
|
377
|
+
* Not exported from index — intended for use by integration tests that
|
|
378
|
+
* share the same module instance and need isolation between cases.
|
|
379
|
+
*/
|
|
380
|
+
function _resetPendingUpdate() {
|
|
381
|
+
pendingUpdateVersion = null;
|
|
382
|
+
}
|
|
383
|
+
|
|
331
384
|
module.exports = router;
|
|
385
|
+
module.exports._resetPendingUpdate = _resetPendingUpdate;
|
package/src/server.js
CHANGED
|
@@ -14,6 +14,19 @@ let db = null;
|
|
|
14
14
|
let server = null;
|
|
15
15
|
let chatSessionManager = null;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Apply env var overrides to config after loadConfig().
|
|
19
|
+
* Currently handles PAIR_REVIEW_SINGLE_PORT — a bridge for callers that
|
|
20
|
+
* need to force multi-port mode (e.g. mcp-stdio.js). Matches the
|
|
21
|
+
* PAIR_REVIEW_YOLO bridge pattern.
|
|
22
|
+
*/
|
|
23
|
+
function applyEnvOverrides(config) {
|
|
24
|
+
if (process.env.PAIR_REVIEW_SINGLE_PORT === 'false') {
|
|
25
|
+
config.single_port = false;
|
|
26
|
+
}
|
|
27
|
+
return config;
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
/**
|
|
18
31
|
* Request logging middleware (disabled for cleaner output)
|
|
19
32
|
*/
|
|
@@ -93,6 +106,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
93
106
|
try {
|
|
94
107
|
// Load configuration
|
|
95
108
|
const { config } = await loadConfig();
|
|
109
|
+
applyEnvOverrides(config);
|
|
96
110
|
|
|
97
111
|
// Apply provider configuration overrides (custom models, commands, etc.)
|
|
98
112
|
applyConfigOverrides(config);
|
|
@@ -294,9 +308,14 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
294
308
|
res.sendFile(path.join(__dirname, '..', 'public', 'local.html'));
|
|
295
309
|
});
|
|
296
310
|
|
|
297
|
-
// Health check endpoint
|
|
311
|
+
// Health check endpoint (also used by single-port detection)
|
|
298
312
|
app.get('/health', (req, res) => {
|
|
299
|
-
res.json({
|
|
313
|
+
res.json({
|
|
314
|
+
status: 'ok',
|
|
315
|
+
service: 'pair-review',
|
|
316
|
+
version: require('../package.json').version,
|
|
317
|
+
timestamp: new Date().toISOString()
|
|
318
|
+
});
|
|
300
319
|
});
|
|
301
320
|
|
|
302
321
|
// Store database instance, GitHub token, and config for routes
|
|
@@ -365,7 +384,9 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
365
384
|
});
|
|
366
385
|
|
|
367
386
|
// Find available port and start server
|
|
368
|
-
const port =
|
|
387
|
+
const port = config.single_port !== false
|
|
388
|
+
? config.port // single-port mode: use exact port, fail if unavailable
|
|
389
|
+
: await findAvailablePort(app, config.port);
|
|
369
390
|
|
|
370
391
|
// Check provider availability before accepting requests so /api/config
|
|
371
392
|
// returns accurate pi_available on the very first request (avoids race
|
|
@@ -381,14 +402,35 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
381
402
|
console.warn('Provider availability check failed:', err.message);
|
|
382
403
|
}
|
|
383
404
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
405
|
+
await new Promise((resolve, reject) => {
|
|
406
|
+
server = app.listen(port, () => {
|
|
407
|
+
console.log(`Server running on http://localhost:${port}`);
|
|
408
|
+
attachWebSocket(server, db, poolLifecycle);
|
|
409
|
+
resolve();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// .once instead of .on: this handler detaches after firing exactly once,
|
|
413
|
+
// so the post-startup handler below doesn't double-handle EADDRINUSE/EACCES
|
|
414
|
+
// during the initial bind.
|
|
415
|
+
server.once('error', (error) => {
|
|
416
|
+
if (error.code === 'EADDRINUSE' && config.single_port !== false) {
|
|
417
|
+
reject(new Error(
|
|
418
|
+
`Port ${port} is already in use. A pair-review server may already be running, ` +
|
|
419
|
+
`or another service is using this port. ` +
|
|
420
|
+
`Set "single_port": false in ~/.pair-review/config.json to use automatic port selection.`
|
|
421
|
+
));
|
|
422
|
+
} else {
|
|
423
|
+
reject(error);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
387
426
|
});
|
|
388
427
|
|
|
428
|
+
// Post-startup error handler. Express middleware handles request-level errors,
|
|
429
|
+
// so this only fires for lower-level issues like accept-loop failures (EMFILE,
|
|
430
|
+
// ENFILE from file descriptor exhaustion). Log but do NOT process.exit — the
|
|
431
|
+
// old code did that and it was too aggressive for transient errors.
|
|
389
432
|
server.on('error', (error) => {
|
|
390
|
-
console.error('Server error:', error);
|
|
391
|
-
process.exit(1);
|
|
433
|
+
console.error('Server error after startup:', error);
|
|
392
434
|
});
|
|
393
435
|
|
|
394
436
|
// Return the actual port the server started on
|
|
@@ -468,4 +510,4 @@ if (require.main === module) {
|
|
|
468
510
|
startServer();
|
|
469
511
|
}
|
|
470
512
|
|
|
471
|
-
module.exports = { startServer };
|
|
513
|
+
module.exports = { startServer, applyEnvOverrides };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const semver = require('semver');
|
|
5
|
+
const { PRArgumentParser } = require('./github/parser');
|
|
6
|
+
const logger = require('./utils/logger');
|
|
7
|
+
const { version: packageVersion } = require('../package.json');
|
|
8
|
+
|
|
9
|
+
const HEALTH_TIMEOUT_MS = 2000;
|
|
10
|
+
|
|
11
|
+
// Default dependencies (overridable for testing)
|
|
12
|
+
const defaults = {
|
|
13
|
+
httpGet: http.get,
|
|
14
|
+
httpRequest: http.request,
|
|
15
|
+
logger,
|
|
16
|
+
open: (...args) => process.env.PAIR_REVIEW_NO_OPEN
|
|
17
|
+
? Promise.resolve()
|
|
18
|
+
: import('open').then(({ default: open }) => open(...args)),
|
|
19
|
+
PRArgumentParser
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a pair-review server is already running on the given port.
|
|
24
|
+
* @param {number} port
|
|
25
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
26
|
+
* @returns {Promise<{running: boolean, isPairReview?: boolean, version?: string}>}
|
|
27
|
+
*/
|
|
28
|
+
function detectRunningServer(port, _deps) {
|
|
29
|
+
const deps = { ...defaults, ..._deps };
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const req = deps.httpGet(`http://localhost:${port}/health`, { timeout: HEALTH_TIMEOUT_MS }, (res) => {
|
|
32
|
+
let data = '';
|
|
33
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
34
|
+
res.on('end', () => {
|
|
35
|
+
try {
|
|
36
|
+
const body = JSON.parse(data);
|
|
37
|
+
if (body.service === 'pair-review') {
|
|
38
|
+
resolve({ running: true, isPairReview: true, version: body.version || null });
|
|
39
|
+
} else {
|
|
40
|
+
resolve({ running: true, isPairReview: false });
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
resolve({ running: true, isPairReview: false });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
req.on('error', () => {
|
|
49
|
+
resolve({ running: false });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
req.on('timeout', () => {
|
|
53
|
+
req.destroy();
|
|
54
|
+
resolve({ running: false });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Notify the running server that a newer version is available.
|
|
61
|
+
* Fire-and-forget — does not block on response.
|
|
62
|
+
* @param {number} port
|
|
63
|
+
* @param {string} currentVersion - Version of the current CLI invocation
|
|
64
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
65
|
+
*/
|
|
66
|
+
function notifyVersion(port, currentVersion, _deps) {
|
|
67
|
+
const deps = { ...defaults, ..._deps };
|
|
68
|
+
const payload = JSON.stringify({ version: currentVersion });
|
|
69
|
+
const req = deps.httpRequest({
|
|
70
|
+
hostname: 'localhost',
|
|
71
|
+
port,
|
|
72
|
+
path: '/api/notify-update',
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
77
|
+
},
|
|
78
|
+
timeout: HEALTH_TIMEOUT_MS
|
|
79
|
+
}, () => { /* ignore response */ });
|
|
80
|
+
|
|
81
|
+
req.on('error', () => { /* fire and forget */ });
|
|
82
|
+
req.on('timeout', () => { req.destroy(); });
|
|
83
|
+
req.write(payload);
|
|
84
|
+
req.end();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the URL to delegate to an existing server.
|
|
89
|
+
* @param {number} port
|
|
90
|
+
* @param {'pr'|'local'|'server'} mode
|
|
91
|
+
* @param {object} context
|
|
92
|
+
* @param {string} [context.owner]
|
|
93
|
+
* @param {string} [context.repo]
|
|
94
|
+
* @param {number} [context.number]
|
|
95
|
+
* @param {string} [context.localPath]
|
|
96
|
+
* @param {boolean} [context.analyze] - Whether to trigger auto-analysis
|
|
97
|
+
* @returns {string} Full URL
|
|
98
|
+
*/
|
|
99
|
+
function buildDelegationUrl(port, mode, context = {}) {
|
|
100
|
+
const base = `http://localhost:${port}`;
|
|
101
|
+
if (mode === 'pr') {
|
|
102
|
+
let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
|
|
103
|
+
if (context.analyze) url += '?analyze=true';
|
|
104
|
+
return url;
|
|
105
|
+
}
|
|
106
|
+
if (mode === 'local') {
|
|
107
|
+
let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
|
|
108
|
+
if (context.analyze) url += '&analyze=true';
|
|
109
|
+
return url;
|
|
110
|
+
}
|
|
111
|
+
return `${base}/`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse PR arguments for URL construction without starting a server.
|
|
116
|
+
* Reuses PRArgumentParser — synchronous for URLs, async for bare numbers.
|
|
117
|
+
* @param {string[]} prArgs - Raw CLI PR arguments
|
|
118
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
119
|
+
* @returns {Promise<{owner: string, repo: string, number: number}>}
|
|
120
|
+
*/
|
|
121
|
+
async function parsePRArgsForDelegation(prArgs, _deps) {
|
|
122
|
+
const deps = { ...defaults, ..._deps };
|
|
123
|
+
const parser = new deps.PRArgumentParser();
|
|
124
|
+
return parser.parsePRArguments(prArgs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Attempt single-port delegation. Returns true if delegation happened (caller should exit).
|
|
129
|
+
* Returns false if no running server was found (caller should start normally).
|
|
130
|
+
* Throws if port is occupied by a non-pair-review service.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} config - Loaded config
|
|
133
|
+
* @param {object} flags - Parsed CLI flags
|
|
134
|
+
* @param {string[]} prArgs - PR arguments from CLI
|
|
135
|
+
* @param {object} [_deps] - Dependency overrides for testing
|
|
136
|
+
* @returns {Promise<boolean>} true if delegated, false if should start fresh
|
|
137
|
+
*/
|
|
138
|
+
async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
139
|
+
const deps = { ...defaults, ..._deps };
|
|
140
|
+
const port = config.port;
|
|
141
|
+
|
|
142
|
+
const result = await detectRunningServer(port, _deps);
|
|
143
|
+
|
|
144
|
+
if (result.running && !result.isPairReview) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Port ${port} is in use by another service. ` +
|
|
147
|
+
`Either stop that service, or set a different port in ~/.pair-review/config.json`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!result.running) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Server is running — delegate to it
|
|
156
|
+
deps.logger.info(`Existing pair-review server detected on port ${port} (v${result.version})`);
|
|
157
|
+
|
|
158
|
+
// Determine mode and build URL
|
|
159
|
+
let url;
|
|
160
|
+
if (flags.local) {
|
|
161
|
+
const targetPath = path.resolve(flags.localPath || process.cwd());
|
|
162
|
+
url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
|
|
163
|
+
} else if (prArgs.length > 0) {
|
|
164
|
+
const prInfo = await parsePRArgsForDelegation(prArgs, _deps);
|
|
165
|
+
url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze: flags.ai });
|
|
166
|
+
} else {
|
|
167
|
+
url = buildDelegationUrl(port, 'server');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Notify running server of newer version if applicable
|
|
171
|
+
if (result.version && semver.valid(packageVersion) && semver.valid(result.version)) {
|
|
172
|
+
if (semver.gt(packageVersion, result.version)) {
|
|
173
|
+
deps.logger.info(`Notifying server of newer version: ${packageVersion} > ${result.version}`);
|
|
174
|
+
notifyVersion(port, packageVersion, _deps);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Open browser and exit
|
|
179
|
+
deps.logger.info(`Delegating to running server: ${url}`);
|
|
180
|
+
await deps.open(url);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
detectRunningServer,
|
|
186
|
+
notifyVersion,
|
|
187
|
+
buildDelegationUrl,
|
|
188
|
+
parsePRArgsForDelegation,
|
|
189
|
+
attemptDelegation,
|
|
190
|
+
HEALTH_TIMEOUT_MS
|
|
191
|
+
};
|