ralph-review 0.1.5 → 0.1.7

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
@@ -131,11 +131,14 @@ Treats review findings as untrusted input — verifies every claim against actua
131
131
  ## Installation
132
132
 
133
133
  ```bash
134
- # Homebrew
134
+ # Homebrew (install or update)
135
135
  brew install kenryu42/tap/ralph-review
136
136
 
137
- # npm
137
+ # npm (install or update)
138
138
  npm install -g ralph-review
139
+
140
+ # Or let ralph-review detect the install method and update itself
141
+ rr update
139
142
  ```
140
143
 
141
144
  ---
@@ -174,9 +177,14 @@ rrr
174
177
  | `rr log` | View review logs (`-n 5` for last 5, `--json` for JSON output) |
175
178
  | `rr dashboard` | Open review dashboard in browser |
176
179
  | `rr doctor` | Run environment and configuration diagnostics (`--fix` to auto-resolve) |
180
+ | `rr update` | Check for and install a newer `ralph-review` version |
177
181
 
178
182
  The `rrr` command is a shorthand alias for `rr run` -- all flags work the same.
179
183
 
184
+ For update checks without installing, run `rr update --check`. If install-source detection is
185
+ ambiguous, force the package manager with `rr update --manager npm` or
186
+ `rr update --manager brew`.
187
+
180
188
  ---
181
189
 
182
190
  ## Supported Coding Agents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-review",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Orchestrating coding agents for code review, verification and fixing via the ralph loop.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli-core.ts CHANGED
@@ -150,6 +150,29 @@ export const COMMANDS: CommandDef[] = [
150
150
  ],
151
151
  examples: ["rr doctor", "rr doctor --fix"],
152
152
  },
