diff --git a/src/commands/delete.test.ts b/src/commands/delete.test.ts index 50a200c..bdfd4ac 100644 --- a/src/commands/delete.test.ts +++ b/src/commands/delete.test.ts @@ -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(); + }); + }); }); diff --git a/src/commands/delete.ts b/src/commands/delete.ts index daeb8bc..89f9621 100644 --- a/src/commands/delete.ts +++ b/src/commands/delete.ts @@ -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()) { diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts index c4ebd13..a7be967 100644 --- a/src/shared/i18n.ts +++ b/src/shared/i18n.ts @@ -77,6 +77,7 @@ export interface Messages { 'err.invalidJson': string; 'err.authFailed': string; 'err.permissionDenied': string; + 'err.nonInteractiveRequiresYes': string; // Prompts 'prompt.confirmDelete': string; @@ -379,6 +380,7 @@ const jaMessages: Messages = { 'err.invalidJson': 'JSONの形式が不正です', 'err.authFailed': '認証に失敗しました', 'err.permissionDenied': '権限がありません', + 'err.nonInteractiveRequiresYes': '非対話実行では -y が必要です(確認プロンプトを表示できません)', // Prompts 'prompt.confirmDelete': 'を削除しますか?', @@ -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',