initial implementation

master
Niclas Thobaben 2022-11-27 11:07:41 +01:00
parent c09114355f
commit 20e38492c9
13 changed files with 484 additions and 0 deletions

View File

@ -0,0 +1,14 @@
const { npmResolver } = require('./src/npm')
module.exports = {
name: 'my_project',
scm: {
},
resolver: {
npm: npmResolver()
},
publisher: {
npm: npmPublisher
}
}

View File

@ -0,0 +1,13 @@
const { npmResolver, npmPublisher } = require('../npm')
module.exports = {
name: 'my_project',
scm: {
url: 'https://some-git.com'
},
resolver: {
npm: npmResolver({})
},
publisher: {
npm: npmPublisher({})
}
}

View File

@ -0,0 +1,84 @@
import { compareVersions, parseSemver } from '../helpers'
import { VersionInfo } from '../resolver'
describe('helpers', () => {
describe('parseSemver()', () => {
it('throws error for invalid semver', () => {
expect(() => parseSemver('aa.12.2')).toThrow()
})
it('correctly parses simple semver', () => {
expect(parseSemver('12.56.2')).toEqual({
version: '12.56.2',
major: 12,
minor: 56,
patch: 2
} as VersionInfo)
})
it('correctly parses semver with prerelease', () => {
expect(parseSemver('12.56.2-beta3')).toEqual({
version: '12.56.2-beta3',
major: 12,
minor: 56,
patch: 2,
prerelease: 'beta3'
} as VersionInfo)
})
it('correctly parses semver with build', () => {
expect(parseSemver('12.56.2+b1')).toEqual({
version: '12.56.2+b1',
major: 12,
minor: 56,
patch: 2,
build: 'b1',
} as VersionInfo)
})
it('correctly parses semver with prerelease and build', () => {
expect(parseSemver('12.56.2-beta3+b1')).toEqual({
version: '12.56.2-beta3+b1',
major: 12,
minor: 56,
patch: 2,
prerelease: 'beta3',
build: 'b1',
} as VersionInfo)
})
})
describe('compareVersions()', () => {
it('returns major for different major versions', () => {
const versionA = parseSemver('1.0.0')
const versionB = parseSemver('2.0.0')
expect(compareVersions(versionA, versionB)).toBe('major')
})
it('returns minor for different minor versions', () => {
const versionA = parseSemver('1.0.0')
const versionB = parseSemver('1.1.0')
expect(compareVersions(versionA, versionB)).toBe('minor')
})
it('returns patch for different patch versions', () => {
const versionA = parseSemver('1.0.0')
const versionB = parseSemver('1.0.1')
expect(compareVersions(versionA, versionB)).toBe('patch')
})
it('returns prerelease for different prerelease versions', () => {
const versionA = parseSemver('1.0.0-beta1')
const versionB = parseSemver('1.0.0-beta2')
expect(compareVersions(versionA, versionB)).toBe('prerelease')
})
it('returns build for different build versions', () => {
const versionA = parseSemver('1.0.0+b1')
const versionB = parseSemver('1.0.0+b2')
expect(compareVersions(versionA, versionB)).toBe('build')
})
it('returns null for same versions', () => {
const versionA = parseSemver('1.0.0')
const versionB = parseSemver('1.0.0')
expect(compareVersions(versionA, versionB)).toBeNull()
})
})
})

View File

@ -0,0 +1,48 @@
import { Project, ProjectRunner, ScmInfo } from '../project'
describe('ProjectLoader', () => {
const newLoader = () => {
return new ProjectRunner()
}
const newProjectOptions = (override?: Partial<Project>): Project => {
return {
name: 'my_project',
scm: {
url: 'https://some-git.com',
type: 'git',
},
publisher: {},
resolver: {},
...override,
}
}
describe('validateProject()', () => {
it('throws error for missing name', () => {
const loader = newLoader()
expect(() => loader.validateProject(newProjectOptions({ name: undefined })))
.toThrow(/Missing project name/)
})
it('throws error for missing scm info', () => {
const loader = newLoader()
expect(() => loader.validateProject(newProjectOptions({ scm: undefined })))
.toThrow(/Missing scm/)
})
it('throws error for invalid scm', () => {
const loader = newLoader()
expect(() => loader.validateProject(newProjectOptions({ scm: { type: 'git' } as ScmInfo })))
.toThrow(/Missing scm url/)
})
it('does not throw error for missing scm type', () => {
const loader = newLoader()
expect(() => loader.validateProject(newProjectOptions({ scm: { url: 'https://some-git.com' } })))
.not.toThrow()
})
})
describe('loadProject()', () => {
it('loads project from js file', () => {
const runner = newLoader()
runner.loadProject(__dirname + '/deptracker.config.js')
})
})
})

