mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8efb55b1a | |||
| e10154b5e4 | |||
| ee5ab094e2 | |||
| 8251ac52d6 | |||
| c3953d123b | |||
| b6a6682f26 |
@@ -51,7 +51,10 @@ finalized plan to the user and requests approval to start the implementation.
|
||||
- Marks the plan as approved for implementation.
|
||||
- If the user rejects the plan:
|
||||
- Stays in Plan Mode.
|
||||
- Saves a versioned backup of the rejected plan.
|
||||
- Returns user feedback to the model to refine the plan.
|
||||
- The next time `exit_plan_mode` is called, a diff against the previous
|
||||
version is shown in the approval step.
|
||||
- **Output (`llmContent`):**
|
||||
- On approval: A message indicating the plan was approved and the new approval
|
||||
mode.
|
||||
|
||||
@@ -190,7 +190,11 @@ interface AskUserDialogProps {
|
||||
/**
|
||||
* Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"])
|
||||
*/
|
||||
extraParts?: string[];
|
||||
extraParts?: React.ReactNode[];
|
||||
/**
|
||||
* Content to render before the options/input field.
|
||||
*/
|
||||
preOptionsContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ReviewViewProps {
|
||||
@@ -198,7 +202,8 @@ interface ReviewViewProps {
|
||||
answers: { [key: string]: string };
|
||||
onSubmit: () => void;
|
||||
progressHeader?: React.ReactNode;
|
||||
extraParts?: string[];
|
||||
extraParts?: React.ReactNode[];
|
||||
preOptionsContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
@@ -207,6 +212,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
onSubmit,
|
||||
progressHeader,
|
||||
extraParts,
|
||||
preOptionsContent,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const unansweredCount = questions.length - Object.keys(answers).length;
|
||||
@@ -232,6 +238,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
Review your answers:
|
||||
</Text>
|
||||
</Box>
|
||||
{preOptionsContent}
|
||||
|
||||
{hasUnanswered && (
|
||||
<Box marginBottom={1}>
|
||||
@@ -276,6 +283,7 @@ interface TextQuestionViewProps {
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
preOptionsContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
@@ -288,6 +296,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
preOptionsContent,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -367,11 +376,14 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<MarkdownDisplay
|
||||
text={autoBoldIfPlain(question.question)}
|
||||
terminalWidth={availableWidth - DIALOG_PADDING}
|
||||
isPending={false}
|
||||
/>
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={autoBoldIfPlain(question.question)}
|
||||
terminalWidth={availableWidth - DIALOG_PADDING}
|
||||
isPending={false}
|
||||
/>
|
||||
{preOptionsContent}
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
@@ -496,6 +508,7 @@ interface ChoiceQuestionViewProps {
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
preOptionsContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
@@ -508,6 +521,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
preOptionsContent,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -889,6 +903,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
{preOptionsContent}
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
@@ -1009,6 +1024,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
width,
|
||||
availableHeight: availableHeightProp,
|
||||
extraParts,
|
||||
preOptionsContent,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const uiState = useContext(UIStateContext);
|
||||
@@ -1206,6 +1222,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onSubmit={handleReviewSubmit}
|
||||
progressHeader={progressHeader}
|
||||
extraParts={extraParts}
|
||||
preOptionsContent={preOptionsContent}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -1246,6 +1263,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
preOptionsContent={preOptionsContent}
|
||||
/>
|
||||
) : (
|
||||
<ChoiceQuestionView
|
||||
@@ -1259,6 +1277,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
preOptionsContent={preOptionsContent}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -139,17 +139,22 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderDialog = async (options?: { useAlternateBuffer?: boolean }) => {
|
||||
const renderDialog = async (options?: {
|
||||
useAlternateBuffer?: boolean;
|
||||
diffContent?: string;
|
||||
availableHeight?: number;
|
||||
}) => {
|
||||
const useAlternateBuffer = options?.useAlternateBuffer ?? true;
|
||||
return renderWithProviders(
|
||||
<ExitPlanModeDialog
|
||||
planPath={mockPlanFullPath}
|
||||
diffContent={options?.diffContent}
|
||||
onApprove={onApprove}
|
||||
onFeedback={onFeedback}
|
||||
onCancel={onCancel}
|
||||
getPreferredEditor={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={24}
|
||||
availableHeight={options?.availableHeight ?? 24}
|
||||
/>,
|
||||
{
|
||||
...options,
|
||||
@@ -200,6 +205,30 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders diffContent if provided', async () => {
|
||||
const diffContent =
|
||||
'--- test-plan.md\n+++ test-plan.md\n@@ -1,1 +1,1 @@\n- old\n+ new';
|
||||
const { lastFrame } = await act(async () =>
|
||||
renderDialog({
|
||||
useAlternateBuffer,
|
||||
diffContent,
|
||||
availableHeight: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Changes since previous version');
|
||||
expect(lastFrame()).toContain('old');
|
||||
expect(lastFrame()).toContain('new');
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onApprove with AUTO_EDIT when first option is selected', async () => {
|
||||
const { stdin, lastFrame } = await act(async () =>
|
||||
renderDialog({ useAlternateBuffer }),
|
||||
|
||||
@@ -25,9 +25,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
|
||||
export interface ExitPlanModeDialogProps {
|
||||
planPath: string;
|
||||
diffContent?: string;
|
||||
onApprove: (approvalMode: ApprovalMode) => void;
|
||||
onFeedback: (feedback: string) => void;
|
||||
onCancel: () => void;
|
||||
@@ -140,6 +142,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
|
||||
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
planPath,
|
||||
diffContent,
|
||||
onApprove,
|
||||
onFeedback,
|
||||
onCancel,
|
||||
@@ -226,6 +229,16 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
|
||||
const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
|
||||
|
||||
const extraParts: React.ReactNode[] = [];
|
||||
extraParts.push(`${editHint} to edit plan`);
|
||||
|
||||
const preOptionsContent = diffContent ? (
|
||||
<Box key="diff" flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
<Text bold>Changes since previous version:</Text>
|
||||
<DiffRenderer diffContent={diffContent} terminalWidth={width} />
|
||||
</Box>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<AskUserDialog
|
||||
@@ -264,7 +277,8 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
onCancel={onCancel}
|
||||
width={width}
|
||||
availableHeight={availableHeight}
|
||||
extraParts={[`${editHint} to edit plan`]}
|
||||
extraParts={extraParts}
|
||||
preOptionsContent={preOptionsContent}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -103,6 +103,38 @@ Files to Modify
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > renders diffContent if provided 1`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
Changes since previous version:
|
||||
1 - old
|
||||
1 + new
|
||||
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
@@ -246,3 +278,35 @@ Files to Modify
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > renders diffContent if provided 1`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
Changes since previous version:
|
||||
1 - old
|
||||
1 + new
|
||||
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -539,6 +539,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
bodyContent = (
|
||||
<ExitPlanModeDialog
|
||||
planPath={confirmationDetails.planPath}
|
||||
diffContent={confirmationDetails.diffContent}
|
||||
getPreferredEditor={getPreferredEditor}
|
||||
onApprove={(approvalMode) => {
|
||||
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface DialogFooterProps {
|
||||
/** Exit shortcut (defaults to "Esc to cancel") */
|
||||
cancelAction?: string;
|
||||
/** Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */
|
||||
extraParts?: string[];
|
||||
extraParts?: React.ReactNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,16 +29,28 @@ export const DialogFooter: React.FC<DialogFooterProps> = ({
|
||||
cancelAction = 'Esc to cancel',
|
||||
extraParts = [],
|
||||
}) => {
|
||||
const parts = [primaryAction];
|
||||
const textParts: string[] = [primaryAction];
|
||||
if (navigationActions) {
|
||||
parts.push(navigationActions);
|
||||
textParts.push(navigationActions);
|
||||
}
|
||||
parts.push(...extraParts);
|
||||
parts.push(cancelAction);
|
||||
|
||||
// We split string parts and node parts to properly render nodes without forcing them into a single string join
|
||||
const stringExtras = extraParts.filter(
|
||||
(p): p is string => typeof p === 'string',
|
||||
);
|
||||
const nodeExtras = extraParts.filter((p) => typeof p !== 'string');
|
||||
|
||||
textParts.push(...stringExtras);
|
||||
textParts.push(cancelAction);
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{parts.join(' · ')}</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{nodeExtras.map((part, i) => (
|
||||
<Box key={i}>{part}</Box>
|
||||
))}
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{textParts.join(' · ')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -137,6 +137,7 @@ export type SerializableConfirmationDetails =
|
||||
title: string;
|
||||
systemMessage?: string;
|
||||
planPath: string;
|
||||
diffContent?: string;
|
||||
};
|
||||
|
||||
export interface UpdatePolicy {
|
||||
|
||||
@@ -141,6 +141,25 @@ describe('ExitPlanModeTool', () => {
|
||||
expect(executeResult.llmContent).toContain('Plan approved');
|
||||
});
|
||||
|
||||
it('should calculate and return diffContent when a backup exists', async () => {
|
||||
const planRelativePath = createPlanFile('test-md', '# Current Plan');
|
||||
createPlanFile('test-md.v1', '# Old Plan');
|
||||
|
||||
const invocation = tool.build({ plan_filename: planRelativePath });
|
||||
const result = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(result).not.toBe(false);
|
||||
if (result === false) return;
|
||||
|
||||
expect(result.type).toBe('exit_plan_mode');
|
||||
if (result.type === 'exit_plan_mode') {
|
||||
expect(result.diffContent).toContain('Current Plan');
|
||||
expect(result.diffContent).toContain('Old Plan');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when policy decision is DENY', async () => {
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
const invocation = tool.build({ plan_filename: planRelativePath });
|
||||
@@ -268,6 +287,28 @@ Revise the plan based on the feedback.`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a backup file when plan is rejected', async () => {
|
||||
const planRelativePath = createPlanFile('test-backup.md', '# Content');
|
||||
const invocation = tool.build({ plan_filename: planRelativePath });
|
||||
|
||||
const confirmDetails = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmDetails).not.toBe(false);
|
||||
if (confirmDetails === false) return;
|
||||
|
||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
approved: false,
|
||||
feedback: 'Please change.',
|
||||
});
|
||||
|
||||
await invocation.execute(new AbortController().signal);
|
||||
|
||||
const expectedBackupPath = path.join(mockPlansDir, 'test-backup.md.v1');
|
||||
expect(fs.existsSync(expectedBackupPath)).toBe(true);
|
||||
expect(fs.readFileSync(expectedBackupPath, 'utf8')).toBe('# Content');
|
||||
});
|
||||
|
||||
it('should handle rejection without feedback gracefully', async () => {
|
||||
const planRelativePath = createPlanFile('test.md', '# Content');
|
||||
const invocation = tool.build({ plan_filename: planRelativePath });
|
||||
|
||||
@@ -18,7 +18,11 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
|
||||
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
|
||||
import {
|
||||
validatePlanPath,
|
||||
validatePlanContent,
|
||||
getPlanVersions,
|
||||
} from '../utils/planUtils.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
|
||||
import { logPlanExecution } from '../telemetry/loggers.js';
|
||||
@@ -26,6 +30,10 @@ import { PlanExecutionEvent } from '../telemetry/types.js';
|
||||
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { getPlanModeExitMessage } from '../utils/approvalModeUtils.js';
|
||||
import * as Diff from 'diff';
|
||||
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
|
||||
export interface ExitPlanModeParams {
|
||||
plan_filename: string;
|
||||
@@ -153,10 +161,40 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
// decision is 'ask_user'
|
||||
let diffContent: string | undefined;
|
||||
try {
|
||||
const versions = await getPlanVersions(resolvedPlanPath);
|
||||
const latestVersion = versions.length > 0 ? Math.max(...versions) : 0;
|
||||
|
||||
if (latestVersion > 0) {
|
||||
const previousPlanPath = `${resolvedPlanPath}.v${latestVersion}`;
|
||||
const previousPlanContent = await fsPromises.readFile(
|
||||
previousPlanPath,
|
||||
'utf8',
|
||||
);
|
||||
const currentPlanContent = await fsPromises.readFile(
|
||||
resolvedPlanPath,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
diffContent = Diff.createPatch(
|
||||
path.basename(resolvedPlanPath),
|
||||
previousPlanContent,
|
||||
currentPlanContent,
|
||||
`Previous version (v${latestVersion})`,
|
||||
'Current version',
|
||||
DEFAULT_DIFF_OPTIONS,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.error('Failed to create diff for plan:', err);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'exit_plan_mode',
|
||||
title: 'Plan Approval',
|
||||
planPath: resolvedPlanPath,
|
||||
diffContent,
|
||||
onConfirm: async (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
@@ -229,6 +267,17 @@ Read and follow the plan strictly during implementation.`,
|
||||
returnDisplay: `Plan approved: ${resolvedPlanPath}`,
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const versions = await getPlanVersions(resolvedPlanPath);
|
||||
const nextVersion =
|
||||
(versions.length > 0 ? Math.max(...versions) : 0) + 1;
|
||||
const backupPath = `${resolvedPlanPath}.v${nextVersion}`;
|
||||
const content = await fsPromises.readFile(resolvedPlanPath, 'utf8');
|
||||
await fsPromises.writeFile(backupPath, content, 'utf8');
|
||||
} catch (err) {
|
||||
debugLogger.error('Failed to create plan backup:', err);
|
||||
}
|
||||
|
||||
const feedback = payload?.feedback?.trim();
|
||||
if (feedback) {
|
||||
return {
|
||||
|
||||
@@ -1067,6 +1067,7 @@ export interface ToolExitPlanModeConfirmationDetails {
|
||||
title: string;
|
||||
systemMessage?: string;
|
||||
planPath: string;
|
||||
diffContent?: string;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { isEmpty, fileExists } from './fileUtils.js';
|
||||
import { isSubpath, resolveToRealPath } from './paths.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
|
||||
/**
|
||||
* Standard error messages for the plan approval workflow.
|
||||
@@ -41,7 +43,6 @@ export async function validatePlanPath(
|
||||
if (!isSubpath(realPlansDir, realPath)) {
|
||||
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolvedPath))) {
|
||||
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
|
||||
}
|
||||
@@ -49,6 +50,26 @@ export async function validatePlanPath(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of version numbers for a plan file by scanning the directory.
|
||||
* @param planPath The path to the plan file.
|
||||
* @returns A promise that resolves to an array of version numbers.
|
||||
*/
|
||||
export async function getPlanVersions(planPath: string): Promise<number[]> {
|
||||
const dir = path.dirname(planPath);
|
||||
const base = path.basename(planPath);
|
||||
try {
|
||||
const files = await fsPromises.readdir(dir);
|
||||
return files
|
||||
.filter((f) => f.startsWith(`${base}.v`))
|
||||
.map((f) => parseInt(f.slice(base.length + 2), 10))
|
||||
.filter((v) => !isNaN(v));
|
||||
} catch (err) {
|
||||
debugLogger.error(`Failed to read plan versions in ${dir}:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a plan file has non-empty content.
|
||||
* @param planPath The path to the plan file.
|
||||
|
||||
Reference in New Issue
Block a user