Skip to content

Commit

Permalink
feat(package-json-hook)!: share state for package.json hook installers
Browse files Browse the repository at this point in the history
This is a breaking change because these new install hooks rely on the
new commitInstall method to actually write to your package.json file,
and will conflict with the older implementation which rewrote the
manifest file for every hook. If some of your hooks use the old version
of this plugin, and others use the new version, then any hooks using the
old version will be lost, as commitInstall is always called last and
ignores any hooks not present in its state object.
  • Loading branch information
ivomurrell committed Dec 5, 2022
1 parent 454f9cd commit 0c8729f
Show file tree
Hide file tree
Showing 7 changed files with 78 additions and 114 deletions.
2 changes: 2 additions & 0 deletions lib/package-json-hook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"license": "ISC",
"dependencies": {
"@financial-times/package-json": "^3.0.0",
"lodash": "^4.17.21",
"tslib": "^2.3.1"
},
"repository": {
Expand All @@ -22,6 +23,7 @@
"homepage": "https://github.com/financial-times/dotcom-tool-kit/tree/main/lib/package-json-hook",
"devDependencies": {
"@jest/globals": "^27.4.6",
"@types/lodash": "^4.14.185",
"winston": "^3.5.1"
},
"files": [
Expand Down
91 changes: 68 additions & 23 deletions lib/package-json-hook/src/package-json-helper.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,76 @@
import { Hook } from '@dotcom-tool-kit/types'
import type { PackageJson } from '@financial-times/package-json'
import loadPackageJson from '@financial-times/package-json'
import fs from 'fs'
import get from 'lodash/get'
import mapValues from 'lodash/mapValues'
import merge from 'lodash/merge'
import update from 'lodash/update'
import path from 'path'

type PackageJsonConfigField = {
[key: string]: string
}
interface PackageJson {
[field: string]: PackageJson | string
}

export abstract class PackageJsonHelper extends Hook {
_packageJson?: PackageJson
abstract field: string
abstract key: string
abstract hook: string
interface PackageJsonStateValue {
hooks: string[]
trailingString: string
}

get packageJson(): PackageJson {
if (!this._packageJson) {
const filepath = path.resolve(process.cwd(), 'package.json')
this._packageJson = loadPackageJson({ filepath })
}
interface PackageJsonState {
[field: string]: PackageJsonState | PackageJsonStateValue
}

return this._packageJson
}
export abstract class PackageJsonHelper extends Hook<PackageJsonState> {
private _packageJson?: PackageJson
abstract field: string | string[]
abstract key: string
abstract hook: string
trailingString?: string

async check(): Promise<boolean> {
const commands = this.packageJson.getField<PackageJsonConfigField>(this.field)
return commands?.[this.key]?.includes(this.hook)
}
installGroup = 'package-json'

abstract install(): Promise<void>
}
filepath = path.resolve(process.cwd(), 'package.json')

async getPackageJson(): Promise<PackageJson> {
if (!this._packageJson) {
const rawPackageJson = await fs.promises.readFile(this.filepath, 'utf8')
const packageJson = JSON.parse(rawPackageJson)
this._packageJson = packageJson
return packageJson
}

return this._packageJson
}

private get hookPath(): string[] {
return Array.isArray(this.field) ? [...this.field, this.key] : [this.field, this.key]
}

async check(): Promise<boolean> {
const packageJson = await this.getPackageJson()
return get(packageJson, this.hookPath)?.includes(this.hook)
}

async install(state?: PackageJsonState): Promise<PackageJsonState> {
state ??= {}
// prepend each hook to maintain the same order as previous implementations
update(state, this.hookPath, (hookState?: PackageJsonStateValue) => ({
hooks: [this.hook, ...(hookState?.hooks ?? [])],
trailingString: this.trailingString
}))
return state
}

async commitInstall(state: PackageJsonState): Promise<void> {
const reduceHooks = (state: PackageJsonState): PackageJson =>
mapValues(state, (field) =>
Array.isArray(field?.hooks)
? `dotcom-tool-kit ${field.hooks.join(' ')}${
field.trailingString ? ' ' + field.trailingString : ''
}`
: reduceHooks(field as PackageJsonState)
)

const newPackageJson = merge(await this.getPackageJson(), reduceHooks(state))
await fs.promises.writeFile(this.filepath, JSON.stringify(newPackageJson, null, 2) + '\n')
}
}
18 changes: 0 additions & 18 deletions lib/package-json-hook/src/script-hook.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,5 @@
import { PackageJsonHelper } from './package-json-helper'

type Scripts = {
[script: string]: string
}

export abstract class PackageJsonScriptHook extends PackageJsonHelper {
field = 'scripts'

async install(): Promise<void> {
let command = `dotcom-tool-kit ${this.hook}`
const existingCommand = this.packageJson.getField<Scripts>(this.field)[this.key]
if (existingCommand && existingCommand.startsWith('dotcom-tool-kit ')) {
command = command.concat(existingCommand.replace('dotcom-tool-kit', ''))
}
this.packageJson.requireScript({
stage: this.key,
command
})

this.packageJson.writeChanges()
}
}
23 changes: 2 additions & 21 deletions lib/package-json-hook/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ describe('package.json hook', () => {

try {
const hook = new TestHook(logger)
await hook.install()
const state = await hook.install()
await hook.commitInstall(state)

const packageJson = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))

Expand All @@ -61,25 +62,5 @@ describe('package.json hook', () => {
await fs.writeFile(pkgPath, originalJson)
}
})

it('should prepend hook to a call with an existing hook', async () => {
const base = path.join(__dirname, 'files', 'existing-hook')

const pkgPath = path.join(base, 'package.json')
const originalJson = await fs.readFile(pkgPath, 'utf-8')

process.chdir(base)

try {
const hook = new TestHook(logger)
await hook.install()

const packageJson = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))

expect(packageJson).toHaveProperty(['scripts', 'test-hook'], 'dotcom-tool-kit test:hook another:hook')
} finally {
await fs.writeFile(pkgPath, originalJson)
}
})
})
})
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 1 addition & 33 deletions plugins/husky-npm/src/husky-hook.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,5 @@
import { PackageJsonHelper } from '@dotcom-tool-kit/package-json-hook'