38
src/helpers.ts 100644
View File

@ -0,0 +1,38 @@
import { VersionChangeType, VersionInfo } from './resolver'
export const parseSemver = (version: string): VersionInfo => {
const regex = /([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([a-z0-9-.]+))?(?:\+([a-z0-9-.]+))?/gi
const match = regex.exec(version)
if (!match) {
throw Error(`Invalid semver '${version}'`)
}
const [, major, minor, patch, prerelease, build] = match
return {
version,
major: parseInt(major),
minor: parseInt(minor),
patch: parseInt(patch),
prerelease,
build,
}
}
export const compareVersions = (versionA: VersionInfo, versionB: VersionInfo): VersionChangeType | null => {
if (versionA.major !== versionB.major) {
return 'major'
}
if (versionA.minor !== versionB.minor) {
return 'minor'
}
if (versionA.patch !== versionB.patch) {
return 'patch'
}
if (versionA.prerelease !== versionB.prerelease) {
return 'prerelease'
}
if (versionA.build !== versionB.build) {
return 'build'
}
return null
}

View File

@ -0,0 +1,72 @@
import { parseOutdatedLibraries } from '../process-yarn'
import { ResolveResult } from '../../resolver'
describe('process-yarn', () => {
describe('parseOutdatedLibraries()', () => {
it('contains correct library for input', () => {
const input = `
yarn outdated v1.22.19
info Color legend :
"<red>" : Major Update backward-incompatible updates
"<yellow>" : Minor Update backward-compatible features
"<green>" : Patch Update backward-compatible bug fixes
Package Current Wanted Latest Package Type URL
@types/jest 23.3.14 23.3.14 29.2.3 devDependencies https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jest
Done in 12.56s.
`
expect(parseOutdatedLibraries(input, 'package.json')).toEqual(
expect.arrayContaining([
{
current: {
name: '@types/jest',
type: 'npm',
version: {
version: '23.3.14',
major: 23,
minor: 3,
patch: 14,
},
details: {
package_type: 'devDependencies',
url: 'https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jest',
},
location: 'package.json',
},
recommended: {
name: '@types/jest',
type: 'npm',
version: {
version: '29.2.3',
major: 29,
minor: 2,
patch: 3,
},
details: {
package_type: 'devDependencies',
url: 'https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jest',
},
location: 'package.json',
},
change_type: 'major',
} as ResolveResult,
]),
)
})
it('returns correct number of results', () => {
const input = `
yarn outdated v1.22.19
info Color legend :
"<red>" : Major Update backward-incompatible updates
"<yellow>" : Minor Update backward-compatible features
"<green>" : Patch Update backward-compatible bug fixes
Package Current Wanted Latest Package Type URL
@types/jest 23.3.14 23.3.14 29.2.3 devDependencies https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jest
jest 28.1.3 28.1.3 29.3.1 devDependencies https://jestjs.io/
jest-junit 14.0.1 14.0.1 15.0.0 devDependencies https://github.com/jest-community/jest-junit#readme
tslint 5.20.1 5.20.1 6.1.3 devDependencies https://palantir.github.io/tslint
Done in 12.56s.`
expect(parseOutdatedLibraries(input, 'package.json')).toHaveLength(4)
})
})
})

4
src/npm/index.ts 100644
View File

@ -0,0 +1,4 @@
import npmResolver from './npm-resolver'
import npmPublisher from './npm-publisher'
export { npmResolver, npmPublisher }

View File

@ -0,0 +1,29 @@
import { Publisher, PublisherFactory } from '../publisher'
import { ResolveResult } from '../resolver'
import { PackageManager } from './npm-resolver'
import * as child_process from 'child_process'
export interface NpmPublisherOptions {
package_manager?: PackageManager
}
export class NpmPublisher implements Publisher<NpmPublisherOptions> {
readonly options: NpmPublisherOptions
constructor(options?: NpmPublisherOptions) {
this.options = {
package_manager: 'yarn',
...options,
}
}
publish(results: ResolveResult[]): void {
const cmd = `yarn upgrade ${results.map((r) => `${r.current.name}@${r.recommended.version.version}`).join(' ')}`
child_process.execSync(cmd, { stdio: 'inherit' })
}
}
const factory: PublisherFactory<NpmPublisherOptions> = (options: NpmPublisherOptions) => new NpmPublisher(options)
export default factory

View File

@ -0,0 +1,39 @@
import { Resolver, ResolveResult, ResolverFactory } from '../resolver'
import * as child_process from 'child_process'
import { parseOutdatedLibraries } from './process-yarn'
export type PackageManager = 'npm' | 'yarn'
export interface NpmResolverOptions {
package_manager?: PackageManager
}
export class NpmResolver implements Resolver<NpmResolverOptions> {
readonly type: string = 'npm'
readonly options: NpmResolverOptions
constructor(options?: NpmResolverOptions) {
this.options = {
package_manager: 'yarn',
...options
}
}
private async resolveWithYarn(): Promise<ResolveResult[]> {
try {
child_process.execSync('yarn outdated', { })
}catch (e: any) {
// Unfortunately the command throws an error, that we have to catch
const stdout = String(e.stdout)
return parseOutdatedLibraries(stdout, 'package.json')
}
return []
}
async resolve(): Promise<ResolveResult[]> {
return this.resolveWithYarn()
}
}
const factory: ResolverFactory<NpmResolverOptions> = (options) => new NpmResolver(options)
export default factory

View File

@ -0,0 +1,66 @@
import { ResolveResult } from '../resolver'
import { compareVersions, parseSemver } from '../helpers'
/**
* yarn outdated v1.22.19
* info Color legend :
* "<red>" : Major Update backward-incompatible updates
* "<yellow>" : Minor Update backward-compatible features
* "<green>" : Patch Update backward-compatible bug fixes
* Package Current Wanted Latest Package Type URL
* @types/jest 23.3.14 23.3.14 29.2.3 devDependencies https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jest
* jest 28.1.3 28.1.3 29.3.1 devDependencies https://jestjs.io/
* jest-junit 14.0.1 14.0.1 15.0.0 devDependencies https://github.com/jest-community/jest-junit#readme
* tslint 5.20.1 5.20.1 6.1.3 devDependencies https://palantir.github.io/tslint
* Done in 12.56s.
*/
export const parseOutdatedLibraries = (stdout: string, location: string): ResolveResult[] => {
const startHeaders = stdout.indexOf('Package')
const endDone = stdout.indexOf('Done in')
const data = stdout.substring(startHeaders, endDone)
const linesRaw = data.split('\n')
const lines = data.split('\n')
.slice(1, linesRaw.length-1) //Ignore headers and 'Done'
.map((line) => line.trim())
.map((line) => line.split(/\s+/).map((item) => item.trim()))
const results = lines
.map(([name, current, , latest, packageType, url]) => {
const currentVersion = parseSemver(current)
const recommendedVersion = parseSemver(latest)
const changeType = compareVersions(currentVersion, recommendedVersion)
if (!changeType) {
return null
}
return {
current: {
name,
location,
type: 'npm',
version: currentVersion,
details: {
url,
package_type: packageType,
},
},
recommended: {
name,
location,
type: 'npm',
version: recommendedVersion,
details: {
url,
package_type: packageType,
},
},
change_type: changeType,
} as ResolveResult
})
return results as ResolveResult[]
}

41
src/project.ts 100644
View File

@ -0,0 +1,41 @@
import { Resolver } from './resolver'
import { Publisher } from './publisher'
export interface Project {
name: string
scm: ScmInfo
resolver: Record<string, Resolver<any>>
publisher?: Record<string, Publisher<any>>
}
export type ScmType = 'git'
export interface ScmInfo {
type?: ScmType
url: string
}
const validateScmInfo = (scm?: ScmInfo) => {
if(!scm) {
throw Error('Missing scm info')
}
if(!scm.url) {
throw Error('Missing scm url')
}
}
export class ProjectRunner {
public loadProject(path: string) {
const project = require(path) as Project
this.validateProject(project)
return project as Project
}
public validateProject(project: Project) {
if(!project.name) {
throw Error('Missing project name')
}
validateScmInfo(project.scm)
}
}

7
src/publisher.ts 100644
View File

@ -0,0 +1,7 @@
import { ResolveResult } from './resolver'
export interface Publisher<T> {
publish: (results: ResolveResult[]) => void
}
export type PublisherFactory<T> = (options: T) => Publisher<T>

29
src/resolver.ts 100644
View File

@ -0,0 +1,29 @@
export interface VersionInfo {
version: string
major: number
minor: number
patch: number
prerelease?: string
build?: string
}
export interface Library {
name: string
type: string
location: string
version: VersionInfo
details?: Record<string, unknown>
}
export type VersionChangeType = 'major' | 'minor' | 'patch' | 'prerelease' | 'build'
export interface ResolveResult {
current: Library
recommended: Library
change_type: VersionChangeType
}
export interface Resolver<T> {
resolve: () => Promise<ResolveResult[]>
}
export type ResolverFactory<T> = (options: T) => Resolver<T>