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,230 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { assertSafeCwd, getProgressFilePath } from '@src/utils/paths.ts';
|
|
3
|
+
import { appendToFile, FileNotFoundError, readTextFile } from '@src/utils/storage.ts';
|
|
4
|
+
import { assertSprintStatus, getSprint, resolveSprintId } from '@src/store/sprint.ts';
|
|
5
|
+
import { withFileLock } from '@src/utils/file-lock.ts';
|
|
6
|
+
import { log } from '@src/theme/ui.ts';
|
|
7
|
+
|
|
8
|
+
export interface LogProgressOptions {
|
|
9
|
+
sprintId?: string;
|
|
10
|
+
projectPath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function logProgress(message: string, options: LogProgressOptions = {}): Promise<void> {
|
|
14
|
+
const id = await resolveSprintId(options.sprintId);
|
|
15
|
+
const sprint = await getSprint(id);
|
|
16
|
+
|
|
17
|
+
// Check sprint status - must be active to log progress
|
|
18
|
+
assertSprintStatus(sprint, ['active'], 'log progress');
|
|
19
|
+
|
|
20
|
+
const timestamp = new Date().toISOString();
|
|
21
|
+
const projectMarker = options.projectPath ? `**Project:** ${options.projectPath}\n\n` : '';
|
|
22
|
+
const entry = `## ${timestamp}\n\n${projectMarker}${message}\n\n---\n\n`;
|
|
23
|
+
const progressPath = getProgressFilePath(id);
|
|
24
|
+
await withFileLock(progressPath, async () => {
|
|
25
|
+
await appendToFile(progressPath, entry);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isExecError(err: unknown): err is Error & { status: number } {
|
|
30
|
+
return err instanceof Error && typeof (err as unknown as Record<string, unknown>)['status'] === 'number';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isNodeError(err: unknown): err is Error & { code: string } {
|
|
34
|
+
return err instanceof Error && typeof (err as unknown as Record<string, unknown>)['code'] === 'string';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the current git commit hash and message for a path.
|
|
39
|
+
*/
|
|
40
|
+
function getGitCommitInfo(projectPath: string): { hash: string; message: string } | null {
|
|
41
|
+
try {
|
|
42
|
+
assertSafeCwd(projectPath);
|
|
43
|
+
// Single git command: "hash message"
|
|
44
|
+
const output = execSync('git log -1 --pretty=format:%H\\ %s', {
|
|
45
|
+
cwd: projectPath,
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
48
|
+
}).trim();
|
|
49
|
+
const spaceIndex = output.indexOf(' ');
|
|
50
|
+
return {
|
|
51
|
+
hash: output.slice(0, spaceIndex),
|
|
52
|
+
message: output.slice(spaceIndex + 1),
|
|
53
|
+
};
|
|
54
|
+
} catch (err: unknown) {
|
|
55
|
+
// Expected: not a git repo (exit code 128) — return null silently
|
|
56
|
+
if (isExecError(err) && err.status === 128) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Expected: git not installed (ENOENT) — return null silently
|
|
60
|
+
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// Unexpected: permission denied, corrupt repo, etc. — warn the user
|
|
64
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
65
|
+
log.warn(`Failed to get git info for ${projectPath}: ${detail}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LogBaselinesOptions {
|
|
71
|
+
sprintId: string;
|
|
72
|
+
sprintName: string;
|
|
73
|
+
projectPaths: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Log baseline git state for each project when a sprint is activated.
|
|
78
|
+
* This enables "git log baseline..HEAD" style reviews of sprint changes.
|
|
79
|
+
*/
|
|
80
|
+
export async function logBaselines(options: LogBaselinesOptions): Promise<void> {
|
|
81
|
+
const { sprintId, sprintName, projectPaths } = options;
|
|
82
|
+
const timestamp = new Date().toISOString();
|
|
83
|
+
|
|
84
|
+
const lines: string[] = [
|
|
85
|
+
`## ${timestamp}`,
|
|
86
|
+
'',
|
|
87
|
+
'### Sprint Baseline State',
|
|
88
|
+
'',
|
|
89
|
+
`Sprint: ${sprintName} (${sprintId})`,
|
|
90
|
+
`Activated: ${timestamp}`,
|
|
91
|
+
'',
|
|
92
|
+
'#### Project Git State at Activation',
|
|
93
|
+
'',
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// Get unique paths
|
|
97
|
+
const uniquePaths = [...new Set(projectPaths)];
|
|
98
|
+
|
|
99
|
+
for (const path of uniquePaths) {
|
|
100
|
+
const commitInfo = getGitCommitInfo(path);
|
|
101
|
+
if (commitInfo) {
|
|
102
|
+
lines.push(`- **${path}**`);
|
|
103
|
+
lines.push(` \`${commitInfo.hash} ${commitInfo.message}\``);
|
|
104
|
+
} else {
|
|
105
|
+
lines.push(`- **${path}**`);
|
|
106
|
+
lines.push(` *(not a git repository or unable to retrieve state)*`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lines.push('');
|
|
111
|
+
lines.push('---');
|
|
112
|
+
lines.push('');
|
|
113
|
+
|
|
114
|
+
await appendToFile(getProgressFilePath(sprintId), lines.join('\n'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function getProgress(sprintId?: string): Promise<string> {
|
|
118
|
+
const id = await resolveSprintId(sprintId);
|
|
119
|
+
try {
|
|
120
|
+
return await readTextFile(getProgressFilePath(id));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof FileNotFoundError) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse progress entries and filter by project path.
|
|
131
|
+
* Entries are delimited by `---` and may contain project markers in either format:
|
|
132
|
+
* - Legacy HTML comments: `<!-- project: /path -->`
|
|
133
|
+
* - Visible format: `**Project:** /path`
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* Extract only "Learnings and Context" and "Notes for Next Tasks" sections
|
|
137
|
+
* from progress entries, capped at maxEntries most recent.
|
|
138
|
+
* Returns compressed summary suitable for task context files.
|
|
139
|
+
*/
|
|
140
|
+
export function summarizeProgressForContext(progress: string, projectPath: string, maxEntries = 3): string {
|
|
141
|
+
const filtered = filterProgressByProject(progress, projectPath);
|
|
142
|
+
if (!filtered.trim()) {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Split into entries by --- delimiter
|
|
147
|
+
const entries = filtered.split(/\n---\n/).filter((e) => e.trim());
|
|
148
|
+
|
|
149
|
+
// Take last maxEntries entries
|
|
150
|
+
const recent = entries.slice(-maxEntries);
|
|
151
|
+
|
|
152
|
+
const summaries: string[] = [];
|
|
153
|
+
|
|
154
|
+
for (const entry of recent) {
|
|
155
|
+
// Extract entry header (first ## line with timestamp and task name)
|
|
156
|
+
const headerMatch = /^##\s+(.+)$/m.exec(entry);
|
|
157
|
+
const header = headerMatch?.[1] ?? 'Unknown entry';
|
|
158
|
+
|
|
159
|
+
// Extract "Learnings and Context" section
|
|
160
|
+
const learnings = extractSection(entry, 'Learnings and Context');
|
|
161
|
+
|
|
162
|
+
// Extract "Notes for Next Tasks" section
|
|
163
|
+
const notes = extractSection(entry, 'Notes for Next Tasks');
|
|
164
|
+
|
|
165
|
+
// Only include entries that have at least one useful section
|
|
166
|
+
if (learnings || notes) {
|
|
167
|
+
const parts: string[] = [`**${header}**`];
|
|
168
|
+
if (learnings) {
|
|
169
|
+
parts.push(`**Learnings:** ${learnings}`);
|
|
170
|
+
}
|
|
171
|
+
if (notes) {
|
|
172
|
+
parts.push(`**Notes for next tasks:** ${notes}`);
|
|
173
|
+
}
|
|
174
|
+
summaries.push(parts.join('\n'));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (summaries.length === 0) {
|
|
179
|
+
return '';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return summaries.join('\n\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract content of a markdown section (### heading) from a progress entry.
|
|
187
|
+
* Returns the section content trimmed, or null if section not found.
|
|
188
|
+
*/
|
|
189
|
+
function extractSection(entry: string, sectionName: string): string | null {
|
|
190
|
+
// Match ### Section Name followed by content until next ### or end of string
|
|
191
|
+
// No 'm' flag — $ must match end of string, not end of line
|
|
192
|
+
const regex = new RegExp(`###\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\n([\\s\\S]*?)(?=###|$)`);
|
|
193
|
+
const match = regex.exec(entry);
|
|
194
|
+
if (!match?.[1]) return null;
|
|
195
|
+
|
|
196
|
+
const content = match[1].trim();
|
|
197
|
+
return content || null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function filterProgressByProject(progress: string, projectPath: string): string {
|
|
201
|
+
if (!progress.trim()) {
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Split by entry delimiter
|
|
206
|
+
const entries = progress.split(/\n---\n/).filter((e) => e.trim());
|
|
207
|
+
|
|
208
|
+
const filtered = entries.filter((entry) => {
|
|
209
|
+
// Try visible format first: **Project:** /some/path
|
|
210
|
+
const visibleMatch = /\*\*Project:\*\*\s*(.+?)(?:\n|$)/.exec(entry);
|
|
211
|
+
if (visibleMatch?.[1]) {
|
|
212
|
+
return visibleMatch[1].trim() === projectPath;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Fall back to legacy HTML comment format: <!-- project: /some/path -->
|
|
216
|
+
const htmlMatch = /<!--\s*project:\s*(.+?)\s*-->/.exec(entry);
|
|
217
|
+
if (htmlMatch?.[1]) {
|
|
218
|
+
return htmlMatch[1] === projectPath;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// No marker = include (baseline entries, general notes)
|
|
222
|
+
return true;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (filtered.length === 0) {
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return filtered.join('\n---\n') + '\n\n---\n\n';
|
|
230
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { basename, resolve } from 'node:path';
|
|
2
|
+
import { getProjectsFilePath, validateProjectPath } from '../utils/paths.js';
|
|
3
|
+
import { fileExists, readValidatedJson, writeValidatedJson } from '../utils/storage.js';
|
|
4
|
+
import { type Project, type Projects, ProjectsSchema, type Repository } from '../schemas/index.js';
|
|
5
|
+
|
|
6
|
+
export class ProjectNotFoundError extends Error {
|
|
7
|
+
public readonly projectName: string;
|
|
8
|
+
|
|
9
|
+
constructor(projectName: string) {
|
|
10
|
+
super(`Project not found: ${projectName}`);
|
|
11
|
+
this.name = 'ProjectNotFoundError';
|
|
12
|
+
this.projectName = projectName;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ProjectExistsError extends Error {
|
|
17
|
+
public readonly projectName: string;
|
|
18
|
+
|
|
19
|
+
constructor(projectName: string) {
|
|
20
|
+
super(`Project already exists: ${projectName}`);
|
|
21
|
+
this.name = 'ProjectExistsError';
|
|
22
|
+
this.projectName = projectName;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Migration: Convert old paths[] format to repositories[] format.
|
|
28
|
+
* Non-production tool - minimal migration support.
|
|
29
|
+
*/
|
|
30
|
+
interface LegacyProject {
|
|
31
|
+
name: string;
|
|
32
|
+
displayName: string;
|
|
33
|
+
paths?: string[];
|
|
34
|
+
repositories?: Repository[];
|
|
35
|
+
description?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function migrateProjectIfNeeded(project: LegacyProject): Project {
|
|
39
|
+
// Already in new format
|
|
40
|
+
if (project.repositories) {
|
|
41
|
+
return project as Project;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Old paths[] format - convert to repositories[]
|
|
45
|
+
if (project.paths) {
|
|
46
|
+
return {
|
|
47
|
+
name: project.name,
|
|
48
|
+
displayName: project.displayName,
|
|
49
|
+
repositories: project.paths.map((p) => ({
|
|
50
|
+
name: basename(p),
|
|
51
|
+
path: p,
|
|
52
|
+
})),
|
|
53
|
+
description: project.description,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error(`Invalid project data: no paths or repositories for ${project.name}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get all projects.
|
|
62
|
+
* Handles migration from old paths[] format to repositories[] format.
|
|
63
|
+
*/
|
|
64
|
+
export async function listProjects(): Promise<Projects> {
|
|
65
|
+
const filePath = getProjectsFilePath();
|
|
66
|
+
if (!(await fileExists(filePath))) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Read raw data to check for migration needs
|
|
71
|
+
const { readFile } = await import('node:fs/promises');
|
|
72
|
+
const content = await readFile(filePath, 'utf-8');
|
|
73
|
+
const rawData = JSON.parse(content) as LegacyProject[];
|
|
74
|
+
|
|
75
|
+
// Check if any projects need migration (old paths[] format)
|
|
76
|
+
const needsMigration = rawData.some((p) => p.paths && !p.repositories);
|
|
77
|
+
|
|
78
|
+
if (needsMigration) {
|
|
79
|
+
const migrated = rawData.map(migrateProjectIfNeeded);
|
|
80
|
+
const validated = ProjectsSchema.parse(migrated);
|
|
81
|
+
await writeValidatedJson(filePath, validated, ProjectsSchema);
|
|
82
|
+
return validated;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return readValidatedJson(filePath, ProjectsSchema);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get a project by name.
|
|
90
|
+
* @throws ProjectNotFoundError if project doesn't exist
|
|
91
|
+
*/
|
|
92
|
+
export async function getProject(name: string): Promise<Project> {
|
|
93
|
+
const projects = await listProjects();
|
|
94
|
+
const project = projects.find((p) => p.name === name);
|
|
95
|
+
if (!project) {
|
|
96
|
+
throw new ProjectNotFoundError(name);
|
|
97
|
+
}
|
|
98
|
+
return project;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if a project exists.
|
|
103
|
+
*/
|
|
104
|
+
export async function projectExists(name: string): Promise<boolean> {
|
|
105
|
+
const projects = await listProjects();
|
|
106
|
+
return projects.some((p) => p.name === name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create a new project.
|
|
111
|
+
* @throws ProjectExistsError if project already exists
|
|
112
|
+
*/
|
|
113
|
+
export async function createProject(project: Project): Promise<Project> {
|
|
114
|
+
const projects = await listProjects();
|
|
115
|
+
|
|
116
|
+
if (projects.some((p) => p.name === project.name)) {
|
|
117
|
+
throw new ProjectExistsError(project.name);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate that all repository paths exist
|
|
121
|
+
const pathErrors: string[] = [];
|
|
122
|
+
for (const repo of project.repositories) {
|
|
123
|
+
const resolved = resolve(repo.path);
|
|
124
|
+
const validation = await validateProjectPath(resolved);
|
|
125
|
+
if (validation !== true) {
|
|
126
|
+
pathErrors.push(` ${repo.path}: ${validation}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (pathErrors.length > 0) {
|
|
130
|
+
throw new Error(`Invalid project paths:\n${pathErrors.join('\n')}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Resolve all paths to absolute and derive names, preserving scripts
|
|
134
|
+
const normalizedProject: Project = {
|
|
135
|
+
...project,
|
|
136
|
+
repositories: project.repositories.map((repo) => ({
|
|
137
|
+
...repo,
|
|
138
|
+
name: repo.name || basename(repo.path),
|
|
139
|
+
path: resolve(repo.path),
|
|
140
|
+
})),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
projects.push(normalizedProject);
|
|
144
|
+
await writeValidatedJson(getProjectsFilePath(), projects, ProjectsSchema);
|
|
145
|
+
|
|
146
|
+
return normalizedProject;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Update an existing project.
|
|
151
|
+
* @throws ProjectNotFoundError if project doesn't exist
|
|
152
|
+
*/
|
|
153
|
+
export async function updateProject(name: string, updates: Partial<Omit<Project, 'name'>>): Promise<Project> {
|
|
154
|
+
const projects = await listProjects();
|
|
155
|
+
const index = projects.findIndex((p) => p.name === name);
|
|
156
|
+
|
|
157
|
+
if (index === -1) {
|
|
158
|
+
throw new ProjectNotFoundError(name);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Validate new repositories if provided
|
|
162
|
+
if (updates.repositories) {
|
|
163
|
+
const pathErrors: string[] = [];
|
|
164
|
+
for (const repo of updates.repositories) {
|
|
165
|
+
const resolved = resolve(repo.path);
|
|
166
|
+
const validation = await validateProjectPath(resolved);
|
|
167
|
+
if (validation !== true) {
|
|
168
|
+
pathErrors.push(` ${repo.path}: ${validation}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (pathErrors.length > 0) {
|
|
172
|
+
throw new Error(`Invalid project paths:\n${pathErrors.join('\n')}`);
|
|
173
|
+
}
|
|
174
|
+
// Resolve paths to absolute and ensure names, preserving scripts
|
|
175
|
+
updates.repositories = updates.repositories.map((repo) => ({
|
|
176
|
+
...repo,
|
|
177
|
+
name: repo.name || basename(repo.path),
|
|
178
|
+
path: resolve(repo.path),
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const existingProject = projects[index];
|
|
183
|
+
if (!existingProject) {
|
|
184
|
+
throw new ProjectNotFoundError(name);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const updatedProject: Project = {
|
|
188
|
+
name: existingProject.name,
|
|
189
|
+
displayName: updates.displayName ?? existingProject.displayName,
|
|
190
|
+
repositories: updates.repositories ?? existingProject.repositories,
|
|
191
|
+
description: updates.description ?? existingProject.description,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
projects[index] = updatedProject;
|
|
195
|
+
await writeValidatedJson(getProjectsFilePath(), projects, ProjectsSchema);
|
|
196
|
+
|
|
197
|
+
return updatedProject;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Remove a project.
|
|
202
|
+
* @throws ProjectNotFoundError if project doesn't exist
|
|
203
|
+
*/
|
|
204
|
+
export async function removeProject(name: string): Promise<void> {
|
|
205
|
+
const projects = await listProjects();
|
|
206
|
+
const index = projects.findIndex((p) => p.name === name);
|
|
207
|
+
|
|
208
|
+
if (index === -1) {
|
|
209
|
+
throw new ProjectNotFoundError(name);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
projects.splice(index, 1);
|
|
213
|
+
await writeValidatedJson(getProjectsFilePath(), projects, ProjectsSchema);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get all repositories for a project.
|
|
218
|
+
* @throws ProjectNotFoundError if project doesn't exist
|
|
219
|
+
*/
|
|
220
|
+
export async function getProjectRepos(name: string): Promise<Repository[]> {
|
|
221
|
+
const project = await getProject(name);
|
|
222
|
+
return project.repositories;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Add a repository to an existing project.
|
|
227
|
+
* Accepts a full Repository object to preserve scripts set during interactive prompting.
|
|
228
|
+
* @throws ProjectNotFoundError if project doesn't exist
|
|
229
|
+
*/
|
|
230
|
+
export async function addProjectRepo(name: string, repo: Repository): Promise<Project> {
|
|
231
|
+
const project = await getProject(name);
|
|
232
|
+
const resolvedPath = resolve(repo.path);
|
|
233
|
+
|
|
234
|
+
// Validate the path
|
|
235
|
+
const validation = await validateProjectPath(resolvedPath);
|
|
236
|
+
if (validation !== true) {
|
|
237
|
+
throw new Error(`Invalid path ${repo.path}: ${validation}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check if path already exists
|
|
241
|
+
if (project.repositories.some((r) => r.path === resolvedPath)) {
|
|
242
|
+
return project; // Already exists, no-op
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const normalizedRepo: Repository = {
|
|
246
|
+
...repo,
|
|
247
|
+
name: repo.name || basename(resolvedPath),
|
|
248
|
+
path: resolvedPath,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return updateProject(name, {
|
|
252
|
+
repositories: [...project.repositories, normalizedRepo],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Remove a repository from an existing project.
|
|
258
|
+
* @throws ProjectNotFoundError if project doesn't exist
|
|
259
|
+
* @throws Error if trying to remove the last repository
|
|
260
|
+
*/
|
|
261
|
+
export async function removeProjectRepo(name: string, path: string): Promise<Project> {
|
|
262
|
+
const project = await getProject(name);
|
|
263
|
+
const resolvedPath = resolve(path);
|
|
264
|
+
|
|
265
|
+
const newRepos = project.repositories.filter((r) => r.path !== resolvedPath);
|
|
266
|
+
|
|
267
|
+
if (newRepos.length === 0) {
|
|
268
|
+
throw new Error('Cannot remove the last repository from a project');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (newRepos.length === project.repositories.length) {
|
|
272
|
+
return project; // Path wasn't in the list, no-op
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return updateProject(name, { repositories: newRepos });
|
|
276
|
+
}
|