Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions .github/workflows/npmpublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,16 @@ on:
- "*.*.*"

jobs:
build:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 10
node-version: 22
registry-url: https://registry.npmjs.org/
- run: |
sed -i -e 's|"version": "1.0.0"|"version": "'"$(sed -e "s/refs\/tags\///g" <<< $GITHUB_REF)"'"|g' ./package.json

- run: npm install
- run: npm test
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.npm_token}}

NODE_AUTH_TOKEN: ${{ secrets.npm_token }}
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Test

on:
pull_request:
push:
branches:
- master
- main

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test
44 changes: 44 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

`tagy` is a globally-installed CLI (`bin: tagy` → `cli.js`) that creates a SemVer git tag, bumps `package.json`, optionally runs custom file replacements, pushes the commit + tag to origin, and optionally creates a GitHub release. It runs against *the user's* current working directory (`process.cwd()`), not against this repo.

## Commands

- Test: `npm test` (Vitest, single run) or `npm run test:watch`. Run one file: `npx vitest run test/lib.test.js`; one case: `npx vitest run -t "nextVersion"`.
- Run locally against another project: `node /Users/awps/Projects/MY/tagy/cli.js --patch` from that project's directory.
- Install for local dev: `npm i -g .` then use `tagy ...` anywhere.
- Releasing tagy itself is done with tagy: `tagy --patch` (see recent commits "Release x.y.z").

There is no build or lint step.

CLI flags: `-p/--patch`, `-m/--minor`, `--major`, `--reverse`, `--info`, `--custom`, `--soft`, `--auto-release`, `-h`.

## Architecture

Code is split between two files (`cli.js` is a 6-line shim that requires and invokes `index.js`):

- **`lib.js`** — pure, side-effect-free helpers, each unit-tested in `test/lib.test.js`: `createColor` (chainable zero-dep chalk replacement; color on for TTY/`FORCE_COLOR`, off for `NO_COLOR`/piped), `replaceInFiles` (zero-dep replace-in-file replacement), `substitute` (`__VERSION__`/`__CURRENT_TAG__`), `buildReplaceConfig`, `resolveIncrement`, `normalizeCurrentVersion`, `nextVersion`, `bumpPackageVersion`. Dependencies (fs, cwd) are passed as args so they're testable. **Prefer adding new logic here** rather than in the IIFE.
- **`index.js`** — the CLI orchestration: one big async IIFE that wires the `lib.js` helpers together with the side-effecting parts (git via `shelljs`, interactive `prompts`, reading the target `package.json`). This shell is not unit-tested.

Execution flow of the `index.js` IIFE:

1. **Arg validation** — rejects too many args or conflicting increment flags (only one of patch/minor/major/reverse/custom/info allowed).
2. **Read `package.json`** from `process.cwd()`; pull the optional `tagy` config block (`tagPrefix`, `soft`, `auto-release`, `replace`).
3. **Soft mode** (`--soft` or `tagy.soft`) — does file replacement only; never touches git. `tagy.method === 'soft'` is the deprecated form.
4. **Determine current version** — in git mode, reads the latest existing tag via `git tag --sort=v:refname | grep '^<prefix>[0-9]' | tail -1`; in soft mode uses `package.json` version. `--info` short-circuits here.
5. **Compute next version** — `semver.inc()` for patch/minor/major, or a prompted custom value. `--major` and `--reverse` require confirmation prompts.
6. **Apply changes** — bump `package.json`; run `tagy.js` hook if present; run `tagy.replace` rules.
7. **Git ops** (non-soft only) — commit, push branch, create tag, push tag, then optionally `gh release create`.

### Key behaviors to preserve

