Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/commands/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,64 @@ describe('DeleteCommand', () => {
expect(result.isOk()).toBe(true);
});
});

describe('non-interactive execution guard (issue #4)', () => {
let originalIsTTY: boolean | undefined;

beforeEach(() => {
originalIsTTY = process.stdout.isTTY;
});

afterEach(() => {
process.stdout.isTTY = originalIsTTY;
});

function createCommand(flags: { recursive: boolean; yes: boolean }): DeleteCommand {
const cmd = new DeleteCommand([], {} as Config);
vi.spyOn(cmd as any, 'parse').mockResolvedValue({
args: { path: 'users/doc1' },
flags,
});
return cmd;
}

it('fails loudly and performs no deletion when non-TTY and --yes is omitted', async () => {
process.stdout.isTTY = false;
const cmd = createCommand({ recursive: false, yes: false });
const initializeSpy = vi.spyOn(cmd as any, 'initialize');

await expect(cmd.run()).rejects.toMatchObject({ oclif: { exit: 1 } });

expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('非対話実行では -y が必要です')
);
expect(initializeSpy).not.toHaveBeenCalled();
});

it('proceeds past the guard when non-TTY and --yes is specified', async () => {
process.stdout.isTTY = false;
const cmd = createCommand({ recursive: false, yes: true });
const initializeSpy = vi
.spyOn(cmd as any, 'initialize')
.mockResolvedValue(err({ type: 'CONFIG_ERROR', message: 'stop after guard' }));
vi.spyOn(cmd as any, 'handleError').mockImplementation(() => {});

await cmd.run();

expect(initializeSpy).toHaveBeenCalled();
});

it('proceeds past the guard on a TTY even when --yes is omitted', async () => {
process.stdout.isTTY = true;
const cmd = createCommand({ recursive: false, yes: false });
const initializeSpy = vi
.spyOn(cmd as any, 'initialize')
.mockResolvedValue(err({ type: 'CONFIG_ERROR', message: 'stop after guard' }));
vi.spyOn(cmd as any, 'handleError').mockImplementation(() => {});

await cmd.run();

expect(initializeSpy).toHaveBeenCalled();
});
});
});
7 changes: 7 additions & 0 deletions src/commands/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export class DeleteCommand extends BaseCommand {
const recursive = flags.recursive;
const skipConfirm = flags.yes;

// A confirmation prompt cannot be shown without a TTY; fail loudly
// instead of silently no-op'ing (see #4).
if (!skipConfirm && !process.stdout.isTTY) {
this.handleError(t('err.nonInteractiveRequiresYes'), 'validation');
return;
}

// Initialize config
const initResult = await this.initialize();
if (initResult.isErr()) {
Expand Down
3 changes: 3 additions & 0 deletions src/shared/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export interface Messages {
'err.invalidJson': string;
'err.authFailed': string;
'err.permissionDenied': string;
'err.nonInteractiveRequiresYes': string;

// Prompts
'prompt.confirmDelete': string;
Expand Down Expand Up @@ -379,6 +380,7 @@ const jaMessages: Messages = {
'err.invalidJson': 'JSONの形式が不正です',
'err.authFailed': '認証に失敗しました',
'err.permissionDenied': '権限がありません',
'err.nonInteractiveRequiresYes': '非対話実行では -y が必要です(確認プロンプトを表示できません)',

// Prompts
'prompt.confirmDelete': 'を削除しますか?',
Expand Down Expand Up @@ -681,6 +683,7 @@ const enMessages: Messages = {
'err.invalidJson': 'Invalid JSON format',
'err.authFailed': 'Authentication failed',
'err.permissionDenied': 'Permission denied',
'err.nonInteractiveRequiresYes': '-y is required for non-interactive execution (confirmation prompt cannot be shown)',

// Prompts
'prompt.confirmDelete': 'Are you sure you want to delete',
Expand Down
Loading