type HuskyField = {
hooks?: {
[hook: string]: string
}
}

export abstract class HuskyHook extends PackageJsonHelper {
field = 'husky'

async install(): Promise<void> {
let command = `dotcom-tool-kit ${this.hook}`

const huskyHooks = this.packageJson.getField<HuskyField>(this.field) || {}

if(!huskyHooks.hooks) {
huskyHooks.hooks = {}
}

const existingCommand = huskyHooks.hooks[this.key]

if (existingCommand?.startsWith('dotcom-tool-kit ')) {
command = command.concat(existingCommand.replace('dotcom-tool-kit', ''))
}

huskyHooks.hooks[this.key] = command

this.packageJson.setField(this.field, huskyHooks)
this.packageJson.writeChanges()
}

async check(): Promise<boolean> {
const husky = this.packageJson.getField<HuskyField>(this.field)
return husky?.hooks?.[this.key]?.includes(this.hook) ?? false
}
field = ['husky', 'hooks']
}
20 changes: 1 addition & 19 deletions plugins/lint-staged/src/hook.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,6 @@
import { PackageJsonHelper } from '@dotcom-tool-kit/package-json-hook'

type LintStagedHooks = {
[glob: string]: string
}

export abstract class LintStagedHook extends PackageJsonHelper {
field = 'lint-staged'

async install(): Promise<void> {
let command = `dotcom-tool-kit ${this.hook}`
const commands = this.packageJson.getField<LintStagedHooks>(this.field) || {}
const existingCommand = commands[this.key]
if (existingCommand?.startsWith('dotcom-tool-kit ')) {
command = command.concat(existingCommand.replace('dotcom-tool-kit', ''))
} else {
command = command.concat(' --')
}
commands[this.key] = command
this.packageJson.setField(this.field, commands)

this.packageJson.writeChanges()
}
trailingString = '--'
}

0 comments on commit 0c8729f

Please sign in to comment.