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
42 changes: 42 additions & 0 deletions src/cli/dashboard/_shared/confirm.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// --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<string> {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

rl.question(prompt, (answer) => {
rl.close();
resolve(answer);
});
});
}
5 changes: 2 additions & 3 deletions src/cli/dashboard/agents/delete.ts
Original file line number Diff line number Diff line change
@@ -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.';
Expand All @@ -16,9 +17,7 @@ export default class AgentsDelete extends DashboardCommand {
async run(): Promise<void> {
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 });
Expand Down
5 changes: 2 additions & 3 deletions src/cli/dashboard/projects/credentials-delete.ts
Original file line number Diff line number Diff line change
@@ -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.';
Expand All @@ -20,9 +21,7 @@ export default class ProjectsCredentialsDelete extends DashboardCommand {
async run(): Promise<void> {
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({
Expand Down
5 changes: 2 additions & 3 deletions src/cli/dashboard/projects/delete.ts
Original file line number Diff line number Diff line change
@@ -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.';
Expand All @@ -16,9 +17,7 @@ export default class ProjectsDelete extends DashboardCommand {
async run(): Promise<void> {
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 });
Expand Down
5 changes: 2 additions & 3 deletions src/cli/dashboard/users/delete.ts
Original file line number Diff line number Diff line change
@@ -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.';
Expand All @@ -16,9 +17,7 @@ export default class UsersDelete extends DashboardCommand {
async run(): Promise<void> {
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 });
Expand Down
180 changes: 180 additions & 0 deletions tests/unit/cli/dashboard/confirm.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;

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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading