From ce6a6b850b5b7bc178299296f061cc042e53131f Mon Sep 17 00:00:00 2001
From: Colten Krauter <18080897+coltenkrauter@users.noreply.github.com>
Date: Sun, 29 Sep 2024 23:09:48 -0600
Subject: [PATCH] feat: version utils
---
.husky/pre-commit | 36 ++++++++++
README.md | 29 ++++++--
package-lock.json | 8 +--
package.json | 10 +--
scripts/readme.js | 120 -------------------------------
src/app.ts | 1 -
src/index.ts | 1 +
src/scripts/README.md | 23 ++++++
src/scripts/example.sh | 36 ++++++++++
src/scripts/pre-commit.ts | 1 +
src/scripts/version.ts | 3 +
src/version.ts | 115 +++++++++++++++++++++++++++++
test/basic.test.ts | 19 -----
test/version.test.ts | 148 ++++++++++++++++++++++++++++++++++++++
tsconfig.json | 2 +
15 files changed, 399 insertions(+), 153 deletions(-)
delete mode 100644 scripts/readme.js
delete mode 100644 src/app.ts
create mode 100644 src/index.ts
create mode 100644 src/scripts/README.md
create mode 100755 src/scripts/example.sh
create mode 100644 src/scripts/pre-commit.ts
create mode 100644 src/scripts/version.ts
create mode 100644 src/version.ts
delete mode 100644 test/basic.test.ts
create mode 100644 test/version.test.ts
diff --git a/.husky/pre-commit b/.husky/pre-commit
index e69de29..e580b26 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+# This script is an example of how a pre-commit hook might be set up
+# to execute pre-commit scripts from @krauters/utils.
+#
+# Prerequisite: Make sure to install @krauters/utils and ts-node in your project
+# npm install --save-dev @krauters/utils ts-node
+
+RED='\033[1;31m'
+GREEN='\033[1;32m'
+NC='\033[0m'
+
+PREFIX='[CHECKS]'
+
+log() {
+ echo "${GREEN}${PREFIX}${NC} $1"
+}
+
+error() {
+ echo "${RED}${PREFIX}${NC} $1"
+}
+
+log 'Running pre-commit scripts...'
+
+# Adjust the path based on whether it's run from node_modules or locally as shown
+# if ! OUTPUT=$(npx ts-node ./node_modules/@krauters/utils/src/scripts/pre-commit.ts 2>&1); then
+if ! OUTPUT=$(npx ts-node ./src/scripts/pre-commit.ts 2>&1); then
+ error "Pre-commit check failed:\n\n${OUTPUT}\n"
+ error 'Aborting commit.'
+ error 'Run commit with '-n' to skip pre-commit hooks.'
+ exit 1
+fi
+
+log "Script output...\n\n${OUTPUT}\n"
+log 'Pre-commit checks passed.'
+exit 0
diff --git a/README.md b/README.md
index 96944a3..a9c0071 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-# Typescript Core
-A TypeScript Repository that stands as a starting point for all other TypeScript repositories.
+# Utils
+A versatile TypeScript utility package packed with reusable, type-safe functions, scripts useful for all kinds of TypeScript projects, and precommit scripts to streamline your development workflow.
## Husky
@@ -11,6 +11,27 @@ Pre-commit hooks run scripts before a commit is finalized to catch issues or enf
This project uses a custom pre-commit hook to run `npm run bundle`. This ensures that our bundled assets are always up to date before any commit (which is especially important for TypeScript GitHub Actions). Husky automates this, so no commits will go through without a fresh bundle, keeping everything streamlined.
+### Using Utils as Pre-Commit Hooks
+
+```sh
+./husky/pre-commit
+#!/bin/sh
+
+RED='\033[1;31m'
+GREEN='\033[1;32m'
+NC='\033[0m'
+
+PREFIX="${GREEN}[HUSKY]${NC} "
+
+if ! OUTPUT=$(npx ts-node ./node_modules/@krauters/utils/src/version.ts 2>&1); then
+ echo -e "${RED}${PREFIX}${OUTPUT}${NC}"
+ echo -e "${RED}${PREFIX}Aborting commit.${NC}"
+ echo -e "${RED}${PREFIX}Run commit with '-n' to skip pre-commit hooks.${NC}"
+ exit 1
+fi
+
+```
+
## Contributing
The goal of this project is to continually evolve and improve its core features, making it more efficient and easier to use. Development happens openly here on GitHub, and we’re thankful to the community for contributing bug fixes, enhancements, and fresh ideas. Whether you're fixing a small bug or suggesting a major improvement, your input is invaluable.
@@ -23,6 +44,6 @@ This project is licensed under the ISC License. Please see the [LICENSE](./LICEN
Thanks for spending time on this project.
-
-
+
+
diff --git a/package-lock.json b/package-lock.json
index 360ac1e..11b41ae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "@krauters/typescript-core",
- "version": "0.4.0",
+ "name": "@krauters/utils",
+ "version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@krauters/typescript-core",
- "version": "0.4.0",
+ "name": "@krauters/utils",
+ "version": "0.0.1",
"license": "ISC",
"devDependencies": {
"@jest/globals": "^29.7.0",
diff --git a/package.json b/package.json
index d9ab520..228990c 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
{
- "name": "@krauters/typescript-core",
- "description": "A TypeScript Repository that stands as a starting point for all other TypeScript repositories.",
- "version": "1.0.0",
- "main": "app.ts",
+ "name": "@krauters/utils",
+ "description": "A versatile TypeScript utility package packed with reusable, type-safe functions, scripts useful for all kinds of TypeScript projects, and precommit scripts to streamline your development workflow.",
+ "version": "0.1.1",
+ "main": "index.ts",
"type": "commonjs",
"scripts": {
- "build": "ts-node ./src/app.ts",
+ "build": "ts-node ./src/index.ts",
"example-1": "ts-node ./example/1.ts",
"fix": "npm run lint -- --fix",
"lint": "npx eslint src/**",
diff --git a/scripts/readme.js b/scripts/readme.js
deleted file mode 100644
index 649f0ae..0000000
--- a/scripts/readme.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/* eslint-disable no-undef */
-
-import { readFileSync, writeFileSync } from 'fs'
-import { join, dirname as pathDirname } from 'path'
-import { fileURLToPath } from 'url'
-
-const filename = fileURLToPath(import.meta.url)
-const dirname = pathDirname(filename)
-console.debug(`Current directory [${dirname}]`)
-
-const packageJsonPath = join(dirname, '../package.json')
-const readmePath = join(dirname, '../README.md')
-const packageNameScopeRegex = /^@.*\//
-
-// Heading level regex constants
-const h1HeadingRegex = /^# (.+)$/m
-
-/**
- * Converts a package name to a formatted title.
- * @param {string} packageName - The original package name.
- * @returns {string} - The formatted title.
- */
-const packageNameToTitle = (packageName) => {
- const nameWithoutScope = packageName.replace(packageNameScopeRegex, '')
-
- return nameWithoutScope
- .split('-')
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(' ')
-}
-
-/**
- * Reads and parses the package.json file.
- * @param {string} packageJsonPath - The path to the package.json file.
- * @returns {object} - The parsed package.json content.
- */
-const readPackageJson = (packageJsonPath) => {
- console.debug(`Reading package.json from [${packageJsonPath}]...`)
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
- console.info(`Extracted package name [${packageJson.name}]`)
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
- return packageJson
-}
-
-/**
- * Reads the README.md file.
- * @param {string} readmePath - The path to the README.md file.
- * @returns {string} - The content of the README.md file.
- */
-const readReadme = (readmePath) => {
- console.debug(`Reading README.md from [${readmePath}]...`)
-
- return readFileSync(readmePath, 'utf8')
-}
-
-/**
- * Writes the updated content to the README.md file.
- * @param {string} readmePath - The path to the README.md file.
- * @param {string} content - The content to write to the README.md file.
- */
-const writeReadme = (readmePath, content) => {
- writeFileSync(readmePath, content)
- console.info(`README.md updated successfully at [${readmePath}]`)
-}
-
-/**
- * Ensures the specified section exists in the README content.
- * @param {string} content - The README content.
- * @param {string} sectionTitle - The section title to ensure exists.
- * @returns {string} - The updated README content.
- */
-const ensureSectionExists = (content, sectionTitle) => {
- const sectionRegex = new RegExp(`^## ${sectionTitle}$`, 'm')
- if (!sectionRegex.test(content)) {
- console.info(`[${sectionTitle}] section not found, adding it to README.md`)
- content += `\n\n## ${sectionTitle}\n\n`
- }
-
- return content
-}
-
-/**
- * Ensures the title exists in the README content and updates it if necessary.
- * @param {string} content - The README content.
- * @param {RegExp} titleRegex - The regex to match the title.
- * @param {string} newTitle - The new title to set.
- * @returns {string} - The updated README content.
- */
-const ensureTitleExists = (content, titleRegex, newTitle) => {
- const currentTitleMatch = content.match(titleRegex)
- const currentTitle = currentTitleMatch ? currentTitleMatch[1] : ''
- console.info(`Current README title [${currentTitle}]`)
-
- if (currentTitle !== newTitle) {
- content = content.replace(titleRegex, `# ${newTitle}`)
- console.info(`README.md title updated to [${newTitle}]`)
- }
-
- return content
-}
-
-try {
- const packageJson = readPackageJson(packageJsonPath)
- const packageName = packageJson.name
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
- const desiredTitle = packageNameToTitle(packageName)
- let readmeContent = readReadme(readmePath)
-
- readmeContent = ensureTitleExists(readmeContent, h1HeadingRegex, desiredTitle)
-
- const sections = ['Usage', 'Collaboration', 'Development']
- sections.forEach((section) => {
- readmeContent = ensureSectionExists(readmeContent, section)
- })
-
- writeReadme(readmePath, readmeContent)
-} catch (error) {
- console.error(`Failed with error [${error}]`)
-}
diff --git a/src/app.ts b/src/app.ts
deleted file mode 100644
index 55d399a..0000000
--- a/src/app.ts
+++ /dev/null
@@ -1 +0,0 @@
-console.info('Hello world!')
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..936df1c
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1 @@
+export * from './version'
diff --git a/src/scripts/README.md b/src/scripts/README.md
new file mode 100644
index 0000000..9c4b0f5
--- /dev/null
+++ b/src/scripts/README.md
@@ -0,0 +1,23 @@
+# Scripts
+
+This folder contains reusable TypeScript scripts intended for pre-commit checks and other automation tasks, designed to be used across multiple repositories.
+
+## Purpose
+
+- **Centralized Automation:** Provides a consistent way to enforce checks, such as version validation, before committing changes.
+- **Ease of Integration:** Ensures all consuming repositories maintain uniform pre-commit behavior.
+
+## Key Script
+
+- **`pre-commit.ts`**: This script handles pre-commit checks (like version validation). It’s meant to be executed via a bash script.
+
+## Usage
+
+1. **Install Dependencies**:
+ ```
+ npm install --save-dev @krauters/utils ts-node
+ ```
+2. **Set Up Your Pre-Commit Hook**:
+ - Use a bash script to call `pre-commit.ts` (or other specific scripst) in your repo.
+
+For a detailed example, see [example.sh](./example.sh).
diff --git a/src/scripts/example.sh b/src/scripts/example.sh
new file mode 100755
index 0000000..a143f27
--- /dev/null
+++ b/src/scripts/example.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+# This script is an example of how a pre-commit hook might be set up
+# to execute pre-commit scripts from @krauters/utils.
+#
+# Prerequisite: Make sure to install @krauters/utils and ts-node in your project
+# npm install --save-dev @krauters/utils ts-node
+
+RED='\033[1;31m'
+GREEN='\033[1;32m'
+NC='\033[0m'
+
+PREFIX='[CHECKS]'
+
+log() {
+ echo "${GREEN}${PREFIX}${NC} $1"
+}
+
+error() {
+ echo "${RED}${PREFIX}${NC} $1"
+}
+
+log 'Running pre-commit scripts...'
+
+# Adjust the path based on whether it's run from node_modules or locally as shown
+# if ! OUTPUT=$(npx ts-node ./node_modules/@krauters/utils/src/scripts/pre-commit.ts 2>&1); then
+if ! OUTPUT=$(npx ts-node ./pre-commit.ts 2>&1); then
+ error "Pre-commit check failed:\n\n${OUTPUT}\n"
+ error 'Aborting commit.'
+ error 'Run commit with '-n' to skip pre-commit hooks.'
+ exit 1
+fi
+
+log "Script output...\n\n${OUTPUT}\n"
+log 'Pre-commit checks passed.'
+exit 0
diff --git a/src/scripts/pre-commit.ts b/src/scripts/pre-commit.ts
new file mode 100644
index 0000000..704c4d2
--- /dev/null
+++ b/src/scripts/pre-commit.ts
@@ -0,0 +1 @@
+import './version'
diff --git a/src/scripts/version.ts b/src/scripts/version.ts
new file mode 100644
index 0000000..6cbbb9d
--- /dev/null
+++ b/src/scripts/version.ts
@@ -0,0 +1,3 @@
+const { compareVersions } = require('../version')
+
+compareVersions()
diff --git a/src/version.ts b/src/version.ts
new file mode 100644
index 0000000..d6ac77c
--- /dev/null
+++ b/src/version.ts
@@ -0,0 +1,115 @@
+import { execSync } from 'child_process'
+import fs from 'fs'
+import path from 'path'
+
+interface PackageJson {
+ version: string
+}
+
+/**
+ * Retrieves the version from a specified Git reference's package.json.
+ *
+ * @param ref The Git reference (e.g., "HEAD", "HEAD~1")
+ * @returns The version string from the package.json
+ * @throws {Error} If fetching fails
+ */
+function getVersionFromGit(ref: string): string {
+ try {
+ const packageJsonContent: string = execSync(`git show ${ref}:package.json`, { encoding: 'utf8' })
+ const data: PackageJson = JSON.parse(packageJsonContent)
+
+ return data.version
+ } catch (error: unknown) {
+ throw new Error(`Failed fetching package.json from ${ref} with error [${error}]`)
+ }
+}
+
+/**
+ * Recursively searches for package.json starting from the given directory and moving up.
+ *
+ * @param dir The directory to start searching from (defaults to current working directory)
+ * @returns The version string from the found package.json
+ * @throws {Error} If package.json is not found or cannot be read
+ */
+function getLocalVersion(dir: string = process.cwd()): string {
+ const packageJsonPath = path.join(dir, 'package.json')
+
+ if (fs.existsSync(packageJsonPath)) {
+ try {
+ const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8')
+ const data: PackageJson = JSON.parse(packageJsonContent)
+
+ return data.version
+ } catch (error: unknown) {
+ throw new Error(`Failed reading local package.json at ${packageJsonPath} with error [${error}]`)
+ }
+ }
+
+ // Move up to the parent directory
+ const parentDir = path.dirname(dir)
+ if (parentDir === dir) {
+ throw new Error('No package.json found in this project')
+ }
+
+ return getLocalVersion(parentDir)
+}
+
+/**
+ * Retrieves the commit SHA for a specified Git reference.
+ *
+ * @param ref The Git reference (e.g., "HEAD", "HEAD~1")
+ * @param short If true, fetch the short SHA (default is true)
+ * @returns The commit SHA
+ */
+function getCommitSha(ref: string, short = true): string {
+ try {
+ const command = `git rev-parse ${short ? '--short' : ''} ${ref}`
+
+ return execSync(command, { encoding: 'utf8' }).trim()
+ } catch (error: unknown) {
+ throw new Error(`Failed to get commit SHA for ${ref} with error [${error}]`)
+ }
+}
+
+/**
+ * Retrieves the version from the previous commit's package.json.
+ *
+ * @returns The previous version string
+ */
+function getPreviousVersion(): string {
+ return getVersionFromGit('HEAD~1')
+}
+
+/**
+ * Compares the previous and current package.json versions.
+ *
+ * @param [allowMatchWithoutError=false] - If true, do not throw an error when versions match
+ * @throws {Error} If versions are the same and `allowMatchWithoutError` is false
+ * @returns
+ */
+function compareVersions(allowMatchWithoutError = false): void {
+ const previous: string = getPreviousVersion()
+ const current: string = getLocalVersion()
+ const previousSha = getCommitSha('HEAD~1')
+
+ if (previous !== current) {
+ console.log(
+ `Version changed from [${previous}] (commit: ${previousSha}) to [${current}] (latest local changes).`,
+ )
+
+ return
+ }
+
+ const message = `Version has not been changed:
+ Previous version: [${previous}] (commit: ${previousSha})
+ Current version: [${current}] (latest local changes)
+ Please update the version before committing.`
+
+ if (!allowMatchWithoutError) {
+ throw new Error(message)
+ }
+
+ console.warn(message)
+}
+
+export { compareVersions, getLocalVersion as getCurrentVersion, getPreviousVersion }
diff --git a/test/basic.test.ts b/test/basic.test.ts
deleted file mode 100644
index 6e55866..0000000
--- a/test/basic.test.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { describe, it, expect, jest } from '@jest/globals'
-
-function add(a: number, b: number) {
- return a + b
-}
-
-describe('add function', () => {
- it('should return the sum of two numbers', () => {
- const result = add(2, 3)
- expect(result).toBe(5)
- })
-
- it('should be called once', () => {
- const spyAdd = jest.fn(add)
- const result = spyAdd(2, 3)
- expect(result).toBe(5)
- expect(spyAdd).toHaveBeenCalledTimes(1)
- })
-})
diff --git a/test/version.test.ts b/test/version.test.ts
new file mode 100644
index 0000000..7a00367
--- /dev/null
+++ b/test/version.test.ts
@@ -0,0 +1,148 @@
+/* eslint-disable @typescript-eslint/no-empty-function */
+
+import { beforeEach, describe, expect, it, jest } from '@jest/globals'
+import { execSync } from 'child_process'
+import fs from 'fs'
+
+import { compareVersions } from '../src/version'
+
+jest.mock('child_process')
+jest.mock('fs')
+
+const mockedExecSync = execSync as jest.Mock
+const mockedFs = fs as jest.Mocked
+
+// eslint-disable-next-line max-lines-per-function
+describe('compareVersions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should log when versions differ', () => {
+ // Mock Git commands for fetching the previous version and commit SHA
+ mockedExecSync.mockImplementation((...args: unknown[]) => {
+ const command = args[0] as string
+ if (command === 'git show HEAD~1:package.json') {
+ return JSON.stringify({ version: '1.0.0' })
+ }
+ if (command.startsWith('git rev-parse')) {
+ return 'abcd123'
+ }
+ throw new Error('Unexpected command')
+ })
+
+ // Mock reading the local package.json
+ mockedFs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.0.1' }))
+ mockedFs.existsSync.mockReturnValue(true)
+
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
+
+ compareVersions()
+
+ expect(mockedExecSync).toHaveBeenCalledWith('git show HEAD~1:package.json', { encoding: 'utf8' })
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ 'Version changed from [1.0.0] (commit: abcd123) to [1.0.1] (latest local changes).',
+ )
+
+ consoleLogSpy.mockRestore()
+ })
+
+ it('should throw an error when versions are the same (default behavior)', () => {
+ // Mock Git commands for fetching the previous version and commit SHA
+ mockedExecSync.mockImplementation((...args: unknown[]) => {
+ const command = args[0] as string
+ if (command === 'git show HEAD~1:package.json') {
+ return JSON.stringify({ version: '1.0.0' })
+ }
+ if (command.startsWith('git rev-parse')) {
+ return 'abcd123'
+ }
+ throw new Error('Unexpected command')
+ })
+
+ // Mock reading the local package.json
+ mockedFs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.0.0' }))
+ mockedFs.existsSync.mockReturnValue(true)
+
+ expect(() => {
+ compareVersions()
+ }).toThrow(
+ `Version has not been changed:
+ Previous version: [1.0.0] (commit: abcd123)
+ Current version: [1.0.0] (latest local changes)
+ Please update the version before committing.`,
+ )
+ })
+
+ it('should log a warning instead of throwing an error when versions are the same if allowMatchWithoutError is true', () => {
+ // Mock Git commands for fetching the previous version and commit SHA
+ mockedExecSync.mockImplementation((...args: unknown[]) => {
+ const command = args[0] as string
+ if (command === 'git show HEAD~1:package.json') {
+ return JSON.stringify({ version: '1.0.0' })
+ }
+ if (command.startsWith('git rev-parse')) {
+ return 'abcd123'
+ }
+ throw new Error('Unexpected command')
+ })
+
+ // Mock reading the local package.json
+ mockedFs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.0.0' }))
+ mockedFs.existsSync.mockReturnValue(true)
+
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
+
+ compareVersions(true)
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ `Version has not been changed:
+ Previous version: [1.0.0] (commit: abcd123)
+ Current version: [1.0.0] (latest local changes)
+ Please update the version before committing.`,
+ )
+
+ consoleWarnSpy.mockRestore()
+ })
+
+ it('should throw an error when fetching the previous package.json fails', () => {
+ mockedExecSync.mockImplementation((...args: unknown[]) => {
+ const command = args[0] as string
+ if (command === 'git show HEAD~1:package.json') {
+ throw new Error('Git command failed')
+ }
+
+ return 'abcd123'
+ })
+
+ mockedFs.readFileSync.mockReturnValue(JSON.stringify({ version: '1.0.0' }))
+ mockedFs.existsSync.mockReturnValue(true)
+
+ expect(() => {
+ compareVersions()
+ }).toThrow('Failed fetching package.json from HEAD~1 with error [Error: Git command failed]')
+ })
+
+ it('should throw an error when reading the local package.json fails', () => {
+ mockedExecSync.mockImplementation((...args: unknown[]) => {
+ const command = args[0] as string
+ if (command === 'git show HEAD~1:package.json') {
+ return JSON.stringify({ version: '1.0.0' })
+ }
+
+ return 'abcd123'
+ })
+
+ // Mock reading the local package.json to throw an error
+ mockedFs.readFileSync.mockImplementation(() => {
+ throw new Error('Failed to read package.json')
+ })
+ mockedFs.existsSync.mockReturnValue(true)
+
+ expect(() => {
+ compareVersions()
+ }).toThrow(
+ 'Failed reading local package.json at /Users/coltenkrauter/Repositories/utils/package.json with error [Error: Failed to read package.json]',
+ )
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index 5a74a36..e143645 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,6 +21,8 @@
"examples/**/*.tsx",
"src/**/*.ts",
"src/**/*.tsx",
+ "scripts/**/*.ts",
+ "scripts/**/*.tsx",
"test/**/*.ts",
"test/**/*.tsx"
]