ralph-cli-sandboxed 0.5.0 → 0.6.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/README.md +15 -0
- package/dist/commands/branch.d.ts +4 -0
- package/dist/commands/branch.js +408 -0
- package/dist/commands/chat.js +115 -2
- package/dist/commands/daemon.js +10 -0
- package/dist/commands/docker.js +25 -15
- package/dist/commands/help.js +16 -0
- package/dist/commands/init.js +2 -1
- package/dist/commands/prd.js +14 -3
- package/dist/commands/progress.d.ts +1 -0
- package/dist/commands/progress.js +98 -0
- package/dist/commands/run.js +388 -64
- package/dist/config/cli-providers.json +1 -1
- package/dist/config/languages.json +1 -1
- package/dist/index.js +4 -0
- package/dist/templates/prompts.d.ts +1 -0
- package/dist/templates/prompts.js +6 -0
- package/dist/tui/components/SectionNav.js +1 -0
- package/dist/utils/config.d.ts +28 -0
- package/dist/utils/config.js +75 -1
- package/dist/utils/prd-validator.d.ts +5 -0
- package/dist/utils/prd-validator.js +138 -4
- package/docs/BRANCHING.md +281 -0
- package/docs/PRD-GENERATOR.md +133 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ ralph docker run
|
|
|
44
44
|
| `ralph clean` | Remove all passing entries from PRD |
|
|
45
45
|
| `ralph fix-prd [opts]` | Validate and recover corrupted PRD file |
|
|
46
46
|
| `ralph prompt [opts]` | Display resolved prompt |
|
|
47
|
+
| `ralph branch <sub>` | Manage PRD branches (list, merge, pr, delete) |
|
|
47
48
|
| `ralph docker <sub>` | Manage Docker sandbox environment |
|
|
48
49
|
| `ralph daemon <sub>` | Manage host daemon for sandbox notifications |
|
|
49
50
|
| `ralph notify [msg]` | Send notification (from sandbox to host) |
|
|
@@ -427,6 +428,20 @@ The PRD (`prd.json`) is an array of requirements:
|
|
|
427
428
|
|
|
428
429
|
Categories: `setup`, `feature`, `bugfix`, `refactor`, `docs`, `test`, `release`, `config`, `ui`
|
|
429
430
|
|
|
431
|
+
### Branching
|
|
432
|
+
|
|
433
|
+
PRD items can be tagged with a `branch` field to group work onto separate git branches. Ralph uses git worktrees to isolate branch work from the main checkout, so the host's working directory stays untouched.
|
|
434
|
+
|
|
435
|
+
```yaml
|
|
436
|
+
- category: feature
|
|
437
|
+
description: Add login page
|
|
438
|
+
branch: feat/auth
|
|
439
|
+
steps: [...]
|
|
440
|
+
passes: false
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
See [docs/BRANCHING.md](docs/BRANCHING.md) for the full architecture, configuration, and branch management commands.
|
|
444
|
+
|
|
430
445
|
### Advanced: File References
|
|
431
446
|
|
|
432
447
|
PRD steps can include file contents using the `@{filepath}` syntax:
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { extname, join } from "path";
|
|
4
|
+
import { getRalphDir, getPrdFiles, loadBranchState, getProjectName } from "../utils/config.js";
|
|
5
|
+
import { readPrdFile, writePrdAuto } from "../utils/prd-validator.js";
|
|
6
|
+
import { promptConfirm } from "../utils/prompt.js";
|
|
7
|
+
import YAML from "yaml";
|
|
8
|
+
/**
|
|
9
|
+
* Converts a branch name to a worktree directory name, prefixed with the project name.
|
|
10
|
+
* e.g., "feat/login" -> "myproject_feat-login"
|
|
11
|
+
* The project prefix avoids conflicts when multiple projects share the same worktrees directory.
|
|
12
|
+
*/
|
|
13
|
+
function branchToWorktreeName(branch) {
|
|
14
|
+
const projectName = getProjectName();
|
|
15
|
+
return `${projectName}_${branch.replace(/\//g, "-")}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Gets the worktrees base path from config or defaults to /worktrees.
|
|
19
|
+
*/
|
|
20
|
+
function getWorktreesBase() {
|
|
21
|
+
return "/worktrees";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Loads PRD entries from the primary PRD file.
|
|
25
|
+
*/
|
|
26
|
+
function loadPrdEntries() {
|
|
27
|
+
const prdFiles = getPrdFiles();
|
|
28
|
+
if (!prdFiles.primary) {
|
|
29
|
+
console.error("\x1b[31mError: No PRD file found. Run 'ralph init' first.\x1b[0m");
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const parsed = readPrdFile(prdFiles.primary);
|
|
33
|
+
if (!parsed || !Array.isArray(parsed.content)) {
|
|
34
|
+
console.error("\x1b[31mError: PRD file is corrupted. Run 'ralph fix-prd' to repair.\x1b[0m");
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return { entries: parsed.content, prdPath: prdFiles.primary };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Gets the base branch (the branch that /workspace is on).
|
|
41
|
+
*/
|
|
42
|
+
function getBaseBranch() {
|
|
43
|
+
try {
|
|
44
|
+
return execSync("git -C /workspace rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "main";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Checks if a git branch exists.
|
|
52
|
+
*/
|
|
53
|
+
function branchExists(branch) {
|
|
54
|
+
try {
|
|
55
|
+
execSync(`git rev-parse --verify "${branch}"`, { stdio: "pipe" });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* List all branches referenced in the PRD and their status.
|
|
64
|
+
* Shows item counts, pass/fail status, worktree existence, and active branch indicator.
|
|
65
|
+
*/
|
|
66
|
+
function branchList() {
|
|
67
|
+
const result = loadPrdEntries();
|
|
68
|
+
if (!result)
|
|
69
|
+
return;
|
|
70
|
+
const { entries } = result;
|
|
71
|
+
const worktreesBase = getWorktreesBase();
|
|
72
|
+
const activeBranch = loadBranchState();
|
|
73
|
+
// Group items by branch
|
|
74
|
+
const branchGroups = new Map();
|
|
75
|
+
const noBranchItems = [];
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (entry.branch) {
|
|
78
|
+
const group = branchGroups.get(entry.branch) || [];
|
|
79
|
+
group.push(entry);
|
|
80
|
+
branchGroups.set(entry.branch, group);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
noBranchItems.push(entry);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (branchGroups.size === 0 && noBranchItems.length === 0) {
|
|
87
|
+
console.log("No PRD items found.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
console.log("\x1b[1mBranches:\x1b[0m\n");
|
|
91
|
+
// Sort branches alphabetically
|
|
92
|
+
const sortedBranches = [...branchGroups.keys()].sort();
|
|
93
|
+
for (const branchName of sortedBranches) {
|
|
94
|
+
const items = branchGroups.get(branchName);
|
|
95
|
+
const passing = items.filter((e) => e.passes).length;
|
|
96
|
+
const total = items.length;
|
|
97
|
+
const allPassing = passing === total;
|
|
98
|
+
// Check if worktree exists on disk
|
|
99
|
+
const dirName = branchToWorktreeName(branchName);
|
|
100
|
+
const worktreePath = join(worktreesBase, dirName);
|
|
101
|
+
const hasWorktree = existsSync(worktreePath);
|
|
102
|
+
// Check if this is the active branch
|
|
103
|
+
const isActive = activeBranch?.currentBranch === branchName;
|
|
104
|
+
// Build the line
|
|
105
|
+
const statusIcon = allPassing ? "\x1b[32m✅\x1b[0m" : "\x1b[33m○\x1b[0m";
|
|
106
|
+
const activeIndicator = isActive ? " \x1b[36m◀ active\x1b[0m" : "";
|
|
107
|
+
const worktreeStatus = hasWorktree ? " \x1b[32m[worktree]\x1b[0m" : "";
|
|
108
|
+
const countStr = `${passing}/${total}`;
|
|
109
|
+
console.log(` ${statusIcon} \x1b[1m${branchName}\x1b[0m ${countStr}${worktreeStatus}${activeIndicator}`);
|
|
110
|
+
}
|
|
111
|
+
// Show no-branch group
|
|
112
|
+
if (noBranchItems.length > 0) {
|
|
113
|
+
const passing = noBranchItems.filter((e) => e.passes).length;
|
|
114
|
+
const total = noBranchItems.length;
|
|
115
|
+
const allPassing = passing === total;
|
|
116
|
+
const statusIcon = allPassing ? "\x1b[32m✅\x1b[0m" : "\x1b[33m○\x1b[0m";
|
|
117
|
+
const countStr = `${passing}/${total}`;
|
|
118
|
+
console.log(` ${statusIcon} \x1b[2m(no branch)\x1b[0m ${countStr}`);
|
|
119
|
+
}
|
|
120
|
+
console.log();
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Merge a completed branch worktree back into the base branch.
|
|
124
|
+
* Handles merge conflicts by aborting and showing conflicting files.
|
|
125
|
+
*/
|
|
126
|
+
async function branchMerge(args) {
|
|
127
|
+
const branchName = args[0];
|
|
128
|
+
if (!branchName) {
|
|
129
|
+
console.error("Usage: ralph branch merge <branch-name>");
|
|
130
|
+
console.error("\nExample: ralph branch merge feat/login");
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
// Verify the branch exists
|
|
134
|
+
if (!branchExists(branchName)) {
|
|
135
|
+
console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
const baseBranch = getBaseBranch();
|
|
139
|
+
const worktreesBase = getWorktreesBase();
|
|
140
|
+
const dirName = branchToWorktreeName(branchName);
|
|
141
|
+
const worktreePath = join(worktreesBase, dirName);
|
|
142
|
+
console.log(`Branch: ${branchName}`);
|
|
143
|
+
console.log(`Base branch: ${baseBranch}`);
|
|
144
|
+
if (existsSync(worktreePath)) {
|
|
145
|
+
console.log(`Worktree: ${worktreePath}`);
|
|
146
|
+
}
|
|
147
|
+
console.log();
|
|
148
|
+
// Ask for confirmation
|
|
149
|
+
const confirmed = await promptConfirm(`Merge "${branchName}" into "${baseBranch}"?`, true);
|
|
150
|
+
if (!confirmed) {
|
|
151
|
+
console.log("Merge cancelled.");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Perform the merge from /workspace (which is on the base branch)
|
|
155
|
+
try {
|
|
156
|
+
console.log(`\nMerging "${branchName}" into "${baseBranch}"...`);
|
|
157
|
+
execSync(`git -C /workspace merge "${branchName}" --no-edit`, { stdio: "pipe" });
|
|
158
|
+
console.log(`\x1b[32mSuccessfully merged "${branchName}" into "${baseBranch}".\x1b[0m`);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
// Check if this is a merge conflict
|
|
162
|
+
let conflictingFiles = [];
|
|
163
|
+
try {
|
|
164
|
+
const status = execSync("git -C /workspace status --porcelain", { encoding: "utf-8" });
|
|
165
|
+
conflictingFiles = status
|
|
166
|
+
.split("\n")
|
|
167
|
+
.filter((line) => line.startsWith("UU") || line.startsWith("AA") || line.startsWith("DD") || line.startsWith("AU") || line.startsWith("UA") || line.startsWith("DU") || line.startsWith("UD"))
|
|
168
|
+
.map((line) => line.substring(3).trim());
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Ignore status errors
|
|
172
|
+
}
|
|
173
|
+
if (conflictingFiles.length > 0) {
|
|
174
|
+
// Merge conflict detected - abort and report
|
|
175
|
+
console.error(`\n\x1b[31mMerge conflict detected!\x1b[0m`);
|
|
176
|
+
console.error(`\nConflicting files:`);
|
|
177
|
+
for (const file of conflictingFiles) {
|
|
178
|
+
console.error(` \x1b[33m${file}\x1b[0m`);
|
|
179
|
+
}
|
|
180
|
+
// Abort the merge
|
|
181
|
+
try {
|
|
182
|
+
execSync("git -C /workspace merge --abort", { stdio: "pipe" });
|
|
183
|
+
console.error(`\n\x1b[36mMerge aborted.\x1b[0m`);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
console.error("\n\x1b[33mWarning: Could not abort merge. You may need to run 'git merge --abort' manually.\x1b[0m");
|
|
187
|
+
}
|
|
188
|
+
console.error(`\nTo resolve:`);
|
|
189
|
+
console.error(` 1. Resolve conflicts manually and merge again`);
|
|
190
|
+
console.error(` 2. Or add a PRD item to resolve the conflicts:`);
|
|
191
|
+
console.error(` ralph prd add # describe the conflict resolution needed`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Some other merge error
|
|
196
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
197
|
+
console.error(`\x1b[31mMerge failed: ${message}\x1b[0m`);
|
|
198
|
+
// Try to abort in case merge is in progress
|
|
199
|
+
try {
|
|
200
|
+
execSync("git -C /workspace merge --abort", { stdio: "pipe" });
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Ignore if nothing to abort
|
|
204
|
+
}
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Clean up worktree if it exists
|
|
209
|
+
if (existsSync(worktreePath)) {
|
|
210
|
+
console.log(`\nCleaning up worktree at ${worktreePath}...`);
|
|
211
|
+
try {
|
|
212
|
+
execSync(`git -C /workspace worktree remove "${worktreePath}"`, { stdio: "pipe" });
|
|
213
|
+
console.log(`\x1b[32mWorktree removed.\x1b[0m`);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
console.warn(`\x1b[33mWarning: Could not remove worktree: ${message}\x1b[0m`);
|
|
218
|
+
console.warn("You can remove it manually with: git worktree remove " + worktreePath);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Clean up the branch itself (optional - merged branches can be deleted)
|
|
222
|
+
console.log(`\n\x1b[32mDone!\x1b[0m Branch "${branchName}" has been merged into "${baseBranch}".`);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Gets the PRD file path, preferring the primary if it exists.
|
|
226
|
+
*/
|
|
227
|
+
function getPrdPath() {
|
|
228
|
+
const prdFiles = getPrdFiles();
|
|
229
|
+
if (prdFiles.primary) {
|
|
230
|
+
return prdFiles.primary;
|
|
231
|
+
}
|
|
232
|
+
return join(getRalphDir(), "prd.json");
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Parses a PRD file (YAML or JSON) and returns the entries.
|
|
236
|
+
*/
|
|
237
|
+
function parsePrdFile(path) {
|
|
238
|
+
const content = readFileSync(path, "utf-8");
|
|
239
|
+
const ext = extname(path).toLowerCase();
|
|
240
|
+
try {
|
|
241
|
+
let result;
|
|
242
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
243
|
+
result = YAML.parse(content);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
result = JSON.parse(content);
|
|
247
|
+
}
|
|
248
|
+
return result ?? [];
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
console.error(`Error parsing ${path}. Run 'ralph fix-prd' to attempt automatic repair.`);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Saves PRD entries to the PRD file (YAML or JSON based on extension).
|
|
257
|
+
*/
|
|
258
|
+
function savePrd(entries) {
|
|
259
|
+
const path = getPrdPath();
|
|
260
|
+
const ext = extname(path).toLowerCase();
|
|
261
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
262
|
+
writeFileSync(path, YAML.stringify(entries));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
writeFileSync(path, JSON.stringify(entries, null, 2) + "\n");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Create a PRD item to open a pull request for a branch.
|
|
270
|
+
*/
|
|
271
|
+
function branchPr(args) {
|
|
272
|
+
const branchName = args[0];
|
|
273
|
+
if (!branchName) {
|
|
274
|
+
console.error("Usage: ralph branch pr <branch-name>");
|
|
275
|
+
console.error("\nExample: ralph branch pr feat/login");
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
// Verify the branch exists
|
|
279
|
+
if (!branchExists(branchName)) {
|
|
280
|
+
console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
const baseBranch = getBaseBranch();
|
|
284
|
+
const entry = {
|
|
285
|
+
category: "feature",
|
|
286
|
+
description: `Create a pull request from \`${branchName}\` into \`${baseBranch}\``,
|
|
287
|
+
steps: [
|
|
288
|
+
`Ensure all changes on \`${branchName}\` are committed`,
|
|
289
|
+
`Push \`${branchName}\` to the remote if not already pushed`,
|
|
290
|
+
`Create a pull request from \`${branchName}\` into \`${baseBranch}\` using the appropriate tool (e.g. gh pr create)`,
|
|
291
|
+
"Include a descriptive title and summary of the changes in the PR",
|
|
292
|
+
],
|
|
293
|
+
passes: false,
|
|
294
|
+
branch: branchName,
|
|
295
|
+
};
|
|
296
|
+
const prdPath = getPrdPath();
|
|
297
|
+
const prd = parsePrdFile(prdPath);
|
|
298
|
+
prd.push(entry);
|
|
299
|
+
savePrd(prd);
|
|
300
|
+
console.log(`Added PRD entry #${prd.length}: Create PR for ${branchName} → ${baseBranch}`);
|
|
301
|
+
console.log(`Branch field set to: ${branchName}`);
|
|
302
|
+
console.log("Run 'ralph run' or 'ralph once' to execute.");
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Delete a branch: remove worktree, delete git branch, and untag PRD items.
|
|
306
|
+
* Asks for confirmation before proceeding.
|
|
307
|
+
*/
|
|
308
|
+
async function branchDelete(args) {
|
|
309
|
+
const branchName = args[0];
|
|
310
|
+
if (!branchName) {
|
|
311
|
+
console.error("Usage: ralph branch delete <branch-name>");
|
|
312
|
+
console.error("\nExample: ralph branch delete feat/old-branch");
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
// Verify the branch exists
|
|
316
|
+
if (!branchExists(branchName)) {
|
|
317
|
+
console.error(`\x1b[31mError: Branch "${branchName}" does not exist.\x1b[0m`);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
const worktreesBase = getWorktreesBase();
|
|
321
|
+
const dirName = branchToWorktreeName(branchName);
|
|
322
|
+
const worktreePath = join(worktreesBase, dirName);
|
|
323
|
+
const hasWorktree = existsSync(worktreePath);
|
|
324
|
+
// Load PRD to check for tagged items
|
|
325
|
+
const result = loadPrdEntries();
|
|
326
|
+
const taggedCount = result
|
|
327
|
+
? result.entries.filter((e) => e.branch === branchName).length
|
|
328
|
+
: 0;
|
|
329
|
+
console.log(`Branch: ${branchName}`);
|
|
330
|
+
if (hasWorktree) {
|
|
331
|
+
console.log(`Worktree: ${worktreePath}`);
|
|
332
|
+
}
|
|
333
|
+
if (taggedCount > 0) {
|
|
334
|
+
console.log(`PRD items tagged: ${taggedCount}`);
|
|
335
|
+
}
|
|
336
|
+
console.log();
|
|
337
|
+
// Ask for confirmation
|
|
338
|
+
const confirmed = await promptConfirm(`Delete branch "${branchName}"${hasWorktree ? " and its worktree" : ""}?`, false);
|
|
339
|
+
if (!confirmed) {
|
|
340
|
+
console.log("Delete cancelled.");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Step 1: Remove worktree if it exists
|
|
344
|
+
if (hasWorktree) {
|
|
345
|
+
console.log(`\nRemoving worktree at ${worktreePath}...`);
|
|
346
|
+
try {
|
|
347
|
+
execSync(`git -C /workspace worktree remove "${worktreePath}" --force`, { stdio: "pipe" });
|
|
348
|
+
console.log(`\x1b[32mWorktree removed.\x1b[0m`);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
352
|
+
console.warn(`\x1b[33mWarning: Could not remove worktree: ${message}\x1b[0m`);
|
|
353
|
+
console.warn("You can remove it manually with: git worktree remove " + worktreePath);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Step 2: Delete the git branch
|
|
357
|
+
console.log(`Deleting branch "${branchName}"...`);
|
|
358
|
+
try {
|
|
359
|
+
execSync(`git -C /workspace branch -D "${branchName}"`, { stdio: "pipe" });
|
|
360
|
+
console.log(`\x1b[32mBranch deleted.\x1b[0m`);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
364
|
+
console.error(`\x1b[31mError deleting branch: ${message}\x1b[0m`);
|
|
365
|
+
}
|
|
366
|
+
// Step 3: Remove branch tag from PRD items
|
|
367
|
+
if (result && taggedCount > 0) {
|
|
368
|
+
console.log(`Removing branch tag from ${taggedCount} PRD item(s)...`);
|
|
369
|
+
const updatedEntries = result.entries.map((entry) => {
|
|
370
|
+
if (entry.branch === branchName) {
|
|
371
|
+
const { branch: _, ...rest } = entry;
|
|
372
|
+
return rest;
|
|
373
|
+
}
|
|
374
|
+
return entry;
|
|
375
|
+
});
|
|
376
|
+
writePrdAuto(result.prdPath, updatedEntries);
|
|
377
|
+
console.log(`\x1b[32mPRD items updated.\x1b[0m`);
|
|
378
|
+
}
|
|
379
|
+
console.log(`\n\x1b[32mDone!\x1b[0m Branch "${branchName}" has been deleted.`);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Main branch command dispatcher.
|
|
383
|
+
*/
|
|
384
|
+
export async function branch(args) {
|
|
385
|
+
const subcommand = args[0];
|
|
386
|
+
switch (subcommand) {
|
|
387
|
+
case "list":
|
|
388
|
+
branchList();
|
|
389
|
+
break;
|
|
390
|
+
case "merge":
|
|
391
|
+
await branchMerge(args.slice(1));
|
|
392
|
+
break;
|
|
393
|
+
case "delete":
|
|
394
|
+
await branchDelete(args.slice(1));
|
|
395
|
+
break;
|
|
396
|
+
case "pr":
|
|
397
|
+
branchPr(args.slice(1));
|
|
398
|
+
break;
|
|
399
|
+
default:
|
|
400
|
+
console.error("Usage: ralph branch <subcommand>");
|
|
401
|
+
console.error("\nSubcommands:");
|
|
402
|
+
console.error(" list List all branches and their status");
|
|
403
|
+
console.error(" merge <name> Merge a branch worktree into the base branch");
|
|
404
|
+
console.error(" delete <name> Delete a branch and its worktree");
|
|
405
|
+
console.error(" pr <name> Create a PRD item to open a PR for a branch");
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
}
|
package/dist/commands/chat.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Chat command for managing Telegram, Slack, and other chat integrations.
|
|
3
3
|
* Allows ralph to receive commands and send notifications via chat services.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, watch } from "fs";
|
|
6
6
|
import { join, basename, extname } from "path";
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
8
|
import YAML from "yaml";
|
|
@@ -11,7 +11,7 @@ import { createTelegramClient } from "../providers/telegram.js";
|
|
|
11
11
|
import { createSlackClient } from "../providers/slack.js";
|
|
12
12
|
import { createDiscordClient } from "../providers/discord.js";
|
|
13
13
|
import { generateProjectId, formatStatusMessage, formatStatusForChat, } from "../utils/chat-client.js";
|
|
14
|
-
import { getMessagesPath, sendMessage, waitForResponse } from "../utils/message-queue.js";
|
|
14
|
+
import { getMessagesPath, sendMessage, waitForResponse, getPendingMessages, respondToMessage, cleanupOldMessages, } from "../utils/message-queue.js";
|
|
15
15
|
const CHAT_STATE_FILE = "chat-state.json";
|
|
16
16
|
/**
|
|
17
17
|
* Load chat state from .ralph/chat-state.json
|
|
@@ -536,6 +536,67 @@ function createChatClient(config, debug) {
|
|
|
536
536
|
allowedChatIds: config.chat.telegram.allowedChatIds,
|
|
537
537
|
};
|
|
538
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* Process a message from the sandbox (container).
|
|
541
|
+
* Handles notification actions like slack_notify, telegram_notify, discord_notify.
|
|
542
|
+
*/
|
|
543
|
+
async function processSandboxMessage(message, client, allowedChatIds, messagesPath, debug) {
|
|
544
|
+
const { action, args } = message;
|
|
545
|
+
if (debug) {
|
|
546
|
+
console.log(`[chat] Processing sandbox message: ${action} ${args?.join(" ") || ""}`);
|
|
547
|
+
}
|
|
548
|
+
// Handle notification actions
|
|
549
|
+
if (action === "slack_notify" || action === "telegram_notify" || action === "discord_notify") {
|
|
550
|
+
const notifyMessage = args?.join(" ") || "Ralph notification";
|
|
551
|
+
// Check if this notification is for our provider
|
|
552
|
+
const expectedProvider = action === "slack_notify" ? "slack" : action === "telegram_notify" ? "telegram" : "discord";
|
|
553
|
+
if (client.provider !== expectedProvider) {
|
|
554
|
+
if (debug) {
|
|
555
|
+
console.log(`[chat] Ignoring ${action} - current provider is ${client.provider}`);
|
|
556
|
+
}
|
|
557
|
+
respondToMessage(messagesPath, message.id, {
|
|
558
|
+
success: false,
|
|
559
|
+
error: `Chat provider is ${client.provider}, not ${expectedProvider}`,
|
|
560
|
+
});
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Send to all allowed chat IDs
|
|
564
|
+
if (!allowedChatIds || allowedChatIds.length === 0) {
|
|
565
|
+
respondToMessage(messagesPath, message.id, {
|
|
566
|
+
success: false,
|
|
567
|
+
error: "No chat IDs configured",
|
|
568
|
+
});
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
for (const chatId of allowedChatIds) {
|
|
573
|
+
await client.sendMessage(chatId, notifyMessage);
|
|
574
|
+
}
|
|
575
|
+
if (debug) {
|
|
576
|
+
console.log(`[chat] Sent notification to ${allowedChatIds.length} chat(s)`);
|
|
577
|
+
}
|
|
578
|
+
respondToMessage(messagesPath, message.id, {
|
|
579
|
+
success: true,
|
|
580
|
+
output: `Sent to ${client.provider}`,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
|
585
|
+
if (debug) {
|
|
586
|
+
console.error(`[chat] Failed to send notification: ${errorMsg}`);
|
|
587
|
+
}
|
|
588
|
+
respondToMessage(messagesPath, message.id, {
|
|
589
|
+
success: false,
|
|
590
|
+
error: errorMsg,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// Unknown action - don't respond (let daemon handle it if running)
|
|
596
|
+
if (debug) {
|
|
597
|
+
console.log(`[chat] Ignoring unknown action: ${action}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
539
600
|
/**
|
|
540
601
|
* Start the chat daemon (listens for messages and handles commands).
|
|
541
602
|
*/
|
|
@@ -610,9 +671,61 @@ async function startChat(config, debug) {
|
|
|
610
671
|
console.error(`Failed to connect: ${err instanceof Error ? err.message : err}`);
|
|
611
672
|
process.exit(1);
|
|
612
673
|
}
|
|
674
|
+
// Watch for sandbox messages (notifications from container)
|
|
675
|
+
const messagesPath = getMessagesPath(false); // host path
|
|
676
|
+
const ralphDir = getRalphDir();
|
|
677
|
+
let sandboxWatcher = null;
|
|
678
|
+
let sandboxPollInterval = null;
|
|
679
|
+
let processingMessages = false;
|
|
680
|
+
const checkSandboxMessages = async () => {
|
|
681
|
+
if (processingMessages)
|
|
682
|
+
return;
|
|
683
|
+
processingMessages = true;
|
|
684
|
+
try {
|
|
685
|
+
const pending = getPendingMessages(messagesPath, "sandbox");
|
|
686
|
+
for (const msg of pending) {
|
|
687
|
+
// Only handle notification actions - let daemon handle others
|
|
688
|
+
if (msg.action === "slack_notify" ||
|
|
689
|
+
msg.action === "telegram_notify" ||
|
|
690
|
+
msg.action === "discord_notify") {
|
|
691
|
+
await processSandboxMessage(msg, client, allowedChatIds, messagesPath, debug);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Cleanup old messages periodically
|
|
695
|
+
cleanupOldMessages(messagesPath, 60000);
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
if (debug) {
|
|
699
|
+
console.error(`[chat] Error processing sandbox messages: ${err}`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
processingMessages = false;
|
|
703
|
+
};
|
|
704
|
+
// Process any pending sandbox messages on startup
|
|
705
|
+
await checkSandboxMessages();
|
|
706
|
+
// Watch the .ralph directory for changes
|
|
707
|
+
if (existsSync(ralphDir)) {
|
|
708
|
+
sandboxWatcher = watch(ralphDir, { persistent: true }, (eventType, filename) => {
|
|
709
|
+
if (filename === "messages.json") {
|
|
710
|
+
checkSandboxMessages();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
// Also poll periodically as backup
|
|
715
|
+
sandboxPollInterval = setInterval(checkSandboxMessages, 1000);
|
|
716
|
+
if (debug) {
|
|
717
|
+
console.log(`[chat] Watching for sandbox notifications at: ${messagesPath}`);
|
|
718
|
+
}
|
|
613
719
|
// Handle shutdown
|
|
614
720
|
const shutdown = async () => {
|
|
615
721
|
console.log("\nShutting down chat daemon...");
|
|
722
|
+
// Stop sandbox message watching
|
|
723
|
+
if (sandboxWatcher) {
|
|
724
|
+
sandboxWatcher.close();
|
|
725
|
+
}
|
|
726
|
+
if (sandboxPollInterval) {
|
|
727
|
+
clearInterval(sandboxPollInterval);
|
|
728
|
+
}
|
|
616
729
|
// Send disconnected message to all allowed chats
|
|
617
730
|
if (allowedChatIds && allowedChatIds.length > 0) {
|
|
618
731
|
for (const chatId of allowedChatIds) {
|
package/dist/commands/daemon.js
CHANGED
|
@@ -240,6 +240,16 @@ async function processMessage(message, actions, messagesPath, debug) {
|
|
|
240
240
|
if (debug) {
|
|
241
241
|
console.log(`[daemon] Processing: ${message.action} (${message.id})`);
|
|
242
242
|
}
|
|
243
|
+
// Skip chat notification actions - these are handled by the chat client
|
|
244
|
+
// (which has the connected Socket Mode client)
|
|
245
|
+
const chatNotifyActions = ["slack_notify", "telegram_notify", "discord_notify"];
|
|
246
|
+
if (chatNotifyActions.includes(message.action)) {
|
|
247
|
+
if (debug) {
|
|
248
|
+
console.log(`[daemon] Skipping ${message.action} - handled by chat client`);
|
|
249
|
+
}
|
|
250
|
+
// Don't respond - let the chat client handle it
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
243
253
|
const action = actions[message.action];
|
|
244
254
|
if (!action) {
|
|
245
255
|
respondToMessage(messagesPath, message.id, {
|
package/dist/commands/docker.js
CHANGED
|
@@ -97,19 +97,24 @@ ${commands}
|
|
|
97
97
|
${commands}
|
|
98
98
|
`;
|
|
99
99
|
}
|
|
100
|
-
// Build git config section if configured
|
|
101
|
-
|
|
102
|
-
if (dockerConfig?.git
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
gitConfigSection = `
|
|
111
|
-
# Configure git identity
|
|
100
|
+
// Build git config section — always set init.defaultBranch, plus identity if configured
|
|
101
|
+
const gitCommands = [`git config --global init.defaultBranch main`];
|
|
102
|
+
if (dockerConfig?.git?.name) {
|
|
103
|
+
gitCommands.push(`git config --global user.name "${dockerConfig.git.name}"`);
|
|
104
|
+
}
|
|
105
|
+
if (dockerConfig?.git?.email) {
|
|
106
|
+
gitCommands.push(`git config --global user.email "${dockerConfig.git.email}"`);
|
|
107
|
+
}
|
|
108
|
+
const gitConfigSection = `
|
|
109
|
+
# Configure git defaults
|
|
112
110
|
RUN ${gitCommands.join(" \\\n && ")}
|
|
111
|
+
`;
|
|
112
|
+
// Build worktrees directory section if configured
|
|
113
|
+
let worktreesDir = "";
|
|
114
|
+
if (dockerConfig?.worktreesPath) {
|
|
115
|
+
worktreesDir = `
|
|
116
|
+
# Create worktrees directory for git worktree storage
|
|
117
|
+
RUN mkdir -p /worktrees && chown node:node /worktrees
|
|
113
118
|
`;
|
|
114
119
|
}
|
|
115
120
|
// Build asciinema installation section if enabled
|
|
@@ -215,7 +220,7 @@ RUN echo "node ALL=(ALL) NOPASSWD: /usr/local/bin/init-firewall.sh" >> /etc/sudo
|
|
|
215
220
|
RUN mkdir -p /workspace && chown node:node /workspace
|
|
216
221
|
RUN mkdir -p /home/node/.claude && chown node:node /home/node/.claude
|
|
217
222
|
RUN mkdir -p /commandhistory && chown node:node /commandhistory
|
|
218
|
-
${asciinemaDir}
|
|
223
|
+
${worktreesDir}${asciinemaDir}
|
|
219
224
|
# Copy firewall script
|
|
220
225
|
COPY init-firewall.sh /usr/local/bin/init-firewall.sh
|
|
221
226
|
RUN chmod +x /usr/local/bin/init-firewall.sh
|
|
@@ -351,6 +356,11 @@ function generateDockerCompose(imageName, dockerConfig) {
|
|
|
351
356
|
" - ${HOME}/.claude:/home/node/.claude",
|
|
352
357
|
` - ${imageName}-history:/commandhistory`,
|
|
353
358
|
];
|
|
359
|
+
// Mount worktrees path if configured
|
|
360
|
+
if (dockerConfig?.worktreesPath) {
|
|
361
|
+
baseVolumes.push(" # Mount host worktrees directory for git worktree storage");
|
|
362
|
+
baseVolumes.push(` - ${dockerConfig.worktreesPath}:/worktrees`);
|
|
363
|
+
}
|
|
354
364
|
if (dockerConfig?.volumes && dockerConfig.volumes.length > 0) {
|
|
355
365
|
const customVolumeLines = dockerConfig.volumes.map((vol) => ` - ${vol}`);
|
|
356
366
|
baseVolumes.push(...customVolumeLines);
|
|
@@ -1075,7 +1085,7 @@ Docker files generated in .ralph/docker/
|
|
|
1075
1085
|
|
|
1076
1086
|
Next steps:
|
|
1077
1087
|
1. Build the image: ralph docker build
|
|
1078
|
-
2. Run container:
|
|
1088
|
+
2. Run container: ralph docker run
|
|
1079
1089
|
|
|
1080
1090
|
Or use docker compose directly:
|
|
1081
1091
|
cd .ralph/docker && docker compose run --rm ralph
|
|
@@ -1186,7 +1196,7 @@ Docker files generated in .ralph/docker/
|
|
|
1186
1196
|
|
|
1187
1197
|
Next steps:
|
|
1188
1198
|
1. Build the image: ralph docker build
|
|
1189
|
-
2. Run container:
|
|
1199
|
+
2. Run container: ralph docker run
|
|
1190
1200
|
|
|
1191
1201
|
Or use docker compose directly:
|
|
1192
1202
|
cd .ralph/docker && docker compose run --rm ralph
|