rafcode 1.0.0 → 1.1.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/RAF/016-planning-scalpel/decisions.md +7 -0
- package/RAF/016-planning-scalpel/input.md +1 -0
- package/RAF/016-planning-scalpel/outcomes/001-update-git-commit-instructions.md +38 -0
- package/RAF/016-planning-scalpel/plans/001-update-git-commit-instructions.md +43 -0
- package/RAF/017-decision-vault/decisions.md +13 -0
- package/RAF/017-decision-vault/input.md +2 -0
- package/RAF/017-decision-vault/outcomes/001-create-git-commit-utility.md +53 -0
- package/RAF/017-decision-vault/outcomes/002-integrate-commit-into-plan.md +44 -0
- package/RAF/017-decision-vault/outcomes/003-add-tests-for-planning-commit.md +51 -0
- package/RAF/017-decision-vault/plans/001-create-git-commit-utility.md +38 -0
- package/RAF/017-decision-vault/plans/002-integrate-commit-into-plan.md +39 -0
- package/RAF/017-decision-vault/plans/003-add-tests-for-planning-commit.md +40 -0
- package/README.md +83 -55
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +5 -0
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/claude-runner.d.ts.map +1 -1
- package/dist/core/claude-runner.js +26 -6
- package/dist/core/claude-runner.js.map +1 -1
- package/dist/core/git.d.ts +8 -0
- package/dist/core/git.d.ts.map +1 -1
- package/dist/core/git.js +61 -0
- package/dist/core/git.js.map +1 -1
- package/dist/prompts/execution.d.ts.map +1 -1
- package/dist/prompts/execution.js +4 -2
- package/dist/prompts/execution.js.map +1 -1
- package/dist/ui/name-picker.d.ts.map +1 -1
- package/dist/ui/name-picker.js +27 -4
- package/dist/ui/name-picker.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/plan.ts +7 -0
- package/src/core/claude-runner.ts +28 -6
- package/src/core/git.ts +71 -0
- package/src/prompts/execution.ts +4 -2
- package/src/ui/name-picker.ts +29 -4
- package/tests/manual/test-pty-cleanup.ts +86 -0
- package/tests/unit/commit-planning-artifacts.test.ts +232 -0
package/src/ui/name-picker.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as pty from 'node-pty';
|
|
6
|
+
import type { IDisposable } from 'node-pty';
|
|
6
7
|
|
|
7
8
|
// For testing: allow direct import of @inquirer/prompts functions
|
|
8
9
|
let directSelect: typeof import('@inquirer/prompts').select | null = null;
|
|
@@ -74,12 +75,33 @@ export async function pickProjectName(names: string[]): Promise<string> {
|
|
|
74
75
|
};
|
|
75
76
|
process.stdin.on('data', onData);
|
|
76
77
|
|
|
78
|
+
// Store disposables for proper cleanup
|
|
79
|
+
const disposables: IDisposable[] = [];
|
|
80
|
+
|
|
81
|
+
// Helper to clean up PTY resources
|
|
82
|
+
const cleanupPty = (): void => {
|
|
83
|
+
// Dispose all event listeners to prevent FD leaks
|
|
84
|
+
for (const disposable of disposables) {
|
|
85
|
+
try {
|
|
86
|
+
disposable.dispose();
|
|
87
|
+
} catch {
|
|
88
|
+
// Ignore disposal errors
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Ensure PTY is fully cleaned up
|
|
92
|
+
try {
|
|
93
|
+
ptyProcess.kill();
|
|
94
|
+
} catch {
|
|
95
|
+
// Ignore - process may already be dead
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
77
99
|
// Forward output from PTY to our stdout
|
|
78
|
-
ptyProcess.onData((data) => {
|
|
100
|
+
disposables.push(ptyProcess.onData((data) => {
|
|
79
101
|
process.stdout.write(data);
|
|
80
|
-
});
|
|
102
|
+
}));
|
|
81
103
|
|
|
82
|
-
ptyProcess.onExit(({ exitCode }) => {
|
|
104
|
+
disposables.push(ptyProcess.onExit(({ exitCode }) => {
|
|
83
105
|
// Cleanup stdin
|
|
84
106
|
process.stdin.off('data', onData);
|
|
85
107
|
if (process.stdin.isTTY) {
|
|
@@ -87,6 +109,9 @@ export async function pickProjectName(names: string[]): Promise<string> {
|
|
|
87
109
|
}
|
|
88
110
|
process.stdin.pause();
|
|
89
111
|
|
|
112
|
+
// Clean up PTY resources
|
|
113
|
+
cleanupPty();
|
|
114
|
+
|
|
90
115
|
if (exitCode === 130) {
|
|
91
116
|
// SIGINT - user cancelled
|
|
92
117
|
process.exit(130);
|
|
@@ -130,7 +155,7 @@ export async function pickProjectName(names: string[]): Promise<string> {
|
|
|
130
155
|
} catch (error) {
|
|
131
156
|
reject(error);
|
|
132
157
|
}
|
|
133
|
-
});
|
|
158
|
+
}));
|
|
134
159
|
});
|
|
135
160
|
}
|
|
136
161
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as pty from 'node-pty';
|
|
2
|
+
import type { IDisposable } from 'node-pty';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
function getPtyCount(): number {
|
|
6
|
+
// Count open file descriptors for this process
|
|
7
|
+
try {
|
|
8
|
+
const result = execSync(`lsof -p ${process.pid} 2>/dev/null | grep -c "/dev/pty\\|/dev/ttys" || echo 0`, { encoding: 'utf-8' });
|
|
9
|
+
return parseInt(result.trim(), 10);
|
|
10
|
+
} catch {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function spawnAndCleanup(withCleanup: boolean): Promise<void> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const ptyProcess = pty.spawn('echo', ['hello'], {
|
|
18
|
+
name: 'xterm-256color',
|
|
19
|
+
cols: 80,
|
|
20
|
+
rows: 24,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const disposables: IDisposable[] = [];
|
|
24
|
+
|
|
25
|
+
disposables.push(ptyProcess.onData(() => {
|
|
26
|
+
// ignore data
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
disposables.push(ptyProcess.onExit(() => {
|
|
30
|
+
if (withCleanup) {
|
|
31
|
+
// Proper cleanup
|
|
32
|
+
for (const d of disposables) {
|
|
33
|
+
try { d.dispose(); } catch {}
|
|
34
|
+
}
|
|
35
|
+
try { ptyProcess.kill(); } catch {}
|
|
36
|
+
}
|
|
37
|
+
resolve();
|
|
38
|
+
}));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const iterations = 20;
|
|
44
|
+
|
|
45
|
+
console.log('Testing PTY cleanup...\n');
|
|
46
|
+
|
|
47
|
+
// Test WITHOUT cleanup
|
|
48
|
+
console.log('=== Without proper cleanup ===');
|
|
49
|
+
const beforeNoCleanup = getPtyCount();
|
|
50
|
+
console.log(`PTY FDs before: ${beforeNoCleanup}`);
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < iterations; i++) {
|
|
53
|
+
await spawnAndCleanup(false);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Give time for any cleanup
|
|
57
|
+
await new Promise(r => setTimeout(r, 500));
|
|
58
|
+
const afterNoCleanup = getPtyCount();
|
|
59
|
+
console.log(`PTY FDs after ${iterations} spawns: ${afterNoCleanup}`);
|
|
60
|
+
console.log(`Leaked: ${afterNoCleanup - beforeNoCleanup}\n`);
|
|
61
|
+
|
|
62
|
+
// Test WITH cleanup
|
|
63
|
+
console.log('=== With proper cleanup ===');
|
|
64
|
+
const beforeCleanup = getPtyCount();
|
|
65
|
+
console.log(`PTY FDs before: ${beforeCleanup}`);
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < iterations; i++) {
|
|
68
|
+
await spawnAndCleanup(true);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Give time for any cleanup
|
|
72
|
+
await new Promise(r => setTimeout(r, 500));
|
|
73
|
+
const afterCleanup = getPtyCount();
|
|
74
|
+
console.log(`PTY FDs after ${iterations} spawns: ${afterCleanup}`);
|
|
75
|
+
console.log(`Leaked: ${afterCleanup - beforeCleanup}\n`);
|
|
76
|
+
|
|
77
|
+
// Summary
|
|
78
|
+
console.log('=== Summary ===');
|
|
79
|
+
if (afterCleanup - beforeCleanup === 0) {
|
|
80
|
+
console.log('✓ Proper cleanup prevents PTY leaks');
|
|
81
|
+
} else {
|
|
82
|
+
console.log('✗ Still leaking PTYs even with cleanup');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
// Mock execSync before importing the module
|
|
4
|
+
const mockExecSync = jest.fn();
|
|
5
|
+
jest.unstable_mockModule('node:child_process', () => ({
|
|
6
|
+
execSync: mockExecSync,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
// Mock logger to prevent console output
|
|
10
|
+
const mockLogger = {
|
|
11
|
+
debug: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
error: jest.fn(),
|
|
14
|
+
};
|
|
15
|
+
jest.unstable_mockModule('../../src/utils/logger.js', () => ({
|
|
16
|
+
logger: mockLogger,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// Import after mocking
|
|
20
|
+
const { commitPlanningArtifacts } = await import('../../src/core/git.js');
|
|
21
|
+
|
|
22
|
+
describe('commitPlanningArtifacts', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should commit input.md and decisions.md with correct message format', async () => {
|
|
28
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
29
|
+
const cmdStr = cmd as string;
|
|
30
|
+
if (cmdStr.includes('rev-parse')) {
|
|
31
|
+
return 'true\n';
|
|
32
|
+
}
|
|
33
|
+
if (cmdStr.includes('git add')) {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
if (cmdStr.includes('git diff --cached')) {
|
|
37
|
+
return 'RAF/017-decision-vault/input.md\nRAF/017-decision-vault/decisions.md\n';
|
|
38
|
+
}
|
|
39
|
+
if (cmdStr.includes('git commit')) {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
return '';
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
|
|
46
|
+
|
|
47
|
+
// Verify git add was called with both files
|
|
48
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
49
|
+
expect.stringContaining('git add'),
|
|
50
|
+
expect.any(Object)
|
|
51
|
+
);
|
|
52
|
+
const addCall = mockExecSync.mock.calls.find(
|
|
53
|
+
(call) => (call[0] as string).includes('git add')
|
|
54
|
+
);
|
|
55
|
+
expect(addCall?.[0]).toContain('input.md');
|
|
56
|
+
expect(addCall?.[0]).toContain('decisions.md');
|
|
57
|
+
|
|
58
|
+
// Verify commit message format
|
|
59
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
60
|
+
expect.stringMatching(/git commit -m "RAF\[017\] Plan: decision-vault"/),
|
|
61
|
+
expect.any(Object)
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should handle base36 project numbers', async () => {
|
|
66
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
67
|
+
const cmdStr = cmd as string;
|
|
68
|
+
if (cmdStr.includes('rev-parse')) {
|
|
69
|
+
return 'true\n';
|
|
70
|
+
}
|
|
71
|
+
if (cmdStr.includes('git add') || cmdStr.includes('git commit')) {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
if (cmdStr.includes('git diff --cached')) {
|
|
75
|
+
return 'RAF/a01-feature/input.md\n';
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await commitPlanningArtifacts('/Users/test/RAF/a01-my-feature');
|
|
81
|
+
|
|
82
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
83
|
+
expect.stringMatching(/git commit -m "RAF\[a01\] Plan: my-feature"/),
|
|
84
|
+
expect.any(Object)
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should warn and return when not in git repository', async () => {
|
|
89
|
+
mockExecSync.mockImplementation(() => {
|
|
90
|
+
throw new Error('not a git repository');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
|
|
94
|
+
|
|
95
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
96
|
+
'Not in a git repository, skipping planning artifacts commit'
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should warn when project number cannot be extracted', async () => {
|
|
101
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
102
|
+
const cmdStr = cmd as string;
|
|
103
|
+
if (cmdStr.includes('rev-parse')) {
|
|
104
|
+
return 'true\n';
|
|
105
|
+
}
|
|
106
|
+
return '';
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await commitPlanningArtifacts('/Users/test/RAF/invalid-project');
|
|
110
|
+
|
|
111
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
112
|
+
'Could not extract project number or name from path, skipping commit'
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should handle "nothing to commit" gracefully', async () => {
|
|
117
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
118
|
+
const cmdStr = cmd as string;
|
|
119
|
+
if (cmdStr.includes('rev-parse')) {
|
|
120
|
+
return 'true\n';
|
|
121
|
+
}
|
|
122
|
+
if (cmdStr.includes('git add')) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
if (cmdStr.includes('git diff --cached')) {
|
|
126
|
+
return ''; // No staged changes
|
|
127
|
+
}
|
|
128
|
+
return '';
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
|
|
132
|
+
|
|
133
|
+
// Should log debug message and not throw
|
|
134
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
135
|
+
'No changes to planning artifacts to commit'
|
|
136
|
+
);
|
|
137
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should handle commit error with "nothing to commit" message', async () => {
|
|
141
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
142
|
+
const cmdStr = cmd as string;
|
|
143
|
+
if (cmdStr.includes('rev-parse')) {
|
|
144
|
+
return 'true\n';
|
|
145
|
+
}
|
|
146
|
+
if (cmdStr.includes('git add')) {
|
|
147
|
+
return '';
|
|
148
|
+
}
|
|
149
|
+
if (cmdStr.includes('git diff --cached')) {
|
|
150
|
+
return 'RAF/017-decision-vault/input.md\n';
|
|
151
|
+
}
|
|
152
|
+
if (cmdStr.includes('git commit')) {
|
|
153
|
+
throw new Error('nothing to commit, working tree clean');
|
|
154
|
+
}
|
|
155
|
+
return '';
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
|
|
159
|
+
|
|
160
|
+
// Should log debug message for "nothing to commit"
|
|
161
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
162
|
+
'Planning artifacts already committed or no changes'
|
|
163
|
+
);
|
|
164
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should warn on other git errors without throwing', async () => {
|
|
168
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
169
|
+
const cmdStr = cmd as string;
|
|
170
|
+
if (cmdStr.includes('rev-parse')) {
|
|
171
|
+
return 'true\n';
|
|
172
|
+
}
|
|
173
|
+
if (cmdStr.includes('git add')) {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
if (cmdStr.includes('git diff --cached')) {
|
|
177
|
+
return 'RAF/017-decision-vault/input.md\n';
|
|
178
|
+
}
|
|
179
|
+
if (cmdStr.includes('git commit')) {
|
|
180
|
+
throw new Error('commit failed for unknown reason');
|
|
181
|
+
}
|
|
182
|
+
return '';
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Should not throw
|
|
186
|
+
await expect(
|
|
187
|
+
commitPlanningArtifacts('/Users/test/RAF/017-decision-vault')
|
|
188
|
+
).resolves.toBeUndefined();
|
|
189
|
+
|
|
190
|
+
// Should log warning
|
|
191
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
192
|
+
expect.stringContaining('Failed to commit planning artifacts')
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should only stage input.md and decisions.md', async () => {
|
|
197
|
+
mockExecSync.mockImplementation((cmd: unknown) => {
|
|
198
|
+
const cmdStr = cmd as string;
|
|
199
|
+
if (cmdStr.includes('rev-parse')) {
|
|
200
|
+
return 'true\n';
|
|
201
|
+
}
|
|
202
|
+
if (cmdStr.includes('git add')) {
|
|
203
|
+
return '';
|
|
204
|
+
}
|
|
205
|
+
if (cmdStr.includes('git diff --cached')) {
|
|
206
|
+
return 'RAF/017-decision-vault/input.md\n';
|
|
207
|
+
}
|
|
208
|
+
if (cmdStr.includes('git commit')) {
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
return '';
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await commitPlanningArtifacts('/Users/test/RAF/017-decision-vault');
|
|
215
|
+
|
|
216
|
+
// Verify git add was called with explicit file paths
|
|
217
|
+
const addCall = mockExecSync.mock.calls.find(
|
|
218
|
+
(call) => (call[0] as string).includes('git add')
|
|
219
|
+
);
|
|
220
|
+
expect(addCall).toBeDefined();
|
|
221
|
+
const addCmd = addCall?.[0] as string;
|
|
222
|
+
|
|
223
|
+
// Should contain explicit file paths
|
|
224
|
+
expect(addCmd).toContain('/Users/test/RAF/017-decision-vault/input.md');
|
|
225
|
+
expect(addCmd).toContain('/Users/test/RAF/017-decision-vault/decisions.md');
|
|
226
|
+
|
|
227
|
+
// Should NOT use wildcards or add all
|
|
228
|
+
expect(addCmd).not.toContain('-A');
|
|
229
|
+
expect(addCmd).not.toContain('--all');
|
|
230
|
+
expect(addCmd).not.toContain('*');
|
|
231
|
+
});
|
|
232
|
+
});
|