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
2 changes: 1 addition & 1 deletion packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ export async function installPreset(
return anyAdded;
}

async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise<void> {
export async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise<void> {
if (options.list) {
await runList(ruleArg);
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
return { results, activeRuleCount: activeRules.length, assetPaths, elapsedMs };
}

async function runCompile(options: CompileOptions): Promise<void> {
export async function runCompile(options: CompileOptions): Promise<void> {
const cwd = process.cwd();

if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export async function checkHashSync(cwd: string, rules: Rule[]): Promise<CheckRe
};
}

async function runDoctor(): Promise<void> {
export async function runDoctor(): Promise<void> {
const cwd = process.cwd();
const startTime = performance.now();
const results: CheckResult[] = [];
Expand Down
82 changes: 82 additions & 0 deletions packages/cli/src/commands/menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { select } from '@inquirer/prompts';
import chalk from 'chalk';
import type { Command } from 'commander';
import { runAdd } from './add.js';
import { runRemove } from './remove.js';
import { runDoctor } from './doctor.js';
import { runCompile } from './compile.js';

const menuTheme = {
style: {
keysHelpTip: (keys: [string, string][]): string =>
[...keys, ['Ctrl+C', 'back']]
.map(([key, action]) => `${chalk.bold(key)} ${chalk.dim(action)}`)
.join(chalk.dim(' • ')),
},
} as const;

const MENU_CHOICES = {
ADD: 'add',
COMPILE: 'compile',
DOCTOR: 'doctor',
REMOVE: 'remove',
EXIT: 'exit',
} as const;

type MenuChoice = (typeof MENU_CHOICES)[keyof typeof MENU_CHOICES];

export async function runMainMenu(command: Command): Promise<void> {
if (!process.stdout.isTTY || !process.stdin.isTTY) {
command.help();
return;
}

while (true) {
let choice: MenuChoice;
try {
choice = await select<MenuChoice>({
message: 'What do you want to do?',
theme: menuTheme,
choices: [
{ name: 'Add rules or assets', value: MENU_CHOICES.ADD },
{ name: 'Compile for all editors', value: MENU_CHOICES.COMPILE },
{ name: 'Check project status', value: MENU_CHOICES.DOCTOR },
{ name: 'Remove something', value: MENU_CHOICES.REMOVE },
{ name: 'Exit', value: MENU_CHOICES.EXIT },
],
});
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') {
process.exit(0);
}
throw err;
}

if (choice === MENU_CHOICES.EXIT) {
process.exit(0);
}

try {
switch (choice) {
case MENU_CHOICES.ADD:
await runAdd(undefined, {});
break;
case MENU_CHOICES.COMPILE:
await runCompile({ verbose: false, dryRun: false });
break;
case MENU_CHOICES.DOCTOR:
await runDoctor();
break;
case MENU_CHOICES.REMOVE:
await runRemove(undefined);
break;
}
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') {
// Ctrl+C inside a subcommand — return to main menu
} else {
throw err;
}
}
}
}
2 changes: 1 addition & 1 deletion packages/cli/src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function removeRule(cwd: string, path: string): Promise<boolean> {
return true;
}

async function runRemove(ruleArg: string | undefined): Promise<void> {
export async function runRemove(ruleArg: string | undefined): Promise<void> {
const cwd = process.cwd();

if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerRemoveCommand } from './commands/remove.js';
import { registerListCommand } from './commands/list.js';
import { registerExplainCommand } from './commands/explain.js';
import { registerWatchCommand } from './commands/watch.js';
import { runMainMenu } from './commands/menu.js';

const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { version: string };
Expand All @@ -28,6 +29,10 @@ registerListCommand(program);
registerExplainCommand(program);
registerWatchCommand(program);

program.action(async (_, command: Command) => {
await runMainMenu(command);
});

program.parse();

export { program };
74 changes: 74 additions & 0 deletions packages/cli/tests/commands/menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { runMainMenu } from '../../src/commands/menu.js';

function makeMockCommand(): { helpCalled: boolean; help: () => void } {
const mock = {
helpCalled: false,
help(): void {
this.helpCalled = true;
},
};
return mock;
}

describe('runMainMenu — TTY guard', () => {
let originalStdoutIsTTY: boolean | undefined;
let originalStdinIsTTY: boolean | undefined;

beforeEach(() => {
originalStdoutIsTTY = process.stdout.isTTY;
originalStdinIsTTY = process.stdin.isTTY;
});

afterEach(() => {
// Restore originals (may be undefined in non-TTY test environments)
Object.defineProperty(process.stdout, 'isTTY', {
value: originalStdoutIsTTY,
writable: true,
configurable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: originalStdinIsTTY,
writable: true,
configurable: true,
});
});

it('calls command.help() when stdout is not a TTY', async () => {
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
writable: true,
configurable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: false,
writable: true,
configurable: true,
});

const mockCommand = makeMockCommand();
// Cast to unknown then to the minimal Command interface required
await runMainMenu(mockCommand as unknown as import('commander').Command);

assert.equal(mockCommand.helpCalled, true);
});

it('calls command.help() when stdin is not a TTY even if stdout is', async () => {
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
writable: true,
configurable: true,
});
Object.defineProperty(process.stdin, 'isTTY', {
value: false,
writable: true,
configurable: true,
});

const mockCommand = makeMockCommand();
await runMainMenu(mockCommand as unknown as import('commander').Command);

assert.equal(mockCommand.helpCalled, true);
});
});
8 changes: 8 additions & 0 deletions packages/cli/tests/e2e/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ rules:
assert.ok(result.stdout.includes('config.yml is valid'));
});

it('devw with no args in non-TTY exits 0 and prints usage', async () => {
// execFile runs in non-TTY by default — menu should display help instead of prompting
const result = await run([], tmpDir);

assert.equal(result.exitCode, 0);
assert.ok(result.stdout.includes('Usage:'));
});

it('add without args and non-TTY exits with error', async () => {
await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir);
// execFile runs in non-TTY mode by default
Expand Down
Loading