- **External tooling via `shelljs`**: relies on the user having `git` and (for releases) the `gh` CLI on PATH. Branch detection only proceeds on `master`/`main` without a confirmation prompt.
- **`tagPrefix`** is prepended to the git tag and release name (e.g. `v1.2.3`) but the `package.json` version stays unprefixed.
- **Extensibility hooks** run before `git push`:
- A `tagy.js` file in the target project: `module.exports = (newVersion, oldVersion, args) => {...}`.
- `tagy.replace[]` rules in `package.json`, each `{files, from, to, flags}`. `from`/`to` support `__VERSION__` (new version) and `__CURRENT_TAG__` placeholders; `from` is compiled to a `RegExp`.
- All user interaction uses `prompts`; all colored output uses `chalk`.
87 changes: 28 additions & 59 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,22 @@
const fs = require('fs-extra')
const path = require('path')
const shell = require("shelljs")
const semver = require('semver')
const args = require('yargs').argv
const prompts = require('prompts')
const chalk = require("chalk");
const replace = require("replace-in-file");

const packageVersionBump = async (vv) => {
const pkgPath = path.join(process.cwd(), 'package.json')

if (fs.existsSync(pkgPath)) {
let pkgContent;

try {
pkgContent = await fs.readJSON(pkgPath);
} catch (err) {
throw new Error(`Couldn't parse "package.json"`)
}

pkgContent.version = vv;

try {
await fs.writeJSON(pkgPath, pkgContent, {
spaces: 2
});
} catch (err) {
throw new Error(`Couldn't write to "package.json"`)
}

return true;
}

return true;
}
const {
createColor,
replaceInFiles,
buildReplaceConfig,
resolveIncrement,
normalizeCurrentVersion,
nextVersion,
bumpPackageVersion,
} = require('./lib')

const chalk = createColor({
isTTY: Boolean(process.stdout && process.stdout.isTTY),
env: process.env,
})

