diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index 6f15855..287caff 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -6,10 +6,20 @@ import os from 'os'; import { fileURLToPath } from 'url'; import { getDefaultConfig } from '@night-watch/core'; import { + applyJobSelectionToConfig, + buildDefaultJobSelection, + buildProviderChoices, + buildProviderSummary, buildInitConfig, + chooseProviderByPrecedence, chooseProviderForNonInteractive, + getInitJobCatalog, + getDetectedProviderPresets, getGitHubRemoteStatus, + isInteractiveInitSession, resolveTemplatePath, + selectProviderOverrideByIndex, + shouldPromptProviderOverride, } from '../../commands/init.js'; // Get project root directory (4 levels up from this test file) @@ -350,6 +360,151 @@ describe('init command', () => { expect(config.boardProvider).toEqual(defaults.boardProvider); expect(config.jobProviders).toEqual(defaults.jobProviders); }); + + it('should write a custom provider preset when provided', () => { + const config = buildInitConfig({ + projectName: 'demo-project', + defaultBranch: 'main', + provider: 'local-agent', + providerPreset: { + name: 'Local Agent', + command: 'local-agent', + promptFlag: '--prompt', + }, + reviewerEnabled: true, + prdDir: 'docs/prds', + }); + + expect(config.provider).toBe('local-agent'); + expect(config.providerPresets).toEqual({ + 'local-agent': { + name: 'Local Agent', + command: 'local-agent', + promptFlag: '--prompt', + }, + }); + }); + + it('should apply explicit job selections to install-consumed config fields', () => { + const jobSelection = buildDefaultJobSelection({ + jobs: 'executor,qa,audit,analytics,slicer,pr-resolver,manager,merger', + noJobs: 'reviewer', + schedule: [ + 'executor=1 * * * *', + 'qa=2 * * * *', + 'audit=3 * * * *', + 'analytics=4 * * * *', + 'slicer=5 * * * *', + 'pr-resolver=6 * * * *', + 'manager=7 * * * *', + 'merger=8 * * * *', + ], + }); + + const config = buildInitConfig({ + projectName: 'demo-project', + defaultBranch: 'main', + provider: 'codex', + reviewerEnabled: true, + prdDir: 'docs/prds', + jobSelection, + }); + + expect(config.executorEnabled).toBe(true); + expect(config.cronSchedule).toBe('1 * * * *'); + expect(config.reviewerEnabled).toBe(false); + expect(config.qa).toMatchObject({ enabled: true, schedule: '2 * * * *' }); + expect(config.audit).toMatchObject({ enabled: true, schedule: '3 * * * *' }); + expect(config.analytics).toMatchObject({ enabled: true, schedule: '4 * * * *' }); + expect(config.roadmapScanner).toMatchObject({ + enabled: true, + slicerSchedule: '5 * * * *', + }); + expect(config.prResolver).toMatchObject({ enabled: true, schedule: '6 * * * *' }); + expect(config.manager).toMatchObject({ enabled: true, schedule: '7 * * * *' }); + expect(config.merger).toMatchObject({ enabled: true, schedule: '8 * * * *' }); + expect(config.autoMerge).toBe(true); + }); + + it('should keep deterministic default job selection for non-interactive init', () => { + const defaults = getDefaultConfig(); + const selection = buildDefaultJobSelection({ reviewerEnabled: false }); + const config = applyJobSelectionToConfig( + buildInitConfig({ + projectName: 'demo-project', + defaultBranch: 'main', + provider: 'codex', + reviewerEnabled: true, + prdDir: 'docs/prds', + }), + selection, + ); + + expect(config.executorEnabled).toBe(defaults.executorEnabled); + expect(config.reviewerEnabled).toBe(false); + expect(config.qa.enabled).toBe(defaults.qa.enabled); + expect(config.roadmapScanner.enabled).toBe(defaults.roadmapScanner.enabled); + expect(config.prResolver.enabled).toBe(defaults.prResolver.enabled); + expect(config.manager.enabled).toBe(defaults.manager.enabled); + expect(config.audit.enabled).toBe(defaults.audit.enabled); + expect(config.analytics.enabled).toBe(defaults.analytics.enabled); + expect(config.merger.enabled).toBe(defaults.merger.enabled); + }); + + it('should reject unknown job and malformed schedule flags', () => { + expect(() => buildDefaultJobSelection({ jobs: 'executor,unknown' })).toThrow( + 'Unknown init job', + ); + expect(() => buildDefaultJobSelection({ schedule: 'qa=bad' })).toThrow( + 'Invalid cron schedule', + ); + }); + + it('should include practical onboarding jobs in the catalog', () => { + const ids = getInitJobCatalog().map((job) => job.id); + + expect(ids).toEqual([ + 'executor', + 'reviewer', + 'qa', + 'audit', + 'analytics', + 'slicer', + 'pr-resolver', + 'manager', + 'merger', + ]); + }); + + it('should persist explicit notification webhooks without masking config values', () => { + const config = buildInitConfig({ + projectName: 'demo-project', + defaultBranch: 'main', + provider: 'codex', + reviewerEnabled: true, + prdDir: 'docs/prds', + notifications: { + skipped: false, + webhooks: [ + { + type: 'telegram', + botToken: '123456:secret', + chatId: '987654', + events: ['run_failed'], + }, + ], + }, + }); + + expect(config.notifications.webhooks).toEqual([ + { + type: 'telegram', + botToken: '123456:secret', + chatId: '987654', + events: ['run_failed'], + }, + ]); + }); }); describeIfExternalTools('should NOT create .claude/commands/ directory', () => { @@ -458,6 +613,32 @@ describe('init command', () => { }); }); + describe('--custom-provider-command flag', () => { + it('should write a custom provider preset in non-interactive mode', () => { + execSync('git init', { cwd: tempDir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: tempDir, stdio: 'pipe' }); + + execSync( + `${TSX_CMD} init --yes --custom-provider-command local-agent --custom-provider-name "Local Agent" --custom-provider-id local-agent`, + { + encoding: 'utf-8', + cwd: tempDir, + stdio: 'pipe', + timeout: 15000, + }, + ); + + const configPath = path.join(tempDir, 'night-watch.config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(config.provider).toBe('local-agent'); + expect(config.providerPresets['local-agent']).toEqual({ + name: 'Local Agent', + command: 'local-agent', + }); + }); + }); + describeIfExternalTools('--no-reviewer flag', () => { it('should set reviewerEnabled to false in config', () => { // Initialize git repo @@ -511,12 +692,76 @@ describe('init command', () => { }); describe('chooseProviderForNonInteractive', () => { - it('should prefer claude when multiple providers are available', () => { - expect(chooseProviderForNonInteractive(['codex', 'claude'])).toBe('claude'); + it('should prefer codex when multiple providers are available', () => { + expect(chooseProviderForNonInteractive(['claude', 'codex'])).toBe('codex'); }); it('should fall back to the first detected provider when claude is unavailable', () => { - expect(chooseProviderForNonInteractive(['codex'])).toBe('codex'); + expect(chooseProviderForNonInteractive(['local-agent'])).toBe('local-agent'); + }); + }); + + describe('provider auto-selection', () => { + it('isInteractiveInitSession returns false for the test process', () => { + expect(isInteractiveInitSession()).toBe(false); + }); + + it('should select codex over claude by precedence', () => { + expect(chooseProviderByPrecedence(['claude', 'codex'])).toBe('codex'); + }); + + it('should map a single detected claude command to claude presets while defaulting to claude', () => { + const detected = getDetectedProviderPresets(['claude']); + + expect(detected).toContain('claude'); + expect(detected).toContain('claude-sonnet-4-6'); + expect(chooseProviderByPrecedence(detected)).toBe('claude'); + }); + + it('should map detected codex and claude commands and default to codex', () => { + const detected = getDetectedProviderPresets(['claude', 'codex']); + + expect(detected[0]).toBe('codex'); + expect(chooseProviderByPrecedence(detected)).toBe('codex'); + }); + + it('should summarize codex as selected and claude as also available', () => { + const summary = buildProviderSummary('codex', ['codex', 'claude']); + + expect(summary).toContain('Auto-selected Codex'); + expect(summary).toContain('Also available: Claude'); + }); + + it('should only prompt for provider override in interactive sessions with multiple presets', () => { + expect(shouldPromptProviderOverride(true, ['codex', 'claude'])).toBe(true); + expect(shouldPromptProviderOverride(true, ['codex'])).toBe(false); + expect(shouldPromptProviderOverride(false, ['codex', 'claude'])).toBe(false); + }); + + it('should build detected provider choices with a custom provider command option', () => { + const choices = buildProviderChoices(['codex', 'claude']); + + expect(choices).toEqual([ + { label: 'Codex', provider: 'codex', custom: false }, + { label: 'Claude', provider: 'claude', custom: false }, + { label: 'Custom provider command', custom: true }, + ]); + }); + + it('should select provider override choices by one-based index', () => { + const choices = buildProviderChoices(['codex', 'claude']); + + expect(selectProviderOverrideByIndex(choices, '2')).toEqual({ + label: 'Claude', + provider: 'claude', + custom: false, + }); + expect(selectProviderOverrideByIndex(choices, '3')).toEqual({ + label: 'Custom provider command', + custom: true, + }); + expect(selectProviderOverrideByIndex(choices, '0')).toBeNull(); + expect(selectProviderOverrideByIndex(choices, 'nope')).toBeNull(); }); }); diff --git a/packages/cli/src/commands/dashboard/tab-schedules.ts b/packages/cli/src/commands/dashboard/tab-schedules.ts index 5a4fc44..24381f4 100644 --- a/packages/cli/src/commands/dashboard/tab-schedules.ts +++ b/packages/cli/src/commands/dashboard/tab-schedules.ts @@ -10,6 +10,8 @@ import { performUninstall } from '../uninstall.js'; import { saveConfig } from '@night-watch/core'; import { ITab, ITabContext } from './types.js'; +type ScheduleField = 'cronSchedule' | 'manager.schedule' | 'reviewerSchedule'; + /** * Convert a cron schedule to a human-readable description using cronstrue. */ @@ -126,7 +128,7 @@ export function createSchedulesTab(): ITab { function applySchedule( ctx: ITabContext, - field: 'cronSchedule' | 'reviewerSchedule' | 'manager.schedule', + field: ScheduleField, cronExpr: string, ) { const patch = @@ -160,7 +162,7 @@ export function createSchedulesTab(): ITab { function showCustomCronInput( ctx: ITabContext, - field: 'cronSchedule' | 'reviewerSchedule' | 'manager.schedule', + field: ScheduleField, label: string, ) { const currentValue = field === 'manager.schedule' ? ctx.config.manager.schedule : ctx.config[field]; @@ -203,7 +205,7 @@ export function createSchedulesTab(): ITab { function editSchedule( ctx: ITabContext, - field: 'cronSchedule' | 'reviewerSchedule' | 'manager.schedule', + field: ScheduleField, label: string, ) { const presetItems = SCHEDULE_PRESETS.map((p) => ` ${p.label} (${p.cron})`); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ef3a754..771acbb 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -8,11 +8,17 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import * as readline from 'readline'; import { + BUILT_IN_PRESETS, BUILT_IN_PRESET_IDS, CONFIG_FILE_NAME, DEFAULT_PRD_DIR, INightWatchConfig, + IProviderPreset, + IWebhookConfig, + JobType, LOG_DIR, + NOTIFICATION_EVENTS, + NotificationEvent, Provider, checkGhCli, checkGitRepo, @@ -22,6 +28,7 @@ import { createTable, detectProviders, getDefaultConfig, + getJobDef, getProjectName, header, info, @@ -30,6 +37,7 @@ import { step, success, error as uiError, + validateWebhook, warn, } from '@night-watch/core'; import { fireTelemetryEvent } from './shared/telemetry.js'; @@ -59,6 +67,13 @@ interface IInitOptions { prdDir?: string; provider?: string; reviewer?: boolean; + yes?: boolean; + customProviderCommand?: string; + customProviderName?: string; + customProviderId?: string; + jobs?: string; + noJobs?: string; + schedule?: string[]; } interface ISkillsInstallResult { @@ -88,6 +103,153 @@ interface IGitHubRemoteStatus { remoteUrl: string | null; } +interface IProviderSelectionResult { + provider: Provider; + providerPreset?: IProviderPreset; + detectedProviders: Provider[]; + detectedCommands: string[]; + summary: string; +} + +type InitJobId = + | 'executor' + | 'reviewer' + | 'qa' + | 'audit' + | 'analytics' + | 'slicer' + | 'pr-resolver' + | 'manager' + | 'merger'; + +type InitJobBundle = 'recommended' | 'minimal' | 'custom'; + +interface IInitJobCatalogItem { + id: InitJobId; + label: string; + description: string; + warning?: string; + defaultSchedule: string; +} + +export interface IJobSelectionAnswer { + bundle: InitJobBundle; + enabledJobs: InitJobId[]; + schedules: Record; +} + +export interface IProviderSelectionAnswer { + provider: Provider; + providerPreset?: IProviderPreset; + summary: string; +} + +export interface INotificationSelectionAnswer { + webhooks: IWebhookConfig[]; + skipped: boolean; +} + +export interface IInitOnboardingAnswers { + jobs: IJobSelectionAnswer; + provider: IProviderSelectionAnswer; + notifications: INotificationSelectionAnswer; +} + +export interface IProviderChoice { + label: string; + provider?: Provider; + custom: boolean; +} + +interface IInitProjectReview { + cwd: string; + projectName: string; + defaultBranch: string; + prdDir: string; + configExists: boolean; + force: boolean; + providerSummary: string; + remoteStatus: IGitHubRemoteStatus; + playwrightStatus: string; +} + +const INIT_PROVIDER_PRECEDENCE = [ + 'codex', + 'claude', + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'glm-47', + 'glm-5', +] as const; + +const SUPPORTED_PROVIDER_INSTALL_GUIDANCE = [ + ' - Codex CLI: https://github.com/openai/codex', + ' - Claude CLI: https://docs.anthropic.com/en/docs/claude-cli', +] as const; + +const INIT_JOB_IDS: InitJobId[] = [ + 'executor', + 'reviewer', + 'qa', + 'audit', + 'analytics', + 'slicer', + 'pr-resolver', + 'manager', + 'merger', +]; + +const RECOMMENDED_INIT_JOBS: InitJobId[] = [ + 'executor', + 'reviewer', + 'qa', + 'slicer', + 'manager', + 'pr-resolver', +]; + +const MINIMAL_INIT_JOBS: InitJobId[] = ['executor']; + +const JOB_ALIASES: Record = { + executor: 'executor', + run: 'executor', + reviewer: 'reviewer', + review: 'reviewer', + qa: 'qa', + audit: 'audit', + analytics: 'analytics', + slicer: 'slicer', + planner: 'slicer', + roadmap: 'slicer', + 'roadmap-scanner': 'slicer', + 'pr-resolver': 'pr-resolver', + resolver: 'pr-resolver', + resolve: 'pr-resolver', + manager: 'manager', + merger: 'merger', + merge: 'merger', + 'auto-merge': 'merger', +}; + +const JOB_WARNINGS: Partial> = { + executor: 'Creates branches and PRs from PRDs.', + reviewer: 'May push fix commits to Night Watch PR branches.', + audit: 'Can create board issues when issue creation is enabled.', + analytics: 'Needs analytics credentials before it can run successfully.', + slicer: 'Can create draft board issues/PRDs from roadmap items.', + 'pr-resolver': 'Can update PR branches and resolve merge conflicts.', + manager: 'Can create draft board issues or PRDs for project gaps.', + merger: 'Can merge eligible PRs and delete/update branches through the merge path.', +}; + +const DEFAULT_NOTIFICATION_EVENTS: NotificationEvent[] = [ + 'run_failed', + 'review_ready_for_human', + 'qa_completed', + 'merge_failed', + 'manager_blocked', +]; + function hasPlaywrightDependency(cwd: string): boolean { const packageJsonPath = path.join(cwd, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -167,17 +329,805 @@ function promptYesNo(question: string, defaultNo: boolean = true): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return Promise.resolve(''); + } + + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(`${question}: `, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function promptChoice( + question: string, + choices: { value: T; label: string }[], + defaultValue: T, +): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return defaultValue; + } + + console.log(question); + choices.forEach((choice, index) => { + console.log(` ${index + 1}. ${choice.label}`); + }); + + const input = await promptText(`Choose 1-${choices.length} (default: ${defaultValue})`); + if (!input) { + return defaultValue; + } + + const selectedIndex = Number.parseInt(input, 10); + if (!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > choices.length) { + warn(`Invalid choice "${input}". Using ${defaultValue}.`); + return defaultValue; + } + + return choices[selectedIndex - 1].value; +} + +function promptWithDefault(question: string, defaultValue: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return Promise.resolve(defaultValue); + } + + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(`${question} [${defaultValue}]: `, (answer) => { + rl.close(); + resolve(answer.trim() || defaultValue); + }); + }); +} + export function isInteractiveInitSession(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } +function scheduleForJob(defaults: INightWatchConfig, jobId: InitJobId): string { + switch (jobId) { + case 'executor': + return defaults.cronSchedule; + case 'reviewer': + return defaults.reviewerSchedule; + case 'slicer': + return defaults.roadmapScanner.slicerSchedule; + case 'pr-resolver': + return defaults.prResolver.schedule; + default: + return defaults[jobId].schedule; + } +} + +function jobEnabledByDefault(defaults: INightWatchConfig, jobId: InitJobId): boolean { + switch (jobId) { + case 'executor': + return defaults.executorEnabled !== false; + case 'reviewer': + return defaults.reviewerEnabled; + case 'slicer': + return defaults.roadmapScanner.enabled; + case 'pr-resolver': + return defaults.prResolver.enabled; + default: + return defaults[jobId].enabled; + } +} + +export function getInitJobCatalog(): IInitJobCatalogItem[] { + const defaults = getDefaultConfig(); + return INIT_JOB_IDS.map((id) => { + const registryId: JobType = id === 'slicer' ? 'slicer' : id; + const def = getJobDef(registryId); + return { + id, + label: def?.name ?? id, + description: def?.description ?? id, + warning: JOB_WARNINGS[id], + defaultSchedule: scheduleForJob(defaults, id), + }; + }); +} + +function parseJobId(value: string): InitJobId | null { + return JOB_ALIASES[value.trim().toLowerCase()] ?? null; +} + +function parseJobCsv(csv: string | undefined): InitJobId[] { + if (!csv) { + return []; + } + + const jobs: InitJobId[] = []; + for (const rawJob of csv.split(',')) { + const job = parseJobId(rawJob); + if (!job) { + throw new Error(`Unknown init job "${rawJob.trim()}". Valid jobs: ${INIT_JOB_IDS.join(', ')}`); + } + if (!jobs.includes(job)) { + jobs.push(job); + } + } + return jobs; +} + +function normalizeScheduleOptions(scheduleOptions: string[] | string | undefined): string[] { + if (scheduleOptions === undefined) { + return []; + } + return Array.isArray(scheduleOptions) ? scheduleOptions : [scheduleOptions]; +} + +function isValidCronSchedule(schedule: string): boolean { + return schedule.trim().split(/\s+/).length === 5; +} + +function parseScheduleOverrides(scheduleOptions: string[] | string | undefined): Partial< + Record +> { + const overrides: Partial> = {}; + for (const option of normalizeScheduleOptions(scheduleOptions)) { + const separatorIndex = option.indexOf('='); + if (separatorIndex <= 0) { + throw new Error(`Invalid schedule override "${option}". Use --schedule job="cron expr".`); + } + const job = parseJobId(option.slice(0, separatorIndex)); + if (!job) { + throw new Error(`Unknown schedule job "${option.slice(0, separatorIndex)}".`); + } + const schedule = option.slice(separatorIndex + 1).trim(); + if (!isValidCronSchedule(schedule)) { + throw new Error(`Invalid cron schedule for ${job}: "${schedule}". Expected five fields.`); + } + overrides[job] = schedule; + } + return overrides; +} + +export function buildDefaultJobSelection(options?: { + reviewerEnabled?: boolean; + jobs?: string; + noJobs?: string; + schedule?: string[] | string; +}): IJobSelectionAnswer { + const defaults = getDefaultConfig(); + const enabled = new Set( + INIT_JOB_IDS.filter((jobId) => jobEnabledByDefault(defaults, jobId)), + ); + + if (options?.reviewerEnabled === false) { + enabled.delete('reviewer'); + } + + for (const jobId of parseJobCsv(options?.jobs)) { + enabled.add(jobId); + } + for (const jobId of parseJobCsv(options?.noJobs)) { + enabled.delete(jobId); + } + + const schedules = Object.fromEntries( + INIT_JOB_IDS.map((jobId) => [jobId, scheduleForJob(defaults, jobId)]), + ) as Record; + Object.assign(schedules, parseScheduleOverrides(options?.schedule)); + + return { + bundle: 'custom', + enabledJobs: INIT_JOB_IDS.filter((jobId) => enabled.has(jobId)), + schedules, + }; +} + +function buildBundleJobSelection(bundle: InitJobBundle): IJobSelectionAnswer { + const defaults = getDefaultConfig(); + let enabledJobs: InitJobId[]; + if (bundle === 'minimal') { + enabledJobs = MINIMAL_INIT_JOBS; + } else if (bundle === 'recommended') { + enabledJobs = RECOMMENDED_INIT_JOBS; + } else { + enabledJobs = INIT_JOB_IDS.filter((jobId) => jobEnabledByDefault(defaults, jobId)); + } + + const schedules = Object.fromEntries( + INIT_JOB_IDS.map((jobId) => [jobId, scheduleForJob(defaults, jobId)]), + ) as Record; + + return { + bundle, + enabledJobs: [...enabledJobs], + schedules, + }; +} + +function enabledJobSet(selection: IJobSelectionAnswer): Set { + return new Set(selection.enabledJobs); +} + +function formatEnabledJobs(selection: IJobSelectionAnswer): string { + const enabled = enabledJobSet(selection); + const catalog = getInitJobCatalog(); + const rows = catalog + .filter((job) => enabled.has(job.id)) + .map((job) => `${job.label}: ${selection.schedules[job.id]}`); + return rows.length > 0 ? rows.join(', ') : 'None'; +} + +export function applyJobSelectionToConfig( + config: IGeneratedInitConfig, + selection: IJobSelectionAnswer, +): IGeneratedInitConfig { + const enabled = enabledJobSet(selection); + + config.executorEnabled = enabled.has('executor'); + config.cronSchedule = selection.schedules.executor; + config.reviewerEnabled = enabled.has('reviewer'); + config.reviewerSchedule = selection.schedules.reviewer; + config.qa = { ...config.qa, enabled: enabled.has('qa'), schedule: selection.schedules.qa }; + config.audit = { + ...config.audit, + enabled: enabled.has('audit'), + schedule: selection.schedules.audit, + }; + config.analytics = { + ...config.analytics, + enabled: enabled.has('analytics'), + schedule: selection.schedules.analytics, + }; + config.roadmapScanner = { + ...config.roadmapScanner, + enabled: enabled.has('slicer'), + slicerSchedule: selection.schedules.slicer, + }; + config.prResolver = { + ...config.prResolver, + enabled: enabled.has('pr-resolver'), + schedule: selection.schedules['pr-resolver'], + }; + config.manager = { + ...config.manager, + enabled: enabled.has('manager'), + schedule: selection.schedules.manager, + }; + config.merger = { + ...config.merger, + enabled: enabled.has('merger'), + schedule: selection.schedules.merger, + }; + config.autoMerge = enabled.has('merger'); + + return config; +} + export function chooseProviderForNonInteractive(providers: Provider[]): Provider { - if (providers.includes('claude')) { - return 'claude'; + return chooseProviderByPrecedence(providers); +} + +export function chooseProviderByPrecedence(providers: Provider[]): Provider { + const providerSet = new Set(providers); + for (const provider of INIT_PROVIDER_PRECEDENCE) { + if (providerSet.has(provider)) { + return provider; + } } return providers[0]; } +function getPresetCommand(provider: Provider): string { + return BUILT_IN_PRESETS[provider]?.command ?? provider; +} + +export function getDetectedProviderPresets(detectedCommands: Provider[]): Provider[] { + const detectedCommandSet = new Set(detectedCommands); + return INIT_PROVIDER_PRECEDENCE.filter((presetId) => + detectedCommandSet.has(getPresetCommand(presetId)), + ); +} + +function formatProviderName(provider: Provider): string { + return BUILT_IN_PRESETS[provider]?.name ?? provider; +} + +export function buildProviderSummary(provider: Provider, detectedProviders: Provider[]): string { + const otherDetected = detectedProviders.filter((detected) => detected !== provider); + const providerName = formatProviderName(provider); + + if (otherDetected.length === 0) { + return `Auto-selected ${providerName}.`; + } + + const otherNames = otherDetected.map(formatProviderName).join(', '); + return `Auto-selected ${providerName}. Also available: ${otherNames}. Use --provider to override.`; +} + +export function shouldPromptProviderOverride( + interactive: boolean, + detectedProviders: Provider[], +): boolean { + return interactive && detectedProviders.length > 1; +} + +export function buildProviderChoices( + detectedProviders: Provider[], + includeCustomProvider: boolean = true, +): IProviderChoice[] { + const choices: IProviderChoice[] = detectedProviders.map((provider) => ({ + label: formatProviderName(provider), + provider, + custom: false, + })); + + if (includeCustomProvider) { + choices.push({ + label: 'Custom provider command', + custom: true, + }); + } + + return choices; +} + +export function selectProviderOverrideByIndex( + choices: IProviderChoice[], + input: string, +): IProviderChoice | null { + const selectedIndex = Number.parseInt(input.trim(), 10); + if (!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > choices.length) { + return null; + } + + return choices[selectedIndex - 1] ?? null; +} + +function createCustomProviderSelection(params: { + command?: string; + name?: string; + id?: string; +}): IProviderSelectionResult | null { + if (!params.command) { + return null; + } + + const provider = params.id?.trim() || 'custom'; + const providerPreset: IProviderPreset = { + name: params.name?.trim() || 'Custom Provider', + command: params.command.trim(), + }; + + return { + provider, + providerPreset, + detectedProviders: [], + detectedCommands: [], + summary: `Using custom provider "${providerPreset.name}" (${providerPreset.command}).`, + }; +} + +function createCustomProviderPreset(options: IInitOptions): IProviderSelectionResult | null { + return createCustomProviderSelection({ + command: options.customProviderCommand, + name: options.customProviderName, + id: options.customProviderId, + }); +} + +function printProviderInstallGuidance(): void { + console.log('\nPlease install one of the following supported provider CLIs:'); + for (const line of SUPPORTED_PROVIDER_INSTALL_GUIDANCE) { + console.log(line); + } + console.log( + '\nFor a custom provider, rerun with --custom-provider-command and optionally --custom-provider-name .', + ); +} + +async function promptCustomProviderSelection(): Promise { + const configureCustom = await promptYesNo( + 'No supported provider CLI was found. Configure a custom provider command now?', + true, + ); + if (!configureCustom) { + return null; + } + + console.log( + 'The command must be an executable CLI command available to Night Watch, or scheduled jobs will fail.', + ); + const command = await promptText('Custom provider command'); + if (!command) { + return null; + } + const name = await promptText('Custom provider display name (optional)'); + const id = await promptText('Custom provider id (default: custom)'); + return createCustomProviderSelection({ command, name, id }); +} + +async function promptProviderOverrideSelection(params: { + selectedProvider: Provider; + detectedProviders: Provider[]; + detectedCommands: string[]; +}): Promise { + const { selectedProvider, detectedProviders, detectedCommands } = params; + const selectedName = formatProviderName(selectedProvider); + console.log(buildProviderSummary(selectedProvider, detectedProviders)); + + const useAutoSelected = await promptYesNo(`Use ${selectedName}?`, false); + if (useAutoSelected) { + return { + provider: selectedProvider, + detectedProviders, + detectedCommands, + summary: `Using auto-selected provider: ${selectedName}.`, + }; + } + + const choices = buildProviderChoices(detectedProviders); + console.log('\nDetected provider presets:'); + choices.forEach((choice, index) => { + console.log(` ${index + 1}. ${choice.label}`); + }); + + const choiceInput = await promptText('Choose provider preset number'); + const choice = selectProviderOverrideByIndex(choices, choiceInput); + if (!choice) { + warn(`Invalid provider choice. Continuing with auto-selected provider: ${selectedName}.`); + return { + provider: selectedProvider, + detectedProviders, + detectedCommands, + summary: `Using auto-selected provider: ${selectedName}.`, + }; + } + + if (choice.custom) { + const command = await promptText('Custom provider command'); + if (!command) { + warn( + `No custom provider command entered. Continuing with auto-selected provider: ${selectedName}.`, + ); + return { + provider: selectedProvider, + detectedProviders, + detectedCommands, + summary: `Using auto-selected provider: ${selectedName}.`, + }; + } + + const name = await promptText('Custom provider display name (optional)'); + const id = await promptText('Custom provider id (default: custom)'); + const customSelection = createCustomProviderSelection({ command, name, id }); + if (customSelection) { + return customSelection; + } + } + + return { + provider: choice.provider ?? selectedProvider, + detectedProviders, + detectedCommands, + summary: `Using provider selected during guided init: ${choice.label}.`, + }; +} + +async function promptJobSelection(): Promise { + header('Job Selection'); + console.log('Choose the scheduled jobs Night Watch should enable.'); + console.log('High-impact jobs are called out below before you choose.'); + for (const job of getInitJobCatalog()) { + if (job.warning) { + console.log(` ${job.label}: ${job.warning}`); + } + } + console.log(); + + const bundle = await promptChoice( + 'Job bundle', + [ + { + value: 'recommended', + label: 'Recommended: executor, reviewer, QA, slicer, manager, PR resolver', + }, + { value: 'minimal', label: 'Minimal: executor only' }, + { value: 'custom', label: 'Custom: answer per job' }, + ], + 'recommended', + ); + + const selection = buildBundleJobSelection(bundle); + const enabled = enabledJobSet(selection); + + if (bundle === 'custom') { + for (const job of getInitJobCatalog()) { + const enable = await promptYesNo( + `Enable ${job.label}? ${job.description}${job.warning ? ` (${job.warning})` : ''}`, + !enabled.has(job.id), + ); + if (enable) { + enabled.add(job.id); + } else { + enabled.delete(job.id); + } + } + } + + selection.enabledJobs = INIT_JOB_IDS.filter((jobId) => enabled.has(jobId)); + + for (const job of getInitJobCatalog()) { + if (!enabled.has(job.id)) { + continue; + } + let schedule = await promptWithDefault(`${job.label} cron schedule`, job.defaultSchedule); + while (!isValidCronSchedule(schedule)) { + warn('Cron schedules must contain five fields, for example: 5 */2 * * *'); + schedule = await promptWithDefault(`${job.label} cron schedule`, job.defaultSchedule); + } + selection.schedules[job.id] = schedule; + } + + return selection; +} + +function parseNotificationEvents(input: string): NotificationEvent[] { + if (!input.trim()) { + return [...DEFAULT_NOTIFICATION_EVENTS]; + } + const events: NotificationEvent[] = []; + for (const rawEvent of input.split(',')) { + const event = rawEvent.trim(); + if (!NOTIFICATION_EVENTS.includes(event as NotificationEvent)) { + warn(`Unknown notification event "${event}". Skipping it.`); + continue; + } + events.push(event as NotificationEvent); + } + return events.length > 0 ? events : [...DEFAULT_NOTIFICATION_EVENTS]; +} + +function maskSecret(value: string | undefined): string { + if (!value) { + return ''; + } + if (value.length <= 8) { + return '********'; + } + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +function formatNotificationStatus(notifications: INotificationSelectionAnswer): string { + if (notifications.webhooks.length === 0) { + return notifications.skipped ? 'Skipped' : 'None'; + } + return notifications.webhooks + .map((webhook) => { + if (webhook.type === 'telegram') { + return `Telegram chat ${webhook.chatId ? maskSecret(webhook.chatId) : '(missing)'}`; + } + return webhook.type.charAt(0).toUpperCase() + webhook.type.slice(1); + }) + .join(', '); +} + +async function promptNotificationSetup(): Promise { + header('Notifications'); + const configure = await promptYesNo( + 'Configure notifications now? Recommended for failures and human review handoffs.', + false, + ); + if (!configure) { + return { webhooks: [], skipped: true }; + } + + const type = await promptChoice<'telegram' | 'slack' | 'discord'>( + 'Notification destination', + [ + { value: 'telegram', label: 'Telegram bot' }, + { value: 'slack', label: 'Slack incoming webhook' }, + { value: 'discord', label: 'Discord webhook' }, + ], + 'telegram', + ); + + console.log( + `Default events: ${DEFAULT_NOTIFICATION_EVENTS.join(', ')}. Leave blank to use these.`, + ); + const events = parseNotificationEvents(await promptText('Notification events CSV')); + let webhook: IWebhookConfig; + + if (type === 'telegram') { + console.log( + 'Telegram setup: create a bot with BotFather for the token. Send the bot a message, then use getUpdates or a known chat ID for chatId.', + ); + webhook = { + type, + botToken: await promptText('Telegram bot token'), + chatId: await promptText('Telegram chat ID'), + events, + }; + } else { + webhook = { + type, + url: await promptText(`${type === 'slack' ? 'Slack' : 'Discord'} webhook URL`), + events, + }; + } + + let issues = validateWebhook(webhook); + while (issues.length > 0) { + warn(`Notification webhook looks invalid: ${issues.join('; ')}`); + const fix = await promptYesNo('Fix this webhook now?', false); + if (!fix) { + const saveInvalid = await promptYesNo('Save it anyway?', true); + return { webhooks: saveInvalid ? [webhook] : [], skipped: !saveInvalid }; + } + + if (type === 'telegram') { + webhook = { + ...webhook, + botToken: await promptText('Telegram bot token'), + chatId: await promptText('Telegram chat ID'), + }; + } else { + webhook = { + ...webhook, + url: await promptText(`${type === 'slack' ? 'Slack' : 'Discord'} webhook URL`), + }; + } + issues = validateWebhook(webhook); + } + + info('Notification webhook saved. Test delivery later with `night-watch notify test`.'); + return { webhooks: [webhook], skipped: false }; +} + +async function resolveProviderSelection( + options: IInitOptions, + interactive: boolean, +): Promise { + const customProvider = createCustomProviderPreset(options); + if (customProvider) { + return customProvider; + } + + if (options.provider) { + if (!BUILT_IN_PRESET_IDS.includes(options.provider)) { + uiError(`Invalid provider "${options.provider}".`); + console.log(`Valid providers: ${BUILT_IN_PRESET_IDS.join(', ')}`); + console.log('For a custom provider, use --custom-provider-command .'); + process.exit(1); + } + + const selectedProvider = options.provider as Provider; + const command = getPresetCommand(selectedProvider); + const providerCheck = checkProviderCli(command); + if (!providerCheck.passed) { + uiError(providerCheck.message); + console.log(`Install the ${formatProviderName(selectedProvider)} CLI command: ${command}`); + printProviderInstallGuidance(); + process.exit(1); + } + + return { + provider: selectedProvider, + detectedProviders: [selectedProvider], + detectedCommands: [command], + summary: `Using provider from flag: ${formatProviderName(selectedProvider)}.`, + }; + } + + const detectedCommands = detectProviders(); + const detectedProviders = getDetectedProviderPresets(detectedCommands); + + if (detectedProviders.length === 0) { + if (interactive) { + const customSelection = await promptCustomProviderSelection(); + if (customSelection) { + return customSelection; + } + } + + uiError('No supported AI provider CLI found.'); + printProviderInstallGuidance(); + process.exit(1); + } + + const selectedProvider = chooseProviderByPrecedence(detectedProviders); + if (shouldPromptProviderOverride(interactive, detectedProviders)) { + return promptProviderOverrideSelection({ + selectedProvider, + detectedProviders, + detectedCommands, + }); + } + + return { + provider: selectedProvider, + detectedProviders, + detectedCommands, + summary: buildProviderSummary(selectedProvider, detectedProviders), + }; +} + +function showProjectReview(review: IInitProjectReview): void { + let configStatus = 'New'; + if (review.configExists) { + configStatus = review.force ? 'Exists, will overwrite' : 'Exists, will keep'; + } + + header('Project Review'); + label('Path', review.cwd); + label('Project', review.projectName); + label('Default branch', review.defaultBranch); + label('PRD directory', review.prdDir); + label('Config', configStatus); + label( + 'GitHub remote', + review.remoteStatus.hasGitHubRemote + ? (review.remoteStatus.remoteUrl ?? 'Detected') + : 'Not detected', + ); + label('Provider', review.providerSummary); + label('Playwright', review.playwrightStatus); + console.log(); +} + +async function confirmProjectReview(review: IInitProjectReview): Promise { + showProjectReview(review); + return promptYesNo('Continue with this Night Watch setup?', false); +} + +function showFinalSetupReview(params: { + projectName: string; + provider: Provider; + providerSummary: string; + jobSelection: IJobSelectionAnswer; + notifications: INotificationSelectionAnswer; + remoteStatus: IGitHubRemoteStatus; + ghAuthenticated: boolean; + playwrightStatus: string; + configPath: string; + force: boolean; + prdDir: string; +}): void { + const configExists = fs.existsSync(params.configPath); + let configWriteStatus = 'Create config'; + if (configExists && params.force) { + configWriteStatus = 'Overwrite existing config'; + } else if (configExists) { + configWriteStatus = 'Skip existing config'; + } + + let boardStatus = 'Skipped (no GitHub remote)'; + if (params.remoteStatus.hasGitHubRemote && params.ghAuthenticated) { + boardStatus = 'Board setup and labels will run'; + } else if (params.remoteStatus.hasGitHubRemote) { + boardStatus = 'Skipped until gh auth is available'; + } + + header('Final Setup Review'); + label('Project', params.projectName); + label('Provider', `${params.provider} (${params.providerSummary})`); + label('Enabled jobs', formatEnabledJobs(params.jobSelection)); + label('Notifications', formatNotificationStatus(params.notifications)); + label('Board/GitHub', boardStatus); + label('Playwright', params.playwrightStatus); + label('Config file', configWriteStatus); + label('Directories', `${params.prdDir}/done, ${LOG_DIR}/, instructions/`); + console.log(); +} + export function getGitHubRemoteStatus(cwd: string): IGitHubRemoteStatus { try { const remoteUrl = execSync('git remote get-url origin', { @@ -289,33 +1239,6 @@ export function getDefaultBranch(cwd: string): string { } } -/** - * Prompt user to select a provider from available options - */ -function promptProviderSelection(providers: Provider[]): Promise { - return new Promise((resolve, reject) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - console.log('\nMultiple AI providers detected:'); - providers.forEach((p, i) => { - console.log(` ${i + 1}. ${p}`); - }); - - rl.question('\nSelect a provider (enter number): ', (answer) => { - rl.close(); - const selection = parseInt(answer.trim(), 10); - if (isNaN(selection) || selection < 1 || selection > providers.length) { - reject(new Error('Invalid selection. Please run init again and select a valid number.')); - return; - } - resolve(providers[selection - 1]); - }); - }); -} - /** * Create directory if it doesn't exist */ @@ -329,12 +1252,15 @@ export function buildInitConfig(params: { projectName: string; defaultBranch: string; provider: Provider; + providerPreset?: IProviderPreset; reviewerEnabled: boolean; prdDir: string; + jobSelection?: IJobSelectionAnswer; + notifications?: INotificationSelectionAnswer; }): IGeneratedInitConfig { const defaults = getDefaultConfig(); - return { + const config: IGeneratedInitConfig = { $schema: 'https://json-schema.org/schema', projectName: params.projectName, defaultBranch: params.defaultBranch, @@ -356,6 +1282,9 @@ export function buildInitConfig(params: { reviewerRetryDelay: defaults.reviewerRetryDelay, provider: params.provider, providerLabel: '', + ...(params.providerPreset + ? { providerPresets: { [params.provider]: params.providerPreset } } + : {}), modelAttribution: defaults.modelAttribution, newPrLabel: defaults.newPrLabel, executorEnabled: defaults.executorEnabled ?? true, @@ -408,6 +1337,19 @@ export function buildInitConfig(params: { }, }, }; + + if (params.jobSelection) { + applyJobSelectionToConfig(config, params.jobSelection); + } + + if (params.notifications) { + config.notifications = { + ...config.notifications, + webhooks: params.notifications.webhooks.map((webhook) => ({ ...webhook })), + }; + } + + return config; } /** @@ -593,7 +1535,21 @@ export function initCommand(program: Command): void { .description('Initialize night-watch in the current project') .option('-f, --force', 'Overwrite existing configuration') .option('-d, --prd-dir ', 'Path to PRD directory') - .option('-p, --provider ', 'AI provider to use (claude or codex)') + .option( + '-p, --provider ', + `AI provider preset to use (${BUILT_IN_PRESET_IDS.join(', ')})`, + ) + .option('-y, --yes', 'Accept guided init defaults without prompting') + .option('--custom-provider-command ', 'Custom AI provider command to write into config') + .option('--custom-provider-name ', 'Display name for a custom AI provider') + .option('--custom-provider-id ', 'Config id for a custom AI provider') + .option('--jobs ', `Enable init jobs (${INIT_JOB_IDS.join(', ')})`) + .option('--no-jobs ', `Disable init jobs (${INIT_JOB_IDS.join(', ')})`) + .option( + '--schedule ', + 'Override an init job schedule; repeat for multiple jobs', + (value: string, previous: string[] = []) => [...previous, value], + ) .option('--no-reviewer', 'Disable reviewer cron job') .action(async (options: IInitOptions) => { const cwd = process.cwd(); @@ -626,55 +1582,12 @@ export function initCommand(program: Command): void { // Step 3: Detect AI providers step(3, totalSteps, 'Detecting AI providers...'); - let selectedProvider: Provider; - - if (options.provider) { - // Validate provider flag - if (!BUILT_IN_PRESET_IDS.includes(options.provider as Provider)) { - uiError(`Invalid provider "${options.provider}".`); - console.log(`Valid providers: ${BUILT_IN_PRESET_IDS.join(', ')}`); - process.exit(1); - } - selectedProvider = options.provider as Provider; - const providerCheck = checkProviderCli(selectedProvider); - if (!providerCheck.passed) { - uiError(providerCheck.message); - console.log( - `Install the ${selectedProvider} CLI or rerun with --provider ${detectProviders()[0] ?? 'claude'}.`, - ); - process.exit(1); - } - info(`Using provider from flag: ${selectedProvider}`); - } else { - // Auto-detect providers - const detectedProviders = detectProviders(); - - if (detectedProviders.length === 0) { - uiError('No AI provider CLI found.'); - console.log('\nPlease install one of the following:'); - console.log(' - Claude CLI: https://docs.anthropic.com/en/docs/claude-cli'); - console.log(' - Codex CLI: https://github.com/openai/codex'); - process.exit(1); - } else if (detectedProviders.length === 1) { - selectedProvider = detectedProviders[0]; - info(`Auto-detected provider: ${selectedProvider}`); - } else { - if (!interactive) { - selectedProvider = chooseProviderForNonInteractive(detectedProviders); - info( - `Multiple providers detected in a non-interactive shell; defaulting to ${selectedProvider}. Use --provider to override.`, - ); - } else { - try { - selectedProvider = await promptProviderSelection(detectedProviders); - info(`Selected provider: ${selectedProvider}`); - } catch (err) { - uiError(`${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - } - } - } + const providerSelection = await resolveProviderSelection( + options, + interactive && !options.yes, + ); + const selectedProvider = providerSelection.provider; + info(providerSelection.summary); // Step 4: Check optional GitHub integration prerequisites step(4, totalSteps, 'Checking GitHub integration prerequisites...'); @@ -698,7 +1611,9 @@ export function initCommand(program: Command): void { info('Playwright: detected'); } else { info('Playwright: not found'); - const installPlaywright = await promptYesNo('Install Playwright for QA now?', true); + const installPlaywright = options.yes + ? false + : await promptYesNo('Install Playwright for QA now?', true); if (installPlaywright) { if (installPlaywrightForQa(cwd)) { playwrightStatus = 'installed during init'; @@ -716,17 +1631,77 @@ export function initCommand(program: Command): void { // Set reviewerEnabled from flag (default: true, --no-reviewer sets to false) const reviewerEnabled = options.reviewer !== false; + let jobSelection: IJobSelectionAnswer; + try { + jobSelection = buildDefaultJobSelection({ + reviewerEnabled, + jobs: options.jobs, + noJobs: options.noJobs, + schedule: options.schedule, + }); + } catch (err) { + uiError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + let notificationSelection: INotificationSelectionAnswer = { + webhooks: [], + skipped: true, + }; // Gather project information const projectName = getProjectName(cwd); const defaultBranch = getDefaultBranch(cwd); + const configPath = path.join(cwd, CONFIG_FILE_NAME); + + if (interactive && !options.yes) { + const confirmed = await confirmProjectReview({ + cwd, + projectName, + defaultBranch, + prdDir, + configExists: fs.existsSync(configPath), + force, + providerSummary: providerSelection.summary, + remoteStatus, + playwrightStatus, + }); + + if (!confirmed) { + info('Init cancelled. No project files were written.'); + return; + } + + jobSelection = await promptJobSelection(); + notificationSelection = await promptNotificationSetup(); + + showFinalSetupReview({ + projectName, + provider: selectedProvider, + providerSummary: providerSelection.summary, + jobSelection, + notifications: notificationSelection, + remoteStatus, + ghAuthenticated, + playwrightStatus, + configPath, + force, + prdDir, + }); + const finalConfirmed = await promptYesNo('Write this Night Watch setup?', false); + if (!finalConfirmed) { + info('Init cancelled. No project files were written.'); + return; + } + } // Display project configuration header('Project Configuration'); label('Project', projectName); label('Default branch', defaultBranch); label('Provider', selectedProvider); - label('Reviewer', reviewerEnabled ? 'Enabled' : 'Disabled'); + label('Jobs', formatEnabledJobs(jobSelection)); + label('Notifications', formatNotificationStatus(notificationSelection)); console.log(); // Define replacements for templates @@ -851,7 +1826,6 @@ export function initCommand(program: Command): void { // Step 9: Create config file step(9, totalSteps, 'Creating configuration file...'); - const configPath = path.join(cwd, CONFIG_FILE_NAME); if (fs.existsSync(configPath) && !force) { console.log(` Skipped (exists): ${configPath}`); @@ -860,8 +1834,11 @@ export function initCommand(program: Command): void { projectName, defaultBranch, provider: selectedProvider, + providerPreset: providerSelection.providerPreset, reviewerEnabled, prdDir, + jobSelection, + notifications: notificationSelection, }); fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); success(`Created ${configPath}`); @@ -874,7 +1851,7 @@ export function initCommand(program: Command): void { unknown >; const existingBoard = existingRaw.boardProvider as { projectNumber?: number } | undefined; - let boardSetupStatus = 'Skipped'; + let boardSetupStatus: string; if (existingBoard?.projectNumber && !force) { boardSetupStatus = `Already configured (#${existingBoard.projectNumber})`; info(`Board already configured (#${existingBoard.projectNumber}), skipping.`); @@ -920,7 +1897,7 @@ export function initCommand(program: Command): void { // Step 11: Sync Night Watch labels to GitHub step(11, totalSteps, 'Syncing Night Watch labels to GitHub...'); - let labelSyncStatus = 'Skipped'; + let labelSyncStatus: string; if (!remoteStatus.hasGitHubRemote || !ghAuthenticated) { labelSyncStatus = !remoteStatus.hasGitHubRemote ? 'Skipped (no GitHub remote)' @@ -1009,7 +1986,10 @@ export function initCommand(program: Command): void { // Configuration summary header('Configuration'); label('Provider', selectedProvider); - label('Reviewer', reviewerEnabled ? 'Enabled' : 'Disabled'); + label('Enabled jobs', formatEnabledJobs(jobSelection)); + label('Notifications', formatNotificationStatus(notificationSelection)); + label('Board', boardSetupStatus); + label('Skills', skillsSummary); label('Playwright', playwrightStatus); console.log(); diff --git a/packages/cli/src/commands/manager.ts b/packages/cli/src/commands/manager.ts index 21c6c18..1c71e18 100644 --- a/packages/cli/src/commands/manager.ts +++ b/packages/cli/src/commands/manager.ts @@ -6,8 +6,8 @@ import { Command } from 'commander'; import * as path from 'path'; import * as Core from '@night-watch/core'; import type { - INightWatchConfig, IManagerConfig, + INightWatchConfig, NotificationEvent, Provider, } from '@night-watch/core';