ralphctl 0.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/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/ralphctl +13 -0
- package/package.json +92 -0
- package/schemas/config.schema.json +20 -0
- package/schemas/ideate-output.schema.json +22 -0
- package/schemas/projects.schema.json +53 -0
- package/schemas/requirements-output.schema.json +24 -0
- package/schemas/sprint.schema.json +109 -0
- package/schemas/task-import.schema.json +49 -0
- package/schemas/tasks.schema.json +72 -0
- package/src/ai/executor.ts +973 -0
- package/src/ai/lifecycle.ts +45 -0
- package/src/ai/parser.ts +40 -0
- package/src/ai/permissions.ts +207 -0
- package/src/ai/process-manager.ts +248 -0
- package/src/ai/prompts/ideate-auto.md +144 -0
- package/src/ai/prompts/ideate.md +165 -0
- package/src/ai/prompts/index.ts +89 -0
- package/src/ai/prompts/plan-auto.md +131 -0
- package/src/ai/prompts/plan-common.md +157 -0
- package/src/ai/prompts/plan-interactive.md +190 -0
- package/src/ai/prompts/task-execution.md +159 -0
- package/src/ai/prompts/ticket-refine.md +230 -0
- package/src/ai/rate-limiter.ts +89 -0
- package/src/ai/runner.ts +478 -0
- package/src/ai/session.ts +319 -0
- package/src/ai/task-context.ts +270 -0
- package/src/cli-metadata.ts +7 -0
- package/src/cli.ts +65 -0
- package/src/commands/completion/index.ts +33 -0
- package/src/commands/config/config.ts +58 -0
- package/src/commands/config/index.ts +33 -0
- package/src/commands/dashboard/dashboard.ts +5 -0
- package/src/commands/dashboard/index.ts +6 -0
- package/src/commands/doctor/doctor.ts +271 -0
- package/src/commands/doctor/index.ts +25 -0
- package/src/commands/progress/index.ts +25 -0
- package/src/commands/progress/log.ts +64 -0
- package/src/commands/progress/show.ts +14 -0
- package/src/commands/project/add.ts +336 -0
- package/src/commands/project/index.ts +104 -0
- package/src/commands/project/list.ts +31 -0
- package/src/commands/project/remove.ts +43 -0
- package/src/commands/project/repo.ts +118 -0
- package/src/commands/project/show.ts +49 -0
- package/src/commands/sprint/close.ts +180 -0
- package/src/commands/sprint/context.ts +109 -0
- package/src/commands/sprint/create.ts +60 -0
- package/src/commands/sprint/current.ts +75 -0
- package/src/commands/sprint/delete.ts +72 -0
- package/src/commands/sprint/health.ts +229 -0
- package/src/commands/sprint/ideate.ts +496 -0
- package/src/commands/sprint/index.ts +226 -0
- package/src/commands/sprint/list.ts +86 -0
- package/src/commands/sprint/plan-utils.ts +207 -0
- package/src/commands/sprint/plan.ts +549 -0
- package/src/commands/sprint/refine.ts +359 -0
- package/src/commands/sprint/requirements.ts +58 -0
- package/src/commands/sprint/show.ts +140 -0
- package/src/commands/sprint/start.ts +119 -0
- package/src/commands/sprint/switch.ts +20 -0
- package/src/commands/task/add.ts +316 -0
- package/src/commands/task/import.ts +150 -0
- package/src/commands/task/index.ts +123 -0
- package/src/commands/task/list.ts +145 -0
- package/src/commands/task/next.ts +45 -0
- package/src/commands/task/remove.ts +47 -0
- package/src/commands/task/reorder.ts +45 -0
- package/src/commands/task/show.ts +111 -0
- package/src/commands/task/status.ts +99 -0
- package/src/commands/ticket/add.ts +265 -0
- package/src/commands/ticket/edit.ts +166 -0
- package/src/commands/ticket/index.ts +114 -0
- package/src/commands/ticket/list.ts +128 -0
- package/src/commands/ticket/refine-utils.ts +89 -0
- package/src/commands/ticket/refine.ts +268 -0
- package/src/commands/ticket/remove.ts +48 -0
- package/src/commands/ticket/show.ts +74 -0
- package/src/completion/handle.ts +30 -0
- package/src/completion/resolver.ts +241 -0
- package/src/interactive/dashboard.ts +268 -0
- package/src/interactive/escapable.ts +81 -0
- package/src/interactive/file-browser.ts +153 -0
- package/src/interactive/index.ts +429 -0
- package/src/interactive/menu.ts +403 -0
- package/src/interactive/selectors.ts +273 -0
- package/src/interactive/wizard.ts +221 -0
- package/src/providers/claude.ts +53 -0
- package/src/providers/copilot.ts +86 -0
- package/src/providers/index.ts +43 -0
- package/src/providers/types.ts +85 -0
- package/src/schemas/index.ts +130 -0
- package/src/store/config.ts +74 -0
- package/src/store/progress.ts +230 -0
- package/src/store/project.ts +276 -0
- package/src/store/sprint.ts +229 -0
- package/src/store/task.ts +443 -0
- package/src/store/ticket.ts +178 -0
- package/src/theme/index.ts +215 -0
- package/src/theme/ui.ts +872 -0
- package/src/utils/detect-scripts.ts +247 -0
- package/src/utils/editor-input.ts +41 -0
- package/src/utils/editor.ts +37 -0
- package/src/utils/exit-codes.ts +27 -0
- package/src/utils/file-lock.ts +135 -0
- package/src/utils/git.ts +185 -0
- package/src/utils/ids.ts +37 -0
- package/src/utils/issue-fetch.ts +244 -0
- package/src/utils/json-extract.ts +62 -0
- package/src/utils/multiline.ts +61 -0
- package/src/utils/path-selector.ts +236 -0
- package/src/utils/paths.ts +108 -0
- package/src/utils/provider.ts +34 -0
- package/src/utils/requirements-export.ts +63 -0
- package/src/utils/storage.ts +107 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonical project-type detection and script suggestion.
|
|
6
|
+
*
|
|
7
|
+
* Used during `project add` and `project repo add` to pre-fill setup/verify
|
|
8
|
+
* scripts as editable suggestions. NOT used at runtime — scripts must come
|
|
9
|
+
* from explicit repo config only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ProjectType = 'node' | 'python' | 'go' | 'rust' | 'java-gradle' | 'java-maven' | 'makefile' | 'other';
|
|
13
|
+
|
|
14
|
+
export interface CheckCandidate {
|
|
15
|
+
label: string;
|
|
16
|
+
command: string;
|
|
17
|
+
selected: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DetectionResult {
|
|
21
|
+
type: ProjectType;
|
|
22
|
+
typeLabel: string;
|
|
23
|
+
installCommand: string | null;
|
|
24
|
+
candidates: CheckCandidate[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect project type from files in the path.
|
|
29
|
+
*/
|
|
30
|
+
export function detectProjectType(projectPath: string): ProjectType {
|
|
31
|
+
if (existsSync(join(projectPath, 'package.json'))) return 'node';
|
|
32
|
+
if (existsSync(join(projectPath, 'pyproject.toml')) || existsSync(join(projectPath, 'setup.py'))) return 'python';
|
|
33
|
+
if (existsSync(join(projectPath, 'go.mod'))) return 'go';
|
|
34
|
+
if (existsSync(join(projectPath, 'Cargo.toml'))) return 'rust';
|
|
35
|
+
if (existsSync(join(projectPath, 'build.gradle')) || existsSync(join(projectPath, 'build.gradle.kts')))
|
|
36
|
+
return 'java-gradle';
|
|
37
|
+
if (existsSync(join(projectPath, 'pom.xml'))) return 'java-maven';
|
|
38
|
+
if (existsSync(join(projectPath, 'Makefile'))) return 'makefile';
|
|
39
|
+
return 'other';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get human-readable label for project type.
|
|
44
|
+
*/
|
|
45
|
+
export function getProjectTypeLabel(type: ProjectType): string {
|
|
46
|
+
const labels: Record<ProjectType, string> = {
|
|
47
|
+
node: 'Node.js',
|
|
48
|
+
python: 'Python',
|
|
49
|
+
go: 'Go',
|
|
50
|
+
rust: 'Rust',
|
|
51
|
+
'java-gradle': 'Java (Gradle)',
|
|
52
|
+
'java-maven': 'Java (Maven)',
|
|
53
|
+
makefile: 'Makefile',
|
|
54
|
+
other: 'Unknown',
|
|
55
|
+
};
|
|
56
|
+
return labels[type];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Internal: Node.js package manager detection
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect the Node.js package manager from lockfiles.
|
|
65
|
+
*/
|
|
66
|
+
function detectNodePackageManager(projectPath: string): string {
|
|
67
|
+
if (existsSync(join(projectPath, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
68
|
+
if (existsSync(join(projectPath, 'yarn.lock'))) return 'yarn';
|
|
69
|
+
return 'npm';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Ecosystem detector registry
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
interface EcosystemDetector {
|
|
77
|
+
type: ProjectType;
|
|
78
|
+
label: string;
|
|
79
|
+
detect: (path: string) => boolean;
|
|
80
|
+
getInstallCommand: (path: string) => string | null;
|
|
81
|
+
getCandidates: (path: string) => CheckCandidate[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Alias groups for Node.js script detection — first match wins per group. */
|
|
85
|
+
const NODE_PRIMARY_GROUPS: { label: string; aliases: string[] }[] = [
|
|
86
|
+
{ label: 'linting', aliases: ['lint', 'eslint', 'lint:check'] },
|
|
87
|
+
{ label: 'type checking', aliases: ['typecheck', 'type-check', 'tsc', 'check-types'] },
|
|
88
|
+
{ label: 'tests', aliases: ['test', 'test:unit', 'test:run', 'vitest', 'jest'] },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const NODE_FALLBACK_GROUPS: { label: string; aliases: string[] }[] = [
|
|
92
|
+
{ label: 'build', aliases: ['build', 'compile'] },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function readPackageJsonScripts(projectPath: string): Record<string, string> {
|
|
96
|
+
try {
|
|
97
|
+
const raw = readFileSync(join(projectPath, 'package.json'), 'utf-8');
|
|
98
|
+
const pkg = JSON.parse(raw) as { scripts?: Record<string, string> };
|
|
99
|
+
return pkg.scripts ?? {};
|
|
100
|
+
} catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nodeDetector: EcosystemDetector = {
|
|
106
|
+
type: 'node',
|
|
107
|
+
label: 'Node.js',
|
|
108
|
+
detect: (path) => existsSync(join(path, 'package.json')),
|
|
109
|
+
getInstallCommand: (path) => {
|
|
110
|
+
const pm = detectNodePackageManager(path);
|
|
111
|
+
return `${pm} install`;
|
|
112
|
+
},
|
|
113
|
+
getCandidates: (path) => {
|
|
114
|
+
const scripts = readPackageJsonScripts(path);
|
|
115
|
+
const pm = detectNodePackageManager(path);
|
|
116
|
+
const run = pm === 'npm' ? 'npm run' : pm;
|
|
117
|
+
const candidates: CheckCandidate[] = [];
|
|
118
|
+
|
|
119
|
+
for (const group of NODE_PRIMARY_GROUPS) {
|
|
120
|
+
const match = group.aliases.find((name) => name in scripts);
|
|
121
|
+
if (match) {
|
|
122
|
+
candidates.push({ label: group.label, command: `${run} ${match}`, selected: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (candidates.length === 0) {
|
|
127
|
+
for (const group of NODE_FALLBACK_GROUPS) {
|
|
128
|
+
const match = group.aliases.find((name) => name in scripts);
|
|
129
|
+
if (match) {
|
|
130
|
+
candidates.push({ label: group.label, command: `${run} ${match}`, selected: false });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return candidates;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const pythonDetector: EcosystemDetector = {
|
|
140
|
+
type: 'python',
|
|
141
|
+
label: 'Python',
|
|
142
|
+
detect: (path) => existsSync(join(path, 'pyproject.toml')) || existsSync(join(path, 'setup.py')),
|
|
143
|
+
getInstallCommand: (path) => {
|
|
144
|
+
if (existsSync(join(path, 'uv.lock'))) return 'uv sync';
|
|
145
|
+
if (existsSync(join(path, 'requirements.txt'))) return 'pip install -r requirements.txt';
|
|
146
|
+
if (existsSync(join(path, 'pyproject.toml'))) return 'pip install -e .';
|
|
147
|
+
return null;
|
|
148
|
+
},
|
|
149
|
+
getCandidates: () => [{ label: 'tests', command: 'pytest', selected: true }],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const goDetector: EcosystemDetector = {
|
|
153
|
+
type: 'go',
|
|
154
|
+
label: 'Go',
|
|
155
|
+
detect: (path) => existsSync(join(path, 'go.mod')),
|
|
156
|
+
getInstallCommand: () => 'go mod download',
|
|
157
|
+
getCandidates: () => [
|
|
158
|
+
{ label: 'tests', command: 'go test ./...', selected: true },
|
|
159
|
+
{ label: 'vet', command: 'go vet ./...', selected: true },
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const rustDetector: EcosystemDetector = {
|
|
164
|
+
type: 'rust',
|
|
165
|
+
label: 'Rust',
|
|
166
|
+
detect: (path) => existsSync(join(path, 'Cargo.toml')),
|
|
167
|
+
getInstallCommand: () => 'cargo build',
|
|
168
|
+
getCandidates: () => [
|
|
169
|
+
{ label: 'tests', command: 'cargo test', selected: true },
|
|
170
|
+
{ label: 'clippy', command: 'cargo clippy', selected: false },
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const gradleDetector: EcosystemDetector = {
|
|
175
|
+
type: 'java-gradle' as ProjectType,
|
|
176
|
+
label: 'Java (Gradle)',
|
|
177
|
+
detect: (path) => existsSync(join(path, 'build.gradle')) || existsSync(join(path, 'build.gradle.kts')),
|
|
178
|
+
getInstallCommand: () => null,
|
|
179
|
+
getCandidates: () => [{ label: 'clean build', command: './gradlew clean build', selected: true }],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const mavenDetector: EcosystemDetector = {
|
|
183
|
+
type: 'java-maven' as ProjectType,
|
|
184
|
+
label: 'Java (Maven)',
|
|
185
|
+
detect: (path) => existsSync(join(path, 'pom.xml')),
|
|
186
|
+
getInstallCommand: () => null,
|
|
187
|
+
getCandidates: () => [{ label: 'clean install', command: 'mvn clean install', selected: true }],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const makefileDetector: EcosystemDetector = {
|
|
191
|
+
type: 'makefile' as ProjectType,
|
|
192
|
+
label: 'Makefile',
|
|
193
|
+
detect: (path) => existsSync(join(path, 'Makefile')),
|
|
194
|
+
getInstallCommand: () => null,
|
|
195
|
+
getCandidates: () => [{ label: 'check/test', command: 'make check || make test', selected: true }],
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** Ordered by priority — first match wins. */
|
|
199
|
+
const ECOSYSTEM_REGISTRY: EcosystemDetector[] = [
|
|
200
|
+
nodeDetector,
|
|
201
|
+
pythonDetector,
|
|
202
|
+
goDetector,
|
|
203
|
+
rustDetector,
|
|
204
|
+
gradleDetector,
|
|
205
|
+
mavenDetector,
|
|
206
|
+
makefileDetector,
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Public API
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Detect ecosystem and return structured check-script candidates.
|
|
215
|
+
* Returns null if the project type is unrecognized ('other').
|
|
216
|
+
*/
|
|
217
|
+
export function detectCheckScriptCandidates(projectPath: string): DetectionResult | null {
|
|
218
|
+
for (const detector of ECOSYSTEM_REGISTRY) {
|
|
219
|
+
if (detector.detect(projectPath)) {
|
|
220
|
+
return {
|
|
221
|
+
type: detector.type,
|
|
222
|
+
typeLabel: detector.label,
|
|
223
|
+
installCommand: detector.getInstallCommand(projectPath),
|
|
224
|
+
candidates: detector.getCandidates(projectPath),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Convenience wrapper: detect ecosystem and combine install command with
|
|
233
|
+
* pre-selected candidates into a single shell command string.
|
|
234
|
+
* Returns null if no ecosystem is detected.
|
|
235
|
+
*/
|
|
236
|
+
export function suggestCheckScript(projectPath: string): string | null {
|
|
237
|
+
const result = detectCheckScriptCandidates(projectPath);
|
|
238
|
+
if (!result) return null;
|
|
239
|
+
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
if (result.installCommand) parts.push(result.installCommand);
|
|
242
|
+
|
|
243
|
+
const selected = result.candidates.filter((c) => c.selected).map((c) => c.command);
|
|
244
|
+
parts.push(...selected);
|
|
245
|
+
|
|
246
|
+
return parts.length > 0 ? parts.join(' && ') : null;
|
|
247
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { editor } from '@inquirer/prompts';
|
|
2
|
+
import { resolveEditor } from '@src/utils/editor.ts';
|
|
3
|
+
|
|
4
|
+
export interface EditorInputOptions {
|
|
5
|
+
/** Message/prompt to display */
|
|
6
|
+
message: string;
|
|
7
|
+
/** Default value (pre-populated in the editor) */
|
|
8
|
+
default?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Open the user's configured editor for multiline text input.
|
|
13
|
+
* Uses @inquirer/editor under the hood, with the editor resolved from config.
|
|
14
|
+
*
|
|
15
|
+
* Falls back to readline-based multilineInput when stdin is not a TTY.
|
|
16
|
+
*/
|
|
17
|
+
export async function editorInput(options: EditorInputOptions): Promise<string> {
|
|
18
|
+
// Non-TTY fallback: delegate to readline-based multilineInput
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
const { multilineInput } = await import('@src/utils/multiline.ts');
|
|
21
|
+
return multilineInput({ message: options.message, default: options.default });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const editorCmd = await resolveEditor();
|
|
25
|
+
|
|
26
|
+
// Temporarily set VISUAL so @inquirer/editor uses our configured editor
|
|
27
|
+
const prevVisual = process.env['VISUAL'];
|
|
28
|
+
process.env['VISUAL'] = editorCmd;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = await editor({
|
|
32
|
+
message: options.message,
|
|
33
|
+
default: options.default,
|
|
34
|
+
postfix: '.md',
|
|
35
|
+
});
|
|
36
|
+
return result.trim();
|
|
37
|
+
} finally {
|
|
38
|
+
if (prevVisual === undefined) delete process.env['VISUAL'];
|
|
39
|
+
else process.env['VISUAL'] = prevVisual;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { getEditor, setEditor } from '@src/store/config.ts';
|
|
3
|
+
import { emoji } from '@src/theme/ui.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the configured editor command.
|
|
7
|
+
* Reads from config; if not set, prompts the user to choose and saves the selection.
|
|
8
|
+
*/
|
|
9
|
+
export async function resolveEditor(): Promise<string> {
|
|
10
|
+
const stored = await getEditor();
|
|
11
|
+
if (stored) return stored;
|
|
12
|
+
|
|
13
|
+
const choice = await select({
|
|
14
|
+
message: `${emoji.donut} Which editor should open for multiline input?`,
|
|
15
|
+
choices: [
|
|
16
|
+
{ name: 'Sublime Text', value: 'subl -w' },
|
|
17
|
+
{ name: 'VS Code', value: 'code --wait' },
|
|
18
|
+
{ name: 'Vim', value: 'vim' },
|
|
19
|
+
{ name: 'Nano', value: 'nano' },
|
|
20
|
+
{ name: 'Use $EDITOR env var', value: '__env__' },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (choice === '__env__') {
|
|
25
|
+
const envEditor = process.env['VISUAL'] ?? process.env['EDITOR'];
|
|
26
|
+
if (!envEditor) {
|
|
27
|
+
// Fallback: no env var set, default to vim
|
|
28
|
+
await setEditor('vim');
|
|
29
|
+
return 'vim';
|
|
30
|
+
}
|
|
31
|
+
await setEditor(envEditor);
|
|
32
|
+
return envEditor;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await setEditor(choice);
|
|
36
|
+
return choice;
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes for CLI commands.
|
|
3
|
+
* Using structured exit codes enables scripting and CI/CD integration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Success - all requested operations completed successfully */
|
|
7
|
+
export const EXIT_SUCCESS = 0;
|
|
8
|
+
|
|
9
|
+
/** Error - validation failed, missing params, execution error, etc. */
|
|
10
|
+
export const EXIT_ERROR = 1;
|
|
11
|
+
|
|
12
|
+
/** No tasks - no tasks available to execute */
|
|
13
|
+
export const EXIT_NO_TASKS = 2;
|
|
14
|
+
|
|
15
|
+
/** All blocked - remaining tasks are blocked by dependencies */
|
|
16
|
+
export const EXIT_ALL_BLOCKED = 3;
|
|
17
|
+
|
|
18
|
+
/** Interrupted - SIGINT received (standard Unix convention: 128 + 2) */
|
|
19
|
+
export const EXIT_INTERRUPTED = 130;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Exit with the given code (wrapper for testability).
|
|
23
|
+
* In non-interactive mode, commands should use this to signal outcome.
|
|
24
|
+
*/
|
|
25
|
+
export function exitWithCode(code: number): never {
|
|
26
|
+
process.exit(code);
|
|
27
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Simple file-based lock for preventing concurrent access.
|
|
6
|
+
* Uses a .lock file with process info.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class LockAcquisitionError extends Error {
|
|
10
|
+
public readonly lockPath: string;
|
|
11
|
+
|
|
12
|
+
constructor(message: string, lockPath: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'LockAcquisitionError';
|
|
15
|
+
this.lockPath = lockPath;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LockInfo {
|
|
20
|
+
pid: number;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How long (ms) before a lock file is considered stale. Override with RALPHCTL_LOCK_TIMEOUT_MS (1 to 3600000). */
|
|
25
|
+
const parsed = parseInt(process.env['RALPHCTL_LOCK_TIMEOUT_MS'] ?? '', 10);
|
|
26
|
+
export const LOCK_TIMEOUT_MS = parsed > 0 && parsed <= 3_600_000 ? parsed : 30_000;
|
|
27
|
+
|
|
28
|
+
/** Delay (ms) between retry attempts when a lock is held by another process. */
|
|
29
|
+
export const RETRY_DELAY_MS = 50;
|
|
30
|
+
|
|
31
|
+
/** Maximum number of retries before giving up (~5 seconds at default RETRY_DELAY_MS). */
|
|
32
|
+
export const MAX_RETRIES = 100;
|
|
33
|
+
|
|
34
|
+
function getLockPath(filePath: string): string {
|
|
35
|
+
return `${filePath}.lock`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sleep(ms: number): Promise<void> {
|
|
39
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a lock file is stale (old process that may have crashed).
|
|
44
|
+
*/
|
|
45
|
+
async function isLockStale(lockPath: string): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
const content = await readFile(lockPath, 'utf-8');
|
|
48
|
+
const info: LockInfo = JSON.parse(content) as LockInfo;
|
|
49
|
+
|
|
50
|
+
// Check if lock is old enough to be considered stale
|
|
51
|
+
const age = Date.now() - info.timestamp;
|
|
52
|
+
if (age > LOCK_TIMEOUT_MS) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check if the process is still running (only works for same-machine processes)
|
|
57
|
+
try {
|
|
58
|
+
process.kill(info.pid, 0); // signal 0 tests existence
|
|
59
|
+
return false; // Process exists
|
|
60
|
+
} catch {
|
|
61
|
+
return true; // Process doesn't exist
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Lock file is corrupted or unreadable, consider it stale
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Acquire a lock on a file path.
|
|
71
|
+
* Returns a release function that must be called when done.
|
|
72
|
+
*/
|
|
73
|
+
export async function acquireLock(filePath: string): Promise<() => Promise<void>> {
|
|
74
|
+
const lockPath = getLockPath(filePath);
|
|
75
|
+
const lockInfo: LockInfo = {
|
|
76
|
+
pid: process.pid,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let retries = 0;
|
|
81
|
+
|
|
82
|
+
while (retries < MAX_RETRIES) {
|
|
83
|
+
try {
|
|
84
|
+
// Ensure directory exists
|
|
85
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
86
|
+
|
|
87
|
+
// Try to create lock file exclusively
|
|
88
|
+
await writeFile(lockPath, JSON.stringify(lockInfo), { flag: 'wx', mode: 0o600 });
|
|
89
|
+
|
|
90
|
+
// Success - return release function
|
|
91
|
+
return async () => {
|
|
92
|
+
try {
|
|
93
|
+
await unlink(lockPath);
|
|
94
|
+
} catch {
|
|
95
|
+
// Ignore errors during cleanup
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// File exists - check if stale
|
|
100
|
+
if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
|
|
101
|
+
if (await isLockStale(lockPath)) {
|
|
102
|
+
// Remove stale lock and retry immediately
|
|
103
|
+
try {
|
|
104
|
+
await unlink(lockPath);
|
|
105
|
+
} catch {
|
|
106
|
+
// Ignore - another process may have grabbed it
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Lock is active, wait and retry
|
|
112
|
+
retries++;
|
|
113
|
+
await sleep(RETRY_DELAY_MS);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
throw new LockAcquisitionError(`Failed to acquire lock after ${String(MAX_RETRIES)} retries`, lockPath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Execute a function with a file lock.
|
|
126
|
+
* Automatically acquires and releases the lock.
|
|
127
|
+
*/
|
|
128
|
+
export async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
|
129
|
+
const release = await acquireLock(filePath);
|
|
130
|
+
try {
|
|
131
|
+
return await fn();
|
|
132
|
+
} finally {
|
|
133
|
+
await release();
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/utils/git.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { assertSafeCwd } from '@src/utils/paths.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Git utility functions for branch management.
|
|
6
|
+
*
|
|
7
|
+
* All functions validate their cwd via assertSafeCwd() before running
|
|
8
|
+
* any git commands — no raw user input reaches the shell.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Branch name pattern: alphanumeric, hyphens, underscores, dots, and slashes.
|
|
12
|
+
// Rejects control chars, spaces, ~, ^, :, ?, *, [, \, consecutive dots, trailing dots/slashes/locks.
|
|
13
|
+
const BRANCH_NAME_RE = /^[a-zA-Z0-9/_.-]+$/;
|
|
14
|
+
const BRANCH_NAME_INVALID_PATTERNS = [/\.\./, /\.$/, /\/$/, /\.lock$/, /^-/, /\/\//];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate a branch name is safe for git operations.
|
|
18
|
+
* Based on `git check-ref-format` rules.
|
|
19
|
+
*/
|
|
20
|
+
export function isValidBranchName(name: string): boolean {
|
|
21
|
+
if (!name || name.length > 250) return false;
|
|
22
|
+
if (!BRANCH_NAME_RE.test(name)) return false;
|
|
23
|
+
for (const pattern of BRANCH_NAME_INVALID_PATTERNS) {
|
|
24
|
+
if (pattern.test(name)) return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the current branch name.
|
|
31
|
+
* Returns 'HEAD' if in detached HEAD state.
|
|
32
|
+
*/
|
|
33
|
+
export function getCurrentBranch(cwd: string): string {
|
|
34
|
+
assertSafeCwd(cwd);
|
|
35
|
+
const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
36
|
+
cwd,
|
|
37
|
+
encoding: 'utf-8',
|
|
38
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
|
+
});
|
|
40
|
+
if (result.status !== 0) {
|
|
41
|
+
throw new Error(`Failed to get current branch in ${cwd}: ${result.stderr.trim()}`);
|
|
42
|
+
}
|
|
43
|
+
return result.stdout.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if a local branch exists.
|
|
48
|
+
*/
|
|
49
|
+
export function branchExists(cwd: string, name: string): boolean {
|
|
50
|
+
assertSafeCwd(cwd);
|
|
51
|
+
if (!isValidBranchName(name)) {
|
|
52
|
+
throw new Error(`Invalid branch name: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
const result = spawnSync('git', ['show-ref', '--verify', `refs/heads/${name}`], {
|
|
55
|
+
cwd,
|
|
56
|
+
encoding: 'utf-8',
|
|
57
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
58
|
+
});
|
|
59
|
+
return result.status === 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a new branch and check it out, or check out if it already exists.
|
|
64
|
+
* Idempotent — safe to call on resume/crash recovery.
|
|
65
|
+
*/
|
|
66
|
+
export function createAndCheckoutBranch(cwd: string, name: string): void {
|
|
67
|
+
assertSafeCwd(cwd);
|
|
68
|
+
if (!isValidBranchName(name)) {
|
|
69
|
+
throw new Error(`Invalid branch name: ${name}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const current = getCurrentBranch(cwd);
|
|
73
|
+
if (current === name) {
|
|
74
|
+
return; // Already on the requested branch
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (branchExists(cwd, name)) {
|
|
78
|
+
// Branch exists — just check it out
|
|
79
|
+
const result = spawnSync('git', ['checkout', name], {
|
|
80
|
+
cwd,
|
|
81
|
+
encoding: 'utf-8',
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
if (result.status !== 0) {
|
|
85
|
+
throw new Error(`Failed to checkout branch '${name}' in ${cwd}: ${result.stderr.trim()}`);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
// Create and checkout new branch
|
|
89
|
+
const result = spawnSync('git', ['checkout', '-b', name], {
|
|
90
|
+
cwd,
|
|
91
|
+
encoding: 'utf-8',
|
|
92
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
93
|
+
});
|
|
94
|
+
if (result.status !== 0) {
|
|
95
|
+
throw new Error(`Failed to create branch '${name}' in ${cwd}: ${result.stderr.trim()}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify that the repo is on the expected branch.
|
|
102
|
+
* Returns true if current branch matches expected.
|
|
103
|
+
*/
|
|
104
|
+
export function verifyCurrentBranch(cwd: string, expected: string): boolean {
|
|
105
|
+
const current = getCurrentBranch(cwd);
|
|
106
|
+
return current === expected;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect the default branch (main or master) from remote origin.
|
|
111
|
+
* Falls back to checking local branches if no remote is configured.
|
|
112
|
+
* Throws on unexpected git errors (permissions, corrupted repo, etc.).
|
|
113
|
+
*/
|
|
114
|
+
export function getDefaultBranch(cwd: string): string {
|
|
115
|
+
assertSafeCwd(cwd);
|
|
116
|
+
const result = spawnSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
|
|
117
|
+
cwd,
|
|
118
|
+
encoding: 'utf-8',
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (result.status === 0) {
|
|
123
|
+
// refs/remotes/origin/main → main
|
|
124
|
+
const ref = result.stdout.trim();
|
|
125
|
+
const parts = ref.split('/');
|
|
126
|
+
return parts[parts.length - 1] ?? 'main';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// "not a symbolic ref" — remote ref not configured, safe to fall through
|
|
130
|
+
const stderr = result.stderr.trim();
|
|
131
|
+
if (stderr.includes('is not a symbolic ref') || stderr.includes('No such ref')) {
|
|
132
|
+
if (branchExists(cwd, 'main')) return 'main';
|
|
133
|
+
if (branchExists(cwd, 'master')) return 'master';
|
|
134
|
+
return 'main';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Unexpected error — don't swallow it
|
|
138
|
+
throw new Error(`Failed to detect default branch in ${cwd}: ${stderr}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if the working directory has uncommitted changes (staged or unstaged).
|
|
143
|
+
*/
|
|
144
|
+
export function hasUncommittedChanges(cwd: string): boolean {
|
|
145
|
+
assertSafeCwd(cwd);
|
|
146
|
+
const result = spawnSync('git', ['status', '--porcelain'], {
|
|
147
|
+
cwd,
|
|
148
|
+
encoding: 'utf-8',
|
|
149
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
150
|
+
});
|
|
151
|
+
if (result.status !== 0) {
|
|
152
|
+
throw new Error(`Failed to check git status in ${cwd}: ${result.stderr.trim()}`);
|
|
153
|
+
}
|
|
154
|
+
return result.stdout.trim().length > 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Generate a branch name from a sprint ID.
|
|
159
|
+
* Format: `ralphctl/<sprint-id>`
|
|
160
|
+
*/
|
|
161
|
+
export function generateBranchName(sprintId: string): string {
|
|
162
|
+
return `ralphctl/${sprintId}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if the `gh` CLI is available in PATH.
|
|
167
|
+
*/
|
|
168
|
+
export function isGhAvailable(): boolean {
|
|
169
|
+
const result = spawnSync('gh', ['--version'], {
|
|
170
|
+
encoding: 'utf-8',
|
|
171
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
172
|
+
});
|
|
173
|
+
return result.status === 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if the `glab` CLI is available in PATH.
|
|
178
|
+
*/
|
|
179
|
+
export function isGlabAvailable(): boolean {
|
|
180
|
+
const result = spawnSync('glab', ['--version'], {
|
|
181
|
+
encoding: 'utf-8',
|
|
182
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
183
|
+
});
|
|
184
|
+
return result.status === 0;
|
|
185
|
+
}
|