module.exports = function () {
(async () => {
Expand Down Expand Up @@ -235,29 +218,19 @@ Options:

currentTag = vv;
} else {
if (!vv) {
vv = '0.0.0';
}

vv = vv.trim();

if (semver.ltr(vv, '0.0.0')) {
vv = '0.0.0'
}
vv = normalizeCurrentVersion(vv);

currentTag = vv;

if (args.p || args.patch) {
vv = semver.inc(vv, 'patch')
} else if (args.m || args.minor) {
vv = semver.inc(vv, 'minor')
} else if (args.major) {
vv = semver.inc(vv, 'major')
} else {
const incrementType = resolveIncrement(args);

if (!incrementType) {
console.log(chalk.red(`Something went wrong!.`))
return;
}

vv = nextVersion(vv, incrementType);

if (args.major) {
const confirmMajorRelease = await prompts({
type: 'confirm',
Expand All @@ -276,7 +249,7 @@ Options:

// Bump the version in package.json
try {
canCreate = await packageVersionBump(vv);
canCreate = await bumpPackageVersion(vv);
} catch (err) {
return console.log(err.message);
}
Expand All @@ -302,18 +275,14 @@ Options:
console.log(chalk.green('♾️ Replacement methods are found!'));

for (let i = 0; i < replacementMethods.length; i++) {
const {files, from, to, flags} = replacementMethods[i];

if (files && from && to) {
const _files = Array.isArray(files) ? files : [files];

const replaceConf = {
files: _files.map(file => path.resolve(`${process.cwd()}/${file}`)),
from: new RegExp(from.replaceAll('__CURRENT_TAG__', currentTag).replaceAll('__VERSION__', vv), flags !== false ? (flags || 'g') : undefined),
to: to.replaceAll('__CURRENT_TAG__', currentTag).replaceAll('__VERSION__', vv),
};
const replaceConf = buildReplaceConfig(replacementMethods[i], {
version: vv,
currentTag,
cwd: process.cwd(),
});

replace.sync(replaceConf);
if (replaceConf) {
replaceInFiles(replaceConf);
}
}
}
Expand Down
145 changes: 145 additions & 0 deletions lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
'use strict'

// Pure, testable helpers for tagy. The CLI orchestration (git, prompts,
// process I/O) lives in index.js; everything here is side-effect-free or
// takes its dependencies (fs, cwd) as arguments so it can be unit-tested.

const path = require('path')
const fs = require('fs-extra')
const semver = require('semver')

const COLOR_CODES = {
red: [31, 39],
green: [32, 39],
blue: [34, 39],
yellow: [33, 39],
bold: [1, 22],
}

// Zero-dependency, cross-platform replacement for `chalk`. Returns a chainable
// object (e.g. `color.red.bold(text)`). Color is enabled when FORCE_COLOR is
// set or the output is a TTY, and disabled when NO_COLOR is set or output is
// piped/non-TTY — matching chalk's behavior.
function createColor({isTTY = false, env = {}} = {}) {
const supportsColor = (() => {
if ('NO_COLOR' in env) return false;
if (env.FORCE_COLOR) return true;
return Boolean(isTTY);
})();

const build = (styles) => {
const fn = (text) => {
if (!supportsColor) return String(text);
return styles.reduce((str, name) => {
const [open, close] = COLOR_CODES[name];
return `\x1b[${open}m${str}\x1b[${close}m`;
}, String(text));
};

Object.keys(COLOR_CODES).forEach((name) => {
Object.defineProperty(fn, name, {
get: () => build([...styles, name]),
});
});

return fn;
};

return build([]);
}

// Zero-dependency replacement for `replace-in-file`. `files` are already
// resolved absolute paths (no glob expansion); `from` is a RegExp and `to` a
// string. Writes back only when the contents actually change.
function replaceInFiles({files, from, to}, fsModule = fs) {
files.forEach((file) => {
if (!fsModule.existsSync(file)) return;
const content = fsModule.readFileSync(file, 'utf8');
const replaced = content.replace(from, to);
if (replaced !== content) {
fsModule.writeFileSync(file, replaced);
}
});
}

// Replace the `__VERSION__` and `__CURRENT_TAG__` placeholders in a string.
function substitute(value, {version, currentTag}) {
return String(value)
.replaceAll('__CURRENT_TAG__', currentTag)
.replaceAll('__VERSION__', version);
}

// Turn a single `tagy.replace` rule into a config for replaceInFiles().
// Returns null when the rule is missing any of files/from/to.
function buildReplaceConfig(rule, {version, currentTag, cwd}) {
const {files, from, to, flags} = rule;

if (!(files && from && to)) return null;

const list = Array.isArray(files) ? files : [files];

return {
files: list.map((file) => path.resolve(`${cwd}/${file}`)),
from: new RegExp(substitute(from, {version, currentTag}), flags !== false ? (flags || 'g') : undefined),
to: substitute(to, {version, currentTag}),
};
}

// Map yargs args to a semver increment type, or null if none/invalid.
function resolveIncrement(args) {
if (args.p || args.patch) return 'patch';
if (args.m || args.minor) return 'minor';
if (args.major) return 'major';
return null;
}

// Normalize a raw current version (from a git tag or package.json) to a clean
// semver string, defaulting to '0.0.0' when missing or below it.
function normalizeCurrentVersion(vv) {
if (!vv) return '0.0.0';
const trimmed = String(vv).trim();
if (!trimmed || semver.ltr(trimmed, '0.0.0')) return '0.0.0';
return trimmed;
}

// Compute the next version for a given increment type.
function nextVersion(current, type) {
return semver.inc(current, type);
}

// Write a new version into the package.json found in `cwd`. Resolves true when
// there is no package.json (nothing to bump) or after a successful write.
async function bumpPackageVersion(vv, {cwd = process.cwd(), fs: fsModule = fs} = {}) {
const pkgPath = path.join(cwd, 'package.json');

if (!fsModule.existsSync(pkgPath)) return true;

let pkgContent;
try {
pkgContent = await fsModule.readJSON(pkgPath);
} catch (err) {
throw new Error(`Couldn't parse "package.json"`);
}

pkgContent.version = vv;

try {
await fsModule.writeJSON(pkgPath, pkgContent, {spaces: 2});
} catch (err) {
throw new Error(`Couldn't write to "package.json"`);
}

return true;
}

module.exports = {
COLOR_CODES,
createColor,
replaceInFiles,
substitute,
buildReplaceConfig,
resolveIncrement,
normalizeCurrentVersion,
nextVersion,
bumpPackageVersion,
}
Loading