153
+ {
154
+ name: "update",
155
+ description: "Check for and install a newer ralph-review version",
156
+ options: [
157
+ {
158
+ name: "check",
159
+ type: "boolean",
160
+ description: "Check for an update without installing it",
161
+ },
162
+ {
163
+ name: "manager",
164
+ type: "string",
165
+ placeholder: "npm|brew",
166
+ description: "Override install-source detection",
167
+ },
168
+ ],
169
+ examples: [
170
+ "rr update",
171
+ "rr update --check",
172
+ "rr update --manager npm",
173
+ "rr update --manager brew",
174
+ ],
175
+ },
153
176
  {
154
177
  name: "_run-foreground",
155
178
  description: "Internal: run review cycle in tmux foreground",
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@ import { runLog } from "./commands/log";
11
11
  import { runForeground, startReview } from "./commands/run";
12
12
  import { runStatus } from "./commands/status";
13
13
  import { runStop } from "./commands/stop";
14
+ import { runUpdate } from "./commands/update";
14
15
  import { CliError, type CommandDef, parseCommand } from "./lib/cli-parser";
15
16
 
16
17
  export {
@@ -41,6 +42,7 @@ export interface CliDeps {
41
42
  runDashboard: typeof runDashboard;
42
43
  runDoctor: typeof runDoctor;
43
44
  runList: typeof runList;
45
+ runUpdate: typeof runUpdate;
44
46
  log: (message: string) => void;
45
47
  logError: (message: string) => void;
46
48
  logMessage: (message: string) => void;
@@ -69,6 +71,7 @@ const DEFAULT_CLI_DEPS: CliDeps = {
69
71
  runDashboard,
70
72
  runDoctor,
71
73
  runList,
74
+ runUpdate,
72
75
  log: CONSOLE_LOG,
73
76
  logError: CLACK_ERROR,
74
77
  logMessage: CLACK_MESSAGE,
@@ -165,6 +168,10 @@ export async function runCli(
165
168
  await cliDeps.runDoctor(commandArgs);
166
169
  break;
167
170
 
171
+ case "update":
172
+ await cliDeps.runUpdate(commandArgs);
173
+ break;
174
+
168
175
  case "list":
169
176
  await cliDeps.runList();
170
177
  break;
@@ -211,11 +211,12 @@ const DEFAULT_MAX_ITERATIONS = 5;
211
211
  const DEFAULT_ITERATION_TIMEOUT_MINUTES = 30;
212
212
 
213
213
  const REVIEWER_AGENT_PRIORITY: readonly AgentType[] = ["codex", "droid", "claude", "gemini"];
214
- const FIXER_AGENT_PRIORITY: readonly AgentType[] = ["claude", "codex", "droid", "gemini"];
215
- const SIMPLIFIER_AGENT_PRIORITY: readonly AgentType[] = ["claude", "codex", "droid", "gemini"];
214
+ const FIXER_AGENT_PRIORITY: readonly AgentType[] = ["codex", "claude", "droid", "gemini"];
215
+ const SIMPLIFIER_AGENT_PRIORITY: readonly AgentType[] = ["codex", "claude", "droid", "gemini"];
216
216
 
217
217
  const MODEL_PRIORITY_MATCHERS: Record<ConfiguredRole, readonly ((model: string) => boolean)[]> = {
218
218
  reviewer: [
219
+ (model) => matchesModelId(model, "gpt-5.4"),
219
220
  (model) => matchesModelId(model, "gpt-5.3-codex"),
220
221
  (model) => matchesModelId(model, "gpt-5.2"),
221
222
  (model) => matchesModelId(model, "gpt-5.2-codex"),
@@ -223,11 +224,13 @@ const MODEL_PRIORITY_MATCHERS: Record<ConfiguredRole, readonly ((model: string)
223
224
  (model) => matchesModelId(model, "gemini-3-pro-preview"),
224
225
  ],
225
226
  fixer: [
226
- (model) => matchesModelId(model, "claude-opus-4-6"),
227
+ (model) => matchesModelId(model, "gpt-5.4"),
227
228
  (model) => matchesModelId(model, "gpt-5.3-codex"),
229
+ (model) => matchesModelId(model, "claude-opus-4-6"),
228
230
  (model) => matchesModelId(model, "gemini-3-pro-preview"),
229
231
  ],
230
232
  "code-simplifier": [
233
+ (model) => matchesModelId(model, "gpt-5.4"),
231
234
  (model) => matchesModelId(model, "claude-opus-4-6"),
232
235
  (model) => matchesModelId(model, "gpt-5.3-codex"),
233
236
  (model) => isClaudeOpus45Model(model),
@@ -0,0 +1,125 @@
1
+ import * as p from "@clack/prompts";
2
+ import { getCommandDef } from "@/cli";
3
+ import { type CommandDef, parseCommand } from "@/lib/cli-parser";
4
+ import {
5
+ getDefaultSelfUpdateDependencies,
6
+ isUpdateManager,
7
+ performSelfUpdate,
8
+ type SelfUpdateDependencies,
9
+ SelfUpdateError,
10
+ type SelfUpdateOptions,
11
+ type SelfUpdateResult,
12
+ } from "@/lib/self-update";
13
+
14
+ interface UpdateRuntime extends SelfUpdateDependencies {
15
+ getCommandDef: (name: string) => CommandDef | undefined;
16
+ parseCommand: typeof parseCommand;
17
+ performSelfUpdate: typeof performSelfUpdate;
18
+ log: {
19
+ error: (message: string) => void;
20
+ info: (message: string) => void;
21
+ message: (message: string) => void;
22
+ success: (message: string) => void;
23
+ };
24
+ exit: (code: number) => void;
25
+ }
26
+
27
+ export interface UpdateRuntimeOverrides extends Partial<Omit<UpdateRuntime, "log">> {
28
+ log?: Partial<UpdateRuntime["log"]>;
29
+ }
30
+
31
+ function createUpdateRuntime(overrides: UpdateRuntimeOverrides = {}): UpdateRuntime {
32
+ const baseDeps = getDefaultSelfUpdateDependencies(overrides.cliPath);
33
+
34
+ return {
35
+ ...baseDeps,
36
+ ...overrides,
37
+ getCommandDef: overrides.getCommandDef ?? getCommandDef,
38
+ parseCommand: overrides.parseCommand ?? parseCommand,
39
+ performSelfUpdate: overrides.performSelfUpdate ?? performSelfUpdate,
40
+ log: {
41
+ error: overrides.log?.error ?? p.log.error,
42
+ info: overrides.log?.info ?? p.log.info,
43
+ message: overrides.log?.message ?? p.log.message,
44
+ success: overrides.log?.success ?? p.log.success,
45
+ },
46
+ exit: overrides.exit ?? process.exit.bind(process),
47
+ };
48
+ }
49
+
50
+ function managerLabel(manager: SelfUpdateResult["manager"]): string {
51
+ return manager === "brew" ? "Homebrew" : "npm";
52
+ }
53
+
54
+ function renderSelfUpdateResult(result: SelfUpdateResult, runtime: UpdateRuntime): void {
55
+ switch (result.status) {
56
+ case "up-to-date":
57
+ runtime.log.success(
58
+ `ralph-review is already up to date via ${managerLabel(result.manager)} (${result.currentVersion}).`
59
+ );
60
+ return;
61
+
62
+ case "update-available":
63
+ if (result.latestVersion) {
64
+ runtime.log.info(
65
+ `Update available via ${managerLabel(result.manager)}: ${result.currentVersion} -> ${result.latestVersion}`
66
+ );
67
+ return;
68
+ }
69
+
70
+ runtime.log.info(
71
+ `Update available via ${managerLabel(result.manager)}. Current version: ${result.currentVersion}`
72
+ );
73
+ return;
74
+
75
+ case "updated":
76
+ runtime.log.success(
77
+ `Updated ralph-review via ${managerLabel(result.manager)}: ${result.previousVersion} -> ${result.finalVersion}`
78
+ );
79
+ return;
80
+ }
81
+ }
82
+
83
+ export async function runUpdate(
84
+ argv: string[],
85
+ overrides: UpdateRuntimeOverrides = {}
86
+ ): Promise<void> {
87
+ const runtime = createUpdateRuntime(overrides);
88
+ const commandDef = runtime.getCommandDef("update");
89
+ if (!commandDef) {
90
+ runtime.log.error("Internal error: update command definition not found");
91
+ runtime.exit(1);
92
+ return;
93
+ }
94
+
95
+ const parsed = runtime.parseCommand<{ check: boolean; manager?: string }>(commandDef, argv);
96
+ const managerValue = parsed.values.manager;
97
+ if (managerValue !== undefined && !isUpdateManager(managerValue)) {
98
+ runtime.log.error(`Invalid value for --manager: "${managerValue}"`);
99
+ runtime.log.message("Valid values: npm, brew");
100
+ runtime.exit(1);
101
+ return;
102
+ }
103
+
104
+ const options: SelfUpdateOptions = {
105
+ checkOnly: parsed.values.check ?? false,
106
+ manager: managerValue,
107
+ };
108
+
109
+ try {
110
+ const result = await runtime.performSelfUpdate(options, runtime);
111
+
112
+ renderSelfUpdateResult(result, runtime);
113
+ } catch (error) {
114
+ if (error instanceof SelfUpdateError) {
115
+ runtime.log.error(error.message);
116
+ for (const note of error.notes) {
117
+ runtime.log.message(note);
118
+ }
119
+ } else {
120
+ runtime.log.error(`${error}`);
121
+ }
122
+
123
+ runtime.exit(1);
124
+ }
125
+ }
@@ -17,6 +17,7 @@ export const claudeModelOptions = [
17
17
  ] as const;
18
18
 
19
19
  export const codexModelOptions = [
20
+ { value: "gpt-5.4", label: "GPT-5.4" },
20
21
  { value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
21
22
  { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
22
23
  { value: "gpt-5.2", label: "GPT-5.2" },
@@ -37,6 +38,7 @@ export const droidModelOptions = [
37
38
  { value: "gpt-5.2", label: "GPT-5.2" },
38
39
  { value: "gpt-5.2-codex", label: "GPT-5.2-Codex" },
39
40
  { value: "gpt-5.3-codex", label: "GPT-5.3-Codex" },
41
+ { value: "gpt-5.4", label: "GPT-5.4" },
40
42
  { value: "gemini-3-pro-preview", label: "Gemini 3 Pro" },
41
43
  { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" },
42
44
  { value: "gemini-3-flash-preview", label: "Gemini 3 Flash" },
@@ -63,6 +65,7 @@ const droidReasoningLevelsByModel: Record<string, readonly ReasoningLevel[]> = {
63
65
  "gpt-5.2": ["low", "medium", "high", "xhigh"],
64
66
  "gpt-5.2-codex": ["low", "medium", "high", "xhigh"],
65
67
  "gpt-5.3-codex": ["low", "medium", "high", "xhigh"],
68
+ "gpt-5.4": ["low", "medium", "high", "xhigh"],
66
69
  "claude-sonnet-4-5-20250929": ["low", "medium", "high"],
67
70
  "claude-sonnet-4-6": ["low", "medium", "high"],
68
71
  "claude-opus-4-5-20251101": ["low", "medium", "high"],
@@ -0,0 +1,573 @@
1
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+
3
+ export type UpdateManager = "npm" | "brew";
4
+
5
+ export interface CommandResult {
6
+ stdout: string;
7
+ stderr: string;
8
+ exitCode: number;
9
+ }
10
+
11
+ export interface SelfUpdateDependencies {
12
+ cliPath: string;
13
+ getCurrentVersion: () => string | Promise<string>;
14
+ which: (command: string) => string | null;
15
+ runText: (command: string[]) => Promise<CommandResult>;
16
+ runInteractive: (command: string[]) => Promise<number>;
17
+ }
18
+
19
+ export interface SelfUpdateOptions {
20
+ checkOnly: boolean;
21
+ manager?: UpdateManager;
22
+ }
23
+
24
+ export type SelfUpdateResult =
25
+ | {
26
+ status: "up-to-date";
27
+ manager: UpdateManager;
28
+ currentVersion: string;
29
+ latestVersion?: string;
30
+ }
31
+ | {
32
+ status: "update-available";
33
+ manager: UpdateManager;
34
+ currentVersion: string;
35
+ latestVersion?: string;
36
+ }
37
+ | {
38
+ status: "updated";
39
+ manager: UpdateManager;
40
+ previousVersion: string;
41
+ finalVersion: string;
42
+ latestVersion?: string;
43
+ };
44
+
45
+ const PACKAGE_NAME = "ralph-review";
46
+ const BREW_TAP_FORMULA = "kenryu42/tap/ralph-review";
47
+ const NPM_INSTALL_COMMAND = ["npm", "install", "-g", `${PACKAGE_NAME}@latest`] as const;
48
+ const BREW_INSTALL_COMMAND = ["brew", "install", BREW_TAP_FORMULA] as const;
49
+ const BREW_INSTALLED_VERSION_ERROR =
50
+ "Could not determine the installed Homebrew version for ralph-review.";
51
+ const BREW_LATEST_VERSION_ERROR =
52
+ "Could not determine the latest Homebrew version for ralph-review.";
53
+ const NPM_INSTALLED_VERSION_ERROR =
54
+ "Could not determine the installed npm version for ralph-review.";
55
+ const VERSION_COMPARE_ERROR =
56
+ "Could not compare the installed and latest versions for ralph-review.";
57
+
58
+ export class SelfUpdateError extends Error {
59
+ constructor(
60
+ message: string,
61
+ public readonly notes: string[] = []
62
+ ) {
63
+ super(message);
64
+ this.name = "SelfUpdateError";
65
+ }
66
+ }
67
+
68
+ export function isUpdateManager(value: unknown): value is UpdateManager {
69
+ return value === "npm" || value === "brew";
70
+ }
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null;
74
+ }
75
+
76
+ function normalizePath(filePath: string): string {
77
+ return resolve(filePath);
78
+ }
79
+
80
+ function isPathWithin(filePath: string, parentPath: string): boolean {
81
+ const relativePath = relative(parentPath, filePath);
82
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
83
+ }
84
+
85
+ function buildDetectionNotes(): string[] {
86
+ return [
87
+ "Run: rr update --manager npm",
88
+ "Run: rr update --manager brew",
89
+ `Run: ${NPM_INSTALL_COMMAND.join(" ")}`,
90
+ `Run: ${BREW_INSTALL_COMMAND.join(" ")}`,
91
+ ];
92
+ }
93
+
94
+ function buildSourceNotes(): string[] {
95
+ return ["Update with git pull, or install globally to enable self-update."];
96
+ }
97
+
98
+ function commandDisplay(command: readonly string[]): string {
99
+ return command.join(" ");
100
+ }
101
+
102
+ function managerDisplay(manager: UpdateManager): string {
103
+ switch (manager) {
104
+ case "brew":
105
+ return "Homebrew";
106
+ case "npm":
107
+ return "npm";
108
+ }
109
+ }
110
+
111
+ function managerManualCommand(manager: UpdateManager): string {
112
+ switch (manager) {
113
+ case "brew":
114
+ return commandDisplay(BREW_INSTALL_COMMAND);
115
+ case "npm":
116
+ return commandDisplay(NPM_INSTALL_COMMAND);
117
+ }
118
+ }
119
+
120
+ async function readTextOutput(
121
+ deps: Pick<SelfUpdateDependencies, "runText">,
122
+ command: string[],
123
+ errorMessage: string
124
+ ): Promise<string> {
125
+ const result = await deps.runText(command);
126
+ if (result.exitCode !== 0) {
127
+ const notes = result.stderr.trim() ? [result.stderr.trim()] : [];
128
+ throw new SelfUpdateError(errorMessage, notes);
129
+ }
130
+ return result.stdout.trim();
131
+ }
132
+
133
+ function getPackageJsonPath(cliPath: string): string {
134
+ const resolvedCliPath = normalizePath(cliPath);
135
+ return join(dirname(dirname(resolvedCliPath)), "package.json");
136
+ }
137
+
138
+ async function readPackageVersion(cliPath: string): Promise<string> {
139
+ const packageJsonPath = getPackageJsonPath(cliPath);
140
+ const pkg = (await Bun.file(packageJsonPath).json()) as { version?: unknown };
141
+ if (typeof pkg.version !== "string" || pkg.version.trim().length === 0) {
142
+ throw new Error(`Could not determine version from ${packageJsonPath}`);
143
+ }
144
+ return pkg.version.trim();
145
+ }
146
+
147
+ async function readSpawnStream(
148
+ stream: ReadableStream<Uint8Array> | null | undefined
149
+ ): Promise<string> {
150
+ if (!stream) {
151
+ return "";
152
+ }
153
+
154
+ return new Response(stream).text();
155
+ }
156
+
157
+ export function getDefaultSelfUpdateDependencies(
158
+ cliPath: string = process.argv[1] ?? resolve(import.meta.dir, "../cli.ts")
159
+ ): SelfUpdateDependencies {
160
+ return {
161
+ cliPath,
162
+ getCurrentVersion: () => readPackageVersion(cliPath),
163
+ which: Bun.which,
164
+ runText: async (command: string[]) => {
165
+ const proc = Bun.spawn(command, {
166
+ stdout: "pipe",
167
+ stderr: "pipe",
168
+ stdin: "ignore",
169
+ });
170
+
171
+ const [exitCode, stdout, stderr] = await Promise.all([
172
+ proc.exited,
173
+ readSpawnStream(proc.stdout),
174
+ readSpawnStream(proc.stderr),
175
+ ]);
176
+
177
+ return {
178
+ stdout,
179
+ stderr,
180
+ exitCode,
181
+ };
182
+ },
183
+ runInteractive: async (command: string[]) => {
184
+ const proc = Bun.spawn(command, {
185
+ stdout: "inherit",
186
+ stderr: "inherit",
187
+ stdin: "inherit",
188
+ });
189
+
190
+ return proc.exited;
191
+ },
192
+ };
193
+ }
194
+
195
+ async function detectNpmMatch(deps: SelfUpdateDependencies, cliPath: string): Promise<boolean> {
196
+ if (!deps.which("npm")) {
197
+ return false;
198
+ }
199
+
200
+ const result = await deps.runText(["npm", "prefix", "-g"]);
201
+ if (result.exitCode !== 0) {
202
+ return false;
203
+ }
204
+
205
+ const prefix = result.stdout.trim();
206
+ if (!prefix) {
207
+ return false;
208
+ }
209
+
210
+ const npmPackageDir = join(prefix, "lib", "node_modules", PACKAGE_NAME);
211
+
212
+ return isPathWithin(cliPath, npmPackageDir);
213
+ }
214
+
215
+ async function detectBrewMatch(deps: SelfUpdateDependencies, cliPath: string): Promise<boolean> {
216
+ if (!deps.which("brew")) {
217
+ return false;
218
+ }
219
+
220
+ const result = await deps.runText(["brew", "--prefix", "--installed", PACKAGE_NAME]);
221
+ if (result.exitCode !== 0) {
222
+ return false;
223
+ }
224
+
225
+ const formulaPrefix = result.stdout.trim();
226
+ if (!formulaPrefix) {
227
+ return false;
228
+ }
229
+
230
+ const brewRoot = dirname(dirname(formulaPrefix));
231
+ const brewBinDir = join(brewRoot, "bin");
232
+ const cellarDir = join(brewRoot, "Cellar", PACKAGE_NAME);
233
+
234
+ return (
235
+ isPathWithin(cliPath, brewBinDir) ||
236
+ isPathWithin(cliPath, formulaPrefix) ||
237
+ isPathWithin(cliPath, cellarDir)
238
+ );
239
+ }
240
+
241
+ async function isSourceCheckout(
242
+ deps: Pick<SelfUpdateDependencies, "runText" | "which">,
243
+ cliPath: string
244
+ ): Promise<boolean> {
245
+ if (!deps.which("git")) {
246
+ return false;
247
+ }
248
+
249
+ const projectRoot = dirname(dirname(cliPath));
250
+ const result = await deps.runText([
251
+ "git",
252
+ "-C",
253
+ projectRoot,
254
+ "rev-parse",
255
+ "--is-inside-work-tree",
256
+ ]);
257
+ return result.exitCode === 0 && result.stdout.trim() === "true";
258
+ }
259
+
260
+ export async function detectUpdateManager(deps: SelfUpdateDependencies): Promise<UpdateManager> {
261
+ const cliPath = normalizePath(deps.cliPath);
262
+ const matches: UpdateManager[] = [];
263
+
264
+ if (await detectNpmMatch(deps, cliPath)) {
265
+ matches.push("npm");
266
+ }
267
+
268
+ if (await detectBrewMatch(deps, cliPath)) {
269
+ matches.push("brew");
270
+ }
271
+
272
+ const detectedManager = matches[0];
273
+ if (matches.length === 1 && detectedManager) {
274
+ return detectedManager;
275
+ }
276
+
277
+ if (matches.length > 1) {
278
+ throw new SelfUpdateError(
279
+ "Could not determine whether ralph-review was installed with npm or Homebrew.",
280
+ buildDetectionNotes()
281
+ );
282
+ }
283
+
284
+ if (await isSourceCheckout(deps, cliPath)) {
285
+ throw new SelfUpdateError(
286
+ "Self-update is not available when running from source.",
287
+ buildSourceNotes()
288
+ );
289
+ }
290
+
291
+ throw new SelfUpdateError(
292
+ "Could not determine how ralph-review was installed.",
293
+ buildDetectionNotes()
294
+ );
295
+ }
296
+
297
+ function ensureManagerAvailable(manager: UpdateManager, deps: SelfUpdateDependencies): void {
298
+ if (deps.which(manager)) {
299
+ return;
300
+ }
301
+
302
+ throw new SelfUpdateError(
303
+ `${managerDisplay(manager)} is not installed or not available in PATH.`,
304
+ [`Run: ${managerManualCommand(manager)}`]
305
+ );
306
+ }
307
+
308
+ async function getNpmVersions(
309
+ deps: SelfUpdateDependencies
310
+ ): Promise<{ currentVersion: string; latestVersion: string }> {
311
+ const currentVersion = await getNpmInstalledVersion(deps);
312
+ const latestVersion = await readTextOutput(
313
+ deps,
314
+ ["npm", "view", PACKAGE_NAME, "version"],
315
+ "Failed to fetch the latest npm version for ralph-review."
316
+ );
317
+
318
+ return {
319
+ currentVersion,
320
+ latestVersion,
321
+ };
322
+ }
323
+
324
+ function parseNpmInstalledVersion(output: string): string {
325
+ const parsed = JSON.parse(output) as unknown;
326
+ if (!isRecord(parsed) || !isRecord(parsed.dependencies)) {
327
+ throw new SelfUpdateError(NPM_INSTALLED_VERSION_ERROR);
328
+ }
329
+
330
+ const dependency = parsed.dependencies[PACKAGE_NAME];
331
+ if (!isRecord(dependency)) {
332
+ throw new SelfUpdateError(NPM_INSTALLED_VERSION_ERROR);
333
+ }
334
+
335
+ if (typeof dependency.version !== "string" || dependency.version.trim().length === 0) {
336
+ throw new SelfUpdateError(NPM_INSTALLED_VERSION_ERROR);
337
+ }
338
+
339
+ return dependency.version.trim();
340
+ }
341
+
342
+ async function getNpmInstalledVersion(deps: SelfUpdateDependencies): Promise<string> {
343
+ const output = await readTextOutput(
344
+ deps,
345
+ ["npm", "list", "-g", PACKAGE_NAME, "--json", "--depth=0"],
346
+ "Failed to read the installed npm version for ralph-review."
347
+ );
348
+
349
+ return parseNpmInstalledVersion(output);
350
+ }
351
+
352
+ function hasNewerVersion(currentVersion: string, latestVersion: string): boolean {
353
+ try {
354
+ return compareVersions(latestVersion, currentVersion) === 1;
355
+ } catch {
356
+ throw new SelfUpdateError(VERSION_COMPARE_ERROR, [
357
+ `Installed version: ${currentVersion}`,
358
+ `Latest version: ${latestVersion}`,
359
+ ]);
360
+ }
361
+ }
362
+
363
+ function parseVersionRevision(version: string): { version: string; revision: number } {
364
+ const trimmed = version.trim();
365
+ const [baseVersion, revisionSuffix, ...extraSegments] = trimmed.split("_");
366
+ if (!baseVersion || extraSegments.length > 0) {
367
+ throw new Error(`Invalid version: ${version}`);
368
+ }
369
+
370
+ if (revisionSuffix === undefined) {
371
+ return { version: baseVersion, revision: 0 };
372
+ }
373
+
374
+ if (!/^\d+$/.test(revisionSuffix)) {
375
+ throw new Error(`Invalid revisioned version: ${version}`);
376
+ }
377
+
378
+ return { version: baseVersion, revision: Number.parseInt(revisionSuffix, 10) };
379
+ }
380
+
381
+ function compareVersions(leftVersion: string, rightVersion: string): number {
382
+ const left = parseVersionRevision(leftVersion);
383
+ const right = parseVersionRevision(rightVersion);
384
+ const versionOrder = Bun.semver.order(left.version, right.version);
385
+
386
+ if (versionOrder !== 0) {
387
+ return versionOrder;
388
+ }
389
+
390
+ if (left.revision > right.revision) {
391
+ return 1;
392
+ }
393
+
394
+ if (left.revision < right.revision) {
395
+ return -1;
396
+ }
397
+
398
+ return 0;
399
+ }
400
+
401
+ function formatBrewVersion(version: string, revision: number): string {
402
+ return revision > 0 ? `${version}_${revision}` : version;
403
+ }
404
+
405
+ function parseBrewVersions(output: string): { currentVersion: string; latestVersion: string } {
406
+ const parsed = JSON.parse(output) as unknown;
407
+ if (!Array.isArray(parsed) || parsed.length === 0) {
408
+ throw new SelfUpdateError(BREW_INSTALLED_VERSION_ERROR);
409
+ }
410
+
411
+ const formula = parsed[0];
412
+ if (!isRecord(formula) || !Array.isArray(formula.installed)) {
413
+ throw new SelfUpdateError(BREW_INSTALLED_VERSION_ERROR);
414
+ }
415
+
416
+ let currentVersion = "";
417
+
418
+ const linkedKeg = typeof formula.linked_keg === "string" ? formula.linked_keg.trim() : "";
419
+ if (linkedKeg) {
420
+ for (const installed of formula.installed) {
421
+ if (
422
+ isRecord(installed) &&
423
+ typeof installed.version === "string" &&
424
+ installed.version.trim() === linkedKeg
425
+ ) {
426
+ currentVersion = linkedKeg;
427
+ break;
428
+ }
429
+ }
430
+ }
431
+
432
+ if (!currentVersion) {
433
+ for (const installed of formula.installed) {
434
+ if (
435
+ isRecord(installed) &&
436
+ typeof installed.version === "string" &&
437
+ installed.version.trim()
438
+ ) {
439
+ currentVersion = installed.version.trim();
440
+ break;
441
+ }
442
+ }
443
+ }
444
+
445
+ if (!currentVersion) {
446
+ throw new SelfUpdateError(BREW_INSTALLED_VERSION_ERROR);
447
+ }
448
+
449
+ if (
450
+ !isRecord(formula.versions) ||
451
+ typeof formula.versions.stable !== "string" ||
452
+ !formula.versions.stable.trim()
453
+ ) {
454
+ throw new SelfUpdateError(BREW_LATEST_VERSION_ERROR);
455
+ }
456
+
457
+ const stableVersion = (formula.versions.stable as string).trim();
458
+ const revision =
459
+ typeof formula.revision === "number" && formula.revision > 0 ? formula.revision : 0;
460
+ const latestVersion = formatBrewVersion(stableVersion, revision);
461
+
462
+ return { currentVersion, latestVersion };
463
+ }
464
+
465
+ async function getBrewVersions(
466
+ deps: SelfUpdateDependencies
467
+ ): Promise<{ currentVersion: string; latestVersion: string }> {
468
+ const output = await readTextOutput(
469
+ deps,
470
+ ["brew", "info", "--json=v1", PACKAGE_NAME],
471
+ "Failed to read Homebrew formula info for ralph-review."
472
+ );
473
+
474
+ return parseBrewVersions(output);
475
+ }
476
+
477
+ async function performNpmSelfUpdate(
478
+ options: SelfUpdateOptions,
479
+ deps: SelfUpdateDependencies
480
+ ): Promise<SelfUpdateResult> {
481
+ ensureManagerAvailable("npm", deps);
482
+
483
+ const { currentVersion, latestVersion } = await getNpmVersions(deps);
484
+ if (!hasNewerVersion(currentVersion, latestVersion)) {
485
+ return {
486
+ status: "up-to-date",
487
+ manager: "npm",
488
+ currentVersion,
489
+ latestVersion,
490
+ };
491
+ }
492
+
493
+ if (options.checkOnly) {
494
+ return {
495
+ status: "update-available",
496
+ manager: "npm",
497
+ currentVersion,
498
+ latestVersion,
499
+ };
500
+ }
501
+
502
+ const exitCode = await deps.runInteractive([...NPM_INSTALL_COMMAND]);
503
+ if (exitCode !== 0) {
504
+ throw new SelfUpdateError(
505
+ `${commandDisplay(NPM_INSTALL_COMMAND)} exited with code ${exitCode}.`
506
+ );
507
+ }
508
+
509
+ const finalVersion = await getNpmInstalledVersion(deps);
510
+ return {
511
+ status: "updated",
512
+ manager: "npm",
513
+ previousVersion: currentVersion,
514
+ finalVersion,
515
+ latestVersion,
516
+ };
517
+ }
518
+
519
+ async function performBrewSelfUpdate(
520
+ options: SelfUpdateOptions,
521
+ deps: SelfUpdateDependencies
522
+ ): Promise<SelfUpdateResult> {
523
+ ensureManagerAvailable("brew", deps);
524
+
525
+ const { currentVersion, latestVersion } = await getBrewVersions(deps);
526
+ if (!hasNewerVersion(currentVersion, latestVersion)) {
527
+ return {
528
+ status: "up-to-date",
529
+ manager: "brew",
530
+ currentVersion,
531
+ latestVersion,
532
+ };
533
+ }
534
+
535
+ if (options.checkOnly) {
536
+ return {
537
+ status: "update-available",
538
+ manager: "brew",
539
+ currentVersion,
540
+ latestVersion,
541
+ };
542
+ }
543
+
544
+ const exitCode = await deps.runInteractive([...BREW_INSTALL_COMMAND]);
545
+ if (exitCode !== 0) {
546
+ throw new SelfUpdateError(
547
+ `${commandDisplay(BREW_INSTALL_COMMAND)} exited with code ${exitCode}.`
548
+ );
549
+ }
550
+
551
+ const { currentVersion: finalVersion } = await getBrewVersions(deps);
552
+ return {
553
+ status: "updated",
554
+ manager: "brew",
555
+ previousVersion: currentVersion,
556
+ finalVersion,
557
+ latestVersion,
558
+ };
559
+ }
560
+
561
+ export async function performSelfUpdate(
562
+ options: SelfUpdateOptions,
563
+ deps: SelfUpdateDependencies
564
+ ): Promise<SelfUpdateResult> {
565
+ const manager = options.manager ?? (await detectUpdateManager(deps));
566
+
567
+ switch (manager) {
568
+ case "npm":
569
+ return performNpmSelfUpdate(options, deps);
570
+ case "brew":
571
+ return performBrewSelfUpdate(options, deps);
572
+ }
573
+ }