From 77a2505a9239e3ff695abeb95e3759608b6395c0 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 22 Jan 2025 08:53:03 -0500 Subject: [PATCH 1/3] feat: WIP refactored default renderer --- codify.json | 3 +- src/ui/components/default-component.tsx | 2 +- src/ui/components/plan/plan.tsx | 6 +- .../components/progress/ProgressDisplay.tsx | 152 ++++++++++ .../progress/{spinner.tsx => Spinner.tsx} | 0 .../components/progress/progress-display.tsx | 61 ---- .../components/sections/CompletionSection.tsx | 12 + src/ui/components/sections/SudoSection.tsx | 15 + src/ui/reporters/default-reporter2.tsx | 260 ++++++++++++++++++ src/ui/reporters/reporter.ts | 6 +- 10 files changed, 448 insertions(+), 69 deletions(-) create mode 100644 src/ui/components/progress/ProgressDisplay.tsx rename src/ui/components/progress/{spinner.tsx => Spinner.tsx} (100%) delete mode 100644 src/ui/components/progress/progress-display.tsx create mode 100644 src/ui/components/sections/CompletionSection.tsx create mode 100644 src/ui/components/sections/SudoSection.tsx create mode 100644 src/ui/reporters/default-reporter2.tsx diff --git a/codify.json b/codify.json index 78006b94..f572d02a 100644 --- a/codify.json +++ b/codify.json @@ -13,7 +13,8 @@ "homebrew/services" ], "formulae": [ - "asciinema" + "asciinema", + "abduco" ], "casks": [ "firefox" diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 1d6b31b2..e7c66270 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -97,7 +97,7 @@ export function DefaultComponent(props: { return { ([RenderState.APPLY_COMPLETE, RenderState.APPLYING, RenderState.GENERATING_PLAN].includes(state)) && progressState && !hideProgress && ( - + ) } { diff --git a/src/ui/components/plan/plan.tsx b/src/ui/components/plan/plan.tsx index 1a914ecd..691f045e 100644 --- a/src/ui/components/plan/plan.tsx +++ b/src/ui/components/plan/plan.tsx @@ -8,17 +8,17 @@ import { ResourceText } from './resource-text.js'; export function PlanComponent(props: { plan: Plan, }) { - const filteredPlan = props.plan.filterNoopResources(); + const { plan } = props; return Codify Plan - Path: {props.plan.project.path} + Path: {plan.project.path} The following actions will be performed: { - filteredPlan.resources.map((p, idx) => + plan.resources.map((p, idx) => {prettyFormatResourcePlan(p)} diff --git a/src/ui/components/progress/ProgressDisplay.tsx b/src/ui/components/progress/ProgressDisplay.tsx new file mode 100644 index 00000000..70cd0a98 --- /dev/null +++ b/src/ui/components/progress/ProgressDisplay.tsx @@ -0,0 +1,152 @@ +import { StatusMessage } from '@inkjs/ui'; +import { Box } from 'ink'; +import EventEmitter from 'node:events'; +import React, { useLayoutEffect, useState } from 'react'; + +import Spinner from './spinner.js'; +import { ctx, Event, ProcessName, SubProcessName } from '../../../events/context.js'; +import chalk from 'chalk'; +import { createUseStore } from '../../hooks/store.js'; + +const ProgressLabelMapping = { + [ProcessName.APPLY]: 'Codify apply', + [ProcessName.PLAN]: 'Codify plan', + [ProcessName.DESTROY]: 'Codify destroy', + [ProcessName.IMPORT]: 'Codify import', + [SubProcessName.APPLYING_RESOURCE]: 'Applying resource', + [SubProcessName.GENERATE_PLAN]: 'Refresh states and generating plan', + [SubProcessName.INITIALIZE_PLUGINS]: 'Initializing plugins', + [SubProcessName.PARSE]: 'Parsing configs', + [SubProcessName.VALIDATE]: 'Validating configs', + [SubProcessName.GET_REQUIRED_PARAMETERS]: 'Getting required parameters', + [SubProcessName.IMPORT_RESOURCE]: 'Importing resource' +} + +export enum ProgressStatus { + IN_PROGRESS, + FINISHED, +} + +export interface ProgressState { + name: string, + label: string; + status: ProgressStatus; + subProgresses: Array<{ + name: string, + label: string; + status: ProgressStatus; + }> | null; +} + +/** + * This component directly subscribes to the global event bus to listen for progress updates. That is why there are no + * props. + * Note: New process_start events will completely wipe out the old ones. + */ +export function ProgressDisplay() { + const [progressState, setProgressState] = useState({} as ProgressState | undefined); + + useLayoutEffect(() => { + ctx.on(Event.PROCESS_START, (name) => onProcessStartEvent(name)) + ctx.on(Event.PROCESS_FINISH, (name) => onProcessFinishEvent(name)) + ctx.on(Event.SUB_PROCESS_START, (name, additionalName) => onSubprocessStartEvent(name, additionalName)); + ctx.on(Event.SUB_PROCESS_FINISH, (name, additionalName) => onSubprocessFinishEvent(name, additionalName)) + }, []); + + const onProcessStartEvent = (name: ProcessName) => { + const label = ProgressLabelMapping[name]; + + log(`${label} started`) + setProgressState({ + label: label + '...', + name, + status: ProgressStatus.IN_PROGRESS, + subProgresses: [], + }); + } + + const onProcessFinishEvent = (name: ProcessName) => { + const label = ProgressLabelMapping[name]; + + log(`${label} finished successfully`) + setProgressState((state) => { + state!.status = ProgressStatus.FINISHED; + return structuredClone(state); + }) + } + + const onSubprocessStartEvent = (name: SubProcessName, additionalName?: string) => { + const label = ProgressLabelMapping[name] + (additionalName + ? ' ' + additionalName + : '' + ); + + log(`${label} started`) + + setProgressState((state) => { + state?.subProgresses?.push({ + label, + name: name + additionalName, + status: ProgressStatus.IN_PROGRESS, + }); + + return structuredClone(state); + }) + } + + const onSubprocessFinishEvent = (name: SubProcessName, additionalName?: string) => { + const label = ProgressLabelMapping[name] + (additionalName + ? ' ' + additionalName + : '' + ); + + log(`${label} finished successfully`) + setProgressState((state) => { + const subProgress = state + ?.subProgresses + ?.find((p) => p.name === name + additionalName); + + if (!subProgress) { + return state; + } + + subProgress.status = ProgressStatus.FINISHED; + + return structuredClone(state); + }) + } + + const log = (log: unknown) => { + console.log(chalk.cyan(log)); + } + + if (!progressState) { + return + } + + const { label, status, subProgresses } = progressState; + return + { + status === ProgressStatus.IN_PROGRESS + ? + : {label} + } + + + + +} + +export function SubProgressDisplay( + props: { subProgresses: ProgressState['subProgresses'] } +) { + const { subProgresses } = props; + + return <>{ + subProgresses && subProgresses.map((s, idx) => + s.status === ProgressStatus.IN_PROGRESS + ? + : {s.label} + ) + } +} diff --git a/src/ui/components/progress/spinner.tsx b/src/ui/components/progress/Spinner.tsx similarity index 100% rename from src/ui/components/progress/spinner.tsx rename to src/ui/components/progress/Spinner.tsx diff --git a/src/ui/components/progress/progress-display.tsx b/src/ui/components/progress/progress-display.tsx deleted file mode 100644 index 38186805..00000000 --- a/src/ui/components/progress/progress-display.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { StatusMessage } from '@inkjs/ui'; -import { Box } from 'ink'; -import EventEmitter from 'node:events'; -import React from 'react'; - -import Spinner from './spinner.js'; - -export enum ProgressStatus { - IN_PROGRESS, - FINISHED, -} - -export interface ProgressState { - name: string, - label: string; - status: ProgressStatus; - subProgresses: Array<{ - name: string, - label: string; - status: ProgressStatus; - }> | null; -} - -export function ProgressDisplay( - props: { - progress: ProgressState, - emitter: EventEmitter, - eventType: string, - } -) { - const { label, status, subProgresses } = props.progress; - - return - { - status === ProgressStatus.IN_PROGRESS - ? - : {label} - } - - - - -} - -export function SubProgressDisplay( - props: { - subProgresses: ProgressState['subProgresses'], - emitter: EventEmitter, - eventType: string, - } -) { - const { subProgresses, emitter, eventType } = props; - - return <>{ - subProgresses && subProgresses.map((s, idx) => - s.status === ProgressStatus.IN_PROGRESS - ? - : {s.label} - ) - } -} diff --git a/src/ui/components/sections/CompletionSection.tsx b/src/ui/components/sections/CompletionSection.tsx new file mode 100644 index 00000000..3a16f796 --- /dev/null +++ b/src/ui/components/sections/CompletionSection.tsx @@ -0,0 +1,12 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +export function CompletionSection() { + return ( + + + 🎉 Finished applying 🎉 + Open a new terminal or source '.zshrc' for the new changes to be reflected + + ); +} diff --git a/src/ui/components/sections/SudoSection.tsx b/src/ui/components/sections/SudoSection.tsx new file mode 100644 index 00000000..4634a307 --- /dev/null +++ b/src/ui/components/sections/SudoSection.tsx @@ -0,0 +1,15 @@ +import { Box } from 'ink'; +import { RenderEvent } from '../../reporters/reporter.js'; +import React from 'react'; +import { PasswordInput } from '@inkjs/ui'; + +export function SudoSection() { + return + Password: + {/* Use sudoAttemptCount as a hack to reset password input between attempts */} + { + emitter.emit(RenderEvent.PROMPT_SUDO_RESULT, password); + }}/> + + +} diff --git a/src/ui/reporters/default-reporter2.tsx b/src/ui/reporters/default-reporter2.tsx new file mode 100644 index 00000000..5797da25 --- /dev/null +++ b/src/ui/reporters/default-reporter2.tsx @@ -0,0 +1,260 @@ +import chalk from 'chalk'; +import { SudoRequestData, SudoRequestResponseData } from 'codify-schemas'; +import { Box, Static, Text, render as inkRender } from 'ink'; +import React from 'react'; + +import { Plan } from '../../entities/plan.js'; +import { Event, ProcessName, SubProcessName, ctx } from '../../events/context.js'; +import { + ImportResult, + RequiredParameters, + UserSuppliedParameters, + UserSuppliedProperties +} from '../../orchestrators/import.js'; +import { ProgressDisplay } from '../components/progress/ProgressDisplay.js'; +import { DisplayPlanStateTransition, RenderEvent, Reporter } from './reporter.js'; +import { PlanComponent } from '../components/plan/plan.js'; +import { ImportParametersForm } from '../components/import/index.js'; +import { ImportResultComponent } from '../components/import/import-result.js'; +import { PasswordInput, Select } from '@inkjs/ui'; +import EventEmitter from 'node:events'; +import { CompletionSection } from '../components/sections/CompletionSection.js'; +import { SudoUtils } from '../../utils/sudo.js'; +import { DefaultComponent } from '../components/default-component.js'; + +enum RenderState { + PROGRESS, + DISPLAY_PLAN, + DISPLAY_IMPORT_RESULT, + IMPORT_PROMPT, + PROMPT_CONFIRMATION, + APPLY_COMPLETE, + SUDO_PROMPT, +} + +enum Callbacks { + CONFIRMATION_RESULT = 'confirmation_result', +} + +interface AppState { + renderState: RenderState; + plan?: Plan; + data?: any; // Any temporary data we want to pass will be stored here. For ex: the apply confirmation message. +} + +class DefaultReporter2 extends React.Component<{}, AppState> implements Reporter { + private renderEmitter = new EventEmitter(); + private callbacks = new EventEmitter(); + + state: AppState = { + renderState: RenderState.PROGRESS, + } + + componentDidMount() { + ctx.on(Event.OUTPUT, (args) => this.log(args)); + } + + async askRequiredParametersForImport(requiredParameters: RequiredParameters): Promise { + if (requiredParameters.size === 0) { + return new Map(); + } + + this.renderEmitter.emit(RenderEvent.PROMPT_IMPORT_PARAMETERS, requiredParameters); + + return new Promise((resolve) => { + this.renderEmitter.once(RenderEvent.PROMPT_IMPORT_PARAMETERS_RESULT, (result: object) => { + const userSuppliedParameters = this.extractUserSuppliedParametersFromResult(result); + resolve(userSuppliedParameters); + }); + }) + } + + displayImportResult(importResult: ImportResult): void { + this.setState(structuredClone({ + ...this.state, + renderState: RenderState.DISPLAY_IMPORT_RESULT, + importResult, + })) + } + + async promptSudo(pluginName: string, data: SudoRequestData, secureMode: boolean): Promise { + console.log(chalk.blue(`Plugin: "${pluginName}" requires root access to run command: "${data.command}"`)); + + let password; + + // Password is only needed outside of sudo timeout. Pass password in as undefined if not needed. + if (secureMode || !SudoUtils.validate()) { + password = await this.getUserPassword(); + } + + const result = await SudoUtils.runCommand(data.command, data.options, secureMode, pluginName, password) + this.renderEmitter.emit(RenderEvent.PROMPT_SUDO_GRANTED); + + return result; + } + + displayPlan(plan: Plan): void { + this.setState(structuredClone({ + ...this.state, + renderState: RenderState.DISPLAY_PLAN, + plan: plan.filterNoopResources(), + })) + } + + async promptConfirmation(message: string): Promise { + this.setState(structuredClone({ + ...this.state, + renderState: RenderState.PROMPT_CONFIRMATION, + data: message, + })); + + const continueApply = await this.awaitCallback(Callbacks.CONFIRMATION_RESULT); + if (continueApply) { + this.setState(structuredClone({ + renderState: RenderState.PROGRESS, + })) + this.log(`${message} -> "Yes"`) + } + + return continueApply; + } + + displayApplyComplete(messages: string[]): Promise | void { + this.setState(structuredClone({ + ...this.state, + renderState: RenderState.APPLY_COMPLETE, + })) + } + + private log(log: string): void { + console.log(chalk.cyan(log)); + } + + private async getUserPassword(): Promise { + let attemptCount = 0; + + while (attemptCount < 3) { + const passwordAttempt = await this.renderSudoPrompt(attemptCount); + + // Validates that the password works + if (SudoUtils.validate(passwordAttempt)) { + this.renderEmitter.emit(RenderEvent.PROMPT_SUDO_GRANTED); + return passwordAttempt + } + + if (attemptCount + 1 < 3) { + console.log('Password:') + console.error(chalk.red(`Sorry, try again. (${attemptCount + 1}/3)`)) + } + + attemptCount++; + } + + this.renderEmitter.emit(RenderEvent.PROMPT_SUDO_ERROR); + throw new Error('sudo: 3 incorrect password attempts') + } + + private async renderSudoPrompt(attemptCount: number): Promise { + return new Promise((resolve) => { + this.renderEmitter.emit(RenderEvent.PROMPT_SUDO, attemptCount); + this.renderEmitter.on(RenderEvent.PROMPT_SUDO_RESULT, (password) => { + resolve(password) + }) + }) + } + + private extractUserSuppliedParametersFromResult(result: object): Map> { + const resources = Object.entries(result) + .map(([key, value]) => { + const [resourceName, parameterName] = key.split('.'); + return [resourceName, parameterName, value] as const; + }) + .reduce((result, parameter) => { + const [resourceName, parameterName, value] = parameter + + if (!result[resourceName]) { + result[resourceName] = {} + } + + result[resourceName][parameterName] = value + + return result; + }, {} as Record>) + + return new Map(Object.entries(resources)); + } + + private awaitCallback(name: string): Promise { + return new Promise((resolve) => this.callbacks.once(name, resolve)) + } + + render() { + return + { + this.state.renderState === RenderState.PROGRESS && ( + + ) + } + { + this.state.renderState === RenderState.DISPLAY_PLAN && { + (plan, idx) => + } + } + { + this.state.renderState === RenderState.PROMPT_CONFIRMATION && ( + + {this.state.data} + this.callbacks.emit(RenderEvent.PROMPT_CONFIRMATION_RESULT, value === 'yes')} options={[ +