diff --git a/src/cli/dashboard/_shared/confirm.ts b/src/cli/dashboard/_shared/confirm.ts new file mode 100644 index 00000000..e78df942 --- /dev/null +++ b/src/cli/dashboard/_shared/confirm.ts @@ -0,0 +1,42 @@ +import readline from 'node:readline'; + +/** + * Prompts the user with an interactive y/n confirmation for destructive actions. + * + * Behaviour: + * - If `skipFlag` is true (--yes passed), auto-accepts and returns immediately. + * - If stdin is not a TTY (piped/CI environment), auto-accepts and returns immediately. + * - Otherwise, prints `message [y/N]:` and reads a single line from stdin. + * - Exits the process with code 1 if the user answers anything other than `y` or `Y`. + */ +export async function confirm(message: string, skipFlag: boolean): Promise { + // --yes flag bypasses the prompt + if (skipFlag) { + return; + } + + // Non-TTY (piped/CI) — auto-accept for scripting compatibility + if (process.stdin.isTTY === undefined || !process.stdin.isTTY) { + return; + } + + const answer = await askQuestion(`${message} [y/N]: `); + if (answer.toLowerCase() !== 'y') { + process.stdout.write('Cancelled.\n'); + process.exit(1); + } +} + +function askQuestion(prompt: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} diff --git a/src/cli/dashboard/agents/delete.ts b/src/cli/dashboard/agents/delete.ts index 1ddaeab9..c5757cf0 100644 --- a/src/cli/dashboard/agents/delete.ts +++ b/src/cli/dashboard/agents/delete.ts @@ -1,5 +1,6 @@ import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; +import { confirm } from '../_shared/confirm.js'; export default class AgentsDelete extends DashboardCommand { static override description = 'Delete an agent configuration.'; @@ -16,9 +17,7 @@ export default class AgentsDelete extends DashboardCommand { async run(): Promise { const { args, flags } = await this.parse(AgentsDelete); - if (!flags.yes) { - this.error('Pass --yes to confirm deletion.'); - } + await confirm(`Delete agent config #${args.id}?`, flags.yes); try { await this.client.agentConfigs.delete.mutate({ id: args.id }); diff --git a/src/cli/dashboard/projects/credentials-delete.ts b/src/cli/dashboard/projects/credentials-delete.ts index 078168a3..7d18cbe4 100644 --- a/src/cli/dashboard/projects/credentials-delete.ts +++ b/src/cli/dashboard/projects/credentials-delete.ts @@ -1,5 +1,6 @@ import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; +import { confirm } from '../_shared/confirm.js'; export default class ProjectsCredentialsDelete extends DashboardCommand { static override description = 'Delete a project-scoped credential.'; @@ -20,9 +21,7 @@ export default class ProjectsCredentialsDelete extends DashboardCommand { async run(): Promise { const { args, flags } = await this.parse(ProjectsCredentialsDelete); - if (!flags.yes) { - this.error('Pass --yes to confirm deletion.'); - } + await confirm(`Delete credential ${flags.key} from project ${args.id}?`, flags.yes); try { await this.client.projects.credentials.delete.mutate({ diff --git a/src/cli/dashboard/projects/delete.ts b/src/cli/dashboard/projects/delete.ts index 349925ee..5b056461 100644 --- a/src/cli/dashboard/projects/delete.ts +++ b/src/cli/dashboard/projects/delete.ts @@ -1,5 +1,6 @@ import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; +import { confirm } from '../_shared/confirm.js'; export default class ProjectsDelete extends DashboardCommand { static override description = 'Delete a project.'; @@ -16,9 +17,7 @@ export default class ProjectsDelete extends DashboardCommand { async run(): Promise { const { args, flags } = await this.parse(ProjectsDelete); - if (!flags.yes) { - this.error('Pass --yes to confirm deletion.'); - } + await confirm(`Delete project ${args.id}?`, flags.yes); try { await this.client.projects.delete.mutate({ id: args.id }); diff --git a/src/cli/dashboard/users/delete.ts b/src/cli/dashboard/users/delete.ts index 79556412..72643433 100644 --- a/src/cli/dashboard/users/delete.ts +++ b/src/cli/dashboard/users/delete.ts @@ -1,5 +1,6 @@ import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; +import { confirm } from '../_shared/confirm.js'; export default class UsersDelete extends DashboardCommand { static override description = 'Delete a user.'; @@ -16,9 +17,7 @@ export default class UsersDelete extends DashboardCommand { async run(): Promise { const { args, flags } = await this.parse(UsersDelete); - if (!flags.yes) { - this.error('Pass --yes to confirm deletion.'); - } + await confirm(`Delete user ${args.id}?`, flags.yes); try { await this.client.users.delete.mutate({ id: args.id }); diff --git a/tests/unit/cli/dashboard/confirm.test.ts b/tests/unit/cli/dashboard/confirm.test.ts new file mode 100644 index 00000000..1b2a96ad --- /dev/null +++ b/tests/unit/cli/dashboard/confirm.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Use vi.hoisted so the mock factory can reference these variables +const { mockRlClose, mockRlQuestion, mockRlInstance, mockCreateInterface } = vi.hoisted(() => { + const mockRlClose = vi.fn(); + const mockRlQuestion = vi.fn(); + const mockRlInstance = { + question: mockRlQuestion, + close: mockRlClose, + }; + const mockCreateInterface = vi.fn().mockReturnValue(mockRlInstance); + return { mockRlClose, mockRlQuestion, mockRlInstance, mockCreateInterface }; +}); + +vi.mock('node:readline', () => ({ + createInterface: (...args: unknown[]) => mockCreateInterface(...args), + default: { + createInterface: (...args: unknown[]) => mockCreateInterface(...args), + }, +})); + +import { confirm } from '../../../../src/cli/dashboard/_shared/confirm.js'; + +describe('confirm', () => { + let originalIsTTY: boolean | undefined; + let exitSpy: ReturnType; + let stdoutSpy: ReturnType; + + beforeEach(() => { + // Save original isTTY so we can restore it after each test + originalIsTTY = process.stdin.isTTY; + + // Spy on process.exit so we can assert it was called without actually exiting + exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number) => { + throw new Error(`process.exit(${_code})`); + }); + + // Spy on stdout.write to capture "Cancelled." messages + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + mockCreateInterface.mockClear(); + mockCreateInterface.mockReturnValue(mockRlInstance); + mockRlQuestion.mockClear(); + mockRlClose.mockClear(); + }); + + afterEach(() => { + // Restore isTTY + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + writable: true, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + // ----------------------------------------------------------------------- + // --yes flag bypass + // ----------------------------------------------------------------------- + describe('yes flag bypass', () => { + it('auto-accepts without prompting when skipFlag is true', async () => { + await expect(confirm('Delete project foo?', true)).resolves.toBeUndefined(); + expect(mockCreateInterface).not.toHaveBeenCalled(); + }); + + it('auto-accepts regardless of stdin.isTTY when skipFlag is true', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + await expect(confirm('Delete project foo?', true)).resolves.toBeUndefined(); + expect(mockCreateInterface).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Non-TTY (piped/CI) auto-accept + // ----------------------------------------------------------------------- + describe('non-TTY auto-accept', () => { + it('auto-accepts when stdin.isTTY is undefined (piped)', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: undefined, + writable: true, + configurable: true, + }); + await expect(confirm('Delete project foo?', false)).resolves.toBeUndefined(); + expect(mockCreateInterface).not.toHaveBeenCalled(); + }); + + it('auto-accepts when stdin.isTTY is false', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + await expect(confirm('Delete project foo?', false)).resolves.toBeUndefined(); + expect(mockCreateInterface).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // TTY prompt — interactive + // ----------------------------------------------------------------------- + describe('TTY interactive prompt', () => { + beforeEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + }); + + it('resolves when user answers "y"', async () => { + mockRlQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('y'); + }); + + await expect(confirm('Delete project foo?', false)).resolves.toBeUndefined(); + }); + + it('resolves when user answers "Y" (case-insensitive)', async () => { + mockRlQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('Y'); + }); + + await expect(confirm('Delete project foo?', false)).resolves.toBeUndefined(); + }); + + it('exits with code 1 when user answers "n"', async () => { + mockRlQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('n'); + }); + + await expect(confirm('Delete project foo?', false)).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(stdoutSpy).toHaveBeenCalledWith('Cancelled.\n'); + }); + + it('exits with code 1 when user answers empty string', async () => { + mockRlQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb(''); + }); + + await expect(confirm('Delete project foo?', false)).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with code 1 when user answers non-y input', async () => { + mockRlQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('no'); + }); + + await expect(confirm('Delete some resource?', false)).rejects.toThrow('process.exit(1)'); + }); + + it('includes the message and [y/N] in the prompt', async () => { + mockRlQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('y'); + }); + + await confirm('Delete project my-project?', false); + + expect(mockRlQuestion).toHaveBeenCalledWith( + 'Delete project my-project? [y/N]: ', + expect.any(Function), + ); + }); + + it('closes the readline interface after the answer', async () => { + mockRlQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('y'); + }); + + await confirm('Delete project foo?', false); + + expect(mockRlClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/cli/dashboard/projects/integration-credentials.test.ts b/tests/unit/cli/dashboard/projects/integration-credentials.test.ts index 3ed004c6..e1872328 100644 --- a/tests/unit/cli/dashboard/projects/integration-credentials.test.ts +++ b/tests/unit/cli/dashboard/projects/integration-credentials.test.ts @@ -157,14 +157,20 @@ describe('ProjectsCredentialsDelete (credentials-delete)', () => { }); }); - it('rejects without --yes flag', async () => { - mockCreateDashboardClient.mockReturnValue(makeClient()); + it('auto-accepts without --yes flag in non-TTY environments', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); const cmd = new ProjectsCredentialsDelete( ['my-project', '--key', 'GITHUB_TOKEN_IMPLEMENTER'], oclifConfig as never, ); - await expect(cmd.run()).rejects.toThrow(); + // In non-TTY environments (CI, piped), confirm() auto-accepts without prompting + await expect(cmd.run()).resolves.toBeUndefined(); + expect(client.projects.credentials.delete.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + }); }); it('requires --key flag', async () => {