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 +10 -2
- package/package.json +1 -1
- package/src/cli-core.ts +23 -0
- package/src/cli.ts +7 -0
- package/src/commands/init.ts +6 -3
- package/src/commands/update.ts +125 -0
- package/src/lib/agents/models.ts +3 -0
- package/src/lib/self-update.ts +573 -0
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
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;
|
package/src/commands/init.ts
CHANGED
|
@@ -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[] = ["
|
|
215
|
-
const SIMPLIFIER_AGENT_PRIORITY: readonly AgentType[] = ["
|
|
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, "
|
|
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
|
+
}
|
package/src/lib/agents/models.ts
CHANGED
|
@@ -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
|
+
}
|