diff --git a/codify.json b/codify.json index 78006b94..50f7fa47 100644 --- a/codify.json +++ b/codify.json @@ -5,6 +5,7 @@ "default": "../codify-homebrew-plugin/src/index.ts" } }, + { "type": "terraform" }, { "type": "homebrew", "taps": [ 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/progress-display.tsx b/src/ui/components/progress/ProgressDisplay.tsx similarity index 97% rename from src/ui/components/progress/progress-display.tsx rename to src/ui/components/progress/ProgressDisplay.tsx index 38186805..2873dfdb 100644 --- a/src/ui/components/progress/progress-display.tsx +++ b/src/ui/components/progress/ProgressDisplay.tsx @@ -3,7 +3,7 @@ import { Box } from 'ink'; import EventEmitter from 'node:events'; import React from 'react'; -import Spinner from './spinner.js'; +import Spinner from './Spinner.js'; export enum ProgressStatus { IN_PROGRESS, 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-subscriber.ts b/src/ui/components/progress/progress-subscriber.ts new file mode 100644 index 00000000..2b3c1ca3 --- /dev/null +++ b/src/ui/components/progress/progress-subscriber.ts @@ -0,0 +1,113 @@ +// Subscribes to the global ctx event bus and translates +import { ctx, Event, ProcessName, SubProcessName } from '../../../events/context.js'; +import { ProgressStatus } from './ProgressDisplay.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' +} + +enum ProgressStatus { + IN_PROGRESS, + FINISHED, +} + +export interface ProgressState { + name: string, + label: string; + status: ProgressStatus; + subProgresses: Array<{ + name: string, + label: string; + status: ProgressStatus; + }> | null; +} + +export class DefaultReporterProgressSubscriber { + private listeners: Array<(newState: ProgressState | null, eventType: Event) => void> = []; + private state: ProgressState | null = null; + + constructor() { + ctx.on(Event.PROCESS_START, (name) => this.onProcessStartEvent(name)) + ctx.on(Event.PROCESS_FINISH, (name) => this.onProcessFinishEvent(name)) + ctx.on(Event.SUB_PROCESS_START, (name, additionalName) => this.onSubprocessStartEvent(name, additionalName)); + ctx.on(Event.SUB_PROCESS_FINISH, (name, additionalName) => this.onSubprocessFinishEvent(name, additionalName)) + } + + onUpdate(fn: (newState: ProgressState | null, eventType: Event) => void) { + this.listeners.push(fn); + } + + private flushUpdate(eventType: Event) { + this.state = structuredClone(this.state); + this.listeners.forEach((listener) => listener(this.state, eventType)); + } + + private onProcessStartEvent(name: ProcessName): void { + const label = ProgressLabelMapping[name]; + + // log(`${label} started`) + this.state = { + label: label + '...', + name, + status: ProgressStatus.IN_PROGRESS, + subProgresses: [], + } + this.flushUpdate(Event.PROCESS_START); + } + + private onProcessFinishEvent(name: ProcessName): void { + const label = ProgressLabelMapping[name]; + + // log(`${label} finished successfully`) + this.state!.status = ProgressStatus.FINISHED; + this.flushUpdate(Event.PROCESS_FINISH) + } + + private onSubprocessStartEvent(name: SubProcessName, additionalName?: string): void { + const label = ProgressLabelMapping[name] + (additionalName + ? ' ' + additionalName + : '' + ); + + // log(`${label} started`) + + this.state?.subProgresses?.push({ + label, + name: name + additionalName, + status: ProgressStatus.IN_PROGRESS, + }) + + this.flushUpdate(Event.SUB_PROCESS_START); + } + + private onSubprocessFinishEvent(name: SubProcessName, additionalName?: string): void { + const label = ProgressLabelMapping[name] + (additionalName + ? ' ' + additionalName + : '' + ); + + const subProgress = this.state + ?.subProgresses + ?.find((p) => p.name === name + additionalName); + + if (!subProgress) { + return; + } + + subProgress.status = ProgressStatus.FINISHED; + + this.flushUpdate(Event.SUB_PROCESS_FINISH); + + // log(`${label} finished successfully`) + } +} 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-reporter.tsx b/src/ui/reporters/default-reporter.tsx index b92dcd6b..21991d4e 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -144,7 +144,6 @@ export class DefaultReporter implements Reporter { this.log(`${label} finished successfully`) this.renderEmitter.emit(RenderEvent.PROGRESS_UPDATE, this.progressState); - } private onSubprocessStartEvent(name: SubProcessName, additionalName?: string): void { diff --git a/src/ui/reporters/default-reporter2.tsx b/src/ui/reporters/default-reporter2.tsx new file mode 100644 index 00000000..e0829e28 --- /dev/null +++ b/src/ui/reporters/default-reporter2.tsx @@ -0,0 +1,285 @@ +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, ProgressState } 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'; +import { DefaultReporterProgressSubscriber } from '../components/progress/progress-subscriber.js'; +import spinner from '../components/progress/Spinner.js'; + +enum RenderState { + PROGRESS, + DISPLAY_PLAN, + DISPLAY_IMPORT_RESULT, + IMPORT_PROMPT, + PROMPT_CONFIRMATION, + APPLY_COMPLETE, + SUDO_PROMPT, +} + +enum Callbacks { + CONFIRMATION_RESULT = 'confirmation_result', + PROMPT_IMPORT_RESULT = 'prompt_import_result', + SUDO_PROMPT_RESULT = 'sudo_prompt_result', +} + +interface AppState { + renderState?: RenderState; + progressState?: ProgressState | null; + 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 spinnerEmitter = new EventEmitter(); + private callbacks = new EventEmitter(); + + state: AppState = { + renderState: RenderState.PROGRESS, + } + + componentDidMount() { + ctx.on(Event.OUTPUT, (args) => this.log(args)); + const progress = new DefaultReporterProgressSubscriber() + progress.onUpdate(this.onProgressUpdate.bind(this)) + } + + 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(); + } + + return SudoUtils.runCommand(data.command, data.options, secureMode, pluginName, password) + } + + 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({ + ...this.state, + renderState: RenderState.PROGRESS, + }) + this.log(`${message} -> "Yes"`) + } + + return continueApply; + } + + displayApplyComplete(messages: string[]): Promise | void { + this.setState(structuredClone({ + ...this.state, + renderState: RenderState.APPLY_COMPLETE, + })) + } + + async askRequiredParametersForImport(requiredParameters: RequiredParameters): Promise { + if (requiredParameters.size === 0) { + return new Map(); + } + + this.setState({ + ...this.state, + renderState: RenderState.IMPORT_PROMPT, + data: requiredParameters, + }) + + const result = await this.awaitCallback(Callbacks.PROMPT_IMPORT_RESULT); + return this.extractUserSuppliedParametersFromResult(result); + } + + displayImportResult(importResult: ImportResult): void { + this.setState({ + ...this.state, + renderState: RenderState.DISPLAY_IMPORT_RESULT, + data: importResult, + }) + } + + private onProgressUpdate(progressState: ProgressState | null, eventType: Event): void { + this.setState({ + ...this.state, + progressState, + }); + + // switch (eventType) { + // case Event.PROCESS_START: this.log(`${progressState?.label} started`); return; + // case Event.PROCESS_FINISH: this.log(`${progressState?.label} finished successfully`); return; + // case Event.SUB_PROCESS_START: + // } + } + + private log(log: string): void { + console.log(chalk.cyan(log)); + this.spinnerEmitter.emit('data'); + } + + private async getUserPassword(): Promise { + let attemptCount = 0; + + while (attemptCount < 3) { + this.setState({ + ...this.state, + renderState: RenderState.SUDO_PROMPT, + data: attemptCount, + }) + + const passwordAttempt = await this.awaitCallback(Callbacks.SUDO_PROMPT_RESULT) + + // Validates that the password works + if (SudoUtils.validate(passwordAttempt)) { + this.setState({ + ...this.state, + renderState: RenderState.PROGRESS, + }) + + return passwordAttempt; + } + + if (attemptCount + 1 < 3) { + console.log('Password:') + console.error(chalk.red(`Sorry, try again. (${attemptCount + 1}/3)`)) + } + + attemptCount++; + } + + this.setState({ + ...this.state, + renderState: undefined, + }); + + throw new Error('sudo: 3 incorrect password attempts') + } + + 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.progressState && ( + + ) + } + { + this.state.renderState === RenderState.DISPLAY_PLAN && { + (plan, idx) => + } + } + { + this.state.renderState === RenderState.PROMPT_CONFIRMATION && ( + + {this.state.data} +