From e836872441007eb2312b5356c43ee9611af004d2 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 23 Jan 2025 17:22:36 -0500 Subject: [PATCH 01/54] feat: WIP refactored default reporter to use a new statement management library (jotai) instead of being event based --- .eslintrc.json | 4 +- codify.json | 1 + package-lock.json | 21 ++++++++++ package.json | 1 + src/ui/components/default-component.tsx | 30 ++++++++++----- src/ui/reporters/default-reporter.tsx | 51 ++++++++++++++++--------- src/ui/reporters/reporter.ts | 2 +- src/ui/store/index.ts | 36 +++++++++++++++++ 8 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 src/ui/store/index.ts diff --git a/.eslintrc.json b/.eslintrc.json index 551e4e5f..e11cb306 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,11 +18,11 @@ "unicorn/no-array-for-each": "off", "unicorn/prefer-object-from-entries": "off", "unicorn/prefer-type-error": "off", - "no-await-in-loop": "off", "quotes": [ "error", "single" - ] + ], + "no-await-in-loop": "off" }, "ignorePatterns": [ "*.test.ts" diff --git a/codify.json b/codify.json index 78006b94..be8b3dc5 100644 --- a/codify.json +++ b/codify.json @@ -19,5 +19,6 @@ "firefox" ] }, + { "type": "terraform" }, { "type": "alias", "alias": "gcdsdd", "value": "git clone" } ] diff --git a/package-lock.json b/package-lock.json index e0a64d61..949fc937 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "debug": "^4.3.4", "ink": "^5", "ink-form": "^2.0.1", + "jotai": "^2.11.1", "js-yaml": "^4.1.0", "js-yaml-source-map": "^0.2.2", "json-source-map": "^0.6.1", @@ -9235,6 +9236,26 @@ "node": ">=8" } }, + "node_modules/jotai": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.1.tgz", + "integrity": "sha512-41Su098mpHIX29hF/XOpDb0SqF6EES7+HXfrhuBqVSzRkxX48hD5i8nGsEewWZNAsBWJCTTmuz8M946Ih2PfcQ==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 5ee561bd..c16cea1f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "debug": "^4.3.4", "ink": "^5", "ink-form": "^2.0.1", + "jotai": "^2.11.1", "js-yaml": "^4.1.0", "js-yaml-source-map": "^0.2.2", "json-source-map": "^0.6.1", diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 1d6b31b2..77d16f65 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -1,12 +1,14 @@ import { PasswordInput, Select } from '@inkjs/ui'; import chalk from 'chalk'; import { Box, Static, Text } from 'ink'; +import { useAtom } from 'jotai'; import { EventEmitter } from 'node:events'; import React, { useLayoutEffect, useState } from 'react'; import { Plan } from '../../entities/plan.js'; import { ImportResult, RequiredParameters } from '../../orchestrators/import.js'; import { RenderEvent, RenderState } from '../reporters/reporter.js'; +import { RenderStatus, store } from '../store/index.js'; import { ImportResultComponent } from './import/import-result.js'; import { ImportParametersForm } from './import/index.js'; import { PlanComponent } from './plan/plan.js'; @@ -20,7 +22,7 @@ export function DefaultComponent(props: { const { emitter } = props; const [state, setState] = useState(RenderState.GENERATING_PLAN); - const [progressState, setProgressState] = useState(null as ProgressState | null); + // const [progressState, setProgressState] = useState(null as ProgressState | null); const [hideProgress, setHideProgress] = useState(false); const [plan, setPlan] = useState(null as Plan | null); const [showSudoPrompt, setShowPromptSudo] = useState(false); @@ -29,19 +31,22 @@ export function DefaultComponent(props: { const [importResult, setImportResult] = useState(null); const [sudoAttemptCount, setSudoAttemptCount] = useState(0); const [confirmationMessage, setConfirmationMessage] = useState(''); + + const [{ status: renderStatus, data: renderData }] = useAtom(store.renderState); + const [progressState] = useAtom(store.progressState); // Use layoutEffect runs before the first render, whereas useEffect runs after useLayoutEffect(() => { emitter.on(RenderEvent.STATE_TRANSITION, (obj) => { switch (obj.nextState) { case RenderState.DISPLAY_PLAN: { - setProgressState(null); + // setProgressState(null); setPlan(obj.plan); break; } case RenderState.DISPLAY_IMPORT_RESULT: { - setProgressState(null); + // setProgressState(null); setImportResult(obj.importResult); break; } @@ -61,7 +66,7 @@ export function DefaultComponent(props: { }); emitter.on(RenderEvent.PROGRESS_UPDATE, (state: ProgressState) => { - setProgressState(structuredClone(state)); + // setProgressState(structuredClone(state)); }); emitter.on(RenderEvent.PROMPT_SUDO, (attemptCount) => { @@ -94,21 +99,28 @@ export function DefaultComponent(props: { }) }, []); + // console.log(renderStatus); + // console.log(renderData); + // + // console.log(renderStatus); + // console.log(progressState); + // console.log(renderData); + return { - ([RenderState.APPLY_COMPLETE, RenderState.APPLYING, RenderState.GENERATING_PLAN].includes(state)) && progressState && !hideProgress && ( + renderStatus === RenderStatus.PROGRESS && progressState && !hideProgress && ( ) } { - state >= RenderState.DISPLAY_PLAN && plan && { + renderStatus === RenderStatus.DISPLAY_PLAN && { (plan, idx) => } } { - state === RenderState.PROMPT_CONFIRMATION && ( + renderStatus === RenderStatus.PROMPT_CONFIRMATION && ( - {confirmationMessage} + {renderData as string} emitter.emit(RenderEvent.PROMPT_CONFIRMATION_RESULT, value === 'yes')} options={[ + emitter.emit(RenderEvent.PROMPT_RESULT, value)} options={ + (renderData as any).options.map((option) => ({ + label: option, value: option + })) + }/> + + ) + } { renderStatus === RenderStatus.APPLY_COMPLETE && ( diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 0ecfccdf..46950f44 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -124,17 +124,27 @@ export class DefaultReporter implements Reporter { } async promptConfirmation(message: string): Promise { - const continueApply = await this.updateStateAndAwaitEvent( + const result = await this.updateStateAndAwaitEvent( () => this.updateRenderState(RenderStatus.PROMPT_CONFIRMATION, message), - RenderEvent.PROMPT_CONFIRMATION_RESULT + RenderEvent.PROMPT_RESULT ) - if (continueApply) { + if (result) { this.updateRenderState(RenderStatus.PROGRESS) this.log(`${message} -> "Yes"`) } - return continueApply; + return result; + } + + async promptOptions(message:string, options:string[]): Promise { + const result = await this.updateStateAndAwaitEvent( + () => this.updateRenderState(RenderStatus.PROMPT_OPTIONS, { message, options }), + RenderEvent.PROMPT_RESULT + ) + + this.log(`${message} -> "${result}"`) + return result } async displayApplyComplete(messages: string[]): Promise { diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 6deba952..9004a952 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -11,7 +11,7 @@ import { ResourceConfig } from '../../entities/resource-config.js'; export enum RenderEvent { LOG = 'log', PROGRESS_UPDATE = 'progressUpdate', - PROMPT_CONFIRMATION_RESULT = 'promptConfirmation', + PROMPT_RESULT = 'promptConfirmation', PROMPT_SUDO = 'promptSudo', DISABLE_SUDO_PROMPT = 'disableSudoPrompt', PROMPT_IMPORT_PARAMETERS = 'promptImportParameters', @@ -47,6 +47,8 @@ export interface Reporter { promptConfirmation(message: string): Promise + promptOptions(message: string, options: string[]): Promise; + promptSudo(pluginName: string, data: SudoRequestData, secureMode: boolean): Promise; promptUserForValues(resources: Array, promptType: PromptType): Promise; diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index cc63eb19..bff1ff60 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -13,6 +13,7 @@ export enum RenderStatus { DISPLAY_IMPORT_RESULT, IMPORT_PROMPT, PROMPT_CONFIRMATION, + PROMPT_OPTIONS, APPLY_COMPLETE, SUDO_PROMPT, } From a18b107c8dd7d5f70b253bfba111c5f49bcee900 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 1 Feb 2025 14:15:15 -0500 Subject: [PATCH 17/54] feat: Added new isSameOnSystem method on resource entity + tests --- src/entities/resource-config.test.ts | 103 ++++++++++++++++++++++++++- src/entities/resource-config.ts | 47 ++++++++++++ src/entities/resource-info.ts | 10 ++- src/utils/index.ts | 30 ++++++++ 4 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/entities/resource-config.test.ts b/src/entities/resource-config.test.ts index 3ce4a5ca..2265befd 100644 --- a/src/entities/resource-config.test.ts +++ b/src/entities/resource-config.test.ts @@ -1,8 +1,9 @@ //import { ProjectConfig } from './project.js'; import { describe, expect, it } from 'vitest'; import { ResourceConfig } from './resource-config'; +import { ResourceInfo } from './resource-info'; -describe('Parser: project entity tests', () => { +describe('Resource config unit tests', () => { it('parses an empty project', () => { expect(new ResourceConfig({ type: 'anything', @@ -23,4 +24,104 @@ describe('Parser: project entity tests', () => { it('plugin versions must be semvers', () => { }) + + it ('detects if two resource configs represent the same thing on the system (different types)', () => { + const resource1 = new ResourceConfig({ + type: 'type1', + }) + const resource2 = new ResourceConfig({ + type: 'type2', + }) + expect(resource1.isSameOnSystem(resource2, false)).to.be.false; + + const resource3 = new ResourceConfig({ + type: 'type1', + }) + const resource4 = new ResourceConfig({ + type: 'type1', + }) + expect(resource3.isSameOnSystem(resource4, false)).to.be.true; + }) + + + it ('detects if two resource configs represent the same thing on the system (different names)', () => { + // Fails + const resource1 = new ResourceConfig({ + type: 'type1', + name: 'name1', + }) + const resource2 = new ResourceConfig({ + type: 'type1', + name: 'name2' + }) + expect(resource1.isSameOnSystem(resource2)).to.be.false; + + // Passes + const resource3 = new ResourceConfig({ + type: 'type1', + name: 'name1', + }) + const resource4 = new ResourceConfig({ + type: 'type1', + name: 'name1' + }) + expect(resource3.isSameOnSystem(resource4, false)).to.be.true; + }) + + it ('detects if two resource configs represent the same thing on the system (different required parameters)', () => { + // Passes + const resourceInfo = ResourceInfo.fromResponseData({ + type: 'type1', + schema: { + type: 'object', + required: ['param1', 'param2'], + properties: { + param1: {}, + param2: {}, + param3: {} + } + } + }); + + const resource1 = new ResourceConfig({ + type: 'type1', + param2: 'b', + name: 'name1', + param1: 'a', + param3: 'c' + }) + resource1.attachResourceInfo(resourceInfo) + + const resource2 = new ResourceConfig({ + param3: 'different', + type: 'type1', + name: 'name1', + param1: 'a', + param2: 'b', + }) + resource2.attachResourceInfo(resourceInfo) + + expect(resource1.isSameOnSystem(resource2)).to.be.true; + + // Fails + const resource3 = new ResourceConfig({ + type: 'type1', + name: 'name1', + param1: 'a', + param2: 'b', + param3: 'c' + }) + resource3.attachResourceInfo(resourceInfo) + + const resource4 = new ResourceConfig({ + type: 'type1', + name: 'name1', + param1: 'a', + param2: 'different', + param3: 'different' + }) + resource4.attachResourceInfo(resourceInfo) + + expect(resource3.isSameOnSystem(resource4)).to.be.false; + }) }); diff --git a/src/entities/resource-config.ts b/src/entities/resource-config.ts index 4f8818f6..efffdcc8 100644 --- a/src/entities/resource-config.ts +++ b/src/entities/resource-config.ts @@ -1,6 +1,8 @@ import { ResourceJson, ResourceConfig as SchemaResourceConfig } from 'codify-schemas'; +import { deepEqual } from '../utils/index.js'; import { ConfigBlock, ConfigType } from './config.js'; +import { ResourceInfo } from './resource-info.js'; /** Resource JSON supported format * { @@ -32,6 +34,8 @@ export class ResourceConfig implements ConfigBlock { dependencyIds: string[] = []; // id of other nodes parameters: Record; + resourceInfo?: ResourceInfo; + constructor(config: SchemaResourceConfig, sourceMapKey?: string) { const { dependsOn, name, type, ...parameters } = config; @@ -69,6 +73,41 @@ export class ResourceConfig implements ConfigBlock { return externalId === this.id; } + /** + * Useful for imports, creates and destroys. This checks if two resources represents the same installation on the system. + */ + isSameOnSystem(other: ResourceConfig, checkResourceInfo = true): boolean { + if (other.type !== this.type) { + return false; + } + + // If names are specified then that means Codify intends for the resources to be the same + if (other.name && this.name && other.name !== this.name) { + return false; + } + + if (!checkResourceInfo) { + return true; + } + + if (!this.resourceInfo || !other.resourceInfo) { + throw new Error(`checkResourceInfo specified but no resource info provided (${this.type}) (other: ${other.type})`) + } + + const thisRequiredKeys = new Set(this.resourceInfo.getRequiredParameters().map((p) => p.name)); + const otherRequiredKeys = new Set(other.resourceInfo.getRequiredParameters().map((p) => p.name)); + + const thisRequiredParameters = Object.fromEntries(Object.entries(this.parameters) + .filter(([k]) => thisRequiredKeys.has(k)) + ); + const otherRequiredParameters = Object.fromEntries(Object.entries(other.parameters) + .filter(([k]) => otherRequiredKeys.has(k)) + ); + + return deepEqual(thisRequiredParameters, otherRequiredParameters); + + } + setName(name: string) { this.name = name; this.raw.name = name; @@ -113,4 +152,12 @@ export class ResourceConfig implements ConfigBlock { addDependencies(dependencies: string[]) { this.dependencyIds.push(...dependencies); } + + attachResourceInfo(resourceInfo: ResourceInfo) { + if (resourceInfo.type !== this.type) { + throw new Error(`Attempting to attach resource info (${resourceInfo.type}) on an un-related resource (${this.type})`) + } + + this.resourceInfo = resourceInfo; + } } diff --git a/src/entities/resource-info.ts b/src/entities/resource-info.ts index 85fe0517..070353e4 100644 --- a/src/entities/resource-info.ts +++ b/src/entities/resource-info.ts @@ -5,7 +5,7 @@ interface ParameterInfo { type?: string; description?: string; isRequired: boolean; - value: unknown; + value?: unknown; } export class ResourceInfo implements GetResourceInfoResponseData { @@ -15,6 +15,8 @@ export class ResourceInfo implements GetResourceInfoResponseData { dependencies?: string[] | undefined; import?: { requiredParameters: null | string[]; } | undefined; + private constructor() {} + get description(): string | undefined { return this.schema?.description as string | undefined; } @@ -31,14 +33,16 @@ export class ResourceInfo implements GetResourceInfoResponseData { return []; } - const { properties } = schema; + const { properties, required } = schema; if (!properties || typeof properties !== 'object') { return []; } return Object.entries(properties) .map(([propertyName, info]) => { - const isRequired = this.import?.requiredParameters?.some((name) => name === propertyName) ?? false + const isRequired = this.import?.requiredParameters?.some((name) => name === propertyName) + ?? (required as string[] | undefined)?.includes(propertyName) + ?? false; return { name: propertyName, diff --git a/src/utils/index.ts b/src/utils/index.ts index b8d2d174..128d9b80 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -29,3 +29,33 @@ export function sleep(ms: number): Promise { setTimeout(resolve, ms) }); } + +export function deepEqual(obj1: unknown, obj2: unknown): boolean { + // Base case: If both objects are identical, return true. + if (obj1 === obj2) { + return true; + } + + // Check if both objects are objects and not null. + if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { + return false; + } + + // Get the keys of both objects. + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + // Check if the number of keys is the same. + if (keys1.length !== keys2.length) { + return false; + } + + // Iterate through the keys and compare their values recursively. + for (const key of keys1) { + if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) { + return false; + } + } + + // If all checks pass, the objects are deep equal. + return true; +} From 0b919e772681fb36326165226c20bec0e89395bf Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sat, 1 Feb 2025 14:28:16 -0500 Subject: [PATCH 18/54] feat: Added new flow for saving import to file --- new.codify.json | 1 + src/entities/plan.test.ts | 2 +- src/orchestrators/import.ts | 56 +++++++++++++++++++++++++-- src/parser/index.ts | 6 +-- src/ui/components/plan/plan.tsx | 2 +- src/ui/plan-pretty-printer.ts | 2 +- src/ui/reporters/default-reporter.tsx | 4 +- src/ui/reporters/reporter.ts | 2 +- src/utils/file.ts | 5 +++ 9 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 new.codify.json diff --git a/new.codify.json b/new.codify.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/new.codify.json @@ -0,0 +1 @@ +[] diff --git a/src/entities/plan.test.ts b/src/entities/plan.test.ts index ab10c285..02aa9bfa 100644 --- a/src/entities/plan.test.ts +++ b/src/entities/plan.test.ts @@ -14,7 +14,7 @@ describe('Unit tests for Plan entity', () => { new ResourceConfig({ type: 'type2', dependsOn: ['type1'] }), new ResourceConfig({ type: 'type3', dependsOn: ['type1', 'type2']}), ], - './somewhere', + ['codify.json'] ); project.resolveDependenciesAndCalculateEvalOrder() diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 4d991df1..d236d080 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { Project } from '../entities/project.js'; import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; @@ -5,6 +7,7 @@ import { ProcessName, SubProcessName, ctx } from '../events/context.js'; import { CodifyParser } from '../parser/index.js'; import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; +import { FileUtils } from '../utils/file.js'; import { InitializeOrchestrator } from './initialize.js'; export type RequiredParameters = Map; @@ -70,6 +73,7 @@ export class ImportOrchestrator { ctx.processFinished(ProcessName.IMPORT) reporter.displayImportResult(importResult); + ImportOrchestrator.attachResourceInfo(importResult, resourceInfoList); await ImportOrchestrator.saveResults(reporter, importResult, project) } @@ -131,16 +135,60 @@ ${JSON.stringify(unsupportedTypeIds)}`); private static async saveResults(reporter: Reporter, importResult: ImportResult, project: Project): Promise { const projectExists = !project.isEmpty(); + const multipleCodifyFiles = project.codifyFiles.length > 1; - const continueSaving = await reporter.promptOptions( + const promptResult = await reporter.promptOptions( '\nDo you want to save the results?', - [projectExists ? `Update ${project.path}` : undefined, 'Save in a new file', 'No'].filter(Boolean) as string[] + [projectExists ? multipleCodifyFiles ? 'Update existing file (multiple found)' : `Update existing file (${project.codifyFiles})` : undefined, 'In a new file', 'No'].filter(Boolean) as string[] ) - if (!continueSaving) { - process.exit(0) + + if (promptResult === 'Update existing file (multiple found)') { + const file = await reporter.promptOptions( + '\nWhich file would you like to update?', + project.codifyFiles, + ) + await ImportOrchestrator.saveToFile(file, importResult); + + } else if (promptResult.startsWith('Update existing file')) { + await ImportOrchestrator.saveToFile(project.codifyFiles[0], importResult); + + } else if (promptResult === 'In a new file') { + const newFileName = await ImportOrchestrator.generateNewImportFileName(); + await ImportOrchestrator.saveToFile(newFileName, importResult); } + } + + private static async saveToFile(filePath: string, importResult: ImportResult): Promise { + const existing = await CodifyParser.parse(filePath); + for (const resource of importResult.result) { + existing.addOrReplace(resource); + } + console.log(JSON.stringify(existing.resourceConfigs.map((r) => r.raw), null, 2)) + } + + private static async generateNewImportFileName(): Promise { + const cwd = process.cwd(); + + let fileName = path.join(cwd, 'import.codify.json') + let counter = 1; + + while(true) { + if (!(await FileUtils.fileExists(fileName))) { + return fileName; + } + + fileName = path.join(cwd, `import-${counter}.codify.json`); + counter++; + } + } + // We have to attach additional info to the imported configs to make saving easier + private static attachResourceInfo(importResult: ImportResult, resourceInfoList: ResourceInfo[]): void { + importResult.result.forEach((resource) => { + const matchedInfo = resourceInfoList.find((info) => info.type === resource.type)!; + resource.attachResourceInfo(matchedInfo); + }) } } diff --git a/src/parser/index.ts b/src/parser/index.ts index 21929cc3..2a5b3020 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -22,13 +22,13 @@ class Parser { async parse(dirOrFile: string): Promise { const absolutePath = path.resolve(dirOrFile); const sourceMaps = new SourceMapCache() + const codifyFiles = await this.getFilePaths(absolutePath) - const configs = await this.getFilePaths(absolutePath) - .then((paths) => this.readFiles(paths)) + const configs = await this.readFiles(codifyFiles) .then((files) => this.parseContents(files, sourceMaps)) .then((config) => this.createConfigBlocks(config, sourceMaps)) - return Project.create(configs, dirOrFile, sourceMaps); + return Project.create(configs, codifyFiles, sourceMaps); } private async getFilePaths(dirOrFile: string): Promise { diff --git a/src/ui/components/plan/plan.tsx b/src/ui/components/plan/plan.tsx index 1a914ecd..70f17242 100644 --- a/src/ui/components/plan/plan.tsx +++ b/src/ui/components/plan/plan.tsx @@ -14,7 +14,7 @@ export function PlanComponent(props: { Codify Plan - Path: {props.plan.project.path} + Path: {props.plan.project.codifyFiles} The following actions will be performed: { diff --git a/src/ui/plan-pretty-printer.ts b/src/ui/plan-pretty-printer.ts index 410d45d6..1c33b172 100644 --- a/src/ui/plan-pretty-printer.ts +++ b/src/ui/plan-pretty-printer.ts @@ -8,7 +8,7 @@ export function prettyFormatPlan(plan: Plan) { '', '', chalk.bold('Codify Plan'), - `Path: ${plan.project.path}`, + `Path: ${plan.project.codifyFiles}`, 'The following actions will be performed', '', ]; diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 46950f44..44386038 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -137,8 +137,8 @@ export class DefaultReporter implements Reporter { return result; } - async promptOptions(message:string, options:string[]): Promise { - const result = await this.updateStateAndAwaitEvent( + async promptOptions(message:string, options:string[]): Promise { + const result = await this.updateStateAndAwaitEvent( () => this.updateRenderState(RenderStatus.PROMPT_OPTIONS, { message, options }), RenderEvent.PROMPT_RESULT ) diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 9004a952..c31116b4 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -47,7 +47,7 @@ export interface Reporter { promptConfirmation(message: string): Promise - promptOptions(message: string, options: string[]): Promise; + promptOptions(message: string, options: string[]): Promise; promptSudo(pluginName: string, data: SudoRequestData, secureMode: boolean): Promise; diff --git a/src/utils/file.ts b/src/utils/file.ts index b653e760..e732f46a 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -26,4 +26,9 @@ export class FileUtils { const lstat = await fs.lstat(path.resolve(fileOrDir)) return lstat.isDirectory() } + + static async readFile(filePath: string): Promise { + const resolvedPath = path.resolve(filePath); + return fs.readFile(resolvedPath, 'utf8') + } } From f1cbda24d034d01da61c69e518af217fbe01f6ab Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 4 Feb 2025 08:03:25 -0500 Subject: [PATCH 19/54] feat: WIP added a file modification calculator to calculate imports --- package-lock.json | 23 +++- package.json | 2 + src/entities/resource-config.ts | 5 + .../file-modification-calculator.test.ts | 65 ++++++++++ src/utils/file-modification-calculator.ts | 119 ++++++++++++++++++ 5 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 src/utils/file-modification-calculator.test.ts create mode 100644 src/utils/file-modification-calculator.ts diff --git a/package-lock.json b/package-lock.json index 95d14f5b..f7554c3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,13 @@ "@oclif/core": "^4.0.8", "@oclif/plugin-help": "^6.2.4", "@oclif/plugin-update": "^4.6.13", + "@types/diff": "^7.0.1", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", "codify-schemas": "^1.0.63", "debug": "^4.3.4", + "diff": "^7.0.0", "ink": "^5", "jotai": "^2.11.1", "js-yaml": "^4.1.0", @@ -4056,6 +4058,11 @@ "@types/ms": "*" } }, + "node_modules/@types/diff": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz", + "integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -6013,10 +6020,9 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "dev": true, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "engines": { "node": ">=0.3.1" } @@ -9669,6 +9675,15 @@ "node": ">= 14.0.0" } }, + "node_modules/mocha/node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/mocha/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", diff --git a/package.json b/package.json index 10b04da2..b4c52960 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "@oclif/core": "^4.0.8", "@oclif/plugin-help": "^6.2.4", "@oclif/plugin-update": "^4.6.13", + "@types/diff": "^7.0.1", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", "codify-schemas": "^1.0.63", "debug": "^4.3.4", + "diff": "^7.0.0", "ink": "^5", "jotai": "^2.11.1", "js-yaml": "^4.1.0", diff --git a/src/entities/resource-config.ts b/src/entities/resource-config.ts index efffdcc8..561f4676 100644 --- a/src/entities/resource-config.ts +++ b/src/entities/resource-config.ts @@ -113,6 +113,11 @@ export class ResourceConfig implements ConfigBlock { this.raw.name = name; } + setParameter(name: string, value: unknown) { + this.parameters[name] = value; + this.raw[name] = value; + } + addDependenciesFromDependsOn(resourceExists: (id: string) => boolean) { for (const id of this.dependsOn) { if (!resourceExists(id)) { diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts new file mode 100644 index 00000000..b02b5b01 --- /dev/null +++ b/src/utils/file-modification-calculator.test.ts @@ -0,0 +1,65 @@ +import { describe, it, vi, afterEach } from 'vitest'; +import { FileModificationCalculator, ModificationType } from './file-modification-calculator'; +import { ResourceConfig } from '../entities/resource-config'; +import { ResourceInfo } from '../entities/resource-info'; +import { FileType, InMemoryFile } from '../parser/entities'; + +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + return fs +}) + +vi.mock('node:fs/promises', async () => { + const { fs } = await import('memfs'); + return fs.promises; +}) + + +describe('File modification calculator tests', () => { + + it('Can generate a diff and a new file', async () => { + const existingResource = new ResourceConfig({ + type: 'resource1' + }); + existingResource.attachResourceInfo(generateResourceInfo('resource1')) + + const existingFileContents = +`[ + { + "type": "project", + "plugins": { + "default": "latest", + } + }, + { "type": "resource1" } +]` + const existingFile = { filePath: '/path/to/file.json', fileType: FileType.JSON, contents: existingFileContents }; + + const modifiedResource = new ResourceConfig({ + type: 'resource1', + parameter1: 'abc' + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) + + const calculator = new FileModificationCalculator([existingResource], existingFile) + const result = await calculator.calculate([{ + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource, + }]) + + console.log(result) + console.log(result.diff) + }) + + afterEach(() => { + vi.resetAllMocks(); + }) +}) + +function generateResourceInfo(type: string, requiredParameters?: string[]): ResourceInfo { + return ResourceInfo.fromResponseData({ + plugin: 'plugin', + type, + import: { requiredParameters } + }) +} diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts new file mode 100644 index 00000000..8ec24770 --- /dev/null +++ b/src/utils/file-modification-calculator.ts @@ -0,0 +1,119 @@ +import chalk from 'chalk'; +import { ResourceConfig } from '../entities/resource-config.js'; +import * as Diff from 'diff' +import { FileType, InMemoryFile } from '../parser/entities.js'; + +export enum ModificationType { + INSERT_OR_UPDATE, + DELETE +} + +export interface ModifiedResource { + resource: ResourceConfig; + modification: ModificationType +} + +export interface FileModificationResult { + newFile: string; + diff: string; +} + +export class FileModificationCalculator { + private existingFile?: InMemoryFile; + private existingResources: ResourceConfig[]; + + constructor(existingResources: ResourceConfig[], existingFile: InMemoryFile) { + this.existingFile = existingFile; + this.existingResources = existingResources; + } + + async calculate(modifications: ModifiedResource[]): Promise { + const resultResources = [...this.existingResources] + + if (this.existingResources.length === 0 || !this.existingFile) { + const newFile = JSON.stringify( + modifications + .filter((r) => r.modification === ModificationType.INSERT_OR_UPDATE) + .map((r) => r.resource.raw), + null, 2) + + return { + newFile, + diff: this.diff('', newFile), + } + } + + this.validate(modifications); + + for (const modified of modifications) { + const duplicateIndex = this.existingResources.findIndex((existing) => existing.isSameOnSystem(modified.resource)) + + if (duplicateIndex === -1) { + if (modified.modification === ModificationType.INSERT_OR_UPDATE) { + resultResources.push(modified.resource); + } + + continue; + } + + if (modified.modification === ModificationType.DELETE) { + resultResources.splice(duplicateIndex, 1); + continue; + } + + const duplicate = resultResources[duplicateIndex]; + for (const [key, newValue] of Object.entries(modified.resource.parameters)) { + duplicate.setParameter(key, newValue); + } + } + + const newFile = JSON.stringify( + resultResources.map((r) => r.raw), + null, 2 + ); + + return { + newFile, + diff: this.diff(this.existingFile.contents, newFile), + } + } + + validate(modifiedResources: ModifiedResource[]): void { + if (!this.existingFile) { + return; + } + + if (this.existingFile?.fileType !== FileType.JSON) { + throw new Error(`Only updating .json files are currently supported. Found ${this.existingFile?.filePath}`); + } + + if (this.existingResources.some((r) => !r.resourceInfo)) { + const badResources = this.existingResources + .filter((r) => !r.resourceInfo) + .map((r) => r.id); + + throw new Error(`All resources must have resource info attached to generate diff. Found bad resources: ${badResources}`); + } + + if (modifiedResources.some((r) => !r.resource.resourceInfo)) { + const badResources = modifiedResources + .filter((r) => !r.resource.resourceInfo) + .map((r) => r.resource.id); + + throw new Error(`All resources must have resource info attached to generate diff. Found bad resources: ${badResources}`); + } + } + + diff(a: string, b: string): string { + const diff = Diff.diffLines(a, b); + + let result = ''; + diff.forEach((part) => { + result += part.added ? chalk.green(part.value) : + part.removed ? chalk.red(part.value) : + part.value; + }); + + return result; + } +} From 834e39ff7e8bbc0439598985dce4a404fe65dff6 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 4 Feb 2025 22:02:19 -0500 Subject: [PATCH 20/54] feat: WIP added ability to delete a resource from an existing file --- package-lock.json | 7 +- package.json | 3 +- .../file-modification-calculator.test.ts | 133 ++++++++++++++++-- src/utils/file-modification-calculator.ts | 94 +++++++++++-- 4 files changed, 205 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index f7554c3b..44734631 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,12 @@ "@oclif/core": "^4.0.8", "@oclif/plugin-help": "^6.2.4", "@oclif/plugin-update": "^4.6.13", - "@types/diff": "^7.0.1", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", "codify-schemas": "^1.0.63", "debug": "^4.3.4", + "detect-indent": "^7.0.1", "diff": "^7.0.0", "ink": "^5", "jotai": "^2.11.1", @@ -41,6 +41,7 @@ "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/debug": "^4.1.12", + "@types/diff": "^7.0.1", "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "^20", @@ -4061,7 +4062,8 @@ "node_modules/@types/diff": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz", - "integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==" + "integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==", + "dev": true }, "node_modules/@types/estree": { "version": "1.0.6", @@ -5994,7 +5996,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.1.tgz", "integrity": "sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==", - "dev": true, "engines": { "node": ">=12.20" } diff --git a/package.json b/package.json index b4c52960..44177c52 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,12 @@ "@oclif/core": "^4.0.8", "@oclif/plugin-help": "^6.2.4", "@oclif/plugin-update": "^4.6.13", - "@types/diff": "^7.0.1", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "chalk": "^5.3.0", "codify-schemas": "^1.0.63", "debug": "^4.3.4", + "detect-indent": "^7.0.1", "diff": "^7.0.0", "ink": "^5", "jotai": "^2.11.1", @@ -34,6 +34,7 @@ "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/debug": "^4.1.12", + "@types/diff": "^7.0.1", "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "^20", diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index b02b5b01..fda5847d 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -1,8 +1,12 @@ -import { describe, it, vi, afterEach } from 'vitest'; -import { FileModificationCalculator, ModificationType } from './file-modification-calculator'; -import { ResourceConfig } from '../entities/resource-config'; -import { ResourceInfo } from '../entities/resource-info'; -import { FileType, InMemoryFile } from '../parser/entities'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { describe, it, vi, afterEach, expect } from 'vitest'; +import { FileModificationCalculator, ModificationType } from './file-modification-calculator.js'; +import { ResourceConfig } from '../entities/resource-config.js'; +import { ResourceInfo } from '../entities/resource-info.js'; +import { CodifyParser } from '../parser/index.js'; vi.mock('node:fs', async () => { const { fs } = await import('memfs'); @@ -14,26 +18,65 @@ vi.mock('node:fs/promises', async () => { return fs.promises; }) +const defaultPath = '/codify.json' + describe('File modification calculator tests', () => { it('Can generate a diff and a new file', async () => { - const existingResource = new ResourceConfig({ - type: 'resource1' + const existingFile = +`[ + { + "type": "project", + "plugins": { + "default": "latest" + } + }, + { "type": "resource1", "param2": ["a", "b", "c"]} +]` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type)) }); - existingResource.attachResourceInfo(generateResourceInfo('resource1')) - const existingFileContents = + const modifiedResource = new ResourceConfig({ + type: 'resource1', + parameter1: 'abc' + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) + + const calculator = new FileModificationCalculator(project.resourceConfigs, project.sourceMaps.getSourceMap(defaultPath).file, project.sourceMaps); + const result = await calculator.calculate([{ + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource, + }]) + + console.log(result) + console.log(result.diff) + }) + + it('Can delete a resource from an existing config (with proper commas)', async () => { + const existingFile = `[ { "type": "project", "plugins": { - "default": "latest", + "default": "latest" } }, - { "type": "resource1" } + { + "type": "resource1", + "param2": ["a", "b", "c"] + } ]` - const existingFile = { filePath: '/path/to/file.json', fileType: FileType.JSON, contents: existingFileContents }; + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type)) + }); const modifiedResource = new ResourceConfig({ type: 'resource1', @@ -41,12 +84,67 @@ describe('File modification calculator tests', () => { }) modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) - const calculator = new FileModificationCalculator([existingResource], existingFile) + const calculator = new FileModificationCalculator(project.resourceConfigs, project.sourceMaps.getSourceMap(defaultPath).file, project.sourceMaps); const result = await calculator.calculate([{ - modification: ModificationType.INSERT_OR_UPDATE, + modification: ModificationType.DELETE, resource: modifiedResource, }]) + expect(result.newFile).to.eq('[\n' + + ' {\n' + + ' "type": "project",\n' + + ' "plugins": {\n' + + ' "default": "latest"\n' + + ' }\n' + + ' }\n' + + ' \n' + + ']') + console.log(result) + console.log(result.diff) + }) + + it('Can delete a resource from an existing config 2 (with proper commas)', async () => { + const existingFile = + `[ + { + "type": "resource1", + "param2": ["a", "b", "c"] + }, + { + "type": "project", + "plugins": { + "default": "latest" + } + } +]` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type)) + }); + + const modifiedResource = new ResourceConfig({ + type: 'resource1', + parameter1: 'abc' + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) + + const calculator = new FileModificationCalculator(project.resourceConfigs, project.sourceMaps.getSourceMap(defaultPath).file, project.sourceMaps); + const result = await calculator.calculate([{ + modification: ModificationType.DELETE, + resource: modifiedResource, + }]) + + // expect(result.newFile).to.eq('[\n' + + // ' {\n' + + // ' "type": "project",\n' + + // ' "plugins": {\n' + + // ' "default": "latest"\n' + + // ' }\n' + + // ' }\n' + + // ' \n' + + // ']') console.log(result) console.log(result.diff) }) @@ -63,3 +161,10 @@ function generateResourceInfo(type: string, requiredParameters?: string[]): Reso import: { requiredParameters } }) } + +/** + * To generate the source maps and parsed resources it's easier to write it to the file-system and parse it for real + */ +function generateTestFile(contents: string, filePath = defaultPath): void { + fs.writeFileSync(filePath, contents, { encoding: 'utf8' }); +} diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index 8ec24770..99561e85 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -2,6 +2,8 @@ import chalk from 'chalk'; import { ResourceConfig } from '../entities/resource-config.js'; import * as Diff from 'diff' import { FileType, InMemoryFile } from '../parser/entities.js'; +import { SourceLocation, SourceMapCache } from '../parser/source-maps.js'; +import detectIndent from 'detect-indent'; export enum ModificationType { INSERT_OR_UPDATE, @@ -21,10 +23,12 @@ export interface FileModificationResult { export class FileModificationCalculator { private existingFile?: InMemoryFile; private existingResources: ResourceConfig[]; + private sourceMaps: SourceMapCache; - constructor(existingResources: ResourceConfig[], existingFile: InMemoryFile) { + constructor(existingResources: ResourceConfig[], existingFile: InMemoryFile, sourceMaps: SourceMapCache) { this.existingFile = existingFile; this.existingResources = existingResources; + this.sourceMaps = sourceMaps; } async calculate(modifications: ModifiedResource[]): Promise { @@ -44,41 +48,49 @@ export class FileModificationCalculator { } this.validate(modifications); + const { sourceMap, file } = this.sourceMaps.getSourceMap('/codify.json')!; + const fileIndents = detectIndent(file.contents); + const indentString = fileIndents.indent; - for (const modified of modifications) { + let newFile = file.contents.trimEnd(); + + console.log(JSON.stringify(sourceMap, null, 2)) + + // Reverse the traversal order so we edit from the back. This way the line numbers won't be messed up with new edits. + for (const modified of modifications.reverse()) { const duplicateIndex = this.existingResources.findIndex((existing) => existing.isSameOnSystem(modified.resource)) if (duplicateIndex === -1) { if (modified.modification === ModificationType.INSERT_OR_UPDATE) { - resultResources.push(modified.resource); + const config = JSON.stringify(modified.resource.raw, null, indentString) + newFile = this.insertConfig(newFile, config, indentString); } continue; } + const duplicate = this.existingResources[duplicateIndex]; + const duplicateSourceKey = duplicate.sourceMapKey?.split('#').at(1)!; + if (modified.modification === ModificationType.DELETE) { - resultResources.splice(duplicateIndex, 1); + const { value, valueEnd } = sourceMap.lookup(duplicateSourceKey)! + + newFile = this.remove(newFile, value, valueEnd); continue; - } - const duplicate = resultResources[duplicateIndex]; - for (const [key, newValue] of Object.entries(modified.resource.parameters)) { - duplicate.setParameter(key, newValue); } - } - const newFile = JSON.stringify( - resultResources.map((r) => r.raw), - null, 2 - ); + resultResources.splice(duplicateIndex, 1, modified.resource); + } return { - newFile, + newFile: newFile, diff: this.diff(this.existingFile.contents, newFile), } } validate(modifiedResources: ModifiedResource[]): void { + // The result of the validation rules only apply if we want to insert into a file. If it's a new file nothing is really needed if (!this.existingFile) { return; } @@ -102,6 +114,10 @@ export class FileModificationCalculator { throw new Error(`All resources must have resource info attached to generate diff. Found bad resources: ${badResources}`); } + + if (!this.sourceMaps) { + throw new Error('Source maps must be provided to generate new code'); + } } diff(a: string, b: string): string { @@ -116,4 +132,54 @@ export class FileModificationCalculator { return result; } + + // Insert always works at the end + private insertConfig( + file: string, + config: string, + indentString: string, + ) { + const configWithIndents = config.split(/\n/).map((l) => `${indentString}l`).join('\n'); + const result = file.substring(0, configWithIndents.length - 1) + ',' + configWithIndents + file.at(-1); + + // Need to fix the position of the comma + + return result; + } + + private remove( + file: string, + value: SourceLocation, + valueEnd: SourceLocation, + ): string { + let result = file.substring(0, value.position) + file.substring(valueEnd.position) + + let commaIndex = - 1; + for (let counter = value.position; counter > 0; counter--) { + if (result[counter] === ',') { + commaIndex = counter; + break; + } + } + + // Not able to find comma behind (this was the first element). We want to delete the comma behind then. + if (commaIndex === -1) { + for (let counter = value.position; counter < file.length - 1; counter++) { + if (result[counter] === ',') { + commaIndex = counter; + break; + } + } + } + + if (commaIndex !== -1) { + result = this.splice(result, commaIndex, 1) + } + + return result; + } + + private splice(s: string, start: number, deleteCount = 0, insert = '') { + return s.substring(0, start) + insert + s.substring(start + deleteCount); + } } From 131b918a91f431547a14b02ab1c027f82e59c17f Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 4 Feb 2025 23:03:47 -0500 Subject: [PATCH 21/54] feat: WIP switched to a new calculation for deletes that removes additional spaces as well --- .../file-modification-calculator.test.ts | 2 +- src/utils/file-modification-calculator.ts | 134 +++++++++++------- 2 files changed, 82 insertions(+), 54 deletions(-) diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index fda5847d..98154ae8 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -130,7 +130,7 @@ describe('File modification calculator tests', () => { }) modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) - const calculator = new FileModificationCalculator(project.resourceConfigs, project.sourceMaps.getSourceMap(defaultPath).file, project.sourceMaps); + const calculator = new FileModificationCalculator(project); const result = await calculator.calculate([{ modification: ModificationType.DELETE, resource: modifiedResource, diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index 99561e85..dfc4e04b 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -2,8 +2,10 @@ import chalk from 'chalk'; import { ResourceConfig } from '../entities/resource-config.js'; import * as Diff from 'diff' import { FileType, InMemoryFile } from '../parser/entities.js'; -import { SourceLocation, SourceMapCache } from '../parser/source-maps.js'; +import { SourceLocation, SourceMap, SourceMapCache } from '../parser/source-maps.js'; import detectIndent from 'detect-indent'; +import { Project } from '../entities/project.js'; +import { ProjectConfig } from '../entities/project-config.js'; export enum ModificationType { INSERT_OR_UPDATE, @@ -22,19 +24,22 @@ export interface FileModificationResult { export class FileModificationCalculator { private existingFile?: InMemoryFile; - private existingResources: ResourceConfig[]; - private sourceMaps: SourceMapCache; - - constructor(existingResources: ResourceConfig[], existingFile: InMemoryFile, sourceMaps: SourceMapCache) { - this.existingFile = existingFile; - this.existingResources = existingResources; - this.sourceMaps = sourceMaps; + private existingConfigs: ResourceConfig[]; + private sourceMap: SourceMap; + private totalConfigLength: number; + + constructor(existing: Project) { + const { file, sourceMap } = existing.sourceMaps?.getSourceMap(existing.codifyFiles[0])!; + this.existingFile = file; + this.sourceMap = sourceMap; + this.existingConfigs = [...existing.resourceConfigs]; + this.totalConfigLength = existing.resourceConfigs.length + (existing.projectConfig ? 1 : 0); } async calculate(modifications: ModifiedResource[]): Promise { - const resultResources = [...this.existingResources] + const resultResources = [...this.existingConfigs] - if (this.existingResources.length === 0 || !this.existingFile) { + if (this.existingConfigs.length === 0 || !this.existingFile) { const newFile = JSON.stringify( modifications .filter((r) => r.modification === ModificationType.INSERT_OR_UPDATE) @@ -48,36 +53,46 @@ export class FileModificationCalculator { } this.validate(modifications); - const { sourceMap, file } = this.sourceMaps.getSourceMap('/codify.json')!; - const fileIndents = detectIndent(file.contents); + + const fileIndents = detectIndent(this.existingFile.contents); const indentString = fileIndents.indent; - let newFile = file.contents.trimEnd(); + let newFile = this.existingFile.contents.trimEnd(); - console.log(JSON.stringify(sourceMap, null, 2)) + console.log(JSON.stringify(this.sourceMap, null, 2)) // Reverse the traversal order so we edit from the back. This way the line numbers won't be messed up with new edits. - for (const modified of modifications.reverse()) { - const duplicateIndex = this.existingResources.findIndex((existing) => existing.isSameOnSystem(modified.resource)) + for (const existing of this.existingConfigs.reverse()) { + // Skip past the project config; This also has the effect of casting the rest of this to resource config. + if (!this.isResourceConfig(existing)) { + continue; + } - if (duplicateIndex === -1) { - if (modified.modification === ModificationType.INSERT_OR_UPDATE) { - const config = JSON.stringify(modified.resource.raw, null, indentString) - newFile = this.insertConfig(newFile, config, indentString); - } + const duplicateIndex = modifications.findIndex((modified) => existing.isSameOnSystem(modified.resource)) + // The resource was not modified in any way. Skip. + if (duplicateIndex === -1) { continue; } - const duplicate = this.existingResources[duplicateIndex]; - const duplicateSourceKey = duplicate.sourceMapKey?.split('#').at(1)!; + const modified = modifications[duplicateIndex]; + const duplicateSourceKey = existing.sourceMapKey?.split('#').at(1)!; + const sourceIndex = Number.parseInt(duplicateSourceKey.split('/').at(1)!) if (modified.modification === ModificationType.DELETE) { - const { value, valueEnd } = sourceMap.lookup(duplicateSourceKey)! + const isLast = sourceIndex === this.totalConfigLength - 1; + const isFirst = sourceIndex === 0; - newFile = this.remove(newFile, value, valueEnd); + const value = !isFirst ? this.sourceMap.lookup(`/${sourceIndex - 1}`)?.valueEnd : this.sourceMap.lookup(duplicateSourceKey)?.value; + const valueEnd = !isLast ? this.sourceMap.lookup(`/${sourceIndex + 1}`)?.value : this.sourceMap.lookup(duplicateSourceKey)?.valueEnd; + + newFile = this.remove(newFile, value!, valueEnd!); continue; + } + if (modified.modification === ModificationType.INSERT_OR_UPDATE) { + const config = JSON.stringify(modified.resource.raw, null, indentString) + newFile = this.insertConfig(newFile, config, indentString); } resultResources.splice(duplicateIndex, 1, modified.resource); @@ -99,10 +114,10 @@ export class FileModificationCalculator { throw new Error(`Only updating .json files are currently supported. Found ${this.existingFile?.filePath}`); } - if (this.existingResources.some((r) => !r.resourceInfo)) { - const badResources = this.existingResources - .filter((r) => !r.resourceInfo) - .map((r) => r.id); + if (this.existingConfigs.some((r) => !r.resourceInfo)) { + const badResources = this.existingConfigs + .filter((r) => this.isResourceConfig(r)) + .map((r) => r.id) throw new Error(`All resources must have resource info attached to generate diff. Found bad resources: ${badResources}`); } @@ -115,7 +130,7 @@ export class FileModificationCalculator { throw new Error(`All resources must have resource info attached to generate diff. Found bad resources: ${badResources}`); } - if (!this.sourceMaps) { + if (!this.sourceMap) { throw new Error('Source maps must be provided to generate new code'); } } @@ -152,29 +167,29 @@ export class FileModificationCalculator { value: SourceLocation, valueEnd: SourceLocation, ): string { - let result = file.substring(0, value.position) + file.substring(valueEnd.position) - - let commaIndex = - 1; - for (let counter = value.position; counter > 0; counter--) { - if (result[counter] === ',') { - commaIndex = counter; - break; - } - } - - // Not able to find comma behind (this was the first element). We want to delete the comma behind then. - if (commaIndex === -1) { - for (let counter = value.position; counter < file.length - 1; counter++) { - if (result[counter] === ',') { - commaIndex = counter; - break; - } - } - } - - if (commaIndex !== -1) { - result = this.splice(result, commaIndex, 1) - } + let result = this.r(file, value.position, valueEnd.position) + + // let commaIndex = - 1; + // for (let counter = value.position; counter > 0; counter--) { + // if (result[counter] === ',') { + // commaIndex = counter; + // break; + // } + // } + // + // // Not able to find comma behind (this was the first element). We want to delete the comma behind then. + // if (commaIndex === -1) { + // for (let counter = value.position; counter < file.length - 1; counter++) { + // if (result[counter] === ',') { + // commaIndex = counter; + // break; + // } + // } + // } + // + // if (commaIndex !== -1) { + // result = this.splice(result, commaIndex, 1) + // } return result; } @@ -182,4 +197,17 @@ export class FileModificationCalculator { private splice(s: string, start: number, deleteCount = 0, insert = '') { return s.substring(0, start) + insert + s.substring(start + deleteCount); } + + private r(s: string, start: number, end: number) { + return s.substring(0, start) + s.substring(end); + } + + /** Transforms a source key from global to file specific */ + private transformSourceKey(key: string): string { + return key.split('#').at(1)!; + } + + private isResourceConfig(config: ProjectConfig | ResourceConfig): config is ResourceConfig { + return config instanceof ResourceConfig; + } } From c7a4712c1b970be50e7348501fd3dfc7715bdf0d Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 4 Feb 2025 23:26:50 -0500 Subject: [PATCH 22/54] feat: WIP delete improvements and bug fixes --- .../file-modification-calculator.test.ts | 61 +++++++++++++++---- src/utils/file-modification-calculator.ts | 42 ++++--------- 2 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index 98154ae8..dc0ee45d 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -84,7 +84,7 @@ describe('File modification calculator tests', () => { }) modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) - const calculator = new FileModificationCalculator(project.resourceConfigs, project.sourceMaps.getSourceMap(defaultPath).file, project.sourceMaps); + const calculator = new FileModificationCalculator(project); const result = await calculator.calculate([{ modification: ModificationType.DELETE, resource: modifiedResource, @@ -97,7 +97,6 @@ describe('File modification calculator tests', () => { ' "default": "latest"\n' + ' }\n' + ' }\n' + - ' \n' + ']') console.log(result) console.log(result.diff) @@ -136,15 +135,55 @@ describe('File modification calculator tests', () => { resource: modifiedResource, }]) - // expect(result.newFile).to.eq('[\n' + - // ' {\n' + - // ' "type": "project",\n' + - // ' "plugins": {\n' + - // ' "default": "latest"\n' + - // ' }\n' + - // ' }\n' + - // ' \n' + - // ']') + expect(result.newFile).to.eq('[\n' + + ' {\n' + + ' "type": "project",\n' + + ' "plugins": {\n' + + ' "default": "latest"\n' + + ' }\n' + + ' }\n' + + ']',) + console.log(result) + console.log(result.diff) + }) + + it('Can delete a resource from an existing config 3 (with proper commas)', async () => { + const existingFile = + `[ + { "type": "resource2", "param2": ["a", "b", "c"] }, { "type": "resource1", "param2": ["a", "b", "c"] }, { + "type": "project", + "plugins": { + "default": "latest" + } + } +]` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type)) + }); + + const modifiedResource = new ResourceConfig({ + type: 'resource1', + parameter1: 'abc' + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) + + const calculator = new FileModificationCalculator(project); + const result = await calculator.calculate([{ + modification: ModificationType.DELETE, + resource: modifiedResource, + }]) + + expect(result.newFile).to.eq('[\n' + + ' { "type": "resource2", "param2": ["a", "b", "c"] }, {\n' + + ' "type": "project",\n' + + ' "plugins": {\n' + + ' "default": "latest"\n' + + ' }\n' + + ' }\n' + + ']') console.log(result) console.log(result.diff) }) diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index dfc4e04b..3f8b3061 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -63,11 +63,6 @@ export class FileModificationCalculator { // Reverse the traversal order so we edit from the back. This way the line numbers won't be messed up with new edits. for (const existing of this.existingConfigs.reverse()) { - // Skip past the project config; This also has the effect of casting the rest of this to resource config. - if (!this.isResourceConfig(existing)) { - continue; - } - const duplicateIndex = modifications.findIndex((modified) => existing.isSameOnSystem(modified.resource)) // The resource was not modified in any way. Skip. @@ -83,10 +78,11 @@ export class FileModificationCalculator { const isLast = sourceIndex === this.totalConfigLength - 1; const isFirst = sourceIndex === 0; + // We try to start deleting from the previous element to the next element if possible. This covers any spaces as well. const value = !isFirst ? this.sourceMap.lookup(`/${sourceIndex - 1}`)?.valueEnd : this.sourceMap.lookup(duplicateSourceKey)?.value; const valueEnd = !isLast ? this.sourceMap.lookup(`/${sourceIndex + 1}`)?.value : this.sourceMap.lookup(duplicateSourceKey)?.valueEnd; - newFile = this.remove(newFile, value!, valueEnd!); + newFile = this.remove(newFile, value!, valueEnd!, isFirst, isLast); continue; } @@ -166,30 +162,18 @@ export class FileModificationCalculator { file: string, value: SourceLocation, valueEnd: SourceLocation, + isFirst: boolean, + isLast: boolean, ): string { - let result = this.r(file, value.position, valueEnd.position) - - // let commaIndex = - 1; - // for (let counter = value.position; counter > 0; counter--) { - // if (result[counter] === ',') { - // commaIndex = counter; - // break; - // } - // } - // - // // Not able to find comma behind (this was the first element). We want to delete the comma behind then. - // if (commaIndex === -1) { - // for (let counter = value.position; counter < file.length - 1; counter++) { - // if (result[counter] === ',') { - // commaIndex = counter; - // break; - // } - // } - // } - // - // if (commaIndex !== -1) { - // result = this.splice(result, commaIndex, 1) - // } + // Start one later so we leave the previous trailing comma alone + const start = isFirst || isLast ? value.position : value.position + 1; + + let result = this.r(file, start, valueEnd.position) + + // If there's no gap between the remaining elements, we add a space. + if (!isFirst && !/\s/.test(result[start])) { + result = this.splice(result, start, 0, ' '); + } return result; } From a0c319a348a97e69d5bcc230efc0888b17cb1382 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 5 Feb 2025 08:53:12 -0500 Subject: [PATCH 23/54] feat: WIP added updates (supports both single line and multi-line configs) --- .../file-modification-calculator.test.ts | 144 +++++++++++++++++- src/utils/file-modification-calculator.ts | 100 ++++++++---- 2 files changed, 213 insertions(+), 31 deletions(-) diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index dc0ee45d..2cc47c40 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -47,7 +47,7 @@ describe('File modification calculator tests', () => { }) modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) - const calculator = new FileModificationCalculator(project.resourceConfigs, project.sourceMaps.getSourceMap(defaultPath).file, project.sourceMaps); + const calculator = new FileModificationCalculator(project); const result = await calculator.calculate([{ modification: ModificationType.INSERT_OR_UPDATE, resource: modifiedResource, @@ -177,11 +177,151 @@ describe('File modification calculator tests', () => { }]) expect(result.newFile).to.eq('[\n' + - ' { "type": "resource2", "param2": ["a", "b", "c"] }, {\n' + + ' { "type": "resource2", "param2": ["a", "b", "c"] },\n' + + ' {\n' + + ' "type": "project",\n' + + ' "plugins": {\n' + + ' "default": "latest"\n' + + ' }\n' + + ' }\n' + + ']') + console.log(result) + console.log(result.diff) + }) + + it('Can update a resource in an existing config', async () => { + const existingFile = + `[ + { + "type": "project", + "plugins": { + "default": "latest" + } + }, + { + "type": "resource1", + "param2": ["a", "b", "c"] + } +]` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type, ['param2'])) + }); + + const modifiedResource = new ResourceConfig({ + type: 'resource1', + param2: ['a', 'b', 'c', 'd'] + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) + + const calculator = new FileModificationCalculator(project); + const result = await calculator.calculate([{ + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource, + }]) + + expect(result.newFile).to.eq('[\n' + + ' {\n' + + ' "type": "project",\n' + + ' "plugins": {\n' + + ' "default": "latest"\n' + + ' }\n' + + ' },\n' + + ' {\n' + + ' "type": "resource1",\n' + + ' "param2": ["a","b","c","d"]\n' + + ' }\n' + + ']',) + console.log(result) + console.log(result.diff) + }) + + it('Can update a resource in an existing config 2 (works between two configs)', async () => { + const existingFile = + `[ + { + "type": "project", + "plugins": { + "default": "latest" + } + }, + { + "type": "resource1", + "param2": ["a", "b", "c"] + }, + { + "type": "resource2", + "param1": false, + "param2": { "a": "aValue" }, + "param3": "this is a string" + }, + { + "type": "resource3", + "param1": "param3", + "param2": [ + "a", + "b" + ] + } +]` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + switch (r.type) { + case 'resource1': { + r.attachResourceInfo(generateResourceInfo(r.type, ['param2'])) + break; + } + case 'resource2': { + r.attachResourceInfo(generateResourceInfo(r.type, ['param1'])) + break; + } + case 'resource3': { + r.attachResourceInfo(generateResourceInfo(r.type, ['param2'])) + break; + } + } + }); + + const modifiedResource = new ResourceConfig({ + type: 'resource2', + param1: false, + param3: "this is another string", + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource2', ['param1'])) + + const calculator = new FileModificationCalculator(project); + const result = await calculator.calculate([{ + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource, + }]) + + expect(result.newFile).to.eq('[\n' + + ' {\n' + ' "type": "project",\n' + ' "plugins": {\n' + ' "default": "latest"\n' + ' }\n' + + ' },\n' + + ' { \n' + + ' "type": "resource1",\n' + + ' "param2": ["a", "b", "c"]\n' + + ' },\n' + + ' {\n' + + ' "type": "resource2",\n' + + ' "param1": false,\n' + + ' "param3": "this is another string"\n' + + ' },\n' + + ' { \n' + + ' "type": "resource3",\n' + + ' "param1": "param3",\n' + + ' "param2": [\n' + + ' "a",\n' + + ' "b"\n' + + ' ]\n' + ' }\n' + ']') console.log(result) diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index 3f8b3061..57d1b8a6 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -1,8 +1,10 @@ import chalk from 'chalk'; import { ResourceConfig } from '../entities/resource-config.js'; import * as Diff from 'diff' +import * as jsonSourceMap from 'json-source-map'; + import { FileType, InMemoryFile } from '../parser/entities.js'; -import { SourceLocation, SourceMap, SourceMapCache } from '../parser/source-maps.js'; +import { SourceMap, SourceMapCache } from '../parser/source-maps.js'; import detectIndent from 'detect-indent'; import { Project } from '../entities/project.js'; import { ProjectConfig } from '../entities/project-config.js'; @@ -27,6 +29,7 @@ export class FileModificationCalculator { private existingConfigs: ResourceConfig[]; private sourceMap: SourceMap; private totalConfigLength: number; + private indentString: string; constructor(existing: Project) { const { file, sourceMap } = existing.sourceMaps?.getSourceMap(existing.codifyFiles[0])!; @@ -34,6 +37,9 @@ export class FileModificationCalculator { this.sourceMap = sourceMap; this.existingConfigs = [...existing.resourceConfigs]; this.totalConfigLength = existing.resourceConfigs.length + (existing.projectConfig ? 1 : 0); + + const fileIndents = detectIndent(this.existingFile.contents); + this.indentString = fileIndents.indent; } async calculate(modifications: ModifiedResource[]): Promise { @@ -54,13 +60,8 @@ export class FileModificationCalculator { this.validate(modifications); - const fileIndents = detectIndent(this.existingFile.contents); - const indentString = fileIndents.indent; - let newFile = this.existingFile.contents.trimEnd(); - console.log(JSON.stringify(this.sourceMap, null, 2)) - // Reverse the traversal order so we edit from the back. This way the line numbers won't be messed up with new edits. for (const existing of this.existingConfigs.reverse()) { const duplicateIndex = modifications.findIndex((modified) => existing.isSameOnSystem(modified.resource)) @@ -75,23 +76,14 @@ export class FileModificationCalculator { const sourceIndex = Number.parseInt(duplicateSourceKey.split('/').at(1)!) if (modified.modification === ModificationType.DELETE) { - const isLast = sourceIndex === this.totalConfigLength - 1; - const isFirst = sourceIndex === 0; - - // We try to start deleting from the previous element to the next element if possible. This covers any spaces as well. - const value = !isFirst ? this.sourceMap.lookup(`/${sourceIndex - 1}`)?.valueEnd : this.sourceMap.lookup(duplicateSourceKey)?.value; - const valueEnd = !isLast ? this.sourceMap.lookup(`/${sourceIndex + 1}`)?.value : this.sourceMap.lookup(duplicateSourceKey)?.valueEnd; + newFile = this.remove(newFile, this.sourceMap, sourceIndex); + this.totalConfigLength -= 1; - newFile = this.remove(newFile, value!, valueEnd!, isFirst, isLast); continue; } - if (modified.modification === ModificationType.INSERT_OR_UPDATE) { - const config = JSON.stringify(modified.resource.raw, null, indentString) - newFile = this.insertConfig(newFile, config, indentString); - } - - resultResources.splice(duplicateIndex, 1, modified.resource); + newFile = this.remove(newFile, this.sourceMap, sourceIndex); + newFile = this.update(newFile, modified.resource, this.sourceMap, sourceIndex); } return { @@ -111,9 +103,9 @@ export class FileModificationCalculator { } if (this.existingConfigs.some((r) => !r.resourceInfo)) { - const badResources = this.existingConfigs - .filter((r) => this.isResourceConfig(r)) - .map((r) => r.id) + const badResources = this.existingConfigs + .filter((r) => this.isResourceConfig(r)) + .map((r) => r.id) throw new Error(`All resources must have resource info attached to generate diff. Found bad resources: ${badResources}`); } @@ -160,24 +152,74 @@ export class FileModificationCalculator { private remove( file: string, - value: SourceLocation, - valueEnd: SourceLocation, - isFirst: boolean, - isLast: boolean, + sourceMap: SourceMap, + sourceIndex: number, ): string { + const isLast = sourceIndex === this.totalConfigLength - 1; + const isFirst = sourceIndex === 0; + + // We try to start deleting from the previous element to the next element if possible. This covers any spaces as well. + const value = !isFirst ? this.sourceMap.lookup(`/${sourceIndex - 1}`)?.valueEnd : this.sourceMap.lookup(`/${sourceIndex}`)?.value; + const valueEnd = !isLast ? this.sourceMap.lookup(`/${sourceIndex + 1}`)?.value : this.sourceMap.lookup(`/${sourceIndex}`)?.valueEnd; + // Start one later so we leave the previous trailing comma alone - const start = isFirst || isLast ? value.position : value.position + 1; + const start = isFirst || isLast ? value!.position : value!.position + 1; - let result = this.r(file, start, valueEnd.position) + let result = this.r(file, start, valueEnd!.position) // If there's no gap between the remaining elements, we add a space. if (!isFirst && !/\s/.test(result[start])) { - result = this.splice(result, start, 0, ' '); + result = this.splice(result, start, 0, `\n${this.indentString}`); } return result; } + /** Updates an existing resource config JSON with new values, this method replaces the old object but tries be either 1 line or multi-line like the original */ + private update( + file: string, + resource: ResourceConfig, + sourceMap: SourceMap, + sourceIndex: number, + ): string { + // Updates: for now let's remove and re-add the entire object. Only two formatting availalbe either same line or multi-line + const { value, valueEnd } = this.sourceMap.lookup(`/${sourceIndex}`)!; + const isSameLine = value.line === valueEnd.line; + + const isLast = sourceIndex === this.totalConfigLength - 1; + const isFirst = sourceIndex === 0; + + // We try to start deleting from the previous element to the next element if possible. This covers any spaces as well. + const start = !isFirst ? this.sourceMap.lookup(`/${sourceIndex - 1}`)?.valueEnd : this.sourceMap.lookup(`/${sourceIndex}`)?.value; + + let content = isSameLine ? JSON.stringify(resource.raw) : JSON.stringify(resource.raw, null, this.indentString); + content = this.updateParamsToOnelineIfNeeded(content, sourceMap, sourceIndex); + + content = content.split(/\n/).map((l) => `${this.indentString}${l}`).join('\n'); + content = isFirst ? `\n${content},` : `,\n${content}` + + return this.splice(file, start?.position!, 0, content); + } + + /** Attempt to make arrays and objects oneliners if they were before. It does this by creating a new source map */ + private updateParamsToOnelineIfNeeded(content: string, sourceMap: SourceMap, sourceIndex: number): string { + // Attempt to make arrays and objects oneliners if they were before. It does this by creating a new source map + const parsedContent = JSON.parse(content); + const parsedPointers = jsonSourceMap.parse(content); + const parsedSourceMap = new SourceMapCache() + parsedSourceMap.addSourceMap({ filePath: '', fileType: FileType.JSON, contents: parsedContent }, parsedPointers); + + for (const [key, value] of Object.entries(parsedContent)) { + const source = sourceMap.lookup(`/${sourceIndex}/${key}`); + if ((Array.isArray(value) || typeof value === 'object') && source && source.value.line === source.valueEnd.line) { + const { value, valueEnd } = parsedSourceMap.lookup(`#/${key}`)! + content = this.splice(content, value.position, valueEnd.position - value.position, JSON.stringify(parsedContent[key])) + } + } + + return content; + } + private splice(s: string, start: number, deleteCount = 0, insert = '') { return s.substring(0, start) + insert + s.substring(start + deleteCount); } From 7abf5696f52072182ad94a010ea8c29b8b29645f Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 5 Feb 2025 09:14:25 -0500 Subject: [PATCH 24/54] feat: Added ability to insert new configs --- .../file-modification-calculator.test.ts | 109 ++++++++++++++++++ src/utils/file-modification-calculator.ts | 62 ++++++---- 2 files changed, 147 insertions(+), 24 deletions(-) diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index 2cc47c40..484400f4 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -328,6 +328,115 @@ describe('File modification calculator tests', () => { console.log(result.diff) }) + it('Can insert a new resource in an existing config', async () => { + const existingFile = +`[ + { + "type": "project", + "plugins": { + "default": "latest" + } + } +]` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type, ['param2'])) + }); + + const modifiedResource = new ResourceConfig({ + type: 'resource1', + param2: ['a', 'b', 'c', 'd'] + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) + + const calculator = new FileModificationCalculator(project); + const result = await calculator.calculate([{ + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource, + }]) + + expect(result.newFile).to.eq('[\n' + + ' {\n' + + ' "type": "project",\n' + + ' "plugins": {\n' + + ' "default": "latest"\n' + + ' }\n' + + ' },\n' + + ' {\n' + + ' "type": "resource1",\n' + + ' "param2": [\n' + + ' "a",\n' + + ' "b",\n' + + ' "c",\n' + + ' "d"\n' + + ' ]\n' + + ' }\n' + + ']') + console.log(result) + console.log(result.diff) + }) + + it('Can insert a new resource in an existing config 2 (multiple)', async () => { + const existingFile = + `[ + { + "type": "project", + "plugins": { + "default": "latest" + } + } +]` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type, ['param2'])) + }); + + const modifiedResource = new ResourceConfig({ + type: 'resource1', + param2: ['a', 'b', 'c', 'd'] + }) + modifiedResource.attachResourceInfo(generateResourceInfo('resource1')) + + const modifiedResource2 = new ResourceConfig({ + type: 'resource2', + param2: ['a', 'b', 'c', 'd'] + }) + modifiedResource2.attachResourceInfo(generateResourceInfo('resource2')) + + const calculator = new FileModificationCalculator(project); + const result = await calculator.calculate([{ + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource, + }, { + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource2, + }]) + + // expect(result.newFile).to.eq('[\n' + + // ' {\n' + + // ' "type": "project",\n' + + // ' "plugins": {\n' + + // ' "default": "latest"\n' + + // ' }\n' + + // ' },\n' + + // ' {\n' + + // ' "type": "resource1",\n' + + // ' "param2": [\n' + + // ' "a",\n' + + // ' "b",\n' + + // ' "c",\n' + + // ' "d"\n' + + // ' ]\n' + + // ' }\n' + + // ']') + console.log(result) + console.log(result.diff) + }) + afterEach(() => { vi.resetAllMocks(); }) diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index 57d1b8a6..516e2720 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -43,24 +43,23 @@ export class FileModificationCalculator { } async calculate(modifications: ModifiedResource[]): Promise { - const resultResources = [...this.existingConfigs] - - if (this.existingConfigs.length === 0 || !this.existingFile) { - const newFile = JSON.stringify( - modifications - .filter((r) => r.modification === ModificationType.INSERT_OR_UPDATE) - .map((r) => r.resource.raw), - null, 2) - - return { - newFile, - diff: this.diff('', newFile), - } - } + // if (this.existingConfigs.length === 0 || !this.existingFile) { + // const newFile = JSON.stringify( + // modifications + // .filter((r) => r.modification === ModificationType.INSERT_OR_UPDATE) + // .map((r) => r.resource.raw), + // null, 2) + // + // return { + // newFile, + // diff: this.diff('', newFile), + // } + // } this.validate(modifications); - let newFile = this.existingFile.contents.trimEnd(); + let newFile = this.existingFile!.contents.trimEnd(); + const updateCache = [...modifications]; // Reverse the traversal order so we edit from the back. This way the line numbers won't be messed up with new edits. for (const existing of this.existingConfigs.reverse()) { @@ -70,6 +69,7 @@ export class FileModificationCalculator { if (duplicateIndex === -1) { continue; } + updateCache.splice(duplicateIndex, 1) const modified = modifications[duplicateIndex]; const duplicateSourceKey = existing.sourceMapKey?.split('#').at(1)!; @@ -82,10 +82,19 @@ export class FileModificationCalculator { continue; } + // Update an existing resource newFile = this.remove(newFile, this.sourceMap, sourceIndex); newFile = this.update(newFile, modified.resource, this.sourceMap, sourceIndex); } + // Insert new resources + const newResourcesToInsert = updateCache + .filter((r) => r.modification === ModificationType.INSERT_OR_UPDATE) + .map((r) => r.resource) + const insertionIndex = newFile.length - 2; // Last element is guarenteed to be the closing bracket. We insert 1 before that + + newFile = this.insert(newFile, newResourcesToInsert, insertionIndex); + return { newFile: newFile, diff: this.diff(this.existingFile.contents, newFile), @@ -137,15 +146,20 @@ export class FileModificationCalculator { } // Insert always works at the end - private insertConfig( + private insert( file: string, - config: string, - indentString: string, - ) { - const configWithIndents = config.split(/\n/).map((l) => `${indentString}l`).join('\n'); - const result = file.substring(0, configWithIndents.length - 1) + ',' + configWithIndents + file.at(-1); + resources: ResourceConfig[], + position: number, + ): string { + let result = file; + + for (const newResource of resources.reverse()) { + let content = JSON.stringify(newResource.raw, null, 2); + content = content.split(/\n/).map((l) => `${this.indentString}${l}`).join('\n') + content = `,\n${content}`; - // Need to fix the position of the comma + result = this.splice(result, position, 0, content) + } return result; } @@ -165,7 +179,7 @@ export class FileModificationCalculator { // Start one later so we leave the previous trailing comma alone const start = isFirst || isLast ? value!.position : value!.position + 1; - let result = this.r(file, start, valueEnd!.position) + let result = this.removeSlice(file, start, valueEnd!.position) // If there's no gap between the remaining elements, we add a space. if (!isFirst && !/\s/.test(result[start])) { @@ -224,7 +238,7 @@ export class FileModificationCalculator { return s.substring(0, start) + insert + s.substring(start + deleteCount); } - private r(s: string, start: number, end: number) { + private removeSlice(s: string, start: number, end: number) { return s.substring(0, start) + s.substring(end); } From 6587cd521d5900a478c2f980a5660c8af022efaa Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 5 Feb 2025 09:21:25 -0500 Subject: [PATCH 25/54] feat: Removed writing to new files from file modification calculator --- src/orchestrators/initialize.ts | 2 +- src/utils/file-modification-calculator.test.ts | 1 + src/utils/file-modification-calculator.ts | 15 +-------------- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/orchestrators/initialize.ts b/src/orchestrators/initialize.ts index ffb006f2..c1864f2f 100644 --- a/src/orchestrators/initialize.ts +++ b/src/orchestrators/initialize.ts @@ -75,7 +75,7 @@ export class InitializeOrchestrator { const project = pathToParse ? await CodifyParser.parse(pathToParse) - : Project.create([], process.cwd()) + : Project.empty() ctx.subprocessFinished(SubProcessName.PARSE); diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index 484400f4..1aa3b4c8 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -7,6 +7,7 @@ import { FileModificationCalculator, ModificationType } from './file-modificatio import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; import { CodifyParser } from '../parser/index.js'; +import { Project } from '../entities/project'; vi.mock('node:fs', async () => { const { fs } = await import('memfs'); diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index 516e2720..88674549 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -25,7 +25,7 @@ export interface FileModificationResult { } export class FileModificationCalculator { - private existingFile?: InMemoryFile; + private existingFile: InMemoryFile; private existingConfigs: ResourceConfig[]; private sourceMap: SourceMap; private totalConfigLength: number; @@ -43,19 +43,6 @@ export class FileModificationCalculator { } async calculate(modifications: ModifiedResource[]): Promise { - // if (this.existingConfigs.length === 0 || !this.existingFile) { - // const newFile = JSON.stringify( - // modifications - // .filter((r) => r.modification === ModificationType.INSERT_OR_UPDATE) - // .map((r) => r.resource.raw), - // null, 2) - // - // return { - // newFile, - // diff: this.diff('', newFile), - // } - // } - this.validate(modifications); let newFile = this.existingFile!.contents.trimEnd(); From 5924bbfb64d88a4cbf256e524153c49e3019dd7b Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 6 Feb 2025 08:21:29 -0500 Subject: [PATCH 26/54] feat: Added rendering for imports and added custom diff implementation to display the file changes --- src/entities/project.ts | 17 ++++-- src/orchestrators/import.ts | 48 +++++++++++---- src/ui/components/default-component.tsx | 8 +++ .../file-modification/FileModification.tsx | 15 +++++ src/ui/components/import/import-result.tsx | 4 +- src/ui/reporters/default-reporter.tsx | 4 ++ src/ui/reporters/reporter.ts | 2 + src/ui/store/index.ts | 1 + src/utils/file-modification-calculator.ts | 59 +++++++++++++++++-- src/utils/file.ts | 5 ++ 10 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 src/ui/components/file-modification/FileModification.tsx diff --git a/src/entities/project.ts b/src/entities/project.ts index 561a1f89..ec3bcc0a 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -16,14 +16,19 @@ export class Project { resourceConfigs: ResourceConfig[]; stateConfigs: ResourceConfig[] | null = null; evaluationOrder: null | string[] = null; - path: string; + + codifyFiles: string[]; sourceMaps?: SourceMapCache; planRequestsCache?: Map isDestroyProject = false; - static create(configs: ConfigBlock[], path: string, sourceMaps?: SourceMapCache): Project { + static empty(): Project { + return Project.create([], []); + } + + static create(configs: ConfigBlock[], codifyFiles: string[], sourceMaps?: SourceMapCache): Project { const projectConfigs = configs.filter((u) => u.configClass === ConfigType.PROJECT); if (projectConfigs.length > 1) { throw new Error(`Only one project config can be specified. Found ${projectConfigs.length}. \n\n @@ -33,16 +38,16 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return new Project( (projectConfigs[0] as ProjectConfig) ?? null, configs.filter((u) => u.configClass !== ConfigType.PROJECT) as ResourceConfig[], - path, + codifyFiles, sourceMaps, ); } - constructor(projectConfig: ProjectConfig | null, resourceConfigs: ResourceConfig[], path: string, sourceMaps?: SourceMapCache) { + constructor(projectConfig: ProjectConfig | null, resourceConfigs: ResourceConfig[], codifyFiles: string[], sourceMaps?: SourceMapCache) { this.projectConfig = projectConfig; this.resourceConfigs = resourceConfigs; this.sourceMaps = sourceMaps; - this.path = path; + this.codifyFiles = codifyFiles; this.addUniqueNamesForDuplicateResources() } @@ -107,7 +112,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`); const uninstallProject = new Project( this.projectConfig, this.resourceConfigs, - this.path, + this.codifyFiles, this.sourceMaps, ) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index d236d080..474fa09e 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -8,6 +8,7 @@ import { CodifyParser } from '../parser/index.js'; import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; +import { FileModificationCalculator, ModificationType } from '../utils/file-modification-calculator.js'; import { InitializeOrchestrator } from './initialize.js'; export type RequiredParameters = Map; @@ -73,8 +74,10 @@ export class ImportOrchestrator { ctx.processFinished(ProcessName.IMPORT) reporter.displayImportResult(importResult); - ImportOrchestrator.attachResourceInfo(importResult, resourceInfoList); - await ImportOrchestrator.saveResults(reporter, importResult, project) + const additionalResourceInfo = await pluginManager.getMultipleResourceInfo(project.resourceConfigs.map((r) => r.type)); + resourceInfoList.push(...additionalResourceInfo); + + await ImportOrchestrator.saveResults(reporter, importResult, project, resourceInfoList) } static async getImportedConfigs( @@ -133,7 +136,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); ctx.subprocessFinished(SubProcessName.VALIDATE) } - private static async saveResults(reporter: Reporter, importResult: ImportResult, project: Project): Promise { + private static async saveResults(reporter: Reporter, importResult: ImportResult, project: Project, resourceInfoList: ResourceInfo[]): Promise { const projectExists = !project.isEmpty(); const multipleCodifyFiles = project.codifyFiles.length > 1; @@ -147,25 +150,40 @@ ${JSON.stringify(unsupportedTypeIds)}`); '\nWhich file would you like to update?', project.codifyFiles, ) - await ImportOrchestrator.saveToFile(file, importResult); + await ImportOrchestrator.updateExistingFile(reporter, file, importResult, resourceInfoList); } else if (promptResult.startsWith('Update existing file')) { - await ImportOrchestrator.saveToFile(project.codifyFiles[0], importResult); + await ImportOrchestrator.updateExistingFile(reporter, project.codifyFiles[0], importResult, resourceInfoList); } else if (promptResult === 'In a new file') { const newFileName = await ImportOrchestrator.generateNewImportFileName(); - await ImportOrchestrator.saveToFile(newFileName, importResult); + await ImportOrchestrator.saveNewFile(newFileName, importResult); } } - private static async saveToFile(filePath: string, importResult: ImportResult): Promise { + private static async updateExistingFile(reporter: Reporter, filePath: string, importResult: ImportResult, resourceInfoList: ResourceInfo[]): Promise { const existing = await CodifyParser.parse(filePath); - - for (const resource of importResult.result) { - existing.addOrReplace(resource); + ImportOrchestrator.attachResourceInfo(importResult.result, resourceInfoList); + ImportOrchestrator.attachResourceInfo(existing.resourceConfigs, resourceInfoList); + + const modificationCalculator = new FileModificationCalculator(existing); + const result = modificationCalculator.calculate(importResult.result.map((resource) => ({ + modification: ModificationType.INSERT_OR_UPDATE, + resource + }))); + + reporter.displayFileModification(result.diff); + const shouldSave = await reporter.promptConfirmation(`Save to file (${filePath})?`); + if (!shouldSave) { + process.exit(0); } - console.log(JSON.stringify(existing.resourceConfigs.map((r) => r.raw), null, 2)) + await FileUtils.writeFile(filePath, result.newFile); + } + + private static async saveNewFile(filePath: string, importResult: ImportResult): Promise { + const newFile = JSON.stringify(importResult, null, 2); + await FileUtils.writeFile(filePath, newFile); } private static async generateNewImportFileName(): Promise { @@ -185,9 +203,13 @@ ${JSON.stringify(unsupportedTypeIds)}`); } // We have to attach additional info to the imported configs to make saving easier - private static attachResourceInfo(importResult: ImportResult, resourceInfoList: ResourceInfo[]): void { - importResult.result.forEach((resource) => { + private static attachResourceInfo(resources: ResourceConfig[], resourceInfoList: ResourceInfo[]): void { + resources.forEach((resource) => { const matchedInfo = resourceInfoList.find((info) => info.type === resource.type)!; + if (!matchedInfo) { + throw new Error(`Could not find type ${resource.type} in the resource info`); + } + resource.attachResourceInfo(matchedInfo); }) } diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index e55d35dc..42c2a03b 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -10,6 +10,7 @@ import { Plan } from '../../entities/plan.js'; import { ImportResult } from '../../orchestrators/import.js'; import { RenderEvent } from '../reporters/reporter.js'; import { RenderStatus, store } from '../store/index.js'; +import { FileModificationDisplay } from './file-modification/FileModification.js'; import { ImportResultComponent } from './import/import-result.js'; import { PlanComponent } from './plan/plan.js'; import { ProgressDisplay } from './progress/progress-display.js'; @@ -103,5 +104,12 @@ export function DefaultComponent(props: { } ) } + { + renderStatus === RenderStatus.DISPLAY_FILE_MODIFICATION && ( + { + (diff, idx) => + } + ) + } } diff --git a/src/ui/components/file-modification/FileModification.tsx b/src/ui/components/file-modification/FileModification.tsx new file mode 100644 index 00000000..48b890e0 --- /dev/null +++ b/src/ui/components/file-modification/FileModification.tsx @@ -0,0 +1,15 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +export function FileModificationDisplay(props: { + diff: string, +}) { + return + + File Modification + + The following changes will be made + + {props.diff} + +} diff --git a/src/ui/components/import/import-result.tsx b/src/ui/components/import/import-result.tsx index ec7c8571..c3e69ffa 100644 --- a/src/ui/components/import/import-result.tsx +++ b/src/ui/components/import/import-result.tsx @@ -17,11 +17,11 @@ export function ImportResultComponent(props: { { JSON.stringify(result.map((r) => r.raw), null, 2)} { errors.length > 0 && ( - The following configs failed to import: + The following configs failed to import: { errors.map((e, idx) => - {e} + {e} ) } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 44386038..4370f7df 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -152,6 +152,10 @@ export class DefaultReporter implements Reporter { await sleep(100); // This gives the renderer enough time to complete before the prompt exits } + displayFileModification(diff: string) { + this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); + } + private log(args: string): void { this.renderEmitter.emit(RenderEvent.LOG, args); } diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index c31116b4..b7289265 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -54,6 +54,8 @@ export interface Reporter { promptUserForValues(resources: Array, promptType: PromptType): Promise; displayImportResult(importResult: ImportResult): void; + + displayFileModification(diff: string): void } export enum ReporterType { diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index bff1ff60..e7399601 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -11,6 +11,7 @@ export enum RenderStatus { PROGRESS, DISPLAY_PLAN, DISPLAY_IMPORT_RESULT, + DISPLAY_FILE_MODIFICATION, IMPORT_PROMPT, PROMPT_CONFIRMATION, PROMPT_OPTIONS, diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index 88674549..065c373f 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -42,7 +42,7 @@ export class FileModificationCalculator { this.indentString = fileIndents.indent; } - async calculate(modifications: ModifiedResource[]): Promise { + calculate(modifications: ModifiedResource[]): FileModificationResult { this.validate(modifications); let newFile = this.existingFile!.contents.trimEnd(); @@ -120,16 +120,65 @@ export class FileModificationCalculator { } diff(a: string, b: string): string { - const diff = Diff.diffLines(a, b); + const diff = Diff.diffChars(a, b); - let result = ''; + const diffedLines = Diff.diffLines(a, b) + .flatMap((change) => change.value.split(/\n/).map(l => ({ added: change.added, removed: change.removed}))) + + let diffGroups = []; + let pointerStart = -1; + + for (let counter = 0; counter < diffedLines.length; counter++) { + const changeAhead = diffedLines.slice(counter, counter + 5).some((change) => change.added || change.removed); + const changeBehind = diffedLines.slice(counter - 5, counter).some((change) => change.added || change.removed); + + if (pointerStart === -1 && changeAhead) { + pointerStart = counter; + continue; + } + + if (pointerStart !== -1 && !changeAhead && !changeBehind) { + diffGroups.push({ start: pointerStart, end: counter }) + pointerStart = -1; + continue; + } + + if (pointerStart !== -1 && counter === diffedLines.length - 1) { + diffGroups.push({ start: pointerStart, end: counter }) + } + } + + let diffString = ''; diff.forEach((part) => { - result += part.added ? chalk.green(part.value) : + diffString += part.added ? chalk.green(part.value) : part.removed ? chalk.red(part.value) : part.value; }); - return result; + const diffLines = diffString.split(/\n/); + const result = []; + + for (const group of diffGroups) { + const maxLineNumberWidth = group.end.toString().length; + + result.push(`${chalk.bold(`Lines ${group.start} to line ${group.end}:`)} +${diffLines.slice(group.start, group.end).map((l, idx) => { + const change = diffedLines[group.start + idx]; + + if (change.added && change.removed) { + return `${chalk.gray((group.start + idx).toString().padEnd(maxLineNumberWidth, ' '))} ${chalk.yellow('~')}${l}` + } else if (change.added) { + return `${chalk.gray((group.start + idx).toString().padEnd(maxLineNumberWidth, ' '))} ${chalk.green('+')}${l}` + } else if (change.removed) { + return `${chalk.gray((group.start + idx.toString()).padEnd(maxLineNumberWidth, ' '))} ${chalk.red('-')}${l}` + } else { + return `${chalk.gray((group.start + idx).toString().padEnd(maxLineNumberWidth, ' '))} ${l}` + } +}).join('\n')}` + ); + } + + return result.join('\n\n'); } // Insert always works at the end diff --git a/src/utils/file.ts b/src/utils/file.ts index e732f46a..8fc5ed56 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -31,4 +31,9 @@ export class FileUtils { const resolvedPath = path.resolve(filePath); return fs.readFile(resolvedPath, 'utf8') } + + static async writeFile(filePath: string, contents: string): Promise { + const resolvedPath = path.resolve(filePath); + await fs.writeFile(resolvedPath, contents, 'utf8') + } } From c29f24de97631e645a3a7b9c591888edd7ba7fe7 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 6 Feb 2025 23:38:56 -0500 Subject: [PATCH 27/54] feat: Split printing logic for file diff another file + fixed bugs with logic --- src/ui/file-diff-pretty-printer.ts | 93 +++++++++++++++++++ .../file-modification-calculator.test.ts | 43 +++++---- src/utils/file-modification-calculator.ts | 68 +------------- 3 files changed, 122 insertions(+), 82 deletions(-) create mode 100644 src/ui/file-diff-pretty-printer.ts diff --git a/src/ui/file-diff-pretty-printer.ts b/src/ui/file-diff-pretty-printer.ts new file mode 100644 index 00000000..5975cae4 --- /dev/null +++ b/src/ui/file-diff-pretty-printer.ts @@ -0,0 +1,93 @@ +import chalk from 'chalk'; +import * as Diff from 'diff' + +export function prettyFormatFileDiff(before: string, after: string): string { + const diff = Diff.diffLines(before, after); + + console.log(diff); + + const changeList: Array<{ added: boolean; removed: boolean; lineNumber: number }> = [] + diff + .reduce((lineNumber, change) => { + const changes = change.value.split(/\n/).filter(Boolean).map((l, idx) => ({ + added: change.added, + removed: change.removed, + lineNumber: lineNumber + idx + 1, + })); + changeList.push(...changes); + + return lineNumber + ((change.added || (!change.added && !change.removed)) ? (change.count ?? 0) : 0) + }, 0) + + const snippetGroups = createSnippetGroups(changeList); + + let diffString = ''; + diff.forEach((part) => { + diffString += part.added ? chalk.green(part.value) : + part.removed ? chalk.red(part.value) : + part.value; + }); + + const diffLines = diffString.split(/\n/); + const result = []; + + for (const group of snippetGroups) { + const numberWidth = group.end.toString().length; + + const snippet = diffLines.slice(group.start, group.end).map((l, idx) => { + const change = changeList[group.start + idx]; + return formatLine(l, change.lineNumber, numberWidth, change.added, change.removed) + }).join('\n') + + result.push(`${chalk.bold(`Lines ${changeList[group.start].lineNumber} to line ${changeList[group.end].lineNumber}:`)} +${snippet}` + ); + } + + return result.join('\n\n'); +} + +function createSnippetGroups(changeList: Array<{ added: boolean; removed: boolean }>) { + const snippetGroups = []; + let pointerStart = -1; + + for (let counter = 0; counter < changeList.length; counter++) { + const changeAhead = changeList.slice(counter, counter + 5).some((change) => change.added || change.removed); + const changeBehind = changeList.slice(counter - 5, counter).some((change) => change.added || change.removed); + + if (pointerStart === -1 && changeAhead) { + pointerStart = counter; + continue; + } + + if (pointerStart !== -1 && !changeAhead && !changeBehind) { + snippetGroups.push({ start: pointerStart, end: counter }) + pointerStart = -1; + continue; + } + + if (pointerStart !== -1 && counter === changeList.length - 1) { + snippetGroups.push({ start: pointerStart, end: counter }) + } + } + + return snippetGroups; +} + +function formatLine(line: string, lineNumber: number, numberWidth: number, added = false, removed = false) { + if (added && removed) { + return `${chalk.gray((lineNumber).toString().padEnd(numberWidth, ' '))} ${chalk.yellow('~')}${line}` + } + + if (added) { + return `${chalk.gray((lineNumber).toString().padEnd(numberWidth, ' '))} ${chalk.green('+')}${line}` + } + + if (removed) { + return `${chalk.gray((lineNumber).toString().padEnd(numberWidth, ' '))} ${chalk.red('-')}${line}` + } + + return `${chalk.gray((lineNumber).toString().padEnd(numberWidth, ' '))} ${line}` + + // return line; +} diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index 1aa3b4c8..081cc5a9 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -417,23 +417,32 @@ describe('File modification calculator tests', () => { resource: modifiedResource2, }]) - // expect(result.newFile).to.eq('[\n' + - // ' {\n' + - // ' "type": "project",\n' + - // ' "plugins": {\n' + - // ' "default": "latest"\n' + - // ' }\n' + - // ' },\n' + - // ' {\n' + - // ' "type": "resource1",\n' + - // ' "param2": [\n' + - // ' "a",\n' + - // ' "b",\n' + - // ' "c",\n' + - // ' "d"\n' + - // ' ]\n' + - // ' }\n' + - // ']') + expect(result.newFile).to.eq('[\n' + + ' {\n' + + ' "type": "project",\n' + + ' "plugins": {\n' + + ' "default": "latest"\n' + + ' }\n' + + ' },\n' + + ' {\n' + + ' "type": "resource1",\n' + + ' "param2": [\n' + + ' "a",\n' + + ' "b",\n' + + ' "c",\n' + + ' "d"\n' + + ' ]\n' + + ' },\n' + + ' {\n' + + ' "type": "resource2",\n' + + ' "param2": [\n' + + ' "a",\n' + + ' "b",\n' + + ' "c",\n' + + ' "d"\n' + + ' ]\n' + + ' }\n' + + ']',) console.log(result) console.log(result.diff) }) diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index 065c373f..ea342660 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -1,6 +1,5 @@ -import chalk from 'chalk'; import { ResourceConfig } from '../entities/resource-config.js'; -import * as Diff from 'diff' + import * as jsonSourceMap from 'json-source-map'; import { FileType, InMemoryFile } from '../parser/entities.js'; @@ -8,6 +7,7 @@ import { SourceMap, SourceMapCache } from '../parser/source-maps.js'; import detectIndent from 'detect-indent'; import { Project } from '../entities/project.js'; import { ProjectConfig } from '../entities/project-config.js'; +import { prettyFormatFileDiff } from '../ui/file-diff-pretty-printer.js'; export enum ModificationType { INSERT_OR_UPDATE, @@ -84,7 +84,7 @@ export class FileModificationCalculator { return { newFile: newFile, - diff: this.diff(this.existingFile.contents, newFile), + diff: prettyFormatFileDiff(this.existingFile.contents, newFile), } } @@ -119,68 +119,6 @@ export class FileModificationCalculator { } } - diff(a: string, b: string): string { - const diff = Diff.diffChars(a, b); - - const diffedLines = Diff.diffLines(a, b) - .flatMap((change) => change.value.split(/\n/).map(l => ({ added: change.added, removed: change.removed}))) - - let diffGroups = []; - let pointerStart = -1; - - for (let counter = 0; counter < diffedLines.length; counter++) { - const changeAhead = diffedLines.slice(counter, counter + 5).some((change) => change.added || change.removed); - const changeBehind = diffedLines.slice(counter - 5, counter).some((change) => change.added || change.removed); - - if (pointerStart === -1 && changeAhead) { - pointerStart = counter; - continue; - } - - if (pointerStart !== -1 && !changeAhead && !changeBehind) { - diffGroups.push({ start: pointerStart, end: counter }) - pointerStart = -1; - continue; - } - - if (pointerStart !== -1 && counter === diffedLines.length - 1) { - diffGroups.push({ start: pointerStart, end: counter }) - } - } - - let diffString = ''; - diff.forEach((part) => { - diffString += part.added ? chalk.green(part.value) : - part.removed ? chalk.red(part.value) : - part.value; - }); - - const diffLines = diffString.split(/\n/); - const result = []; - - for (const group of diffGroups) { - const maxLineNumberWidth = group.end.toString().length; - - result.push(`${chalk.bold(`Lines ${group.start} to line ${group.end}:`)} -${diffLines.slice(group.start, group.end).map((l, idx) => { - const change = diffedLines[group.start + idx]; - - if (change.added && change.removed) { - return `${chalk.gray((group.start + idx).toString().padEnd(maxLineNumberWidth, ' '))} ${chalk.yellow('~')}${l}` - } else if (change.added) { - return `${chalk.gray((group.start + idx).toString().padEnd(maxLineNumberWidth, ' '))} ${chalk.green('+')}${l}` - } else if (change.removed) { - return `${chalk.gray((group.start + idx.toString()).padEnd(maxLineNumberWidth, ' '))} ${chalk.red('-')}${l}` - } else { - return `${chalk.gray((group.start + idx).toString().padEnd(maxLineNumberWidth, ' '))} ${l}` - } -}).join('\n')}` - ); - } - - return result.join('\n\n'); - } - // Insert always works at the end private insert( file: string, From 51aa606fb2085a221aa6bf398f4fcd57b0317334 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 7 Feb 2025 09:25:56 -0500 Subject: [PATCH 28/54] feat: Added parameter key sorting so that the order of parameters remains the same. For new resources, it will default to alphabetical order. Modified the import result to not show the JSON. Fixed bug with not adding trimmed string back and resource ordering issues. --- src/entities/resource-config.ts | 8 ++++ src/ui/components/import/import-result.tsx | 19 ++++++--- src/ui/file-diff-pretty-printer.ts | 2 - src/utils/file-modification-calculator.ts | 45 +++++++++++++++------- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/entities/resource-config.ts b/src/entities/resource-config.ts index 561f4676..0a2d9cd4 100644 --- a/src/entities/resource-config.ts +++ b/src/entities/resource-config.ts @@ -58,6 +58,14 @@ export class ResourceConfig implements ConfigBlock { return this.name ? `${this.type}.${this.name}` : this.type; } + core(excludeName?: boolean): SchemaResourceConfig { + return { + type: this.type, + ...(excludeName || !this.name ? {} : { name: this.name }), + ...(this.dependsOn.length > 0 ? { dependsOn: this.dependsOn } : {}) + }; + } + toJson(): ResourceJson { return { core: { diff --git a/src/ui/components/import/import-result.tsx b/src/ui/components/import/import-result.tsx index c3e69ffa..34417854 100644 --- a/src/ui/components/import/import-result.tsx +++ b/src/ui/components/import/import-result.tsx @@ -10,11 +10,20 @@ export function ImportResultComponent(props: { const { result, errors } = props.importResult return - - Codify Import - -
- { JSON.stringify(result.map((r) => r.raw), null, 2)} + + { + result.length > 0 && ( + Successfully imported the following configs: + + { + result.map((r, idx) => + {r.type} + ) + } + + ) + } + { errors.length > 0 && ( The following configs failed to import: diff --git a/src/ui/file-diff-pretty-printer.ts b/src/ui/file-diff-pretty-printer.ts index 5975cae4..d5f29723 100644 --- a/src/ui/file-diff-pretty-printer.ts +++ b/src/ui/file-diff-pretty-printer.ts @@ -4,8 +4,6 @@ import * as Diff from 'diff' export function prettyFormatFileDiff(before: string, after: string): string { const diff = Diff.diffLines(before, after); - console.log(diff); - const changeList: Array<{ added: boolean; removed: boolean; lineNumber: number }> = [] diff .reduce((lineNumber, change) => { diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index ea342660..c7d413bc 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -8,6 +8,7 @@ import detectIndent from 'detect-indent'; import { Project } from '../entities/project.js'; import { ProjectConfig } from '../entities/project-config.js'; import { prettyFormatFileDiff } from '../ui/file-diff-pretty-printer.js'; +import { deepEqual } from './index.js'; export enum ModificationType { INSERT_OR_UPDATE, @@ -50,15 +51,20 @@ export class FileModificationCalculator { // Reverse the traversal order so we edit from the back. This way the line numbers won't be messed up with new edits. for (const existing of this.existingConfigs.reverse()) { - const duplicateIndex = modifications.findIndex((modified) => existing.isSameOnSystem(modified.resource)) + const duplicateIndex = updateCache.findIndex((modified) => existing.isSameOnSystem(modified.resource)) - // The resource was not modified in any way. Skip. + // The existing was not modified in any way. Skip. if (duplicateIndex === -1) { continue; } + + const modified = updateCache[duplicateIndex]; updateCache.splice(duplicateIndex, 1) - const modified = modifications[duplicateIndex]; + if (deepEqual(modified.resource.parameters, existing.parameters)) { + continue; + } + const duplicateSourceKey = existing.sourceMapKey?.split('#').at(1)!; const sourceIndex = Number.parseInt(duplicateSourceKey.split('/').at(1)!) @@ -71,7 +77,7 @@ export class FileModificationCalculator { // Update an existing resource newFile = this.remove(newFile, this.sourceMap, sourceIndex); - newFile = this.update(newFile, modified.resource, this.sourceMap, sourceIndex); + newFile = this.update(newFile, modified.resource, existing, this.sourceMap, sourceIndex); } // Insert new resources @@ -82,6 +88,10 @@ export class FileModificationCalculator { newFile = this.insert(newFile, newResourcesToInsert, insertionIndex); + const lastCharacterIndex = this.existingFile.contents.lastIndexOf(']') + const ending = this.existingFile.contents.slice(Math.min(lastCharacterIndex + 1, this.existingFile.contents.length - 1)); + newFile += ending; + return { newFile: newFile, diff: prettyFormatFileDiff(this.existingFile.contents, newFile), @@ -128,7 +138,9 @@ export class FileModificationCalculator { let result = file; for (const newResource of resources.reverse()) { - let content = JSON.stringify(newResource.raw, null, 2); + const sortedResource = { ...newResource.core(true), ...this.sortKeys(newResource.parameters) } + let content = JSON.stringify(sortedResource, null, 2); + content = content.split(/\n/).map((l) => `${this.indentString}${l}`).join('\n') content = `,\n${content}`; @@ -167,20 +179,21 @@ export class FileModificationCalculator { private update( file: string, resource: ResourceConfig, + existing: ResourceConfig, sourceMap: SourceMap, sourceIndex: number, ): string { // Updates: for now let's remove and re-add the entire object. Only two formatting availalbe either same line or multi-line const { value, valueEnd } = this.sourceMap.lookup(`/${sourceIndex}`)!; const isSameLine = value.line === valueEnd.line; - - const isLast = sourceIndex === this.totalConfigLength - 1; const isFirst = sourceIndex === 0; // We try to start deleting from the previous element to the next element if possible. This covers any spaces as well. const start = !isFirst ? this.sourceMap.lookup(`/${sourceIndex - 1}`)?.valueEnd : this.sourceMap.lookup(`/${sourceIndex}`)?.value; - let content = isSameLine ? JSON.stringify(resource.raw) : JSON.stringify(resource.raw, null, this.indentString); + const sortedResource = this.sortKeys(resource.raw, existing.raw); + + let content = isSameLine ? JSON.stringify(sortedResource) : JSON.stringify(sortedResource, null, this.indentString); content = this.updateParamsToOnelineIfNeeded(content, sourceMap, sourceIndex); content = content.split(/\n/).map((l) => `${this.indentString}${l}`).join('\n'); @@ -216,12 +229,18 @@ export class FileModificationCalculator { return s.substring(0, start) + s.substring(end); } - /** Transforms a source key from global to file specific */ - private transformSourceKey(key: string): string { - return key.split('#').at(1)!; - } - private isResourceConfig(config: ProjectConfig | ResourceConfig): config is ResourceConfig { return config instanceof ResourceConfig; } + + private sortKeys(obj: Record, referenceOrder?: Record): Record { + const reference = Object.keys(referenceOrder + ?? Object.fromEntries([...Object.keys(obj)].sort().map((k) => [k, undefined])) + ); + + return Object.fromEntries( + Object.entries(obj) + .sort((a, b) => reference.indexOf(a[0]) - reference.indexOf(b[0])) + ) + } } From 60bd1fec215c1255003e5f9b2bcde14c8e8491cf Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 7 Feb 2025 22:07:08 -0500 Subject: [PATCH 29/54] feat: Added generic display message method to the reporter. Added messaging after all save file paths for imports. --- src/orchestrators/apply.ts | 8 +++++- src/orchestrators/import.ts | 35 ++++++++++++++++++------- src/ui/components/default-component.tsx | 14 ++++------ src/ui/reporters/default-reporter.tsx | 11 ++++---- src/ui/reporters/reporter.ts | 8 +++--- src/ui/store/index.ts | 4 +-- 6 files changed, 48 insertions(+), 32 deletions(-) diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index e22c11ee..cda14281 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -1,5 +1,6 @@ import { ProcessName, ctx } from '../events/context.js'; import { Reporter } from '../ui/reporters/reporter.js'; +import { sleep } from '../utils/index.js'; import { PlanOrchestrator } from './plan.js'; export interface ApplyArgs { @@ -30,6 +31,11 @@ export const ApplyOrchestrator = { await pluginManager.apply(project, filteredPlan); ctx.processFinished(ProcessName.APPLY); - await reporter.displayApplyComplete([]); + reporter.displayMessage(` +🎉 Finished applying 🎉 +Open a new terminal or source '.zshrc' for the new changes to be reflected`); + + // Need to sleep to wait for the message to display before we exit + await sleep(100); }, }; diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 474fa09e..64e095b7 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -9,6 +9,7 @@ import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; import { FileModificationCalculator, ModificationType } from '../utils/file-modification-calculator.js'; +import { sleep } from '../utils/index.js'; import { InitializeOrchestrator } from './initialize.js'; export type RequiredParameters = Map; @@ -114,14 +115,6 @@ export class ImportOrchestrator { } } - private static async parse(path: string): Promise { - ctx.subprocessStarted(SubProcessName.PARSE); - const project = await CodifyParser.parse(path); - ctx.subprocessFinished(SubProcessName.PARSE); - - return project - } - private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { ctx.subprocessStarted(SubProcessName.VALIDATE) @@ -158,10 +151,16 @@ ${JSON.stringify(unsupportedTypeIds)}`); } else if (promptResult === 'In a new file') { const newFileName = await ImportOrchestrator.generateNewImportFileName(); await ImportOrchestrator.saveNewFile(newFileName, importResult); + } else if (promptResult === 'No') { } } - private static async updateExistingFile(reporter: Reporter, filePath: string, importResult: ImportResult, resourceInfoList: ResourceInfo[]): Promise { + private static async updateExistingFile( + reporter: Reporter, + filePath: string, + importResult: ImportResult, + resourceInfoList: ResourceInfo[] + ): Promise { const existing = await CodifyParser.parse(filePath); ImportOrchestrator.attachResourceInfo(importResult.result, resourceInfoList); ImportOrchestrator.attachResourceInfo(existing.resourceConfigs, resourceInfoList); @@ -172,13 +171,31 @@ ${JSON.stringify(unsupportedTypeIds)}`); resource }))); + // No changes to be made + if (result.diff === '') { + reporter.displayMessage('\nNo changes are needed! Exiting...') + + // Wait for the message to display before we exit + await sleep(100); + process.exit(0); + } + reporter.displayFileModification(result.diff); const shouldSave = await reporter.promptConfirmation(`Save to file (${filePath})?`); if (!shouldSave) { + reporter.displayMessage('\nSkipping save! Exiting...'); + + // Wait for the message to display before we exit + await sleep(100); process.exit(0); } await FileUtils.writeFile(filePath, result.newFile); + + reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉'); + + // Wait for the message to display before we exit + await sleep(100); } private static async saveNewFile(filePath: string, importResult: ImportResult): Promise { diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 42c2a03b..21ea7efe 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -37,6 +37,11 @@ export function DefaultComponent(props: { }, []); return + { + renderStatus === RenderStatus.DISPLAY_MESSAGE && ( + {renderData as string} + ) + } { renderStatus === RenderStatus.PROGRESS && ( @@ -70,15 +75,6 @@ export function DefaultComponent(props: { ) } - { - renderStatus === RenderStatus.APPLY_COMPLETE && ( - - - 🎉 Finished applying 🎉 - Open a new terminal or source '.zshrc' for the new changes to be reflected - - ) - } { renderStatus === RenderStatus.SUDO_PROMPT && ( diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 4370f7df..5492ce7f 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -10,7 +10,6 @@ import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { Event, ProcessName, SubProcessName, ctx } from '../../events/context.js'; import { ImportResult } from '../../orchestrators/import.js'; -import { sleep } from '../../utils/index.js'; import { SudoUtils } from '../../utils/sudo.js'; import { DefaultComponent } from '../components/default-component.js'; import { ProgressState, ProgressStatus } from '../components/progress/progress-display.js'; @@ -102,6 +101,7 @@ export class DefaultReporter implements Reporter { displayImportResult(importResult: ImportResult): void { this.updateRenderState(RenderStatus.DISPLAY_IMPORT_RESULT, importResult); + store.set(store.progressState, null); } async promptSudo(pluginName: string, data: SudoRequestData, secureMode: boolean): Promise { @@ -123,6 +123,10 @@ export class DefaultReporter implements Reporter { this.progressState = null; } + displayMessage(message: string) { + this.updateRenderState(RenderStatus.DISPLAY_MESSAGE, message); + } + async promptConfirmation(message: string): Promise { const result = await this.updateStateAndAwaitEvent( () => this.updateRenderState(RenderStatus.PROMPT_CONFIRMATION, message), @@ -147,11 +151,6 @@ export class DefaultReporter implements Reporter { return result } - async displayApplyComplete(messages: string[]): Promise { - this.updateRenderState(RenderStatus.APPLY_COMPLETE, messages); - await sleep(100); // This gives the renderer enough time to complete before the prompt exits - } - displayFileModification(diff: string) { this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); } diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index b7289265..55c493c3 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -1,10 +1,8 @@ import { SudoRequestData , SudoRequestResponseData } from 'codify-schemas'; import { Plan } from '../../entities/plan.js'; -import { ImportResult, RequiredParameters, UserSuppliedParameters } from '../../orchestrators/import.js'; -import { DebugReporter } from './debug-reporter.js'; +import { ImportResult } from '../../orchestrators/import.js'; import { DefaultReporter } from './default-reporter.js'; -import { PlainReporter } from './plain-reporter.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { ResourceConfig } from '../../entities/resource-config.js'; @@ -41,8 +39,6 @@ export enum PromptType { } export interface Reporter { - displayApplyComplete(message: string[]): Promise | void; - displayPlan(plan: Plan): void promptConfirmation(message: string): Promise @@ -56,6 +52,8 @@ export interface Reporter { displayImportResult(importResult: ImportResult): void; displayFileModification(diff: string): void + + displayMessage(message: string): void } export enum ReporterType { diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index e7399601..381b449a 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -1,4 +1,4 @@ -import { atom, createStore, getDefaultStore, Setter, Getter, Atom, WritableAtom } from 'jotai' +import { atom, getDefaultStore, Atom, WritableAtom } from 'jotai' import { ProgressState } from '../components/progress/progress-display.js'; @@ -15,8 +15,8 @@ export enum RenderStatus { IMPORT_PROMPT, PROMPT_CONFIRMATION, PROMPT_OPTIONS, - APPLY_COMPLETE, SUDO_PROMPT, + DISPLAY_MESSAGE } export const store = new class { From f6adbcd984fdae510f6fac8483453be75104fc98 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 7 Feb 2025 22:33:59 -0500 Subject: [PATCH 30/54] feat: Added messaging for all paths in import. Added back resource config display when user checks "no" for saving. --- src/orchestrators/import.ts | 8 +++++++- src/ui/components/default-component.tsx | 4 ++-- src/ui/components/import/import-result.tsx | 16 ++++++++++++++-- src/ui/reporters/default-reporter.tsx | 4 ++-- src/ui/reporters/reporter.ts | 2 +- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 64e095b7..2306ec34 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -73,7 +73,7 @@ export class ImportOrchestrator { const importResult = await ImportOrchestrator.getImportedConfigs(pluginManager, valuesToImport) ctx.processFinished(ProcessName.IMPORT) - reporter.displayImportResult(importResult); + reporter.displayImportResult(importResult, false); const additionalResourceInfo = await pluginManager.getMultipleResourceInfo(project.resourceConfigs.map((r) => r.type)); resourceInfoList.push(...additionalResourceInfo); @@ -151,7 +151,13 @@ ${JSON.stringify(unsupportedTypeIds)}`); } else if (promptResult === 'In a new file') { const newFileName = await ImportOrchestrator.generateNewImportFileName(); await ImportOrchestrator.saveNewFile(newFileName, importResult); + } else if (promptResult === 'No') { + reporter.displayImportResult(importResult, true); + reporter.displayMessage('\n🎉 Imported completed 🎉') + + await sleep(100); + process.exit(0); } } diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 21ea7efe..db1ddbe0 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -95,8 +95,8 @@ export function DefaultComponent(props: { } { renderStatus === RenderStatus.DISPLAY_IMPORT_RESULT && ( - { - (importResult, idx) => + { + (renderData, idx) => } ) } diff --git a/src/ui/components/import/import-result.tsx b/src/ui/components/import/import-result.tsx index 34417854..31065e8a 100644 --- a/src/ui/components/import/import-result.tsx +++ b/src/ui/components/import/import-result.tsx @@ -5,14 +5,15 @@ import React from 'react'; import { ImportResult } from '../../../orchestrators/import.js'; export function ImportResultComponent(props: { - importResult: ImportResult + importResult: ImportResult; + showConfigs: boolean }) { const { result, errors } = props.importResult return { - result.length > 0 && ( + result.length > 0 && !props.showConfigs && ( Successfully imported the following configs: { @@ -23,6 +24,17 @@ export function ImportResultComponent(props: { ) } + { + props.showConfigs && ( + + + Codify Import + +
+ { JSON.stringify(result.map((r) => r.raw), null, 2)} +
+ ) + } { errors.length > 0 && ( diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 5492ce7f..62313a6e 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -99,8 +99,8 @@ export class DefaultReporter implements Reporter { } } - displayImportResult(importResult: ImportResult): void { - this.updateRenderState(RenderStatus.DISPLAY_IMPORT_RESULT, importResult); + displayImportResult(importResult: ImportResult, showConfigs: boolean): void { + this.updateRenderState(RenderStatus.DISPLAY_IMPORT_RESULT, { importResult, showConfigs }); store.set(store.progressState, null); } diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 55c493c3..ebdac3bf 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -49,7 +49,7 @@ export interface Reporter { promptUserForValues(resources: Array, promptType: PromptType): Promise; - displayImportResult(importResult: ImportResult): void; + displayImportResult(importResult: ImportResult, showConfigs: boolean): void; displayFileModification(diff: string): void From 6717f38d5c80a814678c2e120456bc584790524e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 9 Feb 2025 11:06:24 -0500 Subject: [PATCH 31/54] feat: Added wild card matching for import --- src/orchestrators/destroy.ts | 6 +-- src/orchestrators/import.ts | 45 ++++++++++++++-- src/orchestrators/initialize.ts | 6 +-- src/orchestrators/plan.ts | 6 +-- src/plugins/plugin-manager.ts | 53 +----------------- src/utils/wild-card-match.ts | 54 +++++++++++++++++++ .../initialize/initialize.test.ts | 4 +- 7 files changed, 108 insertions(+), 66 deletions(-) create mode 100644 src/utils/wild-card-match.ts diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index bb9e26af..52150227 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -23,7 +23,7 @@ export class DestroyOrchestrator { ctx.processStarted(ProcessName.DESTROY) - const { dependencyMap, pluginManager, project } = await InitializeOrchestrator.run({ + const { typeIdsToDependenciesMap, pluginManager, project } = await InitializeOrchestrator.run({ ...args, allowEmptyProject: true, transformProject(project) { @@ -42,10 +42,10 @@ export class DestroyOrchestrator { } }, reporter); - await DestroyOrchestrator.validate(project, pluginManager, dependencyMap) + await DestroyOrchestrator.validate(project, pluginManager, typeIdsToDependenciesMap) const uninstallProject = project.toDestroyProject() - uninstallProject.resolveDependenciesAndCalculateEvalOrder(dependencyMap); + uninstallProject.resolveDependenciesAndCalculateEvalOrder(typeIdsToDependenciesMap); const plan = await ctx.subprocess(ProcessName.PLAN, () => pluginManager.plan(uninstallProject) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 2306ec34..8645f631 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -10,6 +10,7 @@ import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; import { FileModificationCalculator, ModificationType } from '../utils/file-modification-calculator.js'; import { sleep } from '../utils/index.js'; +import { wildCardMatch } from '../utils/wild-card-match.js'; import { InitializeOrchestrator } from './initialize.js'; export type RequiredParameters = Map; @@ -51,16 +52,18 @@ export class ImportOrchestrator { ctx.processStarted(ProcessName.IMPORT) - const { dependencyMap, pluginManager, project } = await InitializeOrchestrator.run( + const { typeIdsToDependenciesMap, pluginManager, project } = await InitializeOrchestrator.run( { ...args, allowEmptyProject: true }, reporter ); - await ImportOrchestrator.validate(typeIds, project, pluginManager, dependencyMap) - const resourceInfoList = await pluginManager.getMultipleResourceInfo(typeIds); + const matchedTypes = this.matchTypeIds(typeIds, [...typeIdsToDependenciesMap.keys()]) + await ImportOrchestrator.validate(matchedTypes, project, pluginManager, typeIdsToDependenciesMap); + + const resourceInfoList = await pluginManager.getMultipleResourceInfo(matchedTypes); + // Figure out which resources we need to prompt the user for additional info (based on the resource info) const [noPrompt, askPrompt] = resourceInfoList.reduce((result, info) => { info.getRequiredParameters().length === 0 ? result[0].push(info) : result[1].push(info); - return result; }, [[], []]) @@ -115,6 +118,39 @@ export class ImportOrchestrator { } } + private static matchTypeIds(typeIds: string[], validTypeIds: string[]): string[] { + const result: string[] = []; + const unsupportedTypeIds: string[] = []; + + for (const typeId of typeIds) { + if (!typeId.includes('*') && !typeId.includes('?')) { + const matched = validTypeIds.includes(typeId); + if (!matched) { + unsupportedTypeIds.push(typeId); + continue; + } + + result.push(typeId) + continue; + } + + const matched = validTypeIds.filter((valid) => wildCardMatch(valid, typeId)) + if (matched.length === 0) { + unsupportedTypeIds.push(typeId); + continue; + } + + result.push(...matched); + } + + if (unsupportedTypeIds.length > 0) { + throw new Error(`The following resources cannot be imported. No plugins found that support the following types: +${JSON.stringify(unsupportedTypeIds)}`); + } + + return result; + } + private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { ctx.subprocessStarted(SubProcessName.VALIDATE) @@ -237,3 +273,4 @@ ${JSON.stringify(unsupportedTypeIds)}`); }) } } + diff --git a/src/orchestrators/initialize.ts b/src/orchestrators/initialize.ts index c1864f2f..67804263 100644 --- a/src/orchestrators/initialize.ts +++ b/src/orchestrators/initialize.ts @@ -16,7 +16,7 @@ export interface InitializeArgs { } export interface InitializationResult { - dependencyMap: DependencyMap + typeIdsToDependenciesMap: DependencyMap pluginManager: PluginManager, project: Project, } @@ -37,10 +37,10 @@ export class InitializeOrchestrator { ctx.subprocessStarted(SubProcessName.INITIALIZE_PLUGINS) const pluginManager = new PluginManager(); - const dependencyMap = await pluginManager.initialize(project, args.secure); + const typeIdsToDependenciesMap = await pluginManager.initialize(project, args.secure); ctx.subprocessFinished(SubProcessName.INITIALIZE_PLUGINS) - return { dependencyMap, pluginManager, project }; + return { typeIdsToDependenciesMap, pluginManager, project }; } private static async parse( diff --git a/src/orchestrators/plan.ts b/src/orchestrators/plan.ts index fe14af52..fbaf7af7 100644 --- a/src/orchestrators/plan.ts +++ b/src/orchestrators/plan.ts @@ -21,14 +21,14 @@ export class PlanOrchestrator { static async run(args: PlanArgs, reporter: Reporter): Promise { ctx.processStarted(ProcessName.PLAN) - const { dependencyMap, pluginManager, project } = await InitializeOrchestrator.run({ + const { typeIdsToDependenciesMap, pluginManager, project } = await InitializeOrchestrator.run({ ...args, }, reporter); await createStartupShellScriptsIfNotExists(); - await PlanOrchestrator.validate(project, pluginManager, dependencyMap) - project.resolveDependenciesAndCalculateEvalOrder(dependencyMap); + await PlanOrchestrator.validate(project, pluginManager, typeIdsToDependenciesMap) + project.resolveDependenciesAndCalculateEvalOrder(typeIdsToDependenciesMap); project.addXCodeToolsConfig(); // We have to add xcode-tools config always since almost every resource depends on it const plan = await PlanOrchestrator.plan(project, pluginManager); diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index b86d529e..299967c4 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -54,25 +54,11 @@ export class PluginManager { ); } - async getResourceInfo(type: string): Promise { - const pluginName = this.resourceToPluginMapping.get(type); - if (!pluginName) { - throw new Error(`Unable to find plugin for resource: ${type}`); - } - - const plugin = this.plugins.get(pluginName) - if (!plugin) { - throw new Error(`Unable to find plugin for resource ${type}`); - } - - return plugin.getResourceInfo(type); - } - async getMultipleResourceInfo(typeIds: string[]): Promise { - return Promise.all(typeIds.map((type) => this.getResourceInfoV2(type))) + return Promise.all(typeIds.map((type) => this.getResourceInfo(type))) } - async getResourceInfoV2(type: string): Promise { + async getResourceInfo(type: string): Promise { const pluginName = this.resourceToPluginMapping.get(type); if (!pluginName) { throw new Error(`Unable to find plugin for resource: ${type}`); @@ -143,41 +129,6 @@ export class PluginManager { } } - async getRequiredParameters( - typeIds: string[], - ): Promise { - const allRequiredParameters = new Map(); - for (const type of typeIds) { - const resourceInfo = await this.getResourceInfo(type); - - const { schema } = resourceInfo; - if (!schema) { - continue; - } - - const requiredParameterNames = resourceInfo.import?.requiredParameters; - if (!requiredParameterNames || requiredParameterNames.length === 0) { - continue; - } - - requiredParameterNames - .forEach((name) => { - if (!allRequiredParameters.has(type)) { - allRequiredParameters.set(type, []); - } - - const schemaInfo = (schema.properties as any)[name]; - - allRequiredParameters.get(type)!.push({ - name, - type: schemaInfo.type ?? null - }) - }); - } - - return allRequiredParameters; - } - private async resolvePlugins(project: Project | null): Promise { const pluginDefinitions: Record = { ...DEFAULT_PLUGINS, diff --git a/src/utils/wild-card-match.ts b/src/utils/wild-card-match.ts new file mode 100644 index 00000000..856a41b6 --- /dev/null +++ b/src/utils/wild-card-match.ts @@ -0,0 +1,54 @@ +// JavaScript program for wild card matching using single traversal. From: https://www.geeksforgeeks.org/wildcard-pattern-matching/ +// O(n) time and O(1) space complexity +export function wildCardMatch(txt: string, pat: string) { + const n = txt.length; + const m = pat.length; + let i = 0, j = 0, startIndex = -1, match = 0; + + while (i < n) { + + // Characters match or '?' in pattern matches + // any character. + if (j < m && (pat[j] === '?' || pat[j] === txt[i])) { + i++; + j++; + } + + else if (j < m && pat[j] === '*') { + + // Wildcard character '*', mark the current + // position in the pattern and the text as a + // proper match. + startIndex = j; + match = i; + j++; + } + + else if (startIndex !== -1) { + + // No match, but a previous wildcard was found. + // Backtrack to the last '*' character position + // and try for a different match. + j = startIndex + 1; + match++; + i = match; + } + + else { + + // If none of the above cases comply, the + // pattern does not match. + return false; + } + } + + // Consume any remaining '*' characters in the given + // pattern. + while (j < m && pat[j] === '*') { + j++; + } + + // If we have reached the end of both the pattern and + // the text, the pattern matches the text. + return j === m; +} diff --git a/test/orchestrator/initialize/initialize.test.ts b/test/orchestrator/initialize/initialize.test.ts index e6ad6725..beb1fdb8 100644 --- a/test/orchestrator/initialize/initialize.test.ts +++ b/test/orchestrator/initialize/initialize.test.ts @@ -64,7 +64,7 @@ describe('Parser integration tests', () => { const cwdSpy = vi.spyOn(process, 'cwd'); cwdSpy.mockReturnValue(folder); - const { project, pluginManager, dependencyMap } = await InitializeOrchestrator.run({}, reporter); + const { project, pluginManager, typeIdsToDependenciesMap } = await InitializeOrchestrator.run({}, reporter); console.log(project); expect(project).toMatchObject({ @@ -106,7 +106,7 @@ describe('Parser integration tests', () => { const cwdSpy = vi.spyOn(process, 'cwd'); cwdSpy.mockReturnValue(innerFolder); - const { project, pluginManager, dependencyMap } = await InitializeOrchestrator.run({}, reporter); + const { project, pluginManager, typeIdsToDependenciesMap } = await InitializeOrchestrator.run({}, reporter); console.log(project); expect(project).toMatchObject({ From 7e4db1914f1b1503e274e37f3d59250aa78ea4c2 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 10 Feb 2025 20:26:05 -0500 Subject: [PATCH 32/54] feat: Added default values for resources that already exist in the project and fixed bug with yoga memory management for large imports --- codify.json | 211 +++++++++++++++++++++++++- src/entities/project.ts | 12 +- src/entities/resource-info.ts | 60 +++++--- src/orchestrators/import.ts | 38 +++-- src/ui/components/import/index.tsx | 31 ---- src/ui/reporters/default-reporter.tsx | 9 +- 6 files changed, 286 insertions(+), 75 deletions(-) delete mode 100644 src/ui/components/import/index.tsx diff --git a/codify.json b/codify.json index be8b3dc5..4c07717b 100644 --- a/codify.json +++ b/codify.json @@ -12,13 +12,212 @@ "hashicorp/tap", "homebrew/services" ], - "formulae": [ - "asciinema" - ], "casks": [ - "firefox" + "android-commandlinetools", + "android-studio", + "mitmproxy" + ], + "formulae": [ + "ack", + "asciinema", + "ca-certificates", + "cairo", + "cirrus", + "expect", + "fontconfig", + "freetype", + "fribidi", + "gettext", + "ghostscript", + "giflib", + "git-lfs", + "glib", + "graphite2", + "groff", + "harfbuzz", + "hyperfine", + "icu4c", + "jasper", + "jbig2dec", + "jenv", + "jpeg-turbo", + "jq", + "krb5", + "leptonica", + "libarchive", + "libb2", + "libidn", + "libpaper", + "libpng", + "libpq", + "libtiff", + "libx11", + "libxau", + "libxcb", + "libxdmcp", + "libxext", + "libxfixes", + "libxi", + "libxrender", + "little-cms2", + "lz4", + "lzo", + "mas", + "mpdecimal", + "netpbm", + "oniguruma", + "openjdk@11", + "openjdk@17", + "openjpeg", + "openssl@3", + "packer", + "pango", + "pcre2", + "pgcli", + "pixman", + "postgresql@14", + "psutils", + "python-packaging", + "python@3.12", + "readline", + "softnet", + "sqlite", + "sshpass", + "tart", + "tcl-tk", + "tesseract", + "uchardet", + "webp", + "xorgproto", + "xz", + "zstd" + ] + }, + {"version":"1.10.5","type":"terraform"}, + { "type": "alias", "alias": "gcdsdd", "value": "git clone" }, + { + "type": "ssh-config", + "hosts": [ + { + "Host": "192.168.64.94", + "HostName": "192.168.64.94", + "User": "admin" + }, + { + "Host": "192.168.2.48", + "HostName": "192.168.2.48", + "User": "pi" + }, + { + "Host": "*", + "AddKeysToAgent": "yes", + "IdentityFile": "~/.ssh/id_ed25519" + }, + { + "Host": "ec2", + "HostName": "54.82.78.202", + "User": "ec2-user", + "IdentityFile": "~/.ssh/ed25519" + }, + { + "Host": "ec2-2", + "HostName": "35.153.180.154", + "User": "ec2-user", + "IdentityFile": "~/.ssh/ed25519" + } ] }, - { "type": "terraform" }, - { "type": "alias", "alias": "gcdsdd", "value": "git clone" } + { + "type": "ssh-key", + "fileName": "id_ed25519", + "passphrase": "", + "folder": "/Users/kevinwang/.ssh", + "keyType": "ed25519" + }, + { + "type": "alias", + "alias": "gcc", + "value": "git commit -v" + }, + { + "type": "alias", + "alias": "gc", + "value": "git commit -v" + }, + { + "type": "pgcli" + }, + { + "type": "aws-cli" + }, + { + "type": "vscode", + "directory": "/Applications" + }, + { + "type": "xcode-tools" + }, + { + "type": "git-clone", + "autoVerifySSH": true, + "directory": "/Users/kevinwang/projects/codify", + "repository": "git@github.com:kevinwang5658/codify.git" + }, + { + "type": "git-lfs" + }, + { + "type": "pyenv", + "global": "3.11", + "pythonVersions": [ + "3.9.19", + "3.10.14", + "3.11.8", + "3.12.2" + ] + }, + { + "type": "git", + "email": "kevinwang5658@gmail.com", + "username": "kevinwang" + }, + { + "type": "android-studio", + "directory": "/Applications", + "version": "2023.3.1.20" + }, + { + "type": "nvm", + "global": "20.15.1", + "nodeVersions": [ + "iojs-2.5.0", + "18.20.3", + "20.15.0", + "20.15.1", + "22.4.1", + "23.3.0" + ] + }, + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17" + }, + { + "type": "aws-profile", + "region": "us-west-2", + "awsAccessKeyId": "AKIATCKATKL55TT5UZ7P", + "awsSecretAccessKey": "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", + "output": "json", + "profile": "codify" + } ] diff --git a/src/entities/project.ts b/src/entities/project.ts index ec3bcc0a..94e55799 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -123,7 +123,15 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return uninstallProject; } - findResource(type: string, name?: string): ResourceConfig | null { + findAll(type: string, name?: string): ResourceConfig[] { + return this.resourceConfigs.filter((r) => + name + ? r.isSame(type, name) + : r.type === type + ); + } + + findSpecific(type: string, name?: string): ResourceConfig | null { return this.resourceConfigs.find((r) => r.isSame(type, name)) ?? null; } @@ -157,7 +165,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`); if (invalidResults.length > 0) { const resourceErrors: PluginValidationErrorParams = invalidResults.map((r,) => ({ customErrorMessage: r.customValidationErrorMessage, - resource: this.findResource(r.resourceType, r.resourceName)!, + resource: this.findSpecific(r.resourceType, r.resourceName)!, schemaErrors: r.schemaValidationErrors, })) diff --git a/src/entities/resource-info.ts b/src/entities/resource-info.ts index 070353e4..40dbb284 100644 --- a/src/entities/resource-info.ts +++ b/src/entities/resource-info.ts @@ -1,5 +1,7 @@ import { GetResourceInfoResponseData } from 'codify-schemas'; +import { ResourceConfig } from './resource-config.js'; + interface ParameterInfo { name: string; type?: string; @@ -15,6 +17,8 @@ export class ResourceInfo implements GetResourceInfoResponseData { dependencies?: string[] | undefined; import?: { requiredParameters: null | string[]; } | undefined; + private parametersCache?: ParameterInfo[]; + private constructor() {} get description(): string | undefined { @@ -26,31 +30,47 @@ export class ResourceInfo implements GetResourceInfoResponseData { Object.assign(resourceInfo, data); return resourceInfo; } + + attachDefaultValues(resource: ResourceConfig): void { + const parameterInfo = this.getParameterInfo(); + parameterInfo.forEach((info) => { + const matchedParameter = resource.parameters[info.name]; + if (matchedParameter) { + info.value = matchedParameter; + } + }) + } getParameterInfo(): ParameterInfo[] { - const { schema } = this; - if (!schema || !schema.properties) { - return []; - } + if (!this.parametersCache) { + const { schema } = this; + if (!schema || !schema.properties) { + this.parametersCache = []; + return []; + } + + const { properties, required } = schema; + if (!properties || typeof properties !== 'object') { + this.parametersCache = []; + return []; + } + + this.parametersCache = Object.entries(properties) + .map(([propertyName, info]) => { + const isRequired = this.import?.requiredParameters?.some((name) => name === propertyName) + ?? (required as string[] | undefined)?.includes(propertyName) + ?? false; - const { properties, required } = schema; - if (!properties || typeof properties !== 'object') { - return []; + return { + name: propertyName, + type: info.type ?? null, + description: info.description, + isRequired + } + }); } - return Object.entries(properties) - .map(([propertyName, info]) => { - const isRequired = this.import?.requiredParameters?.some((name) => name === propertyName) - ?? (required as string[] | undefined)?.includes(propertyName) - ?? false; - - return { - name: propertyName, - type: info.type ?? null, - description: info.description, - isRequired - } - }) + return this.parametersCache; } getRequiredParameters(): ParameterInfo[] { diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 8645f631..c467e30b 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -61,19 +61,9 @@ export class ImportOrchestrator { await ImportOrchestrator.validate(matchedTypes, project, pluginManager, typeIdsToDependenciesMap); const resourceInfoList = await pluginManager.getMultipleResourceInfo(matchedTypes); - // Figure out which resources we need to prompt the user for additional info (based on the resource info) - const [noPrompt, askPrompt] = resourceInfoList.reduce((result, info) => { - info.getRequiredParameters().length === 0 ? result[0].push(info) : result[1].push(info); - return result; - }, [[], []]) - const userSupplied = await reporter.promptUserForValues(askPrompt, PromptType.IMPORT); - - const valuesToImport = [ - ...noPrompt.map((info) => new ResourceConfig({ type: info.type })), - ...userSupplied - ] - const importResult = await ImportOrchestrator.getImportedConfigs(pluginManager, valuesToImport) + const importParameters = await ImportOrchestrator.getImportParameters(reporter, project, resourceInfoList); + const importResult = await ImportOrchestrator.import(pluginManager, importParameters); ctx.processFinished(ProcessName.IMPORT) reporter.displayImportResult(importResult, false); @@ -84,7 +74,7 @@ export class ImportOrchestrator { await ImportOrchestrator.saveResults(reporter, importResult, project, resourceInfoList) } - static async getImportedConfigs( + static async import( pluginManager: PluginManager, resources: ResourceConfig[], ): Promise { @@ -165,6 +155,28 @@ ${JSON.stringify(unsupportedTypeIds)}`); ctx.subprocessFinished(SubProcessName.VALIDATE) } + private static async getImportParameters(reporter: Reporter, project: Project, resourceInfoList: ResourceInfo[]): Promise> { + // Figure out which resources we need to prompt the user for additional info (based on the resource info) + const [noPrompt, askPrompt] = resourceInfoList.reduce((result, info) => { + info.getRequiredParameters().length === 0 ? result[0].push(info) : result[1].push(info); + return result; + }, [[], []]) + + askPrompt.forEach((info) => { + const matchedResources = project.findAll(info.type); + if (matchedResources.length > 0) { + info.attachDefaultValues(matchedResources[0]); + } + }) + + const userSupplied = await reporter.promptUserForValues(askPrompt, PromptType.IMPORT); + + return [ + ...noPrompt.map((info) => new ResourceConfig({ type: info.type })), + ...userSupplied + ] + } + private static async saveResults(reporter: Reporter, importResult: ImportResult, project: Project, resourceInfoList: ResourceInfo[]): Promise { const projectExists = !project.isEmpty(); const multipleCodifyFiles = project.codifyFiles.length > 1; diff --git a/src/ui/components/import/index.tsx b/src/ui/components/import/index.tsx deleted file mode 100644 index cecdf212..00000000 --- a/src/ui/components/import/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Form, FormProps } from '@codifycli/ink-form'; -import React from 'react'; - -import { RequiredParameters } from '../../../orchestrators/import.js'; - -export function ImportParametersForm( - props: { requiredParameters: RequiredParameters, onSubmit?: (result: object) => void } -) { - const { requiredParameters, onSubmit } = props; - - const form: FormProps = { - form: { - title: 'codify import', - description: 'some parameters are required to continue import', - sections: [...requiredParameters.entries()].map(([resourceName, v]) => ({ - title: resourceName, - fields: v.map((resourceParameters) => ({ - type: resourceParameters.type, - name: resourceParameters.name, - label: resourceParameters.name, - required: true, - })), - })), - }, - } - - return
-} diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 62313a6e..ad119694 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -1,4 +1,4 @@ -import { FormReturnValue } from '@codifycli/ink-form'; +import { FormProps, FormReturnValue } from '@codifycli/ink-form'; import chalk from 'chalk'; import { SudoRequestData, SudoRequestResponseData } from 'codify-schemas'; import { render } from 'ink'; @@ -54,7 +54,7 @@ export class DefaultReporter implements Reporter { fullscreen() process.on('beforeExit', exitFullScreen); - const formProps = { + const formProps: FormProps = { form: { title: 'codify import', description: 'specify the resource to import', @@ -65,6 +65,7 @@ export class DefaultReporter implements Reporter { type: parameter.type, name: parameter.name, label: parameter.name, + initialValue: parameter.value, description: parameter.description, required: true, })), @@ -100,8 +101,10 @@ export class DefaultReporter implements Reporter { } displayImportResult(importResult: ImportResult, showConfigs: boolean): void { - this.updateRenderState(RenderStatus.DISPLAY_IMPORT_RESULT, { importResult, showConfigs }); store.set(store.progressState, null); + this.progressState = null; + + this.updateRenderState(RenderStatus.DISPLAY_IMPORT_RESULT, { importResult, showConfigs }); } async promptSudo(pluginName: string, data: SudoRequestData, secureMode: boolean): Promise { From 6f652df83af762672ca839820f5f2ff2e34ad5f9 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 11 Feb 2025 19:54:25 -0500 Subject: [PATCH 33/54] feat: Added ability to import into an existing codify file without any effort (codify import with no arguments). Fixed bug with memory access by limiting how many sub-progresses are shown. Fixed file bug with one resource updates --- src/orchestrators/import.ts | 145 +++++++++++++----- src/ui/components/default-component.tsx | 5 +- .../file-modification/FileModification.tsx | 21 ++- .../components/progress/progress-display.tsx | 17 +- src/ui/reporters/default-reporter.tsx | 3 +- src/ui/reporters/reporter.ts | 3 +- .../file-modification-calculator.test.ts | 70 +++++++++ src/utils/file-modification-calculator.ts | 17 +- 8 files changed, 221 insertions(+), 60 deletions(-) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index c467e30b..b95d53cd 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -9,9 +9,9 @@ import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; import { FileModificationCalculator, ModificationType } from '../utils/file-modification-calculator.js'; -import { sleep } from '../utils/index.js'; +import { groupBy, sleep } from '../utils/index.js'; import { wildCardMatch } from '../utils/wild-card-match.js'; -import { InitializeOrchestrator } from './initialize.js'; +import { InitializationResult, InitializeOrchestrator } from './initialize.js'; export type RequiredParameters = Map; export type UserSuppliedParameters = Map>; @@ -46,34 +46,70 @@ export class ImportOrchestrator { reporter: Reporter ) { const { typeIds } = args - if (typeIds.length === 0) { - throw new Error('At least one resource must be specified. Ex: "codify import homebrew"') - } - ctx.processStarted(ProcessName.IMPORT) - const { typeIdsToDependenciesMap, pluginManager, project } = await InitializeOrchestrator.run( + const initializationResult = await InitializeOrchestrator.run( { ...args, allowEmptyProject: true }, reporter ); - + const { project } = initializationResult; + + if ((!typeIds || typeIds.length === 0) && project.isEmpty()) { + throw new Error('At least one resource [type] must be specified. Ex: "codify import homebrew". Or the import command must be run in a directory with a valid codify file') + } + + if (!typeIds || typeIds.length === 0) { + await ImportOrchestrator.runExistingProject(reporter, initializationResult) + } else { + await ImportOrchestrator.runNewImport(typeIds, reporter, initializationResult) + } + } + + /** Import new resources. Type ids supplied. This will ask for any required parameters */ + static async runNewImport(typeIds: string[], reporter: Reporter, initializeResult: InitializationResult): Promise { + const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; + const matchedTypes = this.matchTypeIds(typeIds, [...typeIdsToDependenciesMap.keys()]) await ImportOrchestrator.validate(matchedTypes, project, pluginManager, typeIdsToDependenciesMap); const resourceInfoList = await pluginManager.getMultipleResourceInfo(matchedTypes); - - const importParameters = await ImportOrchestrator.getImportParameters(reporter, project, resourceInfoList); - const importResult = await ImportOrchestrator.import(pluginManager, importParameters); + const resourcesToImport = await ImportOrchestrator.getImportParameters(reporter, project, resourceInfoList); + const importResult = await ImportOrchestrator.import(pluginManager, resourcesToImport); ctx.processFinished(ProcessName.IMPORT) - reporter.displayImportResult(importResult, false); - const additionalResourceInfo = await pluginManager.getMultipleResourceInfo(project.resourceConfigs.map((r) => r.type)); - resourceInfoList.push(...additionalResourceInfo); + reporter.displayImportResult(importResult, false); + resourceInfoList.push(...(await pluginManager.getMultipleResourceInfo( + project.resourceConfigs.map((r) => r.type) + ))); await ImportOrchestrator.saveResults(reporter, importResult, project, resourceInfoList) } + /** Update an existing project. This will use the existing resources as the parameters (no user input required). */ + static async runExistingProject(reporter: Reporter, initializeResult: InitializationResult): Promise { + const { pluginManager, project } = initializeResult; + + await pluginManager.validate(project); + const importResult = await ImportOrchestrator.import(pluginManager, project.resourceConfigs); + + ctx.processFinished(ProcessName.IMPORT); + + const resourceInfoList = await pluginManager.getMultipleResourceInfo( + project.resourceConfigs.map((r) => r.type), + ); + + await ImportOrchestrator.updateExistingFiles( + reporter, + project, + importResult, + resourceInfoList, + project.codifyFiles[0], + ); + + return project.resourceConfigs; + } + static async import( pluginManager: PluginManager, resources: ResourceConfig[], @@ -90,7 +126,10 @@ export class ImportOrchestrator { if (response.result !== null && response.result.length > 0) { importedConfigs.push(...response ?.result - ?.map((r) => ResourceConfig.fromJson(r)) ?? [] + ?.map((r) => + // Keep the name on the resource if possible, this makes it easier to identify where the import came from + ResourceConfig.fromJson({ ...r, core: { ...r.core, name: resource.name } }) + ) ?? [] ); } else { errors.push(`Unable to import resource '${resource.type}', resource not found`); @@ -183,18 +222,20 @@ ${JSON.stringify(unsupportedTypeIds)}`); const promptResult = await reporter.promptOptions( '\nDo you want to save the results?', - [projectExists ? multipleCodifyFiles ? 'Update existing file (multiple found)' : `Update existing file (${project.codifyFiles})` : undefined, 'In a new file', 'No'].filter(Boolean) as string[] + [ + projectExists ? + multipleCodifyFiles ? `Update existing files (${project.codifyFiles})` : `Update existing file (${project.codifyFiles})` + : undefined, + 'In a new file', + 'No' + ].filter(Boolean) as string[] ) - if (promptResult === 'Update existing file (multiple found)') { - const file = await reporter.promptOptions( - '\nWhich file would you like to update?', - project.codifyFiles, - ) - await ImportOrchestrator.updateExistingFile(reporter, file, importResult, resourceInfoList); - - } else if (promptResult.startsWith('Update existing file')) { - await ImportOrchestrator.updateExistingFile(reporter, project.codifyFiles[0], importResult, resourceInfoList); + if (promptResult.startsWith('Update existing file')) { + const file = multipleCodifyFiles + ? await reporter.promptOptions('\nIf new resources are added, where to write them?', project.codifyFiles) + : project.codifyFiles[0]; + await ImportOrchestrator.updateExistingFiles(reporter, project, importResult, resourceInfoList, file); } else if (promptResult === 'In a new file') { const newFileName = await ImportOrchestrator.generateNewImportFileName(); @@ -209,42 +250,62 @@ ${JSON.stringify(unsupportedTypeIds)}`); } } - private static async updateExistingFile( + private static async updateExistingFiles( reporter: Reporter, - filePath: string, + existingProject: Project, importResult: ImportResult, - resourceInfoList: ResourceInfo[] + resourceInfoList: ResourceInfo[], + preferredFile: string, // File to write any new resources (unknown file path) ): Promise { - const existing = await CodifyParser.parse(filePath); - ImportOrchestrator.attachResourceInfo(importResult.result, resourceInfoList); - ImportOrchestrator.attachResourceInfo(existing.resourceConfigs, resourceInfoList); + const groupedResults = groupBy(importResult.result, (r) => + existingProject.findSpecific(r.type, r.name)?.sourceMapKey?.split('#')?.[0] ?? 'unknown' + ) - const modificationCalculator = new FileModificationCalculator(existing); - const result = modificationCalculator.calculate(importResult.result.map((resource) => ({ - modification: ModificationType.INSERT_OR_UPDATE, - resource - }))); + // New resources exists (they don't belong to any existing files) + if (groupedResults.unknown) { + groupedResults[preferredFile] = [ + ...groupedResults.unknown, + ...groupedResults[preferredFile], + ] + delete groupedResults.unknown; + } + + const diffs = await Promise.all(Object.entries(groupedResults).map(async ([filePath, imported]) => { + const existing = await CodifyParser.parse(filePath!); + ImportOrchestrator.attachResourceInfo(imported, resourceInfoList); + ImportOrchestrator.attachResourceInfo(existing.resourceConfigs, resourceInfoList); + + const modificationCalculator = new FileModificationCalculator(existing); + const modification = modificationCalculator.calculate(imported.map((resource) => ({ + modification: ModificationType.INSERT_OR_UPDATE, + resource + }))); + + return { file: filePath!, modification }; + })); // No changes to be made - if (result.diff === '') { + if (diffs.every((d) => d.modification.diff === '')) { reporter.displayMessage('\nNo changes are needed! Exiting...') // Wait for the message to display before we exit await sleep(100); - process.exit(0); + return; } - reporter.displayFileModification(result.diff); - const shouldSave = await reporter.promptConfirmation(`Save to file (${filePath})?`); + reporter.displayFileModifications(diffs); + const shouldSave = await reporter.promptConfirmation('Save the changes?'); if (!shouldSave) { reporter.displayMessage('\nSkipping save! Exiting...'); // Wait for the message to display before we exit await sleep(100); - process.exit(0); + return; } - await FileUtils.writeFile(filePath, result.newFile); + for (const diff of diffs) { + await FileUtils.writeFile(diff.file, diff.modification.newFile); + } reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉'); diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index db1ddbe0..887a6af2 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -8,6 +8,7 @@ import React, { useLayoutEffect, useState } from 'react'; import { Plan } from '../../entities/plan.js'; import { ImportResult } from '../../orchestrators/import.js'; +import { FileModificationResult } from '../../utils/file-modification-calculator.js'; import { RenderEvent } from '../reporters/reporter.js'; import { RenderStatus, store } from '../store/index.js'; import { FileModificationDisplay } from './file-modification/FileModification.js'; @@ -102,8 +103,8 @@ export function DefaultComponent(props: { } { renderStatus === RenderStatus.DISPLAY_FILE_MODIFICATION && ( - { - (diff, idx) => + ]}>{ + (data, idx) => } ) } diff --git a/src/ui/components/file-modification/FileModification.tsx b/src/ui/components/file-modification/FileModification.tsx index 48b890e0..2a0c6769 100644 --- a/src/ui/components/file-modification/FileModification.tsx +++ b/src/ui/components/file-modification/FileModification.tsx @@ -1,15 +1,26 @@ import { Box, Text } from 'ink'; import React from 'react'; +import { FileModificationResult } from '../../../utils/file-modification-calculator.js'; + export function FileModificationDisplay(props: { - diff: string, + data: Array<{ file: string; modification: FileModificationResult }>, }) { return - File Modification + File Modifications - The following changes will be made - - {props.diff} + { + props.data + .filter(({ modification }) => modification.diff) + .map(({ file, modification }, idx) => + + File {file} + + {modification.diff} + + + ) + } } diff --git a/src/ui/components/progress/progress-display.tsx b/src/ui/components/progress/progress-display.tsx index d53ddef5..f149951b 100644 --- a/src/ui/components/progress/progress-display.tsx +++ b/src/ui/components/progress/progress-display.tsx @@ -44,7 +44,7 @@ export function ProgressDisplay( : {label} } - + } @@ -59,10 +59,15 @@ export function SubProgressDisplay( const { subProgresses, emitter, eventType } = props; return <>{ - subProgresses && subProgresses.map((s, idx) => - s.status === ProgressStatus.IN_PROGRESS - ? - : {s.label} - ) + subProgresses && subProgresses + // Sort the subprocesses so that in progress ones are always at the bottom + .sort((a, b) => a.status === ProgressStatus.IN_PROGRESS ? 1 : -1) + // Limit the max number of subprocesses to 7. Too many doesn't look good and causes a wasm memory access error (yoga) + .slice(Math.max(0, subProgresses.length - 7), subProgresses.length) + .map((s, idx) => + s.status === ProgressStatus.IN_PROGRESS + ? + : {s.label} + ) } } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index ad119694..f1bc9172 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -10,6 +10,7 @@ import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { Event, ProcessName, SubProcessName, ctx } from '../../events/context.js'; import { ImportResult } from '../../orchestrators/import.js'; +import { FileModificationResult } from '../../utils/file-modification-calculator.js'; import { SudoUtils } from '../../utils/sudo.js'; import { DefaultComponent } from '../components/default-component.js'; import { ProgressState, ProgressStatus } from '../components/progress/progress-display.js'; @@ -154,7 +155,7 @@ export class DefaultReporter implements Reporter { return result } - displayFileModification(diff: string) { + displayFileModifications(diff: Array<{ file: string; modification: FileModificationResult}>) { this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); } diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index ebdac3bf..c6af6769 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -5,6 +5,7 @@ import { ImportResult } from '../../orchestrators/import.js'; import { DefaultReporter } from './default-reporter.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { ResourceConfig } from '../../entities/resource-config.js'; +import { FileModificationResult } from '../../utils/file-modification-calculator.js'; export enum RenderEvent { LOG = 'log', @@ -51,7 +52,7 @@ export interface Reporter { displayImportResult(importResult: ImportResult, showConfigs: boolean): void; - displayFileModification(diff: string): void + displayFileModifications(diff: Array<{ file: string, modification: FileModificationResult }>): void displayMessage(message: string): void } diff --git a/src/utils/file-modification-calculator.test.ts b/src/utils/file-modification-calculator.test.ts index 081cc5a9..f3479448 100644 --- a/src/utils/file-modification-calculator.test.ts +++ b/src/utils/file-modification-calculator.test.ts @@ -447,6 +447,76 @@ describe('File modification calculator tests', () => { console.log(result.diff) }) + it('Can handle an update with a single element', async () => { + const existingFile = +`[ + { + "type": "jenv", + "add": [ + "system", + "11", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17" + } +] +` + generateTestFile(existingFile); + + const project = await CodifyParser.parse(defaultPath) + project.resourceConfigs.forEach((r) => { + r.attachResourceInfo(generateResourceInfo(r.type, [])) + }); + + const modifiedResource = new ResourceConfig({ + "type": "jenv", + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17" + }) + modifiedResource.attachResourceInfo(generateResourceInfo('jenv')) + + const calculator = new FileModificationCalculator(project); + const result = await calculator.calculate([{ + modification: ModificationType.INSERT_OR_UPDATE, + resource: modifiedResource, + }]) + + // TODO: The result is currently wrong need to fix + console.log(result); + console.log(result.diff); + + expect(result.newFile).to.eq( + '[\n' + + ' {\n' + + ' "type": "jenv",\n' + + ' "add": [\n' + + ' "system",\n' + + ' "11",\n' + + ' "11.0",\n' + + ' "11.0.24",\n' + + ' "17",\n' + + ' "17.0.12",\n' + + ' "openjdk64-11.0.24",\n' + + ' "openjdk64-17.0.12"\n' + + ' ],\n' + + ' "global": "17"\n' + + ' }\n' + + ']\n', + ) + }) + afterEach(() => { vi.resetAllMocks(); }) diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index c7d413bc..be3870ed 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -67,17 +67,18 @@ export class FileModificationCalculator { const duplicateSourceKey = existing.sourceMapKey?.split('#').at(1)!; const sourceIndex = Number.parseInt(duplicateSourceKey.split('/').at(1)!) + const isOnly = this.totalConfigLength === 1; if (modified.modification === ModificationType.DELETE) { - newFile = this.remove(newFile, this.sourceMap, sourceIndex); + newFile = this.remove(newFile, this.sourceMap, sourceIndex, isOnly); this.totalConfigLength -= 1; continue; } // Update an existing resource - newFile = this.remove(newFile, this.sourceMap, sourceIndex); - newFile = this.update(newFile, modified.resource, existing, this.sourceMap, sourceIndex); + newFile = this.remove(newFile, this.sourceMap, sourceIndex, isOnly); + newFile = this.update(newFile, modified.resource, existing, this.sourceMap, sourceIndex, isOnly); } // Insert new resources @@ -154,7 +155,13 @@ export class FileModificationCalculator { file: string, sourceMap: SourceMap, sourceIndex: number, + isOnly: boolean, ): string { + // The element being removed is the only element left, + if (isOnly) { + return '[]'; + } + const isLast = sourceIndex === this.totalConfigLength - 1; const isFirst = sourceIndex === 0; @@ -182,6 +189,7 @@ export class FileModificationCalculator { existing: ResourceConfig, sourceMap: SourceMap, sourceIndex: number, + isOnly: boolean, ): string { // Updates: for now let's remove and re-add the entire object. Only two formatting availalbe either same line or multi-line const { value, valueEnd } = this.sourceMap.lookup(`/${sourceIndex}`)!; @@ -197,6 +205,9 @@ export class FileModificationCalculator { content = this.updateParamsToOnelineIfNeeded(content, sourceMap, sourceIndex); content = content.split(/\n/).map((l) => `${this.indentString}${l}`).join('\n'); + if (isOnly) { + return `[\n${content}\n]`; + } content = isFirst ? `\n${content},` : `,\n${content}` return this.splice(file, start?.position!, 0, content); From 98c581c7e18d74b1eff26b5778d7d85371f8245d Mon Sep 17 00:00:00 2001 From: kevinwang Date: Tue, 11 Feb 2025 22:58:56 -0500 Subject: [PATCH 34/54] fix: Fixed most tests --- src/orchestrators/destroy.ts | 4 +- src/orchestrators/import.ts | 6 +-- src/ui/components/default-component.test.tsx | 7 ---- src/utils/file-modification-calculator.ts | 6 ++- test/orchestrator/apply/apply.test.ts | 4 +- .../initialize/initialize.test.ts | 10 ++++- test/orchestrator/mocks/reporter.ts | 39 +++++++++++-------- 7 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index 52150227..c270a804 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -69,7 +69,9 @@ export class DestroyOrchestrator { pluginManager.apply(uninstallProject, filteredPlan) ) - await reporter.displayApplyComplete([]); + await reporter.displayMessage(` +🎉 Finished applying 🎉 +Open a new terminal or source '.zshrc' for the new changes to be reflected`); } private static async validate(project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise { diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index b95d53cd..5fd10ea4 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -66,7 +66,7 @@ export class ImportOrchestrator { } /** Import new resources. Type ids supplied. This will ask for any required parameters */ - static async runNewImport(typeIds: string[], reporter: Reporter, initializeResult: InitializationResult): Promise { + static async runNewImport(typeIds: string[], reporter: Reporter, initializeResult: InitializationResult): Promise { const { project, pluginManager, typeIdsToDependenciesMap } = initializeResult; const matchedTypes = this.matchTypeIds(typeIds, [...typeIdsToDependenciesMap.keys()]) @@ -87,7 +87,7 @@ export class ImportOrchestrator { } /** Update an existing project. This will use the existing resources as the parameters (no user input required). */ - static async runExistingProject(reporter: Reporter, initializeResult: InitializationResult): Promise { + static async runExistingProject(reporter: Reporter, initializeResult: InitializationResult): Promise { const { pluginManager, project } = initializeResult; await pluginManager.validate(project); @@ -106,8 +106,6 @@ export class ImportOrchestrator { resourceInfoList, project.codifyFiles[0], ); - - return project.resourceConfigs; } static async import( diff --git a/src/ui/components/default-component.test.tsx b/src/ui/components/default-component.test.tsx index b5a09063..ccc53c84 100644 --- a/src/ui/components/default-component.test.tsx +++ b/src/ui/components/default-component.test.tsx @@ -53,11 +53,4 @@ describe('DefaultComponent', () => { expect(lastFrame()).toContain('Password:'); }); - - it('renders import parameter form when renderStatus is IMPORT_PROMPT', () => { - store.set(store.renderState, { status: RenderStatus.IMPORT_PROMPT, data: new Map() }); - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Mock Import Parameters Form'); - }); }); diff --git a/src/utils/file-modification-calculator.ts b/src/utils/file-modification-calculator.ts index be3870ed..b988a2a5 100644 --- a/src/utils/file-modification-calculator.ts +++ b/src/utils/file-modification-calculator.ts @@ -90,8 +90,10 @@ export class FileModificationCalculator { newFile = this.insert(newFile, newResourcesToInsert, insertionIndex); const lastCharacterIndex = this.existingFile.contents.lastIndexOf(']') - const ending = this.existingFile.contents.slice(Math.min(lastCharacterIndex + 1, this.existingFile.contents.length - 1)); - newFile += ending; + if (lastCharacterIndex < this.existingFile.contents.length - 1) { + const ending = this.existingFile.contents.slice(lastCharacterIndex + 1); + newFile += ending; + } return { newFile: newFile, diff --git a/test/orchestrator/apply/apply.test.ts b/test/orchestrator/apply/apply.test.ts index 5b961af8..df896e62 100644 --- a/test/orchestrator/apply/apply.test.ts +++ b/test/orchestrator/apply/apply.test.ts @@ -32,7 +32,7 @@ describe('Apply orchestrator tests', () => { }); const applyConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); - const applyCompleteSpy = vi.spyOn(reporter, 'displayApplyComplete'); + const applyCompleteSpy = vi.spyOn(reporter, 'displayMessage'); console.log(MockOs.get('xcode-tools')) expect(MockOs.get('mock')).to.be.undefined; @@ -69,7 +69,7 @@ describe('Apply orchestrator tests', () => { }); const applyConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); - const applyCompleteSpy = vi.spyOn(reporter, 'displayApplyComplete'); + const applyCompleteSpy = vi.spyOn(reporter, 'displayMessage'); MockOs.destroy('xcode-tools'); expect(MockOs.get('xcode-tools')).to.be.undefined; diff --git a/test/orchestrator/initialize/initialize.test.ts b/test/orchestrator/initialize/initialize.test.ts index beb1fdb8..6ab486df 100644 --- a/test/orchestrator/initialize/initialize.test.ts +++ b/test/orchestrator/initialize/initialize.test.ts @@ -68,7 +68,10 @@ describe('Parser integration tests', () => { console.log(project); expect(project).toMatchObject({ - path: folder, + codifyFiles: expect.arrayContaining([ + path.resolve(folder, 'home.codify.json'), + path.resolve(folder, 'home-2.codify.json') + ]), resourceConfigs: expect.arrayContaining([ expect.objectContaining({ type: 'customType1', @@ -110,7 +113,10 @@ describe('Parser integration tests', () => { console.log(project); expect(project).toMatchObject({ - path: folder, + codifyFiles: expect.arrayContaining([ + path.resolve(folder, 'home.codify.json'), + path.resolve(folder, 'home-2.codify.json') + ]), resourceConfigs: expect.arrayContaining([ expect.objectContaining({ type: 'customType1', diff --git a/test/orchestrator/mocks/reporter.ts b/test/orchestrator/mocks/reporter.ts index a4e2f55f..01231403 100644 --- a/test/orchestrator/mocks/reporter.ts +++ b/test/orchestrator/mocks/reporter.ts @@ -1,17 +1,22 @@ import { SpawnStatus, SudoRequestData, SudoRequestResponseData } from 'codify-schemas'; import { Plan } from '../../../src/entities/plan.js'; -import { ImportResult, RequiredParameters, UserSuppliedParameters } from '../../../src/orchestrators/import.js'; +import { ResourceConfig } from '../../../src/entities/resource-config.js'; +import { ResourceInfo } from '../../../src/entities/resource-info.js'; +import { ImportResult } from '../../../src/orchestrators/import.js'; import { prettyFormatPlan } from '../../../src/ui/plan-pretty-printer.js'; -import { Reporter } from '../../../src/ui/reporters/reporter.js'; +import { PromptType, Reporter } from '../../../src/ui/reporters/reporter.js'; +import { FileModificationResult } from '../../../src/utils/file-modification-calculator.js'; export interface MockReporterConfig { validatePlan?: (plan: Plan) => Promise | void; - validateApplyComplete?: (message: string[]) => Promise | void; + validateMessage?: (message: string) => Promise | void; validateImport?: (result: ImportResult) => Promise | void; promptApplyConfirmation?: () => boolean; - askRequiredParametersForImport?: (requiredParameters: RequiredParameters) => Promise | UserSuppliedParameters; + promptOptions?: (message: string, options: string[]) => string; + promptUserForValues?: (resourceInfo: ResourceInfo[]) => Promise | ResourceConfig[]; displayImportResult?: (importResult: ImportResult) => Promise | void; + displayFileModifications?: (diff: { file: string; modification: FileModificationResult; }[]) => void, } export class MockReporter implements Reporter { @@ -21,9 +26,17 @@ export class MockReporter implements Reporter { this.config = config ?? null; } - async displayApplyComplete(message: string[]): Promise { + async promptOptions(message: string, options: string[]): Promise { + return this.config?.promptOptions?.(message, options) ?? options[0]; + } + + async displayFileModifications(diff: { file: string; modification: FileModificationResult; }[]): Promise { + this.config?.displayFileModifications?.(diff); + } + + async displayMessage(message: string): Promise { console.log(JSON.stringify(message, null, 2)); - await this.config?.validateApplyComplete?.(message); + await this.config?.validateMessage?.(message); } async displayPlan(plan: Plan): Promise { @@ -42,18 +55,12 @@ export class MockReporter implements Reporter { } } - async promptUserForParameterValues(requiredParameters: RequiredParameters): Promise { - if (this.config?.askRequiredParametersForImport) { - return this.config.askRequiredParametersForImport(requiredParameters); - } - - const result = new Map>(); - - for (const parameter of requiredParameters) { - result.set(parameter[0], Object.fromEntries(parameter[1].map((p) => [p, '']))) + async promptUserForValues(resourceInfo: ResourceInfo[], promptType: PromptType): Promise { + if (this.config?.promptUserForValues) { + return this.config.promptUserForValues(resourceInfo); } - return result; + return resourceInfo.map((i) => new ResourceConfig({ type: i.type })) } displayImportResult(importResult: ImportResult): void { From 996a95dd71a033a8fdc2c186eaa5686055603ce7 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 13 Feb 2025 09:23:18 -0500 Subject: [PATCH 35/54] feat: Added integration tests for import command and fixed bugs --- src/entities/resource-info.ts | 2 +- src/orchestrators/import.ts | 63 ++- src/ui/reporters/default-reporter.tsx | 4 +- src/ui/reporters/reporter.ts | 2 +- test/orchestrator/apply/apply.test.ts | 4 +- test/orchestrator/import/import.test.ts | 608 +++++++++++++++++++++++- test/orchestrator/mocks/reporter.ts | 17 +- test/orchestrator/plan/plan.test.ts | 4 +- 8 files changed, 656 insertions(+), 48 deletions(-) diff --git a/src/entities/resource-info.ts b/src/entities/resource-info.ts index 40dbb284..90882731 100644 --- a/src/entities/resource-info.ts +++ b/src/entities/resource-info.ts @@ -35,7 +35,7 @@ export class ResourceInfo implements GetResourceInfoResponseData { const parameterInfo = this.getParameterInfo(); parameterInfo.forEach((info) => { const matchedParameter = resource.parameters[info.name]; - if (matchedParameter) { + if (matchedParameter !== undefined) { info.value = matchedParameter; } }) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 5fd10ea4..80332dcc 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -6,6 +6,7 @@ import { ResourceInfo } from '../entities/resource-info.js'; import { ProcessName, SubProcessName, ctx } from '../events/context.js'; import { CodifyParser } from '../parser/index.js'; import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js'; +import { prettyFormatFileDiff } from '../ui/file-diff-pretty-printer.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; import { FileModificationCalculator, ModificationType } from '../utils/file-modification-calculator.js'; @@ -18,7 +19,7 @@ export type UserSuppliedParameters = Map>; export type ImportResult = { result: ResourceConfig[], errors: string[] } export interface ImportArgs { - typeIds: string[]; + typeIds?: string[]; path: string; secureMode?: boolean; } @@ -58,11 +59,7 @@ export class ImportOrchestrator { throw new Error('At least one resource [type] must be specified. Ex: "codify import homebrew". Or the import command must be run in a directory with a valid codify file') } - if (!typeIds || typeIds.length === 0) { - await ImportOrchestrator.runExistingProject(reporter, initializationResult) - } else { - await ImportOrchestrator.runNewImport(typeIds, reporter, initializationResult) - } + await (!typeIds || typeIds.length === 0 ? ImportOrchestrator.runExistingProject(reporter, initializationResult) : ImportOrchestrator.runNewImport(typeIds, reporter, initializationResult)); } /** Import new resources. Type ids supplied. This will ask for any required parameters */ @@ -95,6 +92,8 @@ export class ImportOrchestrator { ctx.processFinished(ProcessName.IMPORT); + reporter.displayImportResult(importResult, false); + const resourceInfoList = await pluginManager.getMultipleResourceInfo( project.resourceConfigs.map((r) => r.type), ); @@ -222,30 +221,34 @@ ${JSON.stringify(unsupportedTypeIds)}`); '\nDo you want to save the results?', [ projectExists ? - multipleCodifyFiles ? `Update existing files (${project.codifyFiles})` : `Update existing file (${project.codifyFiles})` + multipleCodifyFiles ? 'Update existing files' : `Update existing file (${project.codifyFiles})` : undefined, 'In a new file', 'No' ].filter(Boolean) as string[] ) - if (promptResult.startsWith('Update existing file')) { + // Update an existing file + if (projectExists && promptResult === 0) { const file = multipleCodifyFiles - ? await reporter.promptOptions('\nIf new resources are added, where to write them?', project.codifyFiles) + ? project.codifyFiles[await reporter.promptOptions('\nIf new resources are added, where to write them?', project.codifyFiles)] : project.codifyFiles[0]; await ImportOrchestrator.updateExistingFiles(reporter, project, importResult, resourceInfoList, file); + return; + } - } else if (promptResult === 'In a new file') { + // Write to a new file + if ((!projectExists && promptResult === 0) || (projectExists && promptResult === 1)) { const newFileName = await ImportOrchestrator.generateNewImportFileName(); - await ImportOrchestrator.saveNewFile(newFileName, importResult); + await ImportOrchestrator.saveNewFile(reporter, newFileName, importResult); + return; + } - } else if (promptResult === 'No') { - reporter.displayImportResult(importResult, true); - reporter.displayMessage('\n🎉 Imported completed 🎉') + // No writes + reporter.displayImportResult(importResult, true); + reporter.displayMessage('\n🎉 Imported completed 🎉') - await sleep(100); - process.exit(0); - } + await sleep(100); } private static async updateExistingFiles( @@ -262,8 +265,8 @@ ${JSON.stringify(unsupportedTypeIds)}`); // New resources exists (they don't belong to any existing files) if (groupedResults.unknown) { groupedResults[preferredFile] = [ - ...groupedResults.unknown, - ...groupedResults[preferredFile], + ...(groupedResults.unknown ?? []), + ...(groupedResults[preferredFile] ?? []), ] delete groupedResults.unknown; } @@ -311,9 +314,27 @@ ${JSON.stringify(unsupportedTypeIds)}`); await sleep(100); } - private static async saveNewFile(filePath: string, importResult: ImportResult): Promise { - const newFile = JSON.stringify(importResult, null, 2); + private static async saveNewFile(reporter: Reporter, filePath: string, importResult: ImportResult): Promise { + const newFile = JSON.stringify(importResult.result.map((r) => r.raw), null, 2); + const diff = prettyFormatFileDiff('', newFile); + + reporter.displayFileModifications([{ file: filePath, modification: { newFile, diff } }]); + + const shouldSave = await reporter.promptConfirmation('Save the changes?'); + if (!shouldSave) { + reporter.displayMessage('\nSkipping save! Exiting...'); + + // Wait for the message to display before we exit + await sleep(100); + return; + } + await FileUtils.writeFile(filePath, newFile); + + reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉'); + + // Wait for the message to display before we exit + await sleep(100); } private static async generateNewImportFileName(): Promise { diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index f1bc9172..f102e592 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -145,14 +145,14 @@ export class DefaultReporter implements Reporter { return result; } - async promptOptions(message:string, options:string[]): Promise { + async promptOptions(message:string, options:string[]): Promise { const result = await this.updateStateAndAwaitEvent( () => this.updateRenderState(RenderStatus.PROMPT_OPTIONS, { message, options }), RenderEvent.PROMPT_RESULT ) this.log(`${message} -> "${result}"`) - return result + return options.indexOf(result); } displayFileModifications(diff: Array<{ file: string; modification: FileModificationResult}>) { diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index c6af6769..1fa36116 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -44,7 +44,7 @@ export interface Reporter { promptConfirmation(message: string): Promise - promptOptions(message: string, options: string[]): Promise; + promptOptions(message: string, options: string[]): Promise; promptSudo(pluginName: string, data: SudoRequestData, secureMode: boolean): Promise; diff --git a/test/orchestrator/apply/apply.test.ts b/test/orchestrator/apply/apply.test.ts index df896e62..8207d549 100644 --- a/test/orchestrator/apply/apply.test.ts +++ b/test/orchestrator/apply/apply.test.ts @@ -26,7 +26,7 @@ describe('Apply orchestrator tests', () => { operation: ResourceOperation.CREATE, }); }, - promptApplyConfirmation(): boolean { + promptConfirmation(): boolean { return true; } }); @@ -63,7 +63,7 @@ describe('Apply orchestrator tests', () => { operation: ResourceOperation.CREATE, }); }, - promptApplyConfirmation(): boolean { + promptConfirmation(): boolean { return true; } }); diff --git a/test/orchestrator/import/import.test.ts b/test/orchestrator/import/import.test.ts index 8ca0d07e..bf43385c 100644 --- a/test/orchestrator/import/import.test.ts +++ b/test/orchestrator/import/import.test.ts @@ -6,6 +6,9 @@ import { MockReporter } from '../mocks/reporter.js'; import { ImportOrchestrator } from '../../../src/orchestrators/import.js'; import { MockResource, MockResourceConfig } from '../mocks/resource.js'; import { ResourceSettings } from 'codify-plugin-lib'; +import { ResourceConfig } from '../../../src/entities/resource-config.js'; +import { FileModificationResult } from '../../../src/utils/file-modification-calculator.js'; +import { fs } from 'memfs'; vi.mock('../mocks/get-mock-resources.js', async () => { return { @@ -31,6 +34,65 @@ vi.mock('../mocks/get-mock-resources.js', async () => { return super.refresh(parameters); } + }, + new class extends MockResource { + getSettings(): ResourceSettings { + return { + id: 'jenv', + schema: { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://www.codifycli.com/jenv.json", + "type": "object", + "properties": { + "add": { + "type": "array" + }, + "global": { + "type": "string" + }, + "requiredProp": { + "type": "string" + } + }, + "required": ["requiredProp"] + }, + parameterSettings: { + add: { type: 'array' }, + }, + import: { + requiredParameters: ['requiredProp'], + refreshKeys: ['add', 'global', 'requiredProp'], + } + } + } + }, + new class extends MockResource { + getSettings(): ResourceSettings { + return { + id: 'alias', + schema: { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://www.codifycli.com/alias.json", + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "value": { + "type": "string" + }, + }, + "required": ["alias"] + }, + parameterSettings: { + add: { type: 'array' }, + }, + import: { + requiredParameters: ['alias'], + refreshKeys: ['alias', 'value'], + } + } + } } ]) } @@ -41,12 +103,25 @@ vi.mock('../../../src/plugins/plugin.js', async () => { return { Plugin: MockPlugin }; }) +vi.mock('node:fs', async () => { + const { fs } = await import('memfs'); + return fs +}) + +vi.mock('node:fs/promises', async () => { + const { fs } = await import('memfs'); + return fs.promises; +}) + describe('Import orchestrator tests', () => { - it('Can import a resource', async () => { + it('Can import a resource (no project) and can save to a new file', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/') + const reporter = new MockReporter({ - askRequiredParametersForImport: (requiredParameters) => { - expect(requiredParameters.get('mock')?.length).to.eq(2); - expect(requiredParameters.get('mock')).toEqual(expect.arrayContaining([ + promptUserForValues: (resourceInfoList): ResourceConfig[] => { + expect(resourceInfoList.length).to.eq(1); + expect(resourceInfoList[0].getRequiredParameters()).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'propA', type: 'string', @@ -57,9 +132,11 @@ describe('Import orchestrator tests', () => { }) ])) - return new Map([ - ['mock', { propA: 'randomPropA', propB: 'randomPropB' }], // User supplied values - ]); + return [new ResourceConfig({ + type: 'mock', + propA: 'randomPropA', + propB: 'randomPropB' + })] }, displayImportResult: (importResult) => { expect(importResult.errors.length).to.eq(0); @@ -70,11 +147,22 @@ describe('Import orchestrator tests', () => { propB: 'currentB', directory: '~/home', }) - } + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + expect(options[0]).toContain('new file'); + return 0; + }, + displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { + expect(diff[0].file).to.eq('/codify.json') + console.log(diff[0].file); + }, }); - const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForParameterValues'); + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); MockOs.create('mock', { propA: 'currentA', @@ -85,13 +173,513 @@ describe('Import orchestrator tests', () => { await ImportOrchestrator.run( { typeIds: ['mock'], - path: path.join(__dirname, 'codify.json') + path: '/' + }, + reporter, + ); + + expect(askRequiredParametersSpy).toHaveBeenCalledOnce(); + expect(displayImportResultSpy).toHaveBeenCalledOnce() + expect(displayFileModifications).toHaveBeenCalledOnce(); + expect(promptConfirmationSpy).toHaveBeenCalledOnce(); + + const fileWritten = fs.readFileSync('/import.codify.json', 'utf8') as string; + console.log(fileWritten); + + expect(JSON.parse(fileWritten)).toMatchObject([ + { + "type": "mock", + "propA": "currentA", + "propB": "currentB", + "directory": "~/home" + } + ]) + }); + + it('Can import a resource and save it into an existing project', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/'); + + fs.writeFileSync('/codify.json', +`[ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0" + ], + "global": "17", + "requiredProp": "this-jenv" + } +]`, + { encoding: 'utf-8'}); + + const reporter = new MockReporter({ + promptUserForValues: (resourceInfoList): ResourceConfig[] => { + expect(resourceInfoList.length).to.eq(1); + expect(resourceInfoList[0].getRequiredParameters()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'requiredProp', + type: 'string', + value: "this-jenv", + isRequired: true, + }), + ])) + + return [new ResourceConfig({ + type: 'jenv', + requiredProp: true, + })] + }, + displayImportResult: (importResult) => { + console.log(JSON.stringify(importResult, null, 2)); + expect(importResult.errors.length).to.eq(0); + expect(importResult.result.length).to.eq(1); + expect(importResult.result[0].type).to.eq('jenv'); + expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + }) + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + expect(options[0]).toContain('Update existing'); + return 0; + }, + displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { + expect(diff[0].file).to.eq('/codify.json') + console.log(diff[0].file); + }, + }); + + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); + const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); + + MockOs.create('jenv', { + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + }) + + await ImportOrchestrator.run( + { + typeIds: ['jenv'], + path: '/' }, reporter, ); expect(askRequiredParametersSpy).toHaveBeenCalledOnce(); + expect(displayImportResultSpy).toHaveBeenCalledOnce() + expect(displayFileModifications).toHaveBeenCalledOnce(); + expect(promptConfirmationSpy).toHaveBeenCalledOnce(); + + const fileWritten = fs.readFileSync('/codify.json', 'utf8') as string; + console.log(fileWritten); + + expect(JSON.parse(fileWritten)).toMatchObject([ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + } + ]) + }); + + it('Can import a resource and save it into an existing project (multiple codify files)', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/'); + + fs.writeFileSync('/codify.json', + `[ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0" + ], + "global": "17", + "requiredProp": "this-jenv" + } +]`, + { encoding: 'utf-8'}); + + fs.writeFileSync('/other.codify.json', + `[ + { "type": "alias", "alias": "gcdsdd", "value": "git clone" }, + { + "type": "alias", + "alias": "gcc", + "value": "git commit -v" + } +]`, + { encoding: 'utf-8'}); + + const reporter = new MockReporter({ + promptUserForValues: (resourceInfoList): ResourceConfig[] => { + expect(resourceInfoList.length).to.eq(2); + expect(resourceInfoList[0].type).to.eq('jenv'); + expect(resourceInfoList[1].type).to.eq('alias'); + + return [new ResourceConfig({ + type: 'jenv', + requiredProp: true, + }), new ResourceConfig({ + type: 'alias', + alias: 'gc-new' + })] + }, + displayImportResult: (importResult) => { + expect(importResult.errors.length).to.eq(0); + expect(importResult.result.length).to.eq(2); + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + if (message.includes('save the results?')) { + expect(options[0]).toContain('Update existing'); + return 0; + } else if (message.includes('where to write')) { + expect(options).toMatchObject([ + '/codify.json', + '/other.codify.json' + ]) + return 1; + } + }, + displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { + expect(diff[0].file).to.eq('/codify.json') + expect(diff[1].file).to.eq('/other.codify.json') + }, + }); + + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); + const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); + + MockOs.create('jenv', { + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + }) + + MockOs.create('alias', { + "alias": 'gc-new', + "value": 'gc-new-value', + }) + + await ImportOrchestrator.run( + { + typeIds: ['jenv', 'alias'], + path: '/' + }, + reporter, + ); + + expect(askRequiredParametersSpy).toHaveBeenCalledOnce(); + expect(displayImportResultSpy).toHaveBeenCalledOnce() + expect(displayFileModifications).toHaveBeenCalledOnce(); + expect(promptConfirmationSpy).toHaveBeenCalledOnce(); + + const otherCodifyFile = fs.readFileSync('/other.codify.json', 'utf8') as string; + console.log(otherCodifyFile); + expect(JSON.parse(otherCodifyFile)).toMatchObject([ + { "type": "alias", "alias": "gcdsdd", "value": "git clone" }, + { + "type": "alias", + "alias": "gcc", + "value": "git commit -v" + }, + { + "type": "alias", + 'alias': 'gc-new', + 'value': 'gc-new-value', + } + ]) + + const codifyFile = fs.readFileSync('/codify.json', 'utf8') as string; + console.log(codifyFile); + + expect(JSON.parse(codifyFile)).toMatchObject([ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + } + ]) + }); + + it('Can import and update an existing project (without prompting the user)(this is the no args version)', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/'); + + fs.writeFileSync('/codify.json', + `[ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0" + ], + "global": "17", + "requiredProp": "this-jenv" + } +]`, + { encoding: 'utf-8'}); + + const reporter = new MockReporter({ + displayImportResult: (importResult) => { + console.log(JSON.stringify(importResult, null, 2)); + expect(importResult.errors.length).to.eq(0); + expect(importResult.result.length).to.eq(1); + expect(importResult.result[0].type).to.eq('jenv'); + expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + }) + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + expect(options[0]).toContain('Update existing'); + return 0; + }, + displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { + expect(diff[0].file).to.eq('/codify.json') + console.log(diff[0].file); + }, + }); + + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); + const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); + + MockOs.create('jenv', { + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + }) + + await ImportOrchestrator.run( + { + path: '/' + }, + reporter, + ); + + expect(askRequiredParametersSpy).toHaveBeenCalledTimes(0); expect(displayImportResultSpy).toHaveBeenCalledOnce(); + expect(displayFileModifications).toHaveBeenCalledOnce(); + expect(promptConfirmationSpy).toHaveBeenCalledOnce(); + + const fileWritten = fs.readFileSync('/codify.json', 'utf8') as string; + console.log(fileWritten); + + expect(JSON.parse(fileWritten)).toMatchObject([ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + } + ]) + }); + + it('Can import a resource and only display it to the user', async () => { + const processSpy = vi.spyOn(process, 'cwd'); + processSpy.mockReturnValue('/'); + + fs.writeFileSync('/codify.json', + `[ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0" + ], + "global": "17", + "requiredProp": "this-jenv" + } +]`, + { encoding: 'utf-8'}); + + const reporter = new MockReporter({ + promptUserForValues: (resourceInfoList): ResourceConfig[] => { + expect(resourceInfoList.length).to.eq(1); + expect(resourceInfoList[0].getRequiredParameters()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'requiredProp', + type: 'string', + value: "this-jenv", + isRequired: true, + }), + ])) + + return [new ResourceConfig({ + type: 'jenv', + requiredProp: true, + })] + }, + displayImportResult: (importResult, showConfigs, ) => { + expect(importResult.errors.length).to.eq(0); + expect(importResult.result.length).to.eq(1); + expect(importResult.result[0].type).to.eq('jenv'); + expect(importResult.result[0].parameters).toMatchObject({ // Make sure the system values are returned here + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + }) + + if (showConfigs) { + JSON.stringify(importResult.result.map((r) => r.raw), null, 2) + } + }, + // Option 0 is write to a new file (no current project exists) + promptOptions: (message, options) => { + expect(options[2]).toContain('No'); + return 2; + } + }); + + const askRequiredParametersSpy = vi.spyOn(reporter, 'promptUserForValues'); + const displayImportResultSpy = vi.spyOn(reporter, 'displayImportResult'); + const displayFileModifications = vi.spyOn(reporter, 'displayFileModifications'); + const promptConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); + + MockOs.create('jenv', { + "add": [ + "system", + "11", + "11.0", + "11.0.24", + "17", + "17.0.12", + "openjdk64-11.0.24", + "openjdk64-17.0.12" + ], + "global": "17", + "requiredProp": "this-jenv" + }) + + await ImportOrchestrator.run( + { + typeIds: ['jenv'], + path: '/' + }, + reporter, + ); + + expect(askRequiredParametersSpy).toHaveBeenCalledOnce(); + expect(displayImportResultSpy).toHaveBeenCalledTimes(2); + expect(displayFileModifications).toHaveBeenCalledTimes(0); + expect(promptConfirmationSpy).toHaveBeenCalledTimes(0); + + const fileWritten = fs.readFileSync('/codify.json', 'utf8') as string; + console.log(fileWritten); + + expect(JSON.parse(fileWritten)).toMatchObject([ + { + "type": "jenv", + "add": [ + "system", + "11", + "11.0" + ], + "global": "17", + "requiredProp": "this-jenv" + } + ]) }); afterEach(() => { diff --git a/test/orchestrator/mocks/reporter.ts b/test/orchestrator/mocks/reporter.ts index 01231403..f468a582 100644 --- a/test/orchestrator/mocks/reporter.ts +++ b/test/orchestrator/mocks/reporter.ts @@ -12,10 +12,10 @@ export interface MockReporterConfig { validatePlan?: (plan: Plan) => Promise | void; validateMessage?: (message: string) => Promise | void; validateImport?: (result: ImportResult) => Promise | void; - promptApplyConfirmation?: () => boolean; - promptOptions?: (message: string, options: string[]) => string; + promptConfirmation?: () => boolean; + promptOptions?: (message: string, options: string[]) => number; promptUserForValues?: (resourceInfo: ResourceInfo[]) => Promise | ResourceConfig[]; - displayImportResult?: (importResult: ImportResult) => Promise | void; + displayImportResult?: (importResult: ImportResult, showConfigs: boolean) => Promise | void; displayFileModifications?: (diff: { file: string; modification: FileModificationResult; }[]) => void, } @@ -26,8 +26,8 @@ export class MockReporter implements Reporter { this.config = config ?? null; } - async promptOptions(message: string, options: string[]): Promise { - return this.config?.promptOptions?.(message, options) ?? options[0]; + async promptOptions(message: string, options: string[]): Promise { + return this.config?.promptOptions?.(message, options) ?? 0; } async displayFileModifications(diff: { file: string; modification: FileModificationResult; }[]): Promise { @@ -45,7 +45,7 @@ export class MockReporter implements Reporter { } async promptConfirmation(): Promise { - return this.config?.promptApplyConfirmation?.() ?? true; + return this.config?.promptConfirmation?.() ?? true; } async promptSudo(pluginName: string, data: SudoRequestData, secureMode: boolean): Promise { @@ -63,8 +63,7 @@ export class MockReporter implements Reporter { return resourceInfo.map((i) => new ResourceConfig({ type: i.type })) } - displayImportResult(importResult: ImportResult): void { - console.log(JSON.stringify(importResult, null, 2)); - this.config?.displayImportResult?.(importResult); + displayImportResult(importResult: ImportResult, showConfigs: boolean): void { + this.config?.displayImportResult?.(importResult, showConfigs); } } diff --git a/test/orchestrator/plan/plan.test.ts b/test/orchestrator/plan/plan.test.ts index 7a60e465..e910b1df 100644 --- a/test/orchestrator/plan/plan.test.ts +++ b/test/orchestrator/plan/plan.test.ts @@ -27,7 +27,7 @@ describe('Plan orchestrator tests', () => { operation: ResourceOperation.CREATE, }); }, - promptApplyConfirmation(): boolean { + promptConfirmation(): boolean { return true; } }); @@ -67,7 +67,7 @@ describe('Plan orchestrator tests', () => { operation: ResourceOperation.CREATE, }); }, - promptApplyConfirmation(): boolean { + promptConfirmation(): boolean { return true; } }); From 77d639701e7992bc092f258a20e9379cd53b4d1b Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 13 Feb 2025 09:26:56 -0500 Subject: [PATCH 36/54] fix: Fixed bug with memfs volume not resetting between tests --- test/orchestrator/import/import.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/orchestrator/import/import.test.ts b/test/orchestrator/import/import.test.ts index bf43385c..6f963b0c 100644 --- a/test/orchestrator/import/import.test.ts +++ b/test/orchestrator/import/import.test.ts @@ -8,7 +8,7 @@ import { MockResource, MockResourceConfig } from '../mocks/resource.js'; import { ResourceSettings } from 'codify-plugin-lib'; import { ResourceConfig } from '../../../src/entities/resource-config.js'; import { FileModificationResult } from '../../../src/utils/file-modification-calculator.js'; -import { fs } from 'memfs'; +import { fs, vol } from 'memfs'; vi.mock('../mocks/get-mock-resources.js', async () => { return { @@ -684,6 +684,7 @@ describe('Import orchestrator tests', () => { afterEach(() => { vi.resetAllMocks(); + vol.reset(); MockOs.reset(); }) From 0dab34d33ee93a33facffa16be182ada4ae6006c Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 13 Feb 2025 09:56:53 -0500 Subject: [PATCH 37/54] feat: Added support for new import prompts in debug and plain reporters. Fixed build errors and bugs --- src/orchestrators/import.ts | 2 - src/plugins/plugin-manager.ts | 1 - src/ui/components/default-component.tsx | 4 +- src/ui/reporters/debug-reporter.ts | 75 ++-------------------- src/ui/reporters/plain-reporter.ts | 85 +++++++++++++++++-------- src/ui/reporters/reporter.ts | 10 +-- src/utils/index.ts | 2 +- 7 files changed, 73 insertions(+), 106 deletions(-) diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 80332dcc..790d5bf7 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -14,8 +14,6 @@ import { groupBy, sleep } from '../utils/index.js'; import { wildCardMatch } from '../utils/wild-card-match.js'; import { InitializationResult, InitializeOrchestrator } from './initialize.js'; -export type RequiredParameters = Map; -export type UserSuppliedParameters = Map>; export type ImportResult = { result: ResourceConfig[], errors: string[] } export interface ImportArgs { diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index 299967c4..7c351d74 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -10,7 +10,6 @@ import { Plan, ResourcePlan } from '../entities/plan.js'; import { Project } from '../entities/project.js'; import { ResourceInfo } from '../entities/resource-info.js'; import { SubProcessName, ctx } from '../events/context.js'; -import { RequiredParameter, RequiredParameters } from '../orchestrators/import.js'; import { groupBy } from '../utils/index.js'; import { Plugin } from './plugin.js'; import { PluginResolver } from './resolver.js'; diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 887a6af2..40d40419 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -67,9 +67,9 @@ export function DefaultComponent(props: { { renderStatus === RenderStatus.PROMPT_OPTIONS && ( - {(renderData as any).message} + {(renderData as { message: string, options: string[] }).message} emitter.emit(RenderEvent.PROMPT_RESULT, value === 'yes')} options={[ + + ]} onSelect={(value) => emitter.emit(RenderEvent.PROMPT_RESULT, value.value === 'yes')}/> ) } { renderStatus === RenderStatus.PROMPT_OPTIONS && ( - + {(renderData as { message: string, options: string[] }).message} -