From 1e19b145f9b0b0b3a1d421bc9a7f20a0529f3f82 Mon Sep 17 00:00:00 2001 From: Andre Weber <138565883+wba2hi@users.noreply.github.com> Date: Mon, 29 Apr 2024 13:22:55 +0200 Subject: [PATCH] feat: Add Package Parameter to Init (#254) * Add Package Parameter * Add 'specifier' Parameter to provide Version --- .vscode/launch.json | 18 +++-- README.md | 22 ++++- src/commands/init/index.ts | 138 ++++++++++++++++++++++++++------ src/modules/project-config.ts | 130 +++++++++++++++++++++++------- test/commands/init/init.test.ts | 64 ++++++++++++++- 5 files changed, 303 insertions(+), 69 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 5986ed4c..40f58a36 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,17 +1,21 @@ { "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug CLI - init all", + "cwd": "${workspaceFolder}/vehicle-app-repo", + "program": "${workspaceFolder}/bin/dev", + "args": ["init"] + }, { "name": "Run tests", + "type": "node", "request": "launch", - "runtimeArgs": [ - "test", - ], + "runtimeArgs": ["test"], "runtimeExecutable": "npm", - "skipFiles": [ - "/**" - ], - "type": "node" + "skipFiles": ["/**"] } ] } diff --git a/README.md b/README.md index eeee95e3..31b77114 100644 --- a/README.md +++ b/README.md @@ -309,12 +309,14 @@ Initializes Velocitas Vehicle App ``` USAGE - $ velocitas init [-v] [-f] [--no-hooks] + $ velocitas init [-p PACKAGE] [-s VERSION_SPECIFIER] [-v] [-f] [--no-hooks] FLAGS - -f, --force Force (re-)download packages - -v, --verbose Enable verbose logging - --no-hooks Skip post init hooks + -p, --package Package to initialize + -s, --specifier Version specifier for the specified package + -f, --force Force (re-)download packages + -v, --verbose Enable verbose logging + --no-hooks Skip post init hooks DESCRIPTION Initializes Velocitas Vehicle App @@ -327,6 +329,18 @@ EXAMPLES ... Downloading package: 'devenv-runtimes:vx.x.x' ... Downloading package: 'devenv-github-templates:vx.x.x' ... Downloading package: 'devenv-github-workflows:vx.x.x' + + $ velocitas init -p devenv-runtimes + Initializing Velocitas packages ... + ... Package 'devenv-runtimes:vx.x.x' added to .velocitas.json + ... Downloading package: 'devenv-runtimes:vx.x.x' + ... > Running post init hook for ...' + + $ velocitas init -p devenv-runtimes -s v3.0.0 + Initializing Velocitas packages ... + ... Package 'devenv-runtimes:v3.0.0' added to .velocitas.json + ... Downloading package: 'devenv-runtimes:v3.0.0' + ... > Running post init hook for ... ``` _See code: [src/commands/init/index.ts](src/commands/init/index.ts)_ diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 46057621..b5067888 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -16,10 +16,19 @@ import { Command, Flags, ux } from '@oclif/core'; import { APP_MANIFEST_PATH_VARIABLE, AppManifest } from '../../modules/app-manifest'; import { ComponentContext, ExecSpec } from '../../modules/component'; import { ExecExitError, runExecSpec } from '../../modules/exec'; +import { PackageConfig } from '../../modules/package'; import { ProjectConfig, ProjectConfigLock } from '../../modules/project-config'; import { resolveVersionIdentifier } from '../../modules/semver'; import { createEnvVars } from '../../modules/variables'; +interface InitFlags { + verbose: boolean; + force: boolean; + ['no-hooks']: boolean; + package: string; + specifier: string; +} + export default class Init extends Command { static description = 'Initializes Velocitas Vehicle App'; @@ -31,6 +40,16 @@ export default class Init extends Command { ... Downloading package: 'devenv-runtimes:vx.x.x' ... Downloading package: 'devenv-github-templates:vx.x.x' ... Downloading package: 'devenv-github-workflows:vx.x.x'`, + `$ velocitas init -p devenv-runtimes + Initializing Velocitas packages ... + ... Package 'devenv-runtimes:vx.x.x' added to .velocitas.json + ... Downloading package: 'devenv-runtimes:vx.x.x' + ... > Running post init hook for ...'`, + `$ velocitas init -p devenv-runtimes -s v3.0.0 + Initializing Velocitas packages ... + ... Package 'devenv-runtimes:v3.0.0' added to .velocitas.json + ... Downloading package: 'devenv-runtimes:v3.0.0' + ... > Running post init hook for ...`, ]; static flags = { @@ -47,26 +66,87 @@ export default class Init extends Command { required: false, default: false, }), + package: Flags.string({ + char: 'p', + aliases: ['package'], + description: `Package to initialize`, + required: false, + default: '', + }), + specifier: Flags.string({ + char: 's', + aliases: ['specifier'], + description: `Version specifier for the specified package`, + required: false, + default: '', + dependsOn: ['package'], + }), }; async run(): Promise { - const { flags } = await this.parse(Init); + const { flags }: { flags: InitFlags } = await this.parse(Init); this.log(`Initializing Velocitas packages ...`); - const projectConfig = this.initializeOrReadProject(); + const projectConfig = this._initializeOrReadProject(); + + if (flags.package) { + await this._handleSinglePackageInit(projectConfig, flags); + } else { + await this._handleCompletePackageInit(projectConfig, flags); + } - const appManifestData = AppManifest.read(projectConfig.getVariableMappings().get(APP_MANIFEST_PATH_VARIABLE)); + this._createProjectLockFile(projectConfig, flags.verbose); + } - await this.ensurePackagesAreDownloaded(projectConfig, flags.force, flags.verbose); + private async _handleCompletePackageInit(projectConfig: ProjectConfig, flags: InitFlags) { + await this._ensurePackagesAreDownloaded(projectConfig.getPackages(), flags); projectConfig.validateUsedComponents(); if (!flags['no-hooks']) { - await this.runPostInitHooks(projectConfig, appManifestData, flags.verbose); + await this._runPostInitHooks(projectConfig.getComponents(), projectConfig, flags.verbose); + } + } + + private async _handleSinglePackageInit(projectConfig: ProjectConfig, flags: InitFlags): Promise { + const requestedPackageConfig = new PackageConfig({ repo: flags.package, version: flags.specifier }); + await this._resolveVersion(requestedPackageConfig, flags.verbose); + + const packageUpdated = projectConfig.updatePackageConfig(requestedPackageConfig); + if (packageUpdated) { + this.log( + `... Updating '${requestedPackageConfig.getPackageName()}' to version '${requestedPackageConfig.version}' in .velocitas.json`, + ); + } else { + const isAdded = projectConfig.addPackageConfig(requestedPackageConfig); + if (isAdded) { + this.log( + `... Package '${requestedPackageConfig.getPackageName()}:${requestedPackageConfig.version}' added to .velocitas.json`, + ); + } + } + + await this._ensurePackagesAreDownloaded([requestedPackageConfig], flags); + this._finalizeSinglePackageInit(requestedPackageConfig, projectConfig); + + if (!flags['no-hooks']) { + await this._runPostInitHooks(projectConfig.getComponentsForPackageConfig(requestedPackageConfig), projectConfig, flags.verbose); + } + } + + private _finalizeSinglePackageInit(requestedPackageConfig: PackageConfig, projectConfig: ProjectConfig): void { + const providedComponents = requestedPackageConfig.readPackageManifest().components; + const enabledComponentIds = projectConfig.getComponents(undefined, true).map((comp) => comp.config.id); + const areComponentsExisting = providedComponents.some((comp) => enabledComponentIds.includes(comp.id)); + + if (!areComponentsExisting) { + providedComponents.forEach((providedComponent) => { + projectConfig.addComponent(providedComponent.id); + }); } - this.createProjectLockFile(projectConfig, flags.verbose); + projectConfig.write(); } - initializeOrReadProject(): ProjectConfig { + private _initializeOrReadProject(): ProjectConfig { let projectConfig: ProjectConfig; if (!ProjectConfig.isAvailable()) { @@ -79,30 +159,34 @@ export default class Init extends Command { return projectConfig; } - async ensurePackagesAreDownloaded(projectConfig: ProjectConfig, force: boolean, verbose: boolean) { - for (const packageConfig of projectConfig.getPackages()) { - const packageVersions = await packageConfig.getPackageVersions(); - const packageVersion = resolveVersionIdentifier(packageVersions, packageConfig.version); + private async _resolveVersion(packageConfig: PackageConfig, verbose: boolean): Promise { + const packageVersions = await packageConfig.getPackageVersions(); + const packageVersion = resolveVersionIdentifier(packageVersions, packageConfig.version); - if (verbose) { - this.log(`... Resolved '${packageConfig.getPackageName()}:${packageConfig.version}' to version: '${packageVersion}'`); - } + if (verbose) { + this.log(`... Resolved '${packageConfig.getPackageName()}:${packageConfig.version}' to version: '${packageVersion}'`); + } + + packageConfig.setPackageVersion(packageVersion); + } - packageConfig.setPackageVersion(packageVersion); + private async _ensurePackagesAreDownloaded(packageConfigs: PackageConfig[], flags: InitFlags) { + for (const packageConfig of packageConfigs) { + await this._resolveVersion(packageConfig, flags.verbose); - if (!force && packageConfig.isPackageInstalled()) { + if (!flags.force && packageConfig.isPackageInstalled()) { this.log(`... '${packageConfig.getPackageName()}:${packageConfig.version}' already installed.`); continue; } this.log(`... Downloading package: '${packageConfig.getPackageName()}:${packageConfig.version}'`); - await packageConfig.downloadPackageVersion(verbose); + await packageConfig.downloadPackageVersion(flags.verbose); } } - async runSinglePostInitHook( + private async _runSinglePostInitHook( execSpec: ExecSpec, - componentContext: ComponentContext, + currentComponentContext: ComponentContext, projectConfig: ProjectConfig, appManifest: any, verbose: boolean, @@ -114,11 +198,11 @@ export default class Init extends Command { this.log(message); } const envVars = createEnvVars( - componentContext.packageConfig.getPackageDirectoryWithVersion(), - projectConfig.getVariableCollection(componentContext), + currentComponentContext.packageConfig.getPackageDirectoryWithVersion(), + projectConfig.getVariableCollection(currentComponentContext), appManifest, ); - await runExecSpec(execSpec, componentContext.manifest.id, projectConfig, envVars, { + await runExecSpec(execSpec, currentComponentContext.manifest.id, projectConfig, envVars, { writeStdout: verbose, verbose: verbose, }); @@ -127,8 +211,10 @@ export default class Init extends Command { } } - async runPostInitHooks(projectConfig: ProjectConfig, appManifest: any, verbose: boolean) { - for (const componentContext of projectConfig.getComponents()) { + private async _runPostInitHooks(components: ComponentContext[], projectConfig: ProjectConfig, verbose: boolean): Promise { + const appManifest = AppManifest.read(projectConfig.getVariableMappings().get(APP_MANIFEST_PATH_VARIABLE)); + + for (const componentContext of components) { if (!componentContext.manifest.onPostInit || componentContext.manifest.onPostInit.length === 0) { continue; } @@ -137,7 +223,7 @@ export default class Init extends Command { for (const execSpec of componentContext.manifest.onPostInit) { try { - await this.runSinglePostInitHook(execSpec, componentContext, projectConfig, appManifest, verbose); + await this._runSinglePostInitHook(execSpec, componentContext, projectConfig, appManifest, verbose); } catch (e) { if (e instanceof ExecExitError) { throw e; @@ -151,7 +237,7 @@ export default class Init extends Command { } } - createProjectLockFile(projectConfig: ProjectConfig, verbose: boolean): void { + private _createProjectLockFile(projectConfig: ProjectConfig, verbose: boolean): void { if (verbose && !ProjectConfigLock.isAvailable()) { this.log('... No .velocitas-lock.json found. Creating it at the root of your repository.'); } diff --git a/src/modules/project-config.ts b/src/modules/project-config.ts index 4b6e62f8..e996365e 100644 --- a/src/modules/project-config.ts +++ b/src/modules/project-config.ts @@ -141,7 +141,7 @@ export class ProjectConfig { let componentsToSerialize: ComponentConfig[] = this._components; if (!componentsToSerialize || componentsToSerialize.length === 0) { - componentsToSerialize = this.getComponents().map((cc) => cc.config); + componentsToSerialize = this.getComponents(false, true).map((cc) => cc.config); } const projectConfigOptions: ProjectConfigOptions = { @@ -157,7 +157,6 @@ export class ProjectConfig { /** * Return the configuration of a component. * - * @param projectConfig The project configuration. * @param componentId The ID of the component. * @returns The configuration of the component. */ @@ -169,6 +168,63 @@ export class ProjectConfig { return maybeComponentConfig ? maybeComponentConfig : new ComponentConfig(componentId); } + /** + * @param onlyInstalled only retrieves the installed packages for the project. Defaults to false. + * @returns all used packages by the project. + */ + getPackages(onlyInstalled: boolean = false): PackageConfig[] { + if (onlyInstalled) { + return this._packages.filter((pkg) => pkg.isPackageInstalled()); + } + + return this._packages; + } + + /** + * Searches through all / only the installed packageConfigs for the packageConfig with the specified + * name and returns it. If no packageConfig is found undefined is returned. + * @param packageName the packageName of the packageConfig to retrieve for. + * @param onlyInstalled true if searching only in the installed packages or false if all packages should be searched through. + * @returns the found packageConfig or undefined if none could be found. + */ + getPackageConfig(packageName: string, onlyInstalled: boolean = false): PackageConfig | undefined { + return this.getPackages(onlyInstalled).find((config) => config.getPackageName() === packageName); + } + + /** + * Adds a new packageConfig to the project. This method won't add a new package if a package with the + * same name already exists. Different versions are not taken into consideration. If updating the + * version of a packageConfig is required use #updatePackageConfig. + * + * @param packageConfig the packageConfig to add. + * @returns true if the package was added successfully, false otherwise. + */ + addPackageConfig(packageConfig: PackageConfig): boolean { + const existingPackage = this.getPackageConfig(packageConfig.getPackageName()); + + if (!existingPackage) { + this._packages.push(packageConfig); + return true; + } + return false; + } + + /** + * Updates the version of the packageConfig with the same packageName as provided. + * + * @param packageConfig the updated packageConfig. + * @returns true if the packageVersion was successfully updated, false otherwise. + */ + updatePackageConfig(packageConfig: PackageConfig): boolean { + const existingPackage = this.getPackageConfig(packageConfig.getPackageName()); + + if (existingPackage && existingPackage.version !== packageConfig.version) { + existingPackage?.setPackageVersion(packageConfig.version); + return true; + } + return false; + } + /** * Add a component from a referenced package to the project. * @@ -192,29 +248,48 @@ export class ProjectConfig { * all components are used by default. * * @param onlyUsed Only include components used by the project. Default: true. + * @param onlyInstalled Only include components from packages which are installed. Default: false. * @returns A list of all components used by the project. */ - getComponents(onlyUsed: boolean = true): ComponentContext[] { + getComponents(onlyUsed: boolean = true, onlyInstalled: boolean = false): ComponentContext[] { + const componentContexts: ComponentContext[] = []; + + let packageConfigs = this.getPackages(onlyInstalled); + for (const packageConfig of packageConfigs) { + const components = this.getComponentsForPackageConfig(packageConfig, onlyUsed); + componentContexts.push(...components); + } + + return componentContexts; + } + + /** + * Return all components used by the specified packageConfig. If the project specifies no components explicitly, + * all components are used by default. + * + * @param packageConfig packageConfig to search the components for. + * @param onlyUsed Only include components used by the project. Default: true. + * @returns A list of all components used by this particular packageConfig. + */ + getComponentsForPackageConfig(packageConfig: PackageConfig, onlyUsed: boolean = true): ComponentContext[] { const componentContexts: ComponentContext[] = []; const usedComponents = this._components; - for (const packageConfig of this.getPackages()) { - const packageManifest = packageConfig.readPackageManifest(); - - for (const componentManifest of packageManifest.components) { - const isComponentUsedByProject = - usedComponents.length === 0 || - usedComponents.find((compCfg: ComponentConfig) => compCfg.id === componentManifest.id) !== undefined; - if (!onlyUsed || isComponentUsedByProject) { - componentContexts.push( - new ComponentContext( - packageConfig, - componentManifest, - this.getComponentConfig(componentManifest.id), - isComponentUsedByProject, - ), - ); - } + const packageManifest = packageConfig.readPackageManifest(); + + for (const componentManifest of packageManifest.components) { + const isComponentUsedByProject = + usedComponents.length === 0 || + usedComponents.find((compCfg: ComponentConfig) => compCfg.id === componentManifest.id) !== undefined; + if (!onlyUsed || isComponentUsedByProject) { + componentContexts.push( + new ComponentContext( + packageConfig, + componentManifest, + this.getComponentConfig(componentManifest.id), + isComponentUsedByProject, + ), + ); } } @@ -224,7 +299,7 @@ export class ProjectConfig { validateUsedComponents() { // Check for components in usedComponents that couldn't be found in any componentManifest this._components.forEach((compCfg: ComponentConfig) => { - const foundInManifest = this.getPackages().some((packageConfig) => + const foundInManifest = this.getPackages(true).some((packageConfig) => packageConfig.readPackageManifest().components.some((componentManifest) => componentManifest.id === compCfg.id), ); if (!foundInManifest) { @@ -239,7 +314,7 @@ export class ProjectConfig { * @returns The context the component is used in. */ findComponentByName(componentId: string): ComponentContext { - let result = this.getComponents().find((compCtx: ComponentContext) => compCtx.manifest.id === componentId); + let result = this.getComponents(undefined, true).find((compCtx: ComponentContext) => compCtx.manifest.id === componentId); if (!result) { throw Error(`Cannot find component with id '${componentId}'!`); @@ -248,13 +323,6 @@ export class ProjectConfig { return result; } - /** - * @returns all used packages by the project. - */ - getPackages(): PackageConfig[] { - return this._packages; - } - /** * @returns all declared variable mappings on project level. */ @@ -262,8 +330,8 @@ export class ProjectConfig { return this._variables; } - getVariableCollection(componentContext: ComponentContext): VariableCollection { - return VariableCollection.build(this.getComponents(), this.getVariableMappings(), componentContext); + getVariableCollection(currentComponentContext: ComponentContext): VariableCollection { + return VariableCollection.build(this.getComponents(undefined, true), this.getVariableMappings(), currentComponentContext); } } diff --git a/test/commands/init/init.test.ts b/test/commands/init/init.test.ts index 91adddce..03f33dc3 100644 --- a/test/commands/init/init.test.ts +++ b/test/commands/init/init.test.ts @@ -88,7 +88,7 @@ describe('init', () => { }) .stdout() .stub(gitModule, 'simpleGit', (stub) => stub.returns(simpleGitInstanceMock())) - .command(['init', '-v', '--no-hooks']) + .command(['init', '-v']) .it('should log warning when no AppManifest.json is found', (ctx) => { console.error(ctx.stdout); expect(ctx.stdout).to.contain('*** Info ***: No AppManifest found'); @@ -118,4 +118,66 @@ describe('init', () => { .it('runs post-init hooks', (ctx) => { expect(ctx.stdout).to.contain(`... > Running post init hook for 'test-runtime-local'`); }); + + test.do(() => { + mockFolders({ velocitasConfig: true, velocitasConfigLock: true, installedComponents: true }); + }) + .stdout() + .stub(gitModule, 'simpleGit', (stub) => stub.returns(simpleGitInstanceMock())) + .stub(exec, 'runExecSpec', (stub) => stub.returns({})) + .command(['init', '--no-hooks']) + .it('does not run post-init hooks when called with --no-hooks parameter', (ctx) => { + expect(ctx.stdout).to.not.contain(`... > Running post init hook`); + }); + + test.do(() => { + mockFolders({ velocitasConfig: true, packageIndex: true }); + }) + .stdout() + .stub(gitModule, 'simpleGit', (stub) => stub.returns(simpleGitInstanceMock())) + .stub(exec, 'runExecSpec', (stub) => stub.returns({})) + .command(['init', '--package', 'devenv-runtime']) + .it('adds a new entry to .velocitas.json if the package does not exist', (ctx) => { + expect(ctx.stdout).to.contain(`... Package 'devenv-runtime:v1.1.1' added to .velocitas.json`); + }); + + test.do(() => { + mockFolders({ velocitasConfig: true, packageIndex: true }); + }) + .stdout() + .stub(gitModule, 'simpleGit', (stub) => stub.returns(simpleGitInstanceMock())) + .stub(exec, 'runExecSpec', (stub) => stub.returns({})) + .command(['init', '--package', `${installedCorePackage.repo}`]) + .it('downloads correctly the latest package if no version is specified', (ctx) => { + expect(ctx.stdout).to.contain(`... Downloading package: '${installedCorePackage.repo}:${installedCorePackage.version}'`); + expect( + CliFileSystem.existsSync(`${userHomeDir}/.velocitas/packages/${installedCorePackage.repo}/${installedCorePackage.version}`), + ).to.be.true; + }); + + test.do(() => { + mockFolders({ velocitasConfig: true, packageIndex: true }); + }) + .stdout() + .stub(gitModule, 'simpleGit', (stub) => stub.returns(simpleGitInstanceMock())) + .stub(exec, 'runExecSpec', (stub) => stub.returns({})) + .command(['init', '--package', `${installedCorePackage.repo}`, '--specifier', `${installedCorePackage.version}`]) + .it('correctly downloads the defined package if a version is specified', (ctx) => { + expect(ctx.stdout).to.contain(`... Downloading package: '${installedCorePackage.repo}:${installedCorePackage.version}'`); + expect( + CliFileSystem.existsSync(`${userHomeDir}/.velocitas/packages/${installedCorePackage.repo}/${installedCorePackage.version}`), + ).to.be.true; + }); + + test.do(() => { + mockFolders({ velocitasConfig: true, packageIndex: true }); + }) + .stdout() + .stub(gitModule, 'simpleGit', (stub) => stub.returns(simpleGitInstanceMock())) + .stub(exec, 'runExecSpec', (stub) => stub.returns({})) + .command(['init', '--package', `${installedCorePackage.repo}`, '--specifier', 'v10.5.2']) + .catch((err) => { + expect(err.message).to.contain(`Can't find matching version for v10.5.2.`); + }) + .it('throws an error if an invalid version is specified', (ctx) => {}); });