From f79f1850ffb05b7565331b8bd86f21d3c4d9d51c Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:05:34 +0300 Subject: [PATCH 01/13] docs: add tagy v2 design spec and implementation plan --- docs/superpowers/plans/2026-06-18-tagy-v2.md | 1235 +++++++++++++++++ .../specs/2026-06-18-tagy-v2-design.md | 269 ++++ 2 files changed, 1504 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-tagy-v2.md create mode 100644 docs/superpowers/specs/2026-06-18-tagy-v2-design.md diff --git a/docs/superpowers/plans/2026-06-18-tagy-v2.md b/docs/superpowers/plans/2026-06-18-tagy-v2.md new file mode 100644 index 0000000..9bab30c --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-tagy-v2.md @@ -0,0 +1,1235 @@ +# tagy v2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite tagy in TypeScript as a git-first, `.tagyrc`-configured general release tool that is safe-by-default and no longer tied to `package.json`. + +**Architecture:** Modular TypeScript under `src/`, compiled with plain `tsc` to `dist/`. `index.ts` orchestrates thin; all side effects (git, fs, prompts) live behind injectable module boundaries (`git.ts`, `bump.ts`, `config.ts`, `wizard.ts`) so every unit is testable. Current version comes from the latest git tag; files are only written when configured. + +**Tech Stack:** Node ≥18, TypeScript (plain `tsc`), Vitest, and the existing runtime deps (`shelljs`, `prompts`, `semver`, `fs-extra`, `yargs`). + +## Global Constraints + +- **No NEW runtime dependencies.** devDependencies (TypeScript, `@types/*`, Vitest) are fine; nothing new under `dependencies`. +- **Node ≥18** (`engines.node`). +- **TypeScript `strict: true`**, compiled with plain `tsc` (no bundler), CommonJS output to `dist/`. +- **Git tag is the source of truth** for the current version; file versions are written unprefixed. +- **Safe by default:** with no `bump`/`replace` configured, tagy touches no files. +- **Commit signing:** commits require the 1Password agent unlocked. If a commit step fails with `1Password: failed to fill whole buffer`, unlock 1Password and retry (do not bypass signing). +- Spec: `docs/superpowers/specs/2026-06-18-tagy-v2-design.md`. + +--- + +## File Structure + +``` +src/cli.ts # shebang shim -> calls default export of index.ts (bin -> dist/cli.js) +src/index.ts # orchestration only: parse args, resolve config, drive flow +src/types.ts # shared interfaces: ReplaceRule, TagyConfig +src/lib.ts # pure helpers (port of current lib.js, typed) +src/git.ts # command-string builders + thin shelljs wrappers (shell injectable) +src/bump.ts # KNOWN_BUMP_FILES registry, JSON bumpers, replace rules, writeConfig +src/config.ts # load/normalize .tagyrc, detect + migrate legacy package.json.tagy +src/wizard.ts # interactive prompts -> TagyConfig + save decision (prompts injectable) + +test/lib.test.ts +test/git.test.ts +test/bump.test.ts +test/config.test.ts +test/wizard.test.ts + +tsconfig.json +``` + +Removed after migration: `lib.js`, `index.js`, `test/lib.test.js` (replaced by `src/` + `.ts` tests). + +**Build order / dependencies:** `lib` → `git` → `bump` → `config` (imports `bump`) → `wizard` (imports `bump`) → `index` (imports all) → CI/docs. Tasks follow this order. + +--- + +## Task 1: TypeScript toolchain + port `lib.ts` and `types.ts` + +**Files:** +- Create: `tsconfig.json`, `src/types.ts`, `src/lib.ts`, `test/lib.test.ts` +- Modify: `package.json` (scripts, bin, main, files, engines, devDeps) +- Delete: `lib.js`, `test/lib.test.js` + +**Interfaces:** +- Produces: `src/types.ts` → `ReplaceRule`, `TagyConfig`. `src/lib.ts` → `createColor({isTTY,env}): Color`, `substitute(value, {version,currentTag}): string`, `buildReplaceConfig(rule, {version,currentTag,cwd}): ReplaceConfig|null`, `replaceInFiles(conf, fsModule?): void`, `resolveIncrement(args): Increment|null`, `normalizeCurrentVersion(vv): string`, `nextVersion(current, type): string`. Types: `Color`, `Increment = 'patch'|'minor'|'major'`, `ReplaceConfig = {files:string[]; from:RegExp; to:string}`. + +- [ ] **Step 1: Install dev tooling** + +```bash +npm install -D typescript @types/node @types/fs-extra @types/semver @types/shelljs @types/prompts @types/yargs +``` + +- [ ] **Step 2: Create `tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Update `package.json`** (scripts, bin, main, files, engines) + +Set these fields (leave `dependencies` untouched): + +```jsonc +{ + "main": "dist/index.js", + "bin": { "tagy": "dist/cli.js" }, + "files": ["dist"], + "engines": { "node": ">=18" }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "prepack": "npm run build" + } +} +``` + +- [ ] **Step 4: Create `src/types.ts`** + +```ts +export interface ReplaceRule { + files: string | string[] + from: string + to: string + flags?: string | false +} + +export interface TagyConfig { + branch: string | null + tagPrefix: string + bump: string[] + replace: ReplaceRule[] + autoRelease: boolean +} +``` + +- [ ] **Step 5: Write `test/lib.test.ts`** (port of existing JS tests, retargeted to `src/lib`) + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + createColor, substitute, buildReplaceConfig, replaceInFiles, + resolveIncrement, normalizeCurrentVersion, nextVersion, +} from '../src/lib' + +describe('createColor', () => { + it('emits ANSI when FORCE_COLOR set', () => { + expect(createColor({ isTTY: false, env: { FORCE_COLOR: '1' } }).blue('Y')).toBe('\x1b[34mY\x1b[39m') + }) + it('nests red.bold', () => { + expect(createColor({ isTTY: true, env: {} }).red.bold('X')).toBe('\x1b[1m\x1b[31mX\x1b[39m\x1b[22m') + }) + it('plain text when NO_COLOR set', () => { + expect(createColor({ isTTY: true, env: { NO_COLOR: '1' } }).red.bold('X')).toBe('X') + }) + it('plain text for non-TTY', () => { + expect(createColor({ isTTY: false, env: {} }).yellow('W')).toBe('W') + }) +}) + +describe('substitute', () => { + it('replaces both placeholders, all occurrences', () => { + expect(substitute('__VERSION__ __CURRENT_TAG__ __VERSION__', { version: '1.2.4', currentTag: '1.2.3' })) + .toBe('1.2.4 1.2.3 1.2.4') + }) +}) + +describe('resolveIncrement', () => { + it('maps flags with patch>minor>major priority', () => { + expect(resolveIncrement({ patch: true })).toBe('patch') + expect(resolveIncrement({ m: true })).toBe('minor') + expect(resolveIncrement({ major: true })).toBe('major') + expect(resolveIncrement({ p: true, major: true })).toBe('patch') + expect(resolveIncrement({})).toBeNull() + }) +}) + +describe('normalizeCurrentVersion', () => { + it('defaults and trims', () => { + expect(normalizeCurrentVersion(undefined)).toBe('0.0.0') + expect(normalizeCurrentVersion(' ')).toBe('0.0.0') + expect(normalizeCurrentVersion('1.2.3\n')).toBe('1.2.3') + }) +}) + +describe('nextVersion', () => { + it('bumps', () => { + expect(nextVersion('1.2.3', 'patch')).toBe('1.2.4') + expect(nextVersion('1.2.3', 'minor')).toBe('1.3.0') + expect(nextVersion('1.9.9', 'major')).toBe('2.0.0') + }) +}) + +describe('buildReplaceConfig', () => { + const ctx = { version: '1.2.4', currentTag: '1.2.3', cwd: '/proj' } + it('returns null when incomplete', () => { + expect(buildReplaceConfig({ from: 'a', to: 'b' } as any, ctx)).toBeNull() + }) + it('resolves files, substitutes, defaults global flag', () => { + const c = buildReplaceConfig({ files: 'f', from: 'V __CURRENT_TAG__', to: 'V __VERSION__' }, ctx)! + expect(c.files).toEqual([resolve('/proj/f')]) + expect(c.from.flags).toBe('g') + expect(c.from.test('V 1.2.3')).toBe(true) + expect(c.to).toBe('V 1.2.4') + }) + it('flags:false -> no flags', () => { + expect(buildReplaceConfig({ files: 'f', from: 'a', to: 'b', flags: false }, ctx)!.from.flags).toBe('') + }) +}) + +describe('replaceInFiles', () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-')) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + it('replaces all matches', () => { + const f = join(dir, 's.css'); writeFileSync(f, 'V 1.2.3\nV 1.2.3\n') + replaceInFiles({ files: [f], from: /V \d+\.\d+\.\d+/g, to: 'V 1.2.4' }) + expect(readFileSync(f, 'utf8')).toBe('V 1.2.4\nV 1.2.4\n') + }) + it('skips missing files', () => { + const f = join(dir, 'no.txt') + expect(() => replaceInFiles({ files: [f], from: /x/g, to: 'y' })).not.toThrow() + expect(existsSync(f)).toBe(false) + }) +}) +``` + +- [ ] **Step 6: Run the test to verify it fails** + +Run: `npx vitest run test/lib.test.ts` +Expected: FAIL — `Cannot find module '../src/lib'`. + +- [ ] **Step 7: Create `src/lib.ts`** + +```ts +import path from 'path' +import fs from 'fs-extra' +import semver from 'semver' +import type { ReplaceRule } from './types' + +const COLOR_CODES = { + red: [31, 39], green: [32, 39], blue: [34, 39], yellow: [33, 39], bold: [1, 22], +} as const +type StyleName = keyof typeof COLOR_CODES + +export type Color = ((text: unknown) => string) & { [K in StyleName]: Color } +export type Increment = 'patch' | 'minor' | 'major' +export interface ReplaceConfig { files: string[]; from: RegExp; to: string } + +export function createColor( + { isTTY = false, env = {} as Record } = {}, +): Color { + const supportsColor = (() => { + if ('NO_COLOR' in env) return false + if (env.FORCE_COLOR) return true + return Boolean(isTTY) + })() + + const build = (styles: StyleName[]): Color => { + const fn = ((text: unknown) => { + 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)) + }) as Color + ;(Object.keys(COLOR_CODES) as StyleName[]).forEach((name) => { + Object.defineProperty(fn, name, { get: () => build([...styles, name]) }) + }) + return fn + } + + return build([]) +} + +export function substitute(value: unknown, { version, currentTag }: { version: string; currentTag: string }): string { + return String(value).replaceAll('__CURRENT_TAG__', currentTag).replaceAll('__VERSION__', version) +} + +export function buildReplaceConfig( + rule: ReplaceRule, + { version, currentTag, cwd }: { version: string; currentTag: string; cwd: string }, +): ReplaceConfig | null { + 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 }), + } +} + +export function replaceInFiles({ files, from, to }: ReplaceConfig, fsModule: typeof fs = fs): void { + 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) + }) +} + +export function resolveIncrement(args: Record): Increment | null { + if (args.p || args.patch) return 'patch' + if (args.m || args.minor) return 'minor' + if (args.major) return 'major' + return null +} + +export function normalizeCurrentVersion(vv: unknown): string { + 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 +} + +export function nextVersion(current: string, type: Increment): string { + return semver.inc(current, type) as string +} +``` + +- [ ] **Step 8: Delete the old JS files** + +```bash +git rm lib.js test/lib.test.js +``` + +- [ ] **Step 9: Run tests and build** + +Run: `npx vitest run test/lib.test.ts && npx tsc --noEmit` +Expected: tests PASS; `tsc` reports no errors. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "refactor: port lib to TypeScript, add tsc toolchain" +``` + +--- + +## Task 2: `git.ts` — command builders + shell wrappers + +**Files:** +- Create: `src/git.ts`, `test/git.test.ts` + +**Interfaces:** +- Produces: `cmd` (object of pure command-string builders), and wrappers `insideRepo(sh?)`, `currentBranch(sh?)`, `fetchTags(sh?)`, `latestTag(prefix, sh?)`, `commit(message, sh?)`, `pushBranch(branch, sh?)`, `createTag(tag, sh?)`, `pushTag(tag, sh?)`, `deleteTag(tag, sh?)`, `ghAvailable(sh?)`, `ghReleaseCreate(tag, title, notes, sh?)`. `Shell` type = `{ exec(cmd: string, opts?: { silent?: boolean }): { code: number; stdout: string } }`. + +- [ ] **Step 1: Write `test/git.test.ts`** + +```ts +import { describe, it, expect } from 'vitest' +import { cmd, insideRepo, currentBranch, latestTag, deleteTag, ghAvailable } from '../src/git' + +function fakeShell(result: { code?: number; stdout?: string }) { + const calls: string[] = [] + return { + calls, + exec(c: string) { calls.push(c); return { code: result.code ?? 0, stdout: result.stdout ?? '' } }, + } +} + +describe('cmd builders', () => { + it('latestTag includes the prefix', () => { + expect(cmd.latestTag('v')).toBe("git tag --sort=v:refname | grep -E '^v[0-9]' | tail -1") + }) + it('commit/tag/push builders', () => { + expect(cmd.commit('Release v1.2.3')).toBe('git commit -a -m "Release v1.2.3"') + expect(cmd.createTag('v1.2.3')).toBe('git tag v1.2.3') + expect(cmd.pushBranch('main')).toBe('git push origin main') + expect(cmd.pushDeleteTag('v1.2.3')).toBe('git push origin :refs/tags/v1.2.3') + }) +}) + +describe('wrappers', () => { + it('insideRepo true on exit 0', () => { + expect(insideRepo(fakeShell({ code: 0 }))).toBe(true) + }) + it('insideRepo false on nonzero', () => { + expect(insideRepo(fakeShell({ code: 128 }))).toBe(false) + }) + it('currentBranch trims stdout', () => { + expect(currentBranch(fakeShell({ stdout: 'main\n' }))).toBe('main') + }) + it('latestTag trims stdout', () => { + expect(latestTag('', fakeShell({ stdout: '1.2.3\n' }))).toBe('1.2.3') + }) + it('deleteTag runs delete then remote-delete', () => { + const sh = fakeShell({}) + deleteTag('1.2.3', sh) + expect(sh.calls).toEqual([cmd.deleteTag('1.2.3'), cmd.pushDeleteTag('1.2.3')]) + }) + it('ghAvailable false when stdout empty', () => { + expect(ghAvailable(fakeShell({ stdout: '' }))).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/git.test.ts` +Expected: FAIL — `Cannot find module '../src/git'`. + +- [ ] **Step 3: Create `src/git.ts`** + +```ts +import shell from 'shelljs' + +export interface Shell { + exec(command: string, opts?: { silent?: boolean }): { code: number; stdout: string } +} + +export const cmd = { + insideRepo: () => 'git rev-parse --is-inside-work-tree', + currentBranch: () => "git branch | grep \\* | cut -d ' ' -f2", + fetchTags: () => 'git fetch --tags', + latestTag: (prefix: string) => `git tag --sort=v:refname | grep -E '^${prefix}[0-9]' | tail -1`, + commit: (message: string) => `git commit -a -m "${message}"`, + pushBranch: (branch: string) => `git push origin ${branch}`, + createTag: (tag: string) => `git tag ${tag}`, + pushTag: (tag: string) => `git push origin ${tag}`, + deleteTag: (tag: string) => `git tag -d ${tag}`, + pushDeleteTag: (tag: string) => `git push origin :refs/tags/${tag}`, + ghVersion: () => 'gh --version', + ghRelease: (tag: string, title: string, notes: string) => + `gh release create ${tag} --title "${title}" --notes "${notes}"`, +} + +const sh = shell as unknown as Shell + +export function insideRepo(s: Shell = sh): boolean { + return s.exec(cmd.insideRepo(), { silent: true }).code === 0 +} +export function currentBranch(s: Shell = sh): string { + return s.exec(cmd.currentBranch(), { silent: true }).stdout.trim() +} +export function fetchTags(s: Shell = sh): void { + s.exec(cmd.fetchTags(), { silent: true }) +} +export function latestTag(prefix: string, s: Shell = sh): string { + return s.exec(cmd.latestTag(prefix), { silent: true }).stdout.trim() +} +export function commit(message: string, s: Shell = sh): void { + s.exec(cmd.commit(message)) +} +export function pushBranch(branch: string, s: Shell = sh): void { + s.exec(cmd.pushBranch(branch)) +} +export function createTag(tag: string, s: Shell = sh): void { + s.exec(cmd.createTag(tag)) +} +export function pushTag(tag: string, s: Shell = sh): void { + s.exec(cmd.pushTag(tag)) +} +export function deleteTag(tag: string, s: Shell = sh): void { + s.exec(cmd.deleteTag(tag)) + s.exec(cmd.pushDeleteTag(tag)) +} +export function ghAvailable(s: Shell = sh): boolean { + return Boolean(s.exec(cmd.ghVersion(), { silent: true }).stdout) +} +export function ghReleaseCreate(tag: string, title: string, notes: string, s: Shell = sh): void { + s.exec(cmd.ghRelease(tag, title, notes)) +} +``` + +- [ ] **Step 4: Run tests + typecheck** + +Run: `npx vitest run test/git.test.ts && npx tsc --noEmit` +Expected: PASS; no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/git.ts test/git.test.ts +git commit -m "feat: add git module (command builders + shell wrappers)" +``` + +--- + +## Task 3: `bump.ts` — registry, JSON bumpers, replace rules, writeConfig + +**Files:** +- Create: `src/bump.ts`, `test/bump.test.ts` + +**Interfaces:** +- Consumes: `buildReplaceConfig`, `replaceInFiles` from `src/lib`; `ReplaceRule`, `TagyConfig` from `src/types`. +- Produces: `KNOWN_BUMP_FILES: readonly string[]`, `bumpJsonVersion(absFile, version, fsModule?): boolean`, `bumpFiles(names, version, {cwd, fs?}): string[]`, `applyReplaceRules(rules, {version, currentTag, cwd, fs?}): void`, `writeConfig(config, {cwd, fs?}): void`. + +- [ ] **Step 1: Write `test/bump.test.ts`** + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { KNOWN_BUMP_FILES, bumpFiles, applyReplaceRules, writeConfig } from '../src/bump' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-bump-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('KNOWN_BUMP_FILES', () => { + it('contains the two JSON manifests', () => { + expect([...KNOWN_BUMP_FILES]).toEqual(['package.json', 'composer.json']) + }) +}) + +describe('bumpFiles', () => { + it('bumps existing known JSON files, preserves other fields + indent', () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2)) + const bumped = bumpFiles(['package.json'], '1.0.1', { cwd: dir }) + expect(bumped).toEqual(['package.json']) + const out = readFileSync(join(dir, 'package.json'), 'utf8') + expect(JSON.parse(out)).toMatchObject({ name: 'x', version: '1.0.1' }) + expect(out).toContain('\n "name"') + }) + it('skips files that do not exist', () => { + expect(bumpFiles(['composer.json'], '1.0.1', { cwd: dir })).toEqual([]) + }) + it('ignores unknown file names', () => { + writeFileSync(join(dir, 'random.json'), '{"version":"1.0.0"}') + expect(bumpFiles(['random.json'], '2.0.0', { cwd: dir })).toEqual([]) + }) +}) + +describe('applyReplaceRules', () => { + it('applies regex replacement with placeholders', () => { + const f = join(dir, 'style.css'); writeFileSync(f, 'Version: 1.2.3\n') + applyReplaceRules( + [{ files: 'style.css', from: 'Version: __CURRENT_TAG__', to: 'Version: __VERSION__' }], + { version: '1.2.4', currentTag: '1.2.3', cwd: dir }, + ) + expect(readFileSync(f, 'utf8')).toBe('Version: 1.2.4\n') + }) +}) + +describe('writeConfig', () => { + it('writes .tagyrc as pretty JSON', () => { + writeConfig({ branch: 'main', tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }, { cwd: dir }) + const out = readFileSync(join(dir, '.tagyrc'), 'utf8') + expect(JSON.parse(out)).toEqual({ branch: 'main', tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }) + expect(out.endsWith('\n')).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/bump.test.ts` +Expected: FAIL — `Cannot find module '../src/bump'`. + +- [ ] **Step 3: Create `src/bump.ts`** + +```ts +import path from 'path' +import fs from 'fs-extra' +import { buildReplaceConfig, replaceInFiles } from './lib' +import type { ReplaceRule, TagyConfig } from './types' + +export const KNOWN_BUMP_FILES = ['package.json', 'composer.json'] as const + +export function bumpJsonVersion(absFile: string, version: string, fsModule: typeof fs = fs): boolean { + if (!fsModule.existsSync(absFile)) return false + const content = fsModule.readJsonSync(absFile) + content.version = version + fsModule.writeJsonSync(absFile, content, { spaces: 2 }) + return true +} + +export function bumpFiles( + names: string[], + version: string, + { cwd, fs: fsModule = fs }: { cwd: string; fs?: typeof fs }, +): string[] { + const bumped: string[] = [] + names.forEach((name) => { + if (!(KNOWN_BUMP_FILES as readonly string[]).includes(name)) return + if (bumpJsonVersion(path.resolve(cwd, name), version, fsModule)) bumped.push(name) + }) + return bumped +} + +export function applyReplaceRules( + rules: ReplaceRule[], + { version, currentTag, cwd, fs: fsModule = fs }: + { version: string; currentTag: string; cwd: string; fs?: typeof fs }, +): void { + rules.forEach((rule) => { + const conf = buildReplaceConfig(rule, { version, currentTag, cwd }) + if (conf) replaceInFiles(conf, fsModule) + }) +} + +export function writeConfig( + config: TagyConfig, + { cwd, fs: fsModule = fs }: { cwd: string; fs?: typeof fs }, +): void { + fsModule.writeFileSync(path.resolve(cwd, '.tagyrc'), JSON.stringify(config, null, 2) + '\n') +} +``` + +- [ ] **Step 4: Run tests + typecheck** + +Run: `npx vitest run test/bump.test.ts && npx tsc --noEmit` +Expected: PASS; no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/bump.ts test/bump.test.ts +git commit -m "feat: add bump module (JSON bumpers, replace rules, writeConfig)" +``` + +--- + +## Task 4: `config.ts` — load, normalize, migrate + +**Files:** +- Create: `src/config.ts`, `test/config.test.ts` + +**Interfaces:** +- Consumes: `KNOWN_BUMP_FILES` from `src/bump`; `TagyConfig`, `ReplaceRule` from `src/types`. +- Produces: `DEFAULT_CONFIG: TagyConfig`, `normalizeConfig(raw, warn?): TagyConfig`, `loadConfig({cwd, fs?, warn?}): { config: TagyConfig|null; legacy: Record|null }`, `migrateLegacy(legacy): TagyConfig`. + +- [ ] **Step 1: Write `test/config.test.ts`** + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { normalizeConfig, loadConfig, migrateLegacy } from '../src/config' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-cfg-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('normalizeConfig', () => { + it('fills defaults and coerces types', () => { + expect(normalizeConfig({})).toEqual({ branch: null, tagPrefix: '', bump: [], replace: [], autoRelease: false }) + }) + it('warns on unknown keys', () => { + const warnings: string[] = [] + normalizeConfig({ nope: 1 }, (m) => warnings.push(m)) + expect(warnings[0]).toContain('nope') + }) + it('throws on unsupported bump file', () => { + expect(() => normalizeConfig({ bump: ['Cargo.toml'] })).toThrow('Cargo.toml') + }) +}) + +describe('loadConfig', () => { + it('reads .tagyrc when present', () => { + writeFileSync(join(dir, '.tagyrc'), JSON.stringify({ tagPrefix: 'v', bump: ['package.json'] })) + const { config, legacy } = loadConfig({ cwd: dir }) + expect(legacy).toBeNull() + expect(config).toMatchObject({ tagPrefix: 'v', bump: ['package.json'] }) + }) + it('detects legacy package.json.tagy when no .tagyrc', () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '1.0.0', tagy: { tagPrefix: 'v' } })) + const { config, legacy } = loadConfig({ cwd: dir }) + expect(config).toBeNull() + expect(legacy).toEqual({ tagPrefix: 'v' }) + }) + it('returns nulls when no config anywhere', () => { + expect(loadConfig({ cwd: dir })).toEqual({ config: null, legacy: null }) + }) +}) + +describe('migrateLegacy', () => { + it('maps keys and forces package.json bump', () => { + expect(migrateLegacy({ tagPrefix: 'v', 'auto-release': true, soft: true, replace: [{ files: 'a', from: 'b', to: 'c' }] })) + .toEqual({ branch: null, tagPrefix: 'v', bump: ['package.json'], replace: [{ files: 'a', from: 'b', to: 'c' }], autoRelease: true }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/config.test.ts` +Expected: FAIL — `Cannot find module '../src/config'`. + +- [ ] **Step 3: Create `src/config.ts`** + +```ts +import path from 'path' +import fs from 'fs-extra' +import { KNOWN_BUMP_FILES } from './bump' +import type { TagyConfig, ReplaceRule } from './types' + +export const DEFAULT_CONFIG: TagyConfig = { + branch: null, tagPrefix: '', bump: [], replace: [], autoRelease: false, +} + +const KNOWN_KEYS = ['branch', 'tagPrefix', 'bump', 'replace', 'autoRelease'] + +export function normalizeConfig( + raw: Record, + warn: (msg: string) => void = () => {}, +): TagyConfig { + Object.keys(raw).forEach((k) => { + if (!KNOWN_KEYS.includes(k)) warn(`Unknown .tagyrc key ignored: "${k}"`) + }) + + const config: TagyConfig = { + branch: typeof raw.branch === 'string' ? raw.branch : null, + tagPrefix: typeof raw.tagPrefix === 'string' ? raw.tagPrefix : '', + bump: Array.isArray(raw.bump) ? (raw.bump as string[]) : [], + replace: Array.isArray(raw.replace) ? (raw.replace as ReplaceRule[]) : [], + autoRelease: raw.autoRelease === true, + } + + config.bump.forEach((file) => { + if (!(KNOWN_BUMP_FILES as readonly string[]).includes(file)) { + throw new Error(`Unsupported bump file "${file}". Supported: ${KNOWN_BUMP_FILES.join(', ')}`) + } + }) + + return config +} + +export function loadConfig( + { cwd, fs: fsModule = fs, warn }: { cwd: string; fs?: typeof fs; warn?: (m: string) => void }, +): { config: TagyConfig | null; legacy: Record | null } { + for (const name of ['.tagyrc', '.tagyrc.json']) { + const p = path.resolve(cwd, name) + if (fsModule.existsSync(p)) { + const raw = JSON.parse(fsModule.readFileSync(p, 'utf8')) + return { config: normalizeConfig(raw, warn), legacy: null } + } + } + + const pkgPath = path.resolve(cwd, 'package.json') + if (fsModule.existsSync(pkgPath)) { + const pkg = fsModule.readJsonSync(pkgPath) + if (pkg && pkg.tagy && typeof pkg.tagy === 'object') { + return { config: null, legacy: pkg.tagy as Record } + } + } + + return { config: null, legacy: null } +} + +export function migrateLegacy(legacy: Record): TagyConfig { + return { + branch: null, + tagPrefix: typeof legacy.tagPrefix === 'string' ? legacy.tagPrefix : '', + bump: ['package.json'], + replace: Array.isArray(legacy.replace) ? legacy.replace : [], + autoRelease: legacy['auto-release'] === true, + } +} +``` + +- [ ] **Step 4: Run tests + typecheck** + +Run: `npx vitest run test/config.test.ts && npx tsc --noEmit` +Expected: PASS; no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/config.ts test/config.test.ts +git commit -m "feat: add config module (.tagyrc load/normalize/migrate)" +``` + +--- + +## Task 5: `wizard.ts` — interactive config + +**Files:** +- Create: `src/wizard.ts`, `test/wizard.test.ts` + +**Interfaces:** +- Consumes: `KNOWN_BUMP_FILES` from `src/bump`; `TagyConfig` from `src/types`. +- Produces: `existingBumpFiles(cwd, fs?): string[]`, `runWizard({cwd, branch, promptsLib?, fs?}): Promise<{config: TagyConfig; save: boolean} | null>`. Returns `null` if the user declines to tag the branch. `promptsLib` matches the call signature `(q) => Promise<{ value: any }>`. + +- [ ] **Step 1: Write `test/wizard.test.ts`** + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { runWizard, existingBumpFiles } from '../src/wizard' + +// scripted prompts: returns answers in order +function scripted(answers: any[]) { + let i = 0 + return async () => ({ value: answers[i++] }) +} + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-wiz-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('existingBumpFiles', () => { + it('lists only known files that exist', () => { + writeFileSync(join(dir, 'package.json'), '{}') + expect(existingBumpFiles(dir)).toEqual(['package.json']) + }) +}) + +describe('runWizard', () => { + it('returns null when user declines to tag the branch', async () => { + const result = await runWizard({ cwd: dir, branch: 'feature', promptsLib: scripted([false]) as any }) + expect(result).toBeNull() + }) + + it('builds config from answers and save decision', async () => { + writeFileSync(join(dir, 'package.json'), '{}') + // answers: confirm branch=true, prefix='v', bump=['package.json'], save=true + const result = await runWizard({ cwd: dir, branch: 'main', promptsLib: scripted([true, 'v', ['package.json'], true]) as any }) + expect(result).toEqual({ + config: { branch: 'main', tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }, + save: true, + }) + }) + + it('skips bump question when no known files exist', async () => { + // answers: confirm=true, prefix='', save=false (no bump prompt) + const result = await runWizard({ cwd: dir, branch: 'main', promptsLib: scripted([true, '', false]) as any }) + expect(result).toEqual({ + config: { branch: 'main', tagPrefix: '', bump: [], replace: [], autoRelease: false }, + save: false, + }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/wizard.test.ts` +Expected: FAIL — `Cannot find module '../src/wizard'`. + +- [ ] **Step 3: Create `src/wizard.ts`** + +```ts +import path from 'path' +import fs from 'fs-extra' +import { KNOWN_BUMP_FILES } from './bump' +import type { TagyConfig } from './types' + +type Ask = (question: any) => Promise<{ value: any }> + +export interface WizardResult { config: TagyConfig; save: boolean } + +export function existingBumpFiles(cwd: string, fsModule: typeof fs = fs): string[] { + return (KNOWN_BUMP_FILES as readonly string[]).filter((f) => fsModule.existsSync(path.resolve(cwd, f))) +} + +export async function runWizard( + { cwd, branch, promptsLib, fs: fsModule = fs }: + { cwd: string; branch: string; promptsLib: Ask; fs?: typeof fs }, +): Promise { + const confirmBranch = await promptsLib({ + type: 'confirm', name: 'value', + message: `You're on branch '${branch}'. Tag this branch?`, initial: true, + }) + if (!confirmBranch.value) return null + + const prefixAns = await promptsLib({ + type: 'text', name: 'value', message: 'Tag prefix? (blank for none)', initial: '', + }) + const tagPrefix = String(prefixAns.value || '').trim() + + const choices = existingBumpFiles(cwd, fsModule) + let bump: string[] = [] + if (choices.length) { + const bumpAns = await promptsLib({ + type: 'multiselect', name: 'value', message: 'Bump version in a file?', + choices: choices.map((c) => ({ title: c, value: c })), instructions: false, + }) + bump = Array.isArray(bumpAns.value) ? bumpAns.value : [] + } + + const config: TagyConfig = { branch, tagPrefix, bump, replace: [], autoRelease: false } + + const saveAns = await promptsLib({ + type: 'confirm', name: 'value', + message: 'Save these answers to .tagyrc for next time?', initial: true, + }) + + return { config, save: Boolean(saveAns.value) } +} +``` + +- [ ] **Step 4: Run tests + typecheck** + +Run: `npx vitest run test/wizard.test.ts && npx tsc --noEmit` +Expected: PASS; no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/wizard.ts test/wizard.test.ts +git commit -m "feat: add interactive wizard module" +``` + +--- + +## Task 6: `index.ts` orchestration + `cli.ts` shim + +**Files:** +- Create: `src/index.ts`, `src/cli.ts` +- Delete: `index.js`, `cli.js` + +**Interfaces:** +- Consumes: everything from `lib`, `git`, `bump`, `config`, `wizard`, `types`. +- Produces: `src/index.ts` default export `() => void` (runs the async CLI). `src/cli.ts` shim invoking it. + +This module wires the three execution paths from the spec. It is the un-unit-tested shell; verification is manual (build + run in a temp repo). + +- [ ] **Step 1: Create `src/index.ts`** + +```ts +import fs from 'fs-extra' +import yargs from 'yargs' +import prompts from 'prompts' +import { + createColor, resolveIncrement, normalizeCurrentVersion, nextVersion, +} from './lib' +import * as git from './git' +import { bumpFiles, applyReplaceRules, writeConfig } from './bump' +import { loadConfig, migrateLegacy } from './config' +import { runWizard } from './wizard' +import type { TagyConfig } from './types' + +export default function (): void { + void (async () => { + const args = yargs(process.argv.slice(2)).argv as Record + const chalk = createColor({ isTTY: Boolean(process.stdout && process.stdout.isTTY), env: process.env }) + const cwd = process.cwd() + const ask = prompts as unknown as (q: any) => Promise<{ value: any }> + + // --- help / no-op --- + const increment = resolveIncrement(args) + const hasAction = increment || args.reverse || args.custom || args.info + if (!hasAction || args.h || args.help) { + console.log(chalk.blue('Usage: ') + chalk.yellow('tagy [-p|-m|--major|--custom|--reverse|--info|--soft|--auto-release]')) + return + } + + // mutually-exclusive increment validation + const exclusive = ['p', 'm', 'major', 'minor', 'patch', 'reverse', 'custom', 'info'] + .filter((k) => args[k]) + if (exclusive.length > 1) { + console.log(chalk.red.bold('Too many arguments! Pick one of patch/minor/major/reverse/custom/info.')) + return + } + + if (!git.insideRepo()) { + console.log(chalk.red.bold('Not a git repository.')) + return + } + + // --- resolve config --- + const { config: loaded, legacy } = loadConfig({ cwd, warn: (m) => console.log(chalk.yellow(m)) }) + let config: TagyConfig | null = loaded + + if (!config && legacy) { + const migrate = await ask({ + type: 'confirm', name: 'value', + message: 'Found legacy tagy config in package.json. Create .tagyrc from it?', initial: true, + }) + if (migrate.value) { + config = migrateLegacy(legacy) + writeConfig(config, { cwd }) + console.log(chalk.green('Created .tagyrc from legacy config.')) + } + } + + const branch = git.currentBranch() + if (!branch) { + console.log(chalk.red.bold("Can't determine the branch name!")) + return + } + + // --- no config at all: wizard --- + let isSoft = Boolean(args.soft) + if (!config) { + const wizard = await runWizard({ cwd, branch, promptsLib: ask }) + if (!wizard) { + console.log(chalk.red.bold('Aborted! Please switch the branch.')) + return + } + config = wizard.config + if (wizard.save) { + writeConfig(config, { cwd }) + console.log(chalk.green('Saved .tagyrc.')) + } + } else { + // config-driven branch check + if (config.branch) { + if (branch !== config.branch) { + console.log(chalk.red.bold(`On '${branch}', but .tagyrc requires '${config.branch}'. Aborted.`)) + return + } + } else if (branch !== 'master' && branch !== 'main') { + const confirmBranch = await ask({ + type: 'confirm', name: 'value', + message: `Current branch is '${branch}'. Tag this branch?`, initial: false, + }) + if (!confirmBranch.value) { + console.log(chalk.red.bold('Aborted! Please switch the branch.')) + return + } + } + } + + const tagPrefix = config.tagPrefix || '' + + // --- current version from latest tag --- + if (!isSoft) git.fetchTags() + let raw = isSoft ? '' : git.latestTag(tagPrefix) + + if (args.info) { + console.log(raw ? chalk.blue(`Last created tag is: ${raw}`) : chalk.blue('No tags created yet.')) + return + } + + if (args.reverse) { + if (isSoft) { console.log(chalk.red("Can't reverse in soft mode.")); return } + if (!raw) { console.log(chalk.blue('No tags to delete.')); return } + const confirmReverse = await ask({ + type: 'confirm', name: 'value', message: `Remove tag ${raw.trim()}?`, initial: false, + }) + if (!confirmReverse.value) { console.log(chalk.red.bold('Aborted!')); return } + git.deleteTag(raw.trim()) + console.log(chalk.blue(`Tag ${raw.trim()} deleted.`)) + return + } + + // --- compute next version --- + let currentTag: string + let version: string + + if (args.custom) { + const customVer = await ask({ + type: 'text', name: 'value', + message: 'Enter a custom version (semver):', + validate: (val: string) => /^\d+\.\d+\.\d+$/.test(val) ? true : 'Invalid version!', + }) + if (!customVer.value) { console.log(chalk.red.bold('Aborted!')); return } + version = customVer.value + currentTag = version + } else { + currentTag = normalizeCurrentVersion(raw) + if (!increment) { console.log(chalk.red('Something went wrong!')); return } + version = nextVersion(currentTag, increment) + if (args.major) { + const confirmMajor = await ask({ + type: 'confirm', name: 'value', + message: `Create a major release? ${currentTag} -> ${version}`, initial: false, + }) + if (!confirmMajor.value) { console.log(chalk.red.bold('Aborted!')); return } + } + } + + // --- apply file changes (bump + replace) --- + const bumped = bumpFiles(config.bump, version, { cwd }) + if (bumped.length) console.log(chalk.green(`Bumped: ${bumped.join(', ')}`)) + if (config.replace.length) applyReplaceRules(config.replace, { version, currentTag, cwd }) + + // --- tagy.js hook (before push) --- + const hookPath = `${cwd}/tagy.js` + if (fs.existsSync(hookPath)) { + try { + const hook = require(hookPath) + await hook(version, currentTag, args) + } catch (err) { + console.error(err) + } + } + + if (isSoft) { + console.log(chalk.green(`Soft: files updated to ${version} (no git changes).`)) + return + } + + // --- git ops --- + git.commit(`Release ${tagPrefix}${version}`) + git.pushBranch(branch) + git.createTag(`${tagPrefix}${version}`) + git.pushTag(`${tagPrefix}${version}`) + + // --- optional GitHub release --- + if (git.ghAvailable()) { + let releaseIt = config.autoRelease || Boolean(args['auto-release']) + if (!releaseIt) { + const ghRelease = await ask({ + type: 'confirm', name: 'value', message: 'Create a GitHub release?', initial: false, + }) + releaseIt = Boolean(ghRelease.value) + } + if (releaseIt) { + const tag = `${tagPrefix}${version}` + git.ghReleaseCreate(tag, tag, `Release ${tag}`) + } + } + + console.log(chalk.blue(`Tag ${tagPrefix}${version} created!`)) + })() +} +``` + +- [ ] **Step 2: Create `src/cli.ts`** + +```ts +#!/usr/bin/env node +import tagy from './index' + +tagy() +``` + +- [ ] **Step 3: Delete old root JS entrypoints** + +```bash +git rm index.js cli.js +``` + +- [ ] **Step 4: Build and verify the shebang is preserved** + +Run: `npx tsc && head -1 dist/cli.js` +Expected: build succeeds; first line is `#!/usr/bin/env node`. (If tsc strips it, prepend the shebang in `src/cli.ts` is already first line — tsc preserves a leading shebang.) + +- [ ] **Step 5: Manual smoke test in a throwaway repo** + +```bash +# help +node dist/cli.js -h +# info inside this repo +node dist/cli.js --info +``` +Expected: help prints usage; `--info` prints the latest tag (or "No tags created yet"). + +- [ ] **Step 6: Manual end-to-end in a temp git repo (no config -> wizard, soft)** + +```bash +tmp=$(mktemp -d); cd "$tmp"; git init -q; git commit -q --allow-empty -m init +echo '{"name":"demo","version":"0.0.0"}' > package.json +node /dist/cli.js --patch --soft +# follow the wizard prompts; confirm package.json version is bumped, no tag created +cat package.json; git tag +cd -; rm -rf "$tmp" +``` +Expected: wizard runs, `package.json` version becomes `0.0.1` (soft bumps from custom/answers), no git tag created in soft mode. + +- [ ] **Step 7: Run the full test suite + typecheck** + +Run: `npm test && npx tsc --noEmit` +Expected: all tests PASS; no type errors. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat: TypeScript orchestration (index + cli) with .tagyrc, wizard, git-first flow" +``` + +--- + +## Task 7: Publish workflow build step + docs + +**Files:** +- Modify: `.github/workflows/npmpublish.yml`, `.github/workflows/test.yml`, `README.md`, `CLAUDE.md` + +**Interfaces:** none (CI + docs only). + +- [ ] **Step 1: Add a build step before publish in `.github/workflows/npmpublish.yml`** + +Insert `npm run build` after `npm test` and before `npm publish`: + +```yaml + - run: npm install + - run: npm test + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.npm_token }} +``` + +- [ ] **Step 2: Add a build/typecheck step to `.github/workflows/test.yml`** + +After `npm test`, add a typecheck so CI catches TS errors on PRs: + +```yaml + - run: npm install + - run: npm test + - run: npx tsc --noEmit +``` + +- [ ] **Step 3: Update `README.md`** — document `.tagyrc` (replace the old `package.json` `tagy` block docs) + +Replace the "`package.json` configuration" section with a `.tagyrc` section documenting `branch`, `tagPrefix`, `bump` (supported: `package.json`, `composer.json`), `replace`, `autoRelease`, the interactive wizard, and the legacy migration note. + +- [ ] **Step 4: Update `CLAUDE.md`** — reflect the v2 architecture + +Update the "Architecture" and "Commands" sections: TypeScript under `src/`, `tsc` build, `dist/` output, the new modules (`config`, `wizard`, `bump`, `git`), `.tagyrc` config, git-first version source, and `npm run build`. + +- [ ] **Step 5: Verify workflows are valid YAML and tests still pass** + +Run: `npm test && npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "ci+docs: build step for publish, .tagyrc docs, v2 architecture notes" +``` + +--- + +## Self-Review + +**Spec coverage:** +- `.tagyrc` JSON config, resolution order, schema, validation → Task 4 ✓ +- Legacy migration (offer + mapping, forces `package.json` bump) → Task 4 (`migrateLegacy`) + Task 6 (prompt/write) ✓ +- Interactive wizard (branch/prefix/bump, save prompt, omits replace) → Task 5 ✓ +- Git-first version source → Task 6 (`git.latestTag` → `normalizeCurrentVersion` → `nextVersion`) ✓ +- Structural JSON bumpers (`package.json`, `composer.json`) → Task 3 ✓ +- Replace rules + placeholders → Task 1 (`lib`) + Task 3 (`applyReplaceRules`) ✓ +- Branch handling (strict string / confirm default) → Task 6 ✓ +- Soft mode redefined (files only) → Task 6 ✓ +- `tagy.js` hook before push → Task 6 ✓ +- GitHub release (auto/prompt/skip) → Task 6 ✓ +- CLI surface + mutually-exclusive validation → Task 6 ✓ +- TypeScript + plain `tsc`, `dist/`, `files`, `prepack` → Task 1 ✓ +- Module split + per-module tests + DI boundary → Tasks 1–6 ✓ +- Build step in publish CI + docs → Task 7 ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows full code; manual steps in Task 6 are explicit commands. + +**Type consistency:** `TagyConfig`/`ReplaceRule` defined in Task 1 and reused verbatim in Tasks 3–6. `Increment` from `lib` used in `index`. `KNOWN_BUMP_FILES` defined in Task 3, consumed by Tasks 4–5. Wizard returns `{config, save}` consumed in Task 6. `git.*` signatures defined in Task 2 match calls in Task 6. + +## Notes / risks for the implementer + +- **`prompts` typing:** the real `prompts` default export is typed loosely; the code casts it to a simple `(q) => Promise<{value:any}>` for injection symmetry with tests. Keep the cast localized. +- **`yargs` import:** `import yargs from 'yargs'` with `esModuleInterop`; `yargs(process.argv.slice(2)).argv` may be a promise in yargs ≥18 but is sync in the pinned v13 — keep v13 (no dep change). +- **Shebang:** `tsc` preserves a leading `#!` line; Step 4 of Task 6 verifies it. +- **Soft + custom interaction:** in soft mode there is no git tag to read; the implementer should confirm soft-mode runs prompt for `--custom` or derive from answers. Current flow: soft sets `raw=''` → `normalizeCurrentVersion('')='0.0.0'` → patch → `0.0.1`. Validate during Task 6 Step 6. diff --git a/docs/superpowers/specs/2026-06-18-tagy-v2-design.md b/docs/superpowers/specs/2026-06-18-tagy-v2-design.md new file mode 100644 index 0000000..6f87997 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-tagy-v2-design.md @@ -0,0 +1,269 @@ +# tagy v2 — Design + +**Date:** 2026-06-18 +**Status:** Approved (design); pending implementation plan + +## Goal + +Turn `tagy` from an npm/Node-coupled release tool into a **general-purpose git +tagger** that works in any repository, while keeping **zero runtime +dependencies** as the north star. + +Two defining shifts from v1: + +1. **No longer tied to `package.json`.** Configuration moves to a standalone + `.tagyrc` (JSON). The current version comes from git tags, not a manifest. +2. **Safe by default.** With no/minimal config, tagy only verifies it is a git + repo, then tags + pushes. It touches **no files** unless told to. + +The codebase is also **rewritten in TypeScript** (dev-only tooling; nothing +extra ships to consumers). + +## Non-goals + +- TOML manifest parsing (`Cargo.toml`, `pyproject.toml`) — covered by generic + `replace` rules, not structural bumpers (avoids a parser dependency). +- Publishing TypeScript type declarations — tagy is a CLI, not an imported + library. +- A `tagy init` subcommand — the wizard's "save to `.tagyrc`?" prompt covers + config generation. +- Bundling runtime deps to fake a zero-dep `package.json` — deps stay external + and visible so they can be removed incrementally over time. + +## Architecture + +### Language & build + +- **TypeScript**, compiled with **plain `tsc`** (one devDep, readable + un-bundled CommonJS output). No bundler. +- Source in `src/*.ts`, compiled to `dist/`. +- `package.json`: `main` → `dist/index.js`, `bin.tagy` → `dist/cli.js`, + `files` → `["dist"]`. +- A `prepack` / `prepublishOnly` script runs the build so publishing can never + ship stale output. +- **Tests** run against `.ts` source directly via Vitest's esbuild transform — + no pre-build needed for `npm test`. + +### Modules (one responsibility each, independently testable) + +``` +src/cli.ts # shim -> index (shebang); compiles to dist/cli.js (bin) +src/index.ts # orchestration ONLY: wire modules, drive CLI flow +src/lib.ts # pure helpers: createColor, substitute, semver wrappers, + # normalizeCurrentVersion, nextVersion, resolveIncrement, + # buildReplaceConfig, replaceInFiles +src/config.ts # resolve + parse .tagyrc (JSON), validate/normalize, + # detect legacy package.json.tagy, build migration object +src/wizard.ts # interactive prompts when no config; returns a normalized + # config object + "save to .tagyrc?" decision +src/bump.ts # known-file registry (package.json, composer.json, + # structural) + applyReplaceRules + writeConfig (.tagyrc) +src/git.ts # thin shelljs wrapper: insideRepo, currentBranch, + # latestTag, fetchTags, commit, push, tag, deleteTag, + # ghReleaseCreate, ghAvailable +``` + +Test files, one per module: + +``` +test/lib.test.ts +test/config.test.ts +test/wizard.test.ts # answer-shape -> config; `prompts` injected/mocked +test/bump.test.ts # JSON bump + replace + writeConfig on temp files +test/git.test.ts # command-string builders verified; `shell` injected/mocked +``` + +### Dependency-injection boundary + +- `index.ts` never calls `shelljs`/`fs`/`prompts` directly — only through + `git.ts` / `bump.ts` / `config.ts` / `wizard.ts`. This keeps orchestration + thin and every unit testable. +- `wizard.ts` and `git.ts` take their side-effecting deps (`prompts`, `shell`) + as injected arguments (the pattern `lib.ts` already uses for `fs`), so tests + run without real prompts or real git. +- `bump.ts` owns **all** file writes: manifest bumps, replace rules, and + writing `.tagyrc` itself. + +## Configuration model (`.tagyrc`) + +### Resolution order (first match wins) + +1. `.tagyrc` (JSON) — primary +2. `.tagyrc.json` — alias, identical parsing +3. No `.tagyrc`, but `package.json` has a legacy `tagy` block → **offer + migration** (write `.tagyrc` from it, then proceed) +4. Nothing → **interactive wizard** + +### Schema (all keys optional; defaults shown) + +```jsonc +{ + "branch": null, // null = confirm current branch; string = required branch + "tagPrefix": "", // e.g. "v" -> tag "v1.2.3"; file versions stay unprefixed + "bump": [], // structural bumps: ["package.json","composer.json"] + "replace": [], // regex rules: { files, from, to, flags } + "autoRelease": false // gh release without prompting +} +``` + +### Validation / normalization (`config.ts`) + +- Unknown keys → ignored with a printed warning (forward-compatible). +- `bump` entries not in the known registry → error listing supported files. +- `replace` rules missing `files`/`from`/`to` → skipped (as in v1). +- Produces a single normalized config object. The **wizard produces the same + shape**, so `index.ts` sees one config regardless of source. + +### Legacy migration (one-time, when migrator runs) + +| legacy `package.json.tagy` | `.tagyrc` | +|---|---| +| `tagPrefix` | `tagPrefix` | +| `replace` | `replace` (unchanged) | +| `auto-release` | `autoRelease` | +| `soft` / `method: "soft"` | dropped (soft is now a CLI flag only) | +| (implicit `package.json` bump) | `bump: ["package.json"]` (preserves v1 behavior) | + +v1 always bumped `package.json`, so the migrator adds it to `bump` to avoid +silently dropping that behavior on upgrade. + +## Execution flow + +### Path 1 — `.tagyrc` present (non-interactive) + +``` +1. git.insideRepo() else abort +2. branch check: + - config.branch string -> abort if current != it + - config.branch null -> proceed on master/main, else confirm prompt +3. git.fetchTags(); current = latestTag(prefix); next = semver bump (or --custom) +4. --info / --reverse short-circuit here +5. bump.ts: structural bump each existing config.bump file; apply replace rules +6. tagy.js hook (if present) runs here, before git push +7. git: commit -> push branch -> tag -> push tag +8. gh release (autoRelease ? silent : prompt; skip if gh absent) +``` + +### Path 2 — no `.tagyrc`, legacy `package.json.tagy` found + +``` +prompt "Found legacy tagy config in package.json. Create .tagyrc from it? (Y/n)" + Y -> config builds migration object, bump.writeConfig(.tagyrc), continue as Path 1 + n -> fall through to Path 3 (wizard) for this run +``` + +### Path 3 — no config (interactive wizard) + +``` +1. git.insideRepo() else abort +2. ask: "You're on branch ''. Tag this branch?" (no master assumption) +3. ask: "Tag prefix? (blank for none)" -> e.g. "v" +4. ask: "Bump a version file? [none | package.json | composer.json]" + (multi-select from known registry; only offers files that exist) +5. replace rules are NOT asked (advanced; edit .tagyrc by hand) +6. run bump/tag/push using these answers +7. ask: "Save these answers to .tagyrc for next time? (Y/n)" -> bump.writeConfig +``` + +### Default = nothing destructive + +An empty/minimal `.tagyrc` (no `bump`, no `replace`) makes tagy tag + push only — +no file is touched. This is the new zero-config baseline. + +## Version & bump mechanics + +### Current/next version + +- `current` = latest git tag matching `tagPrefix`, prefix stripped. +- normalized via `normalizeCurrentVersion` (defaults to `0.0.0` when no tags). +- `next` = `semver.inc(current, type)` (patch/minor/major) or `--custom` value. +- Tag written = `tagPrefix + next`; file versions written **unprefixed**. + +### Structural bumpers (`bump.ts`) + +Registry keyed by filename: + +``` +package.json -> parse JSON, set .version = next, write (2-space indent) +composer.json -> parse JSON, set .version = next, write (2-space indent) +``` + +Only files in `config.bump` are touched, and only **if they exist** (missing → +skip with a notice, never error). The registry is the single extension point +for future JSON manifests. + +### Replace rules (`bump.ts`) + +Unchanged semantics from v1: `{ files, from, to, flags }`, `from` compiled to a +`RegExp`, `__VERSION__` / `__CURRENT_TAG__` substituted. Reuses the existing +`substitute` / `buildReplaceConfig` / `replaceInFiles` helpers. + +### Order of operations + +structural bumps → replace rules → `tagy.js` hook → git commit/push/tag. The +hook can react to already-bumped files (same as v1). + +## Branch handling + +- `config.branch` string → abort if current branch differs (strict, CI-friendly). +- `config.branch` null/absent → silent on `master`/`main`; confirm prompt on any + other branch (v1 behavior, now the configurable default). +- Wizard path → asks "tag branch ''?" directly. + +## Soft mode + +`--soft` is **kept, redefined**: apply structural bumps + replace rules, but do +**not** touch git. Only meaningful with `bump`/`replace` configured. Legacy +`tagy.soft` / `method: "soft"` config keys are dropped (CLI flag only). + +## GitHub release + +After pushing the tag, if `gh` is on PATH: `autoRelease` (config) or +`--auto-release` (flag) → create silently; otherwise prompt. No `gh` → skip +silently. Unchanged from v1. + +## CLI surface + +``` +-p, --patch increment patch +-m, --minor increment minor + --major increment major (confirm prompt) + --reverse delete/revert last tag (confirm prompt) + --custom enter version manually + --info show latest tag, then exit + --soft file changes only, no git + --auto-release create GitHub release without prompting +-h, --help +``` + +- No breaking **flag** changes vs v1; mutually-exclusive increment validation + retained (only one of patch/minor/major/reverse/custom/info). +- All breaking changes are in **config location/format**, not the CLI. + +## Breaking changes (v1 → v2) + +1. Config no longer read from `package.json.tagy` — moved to `.tagyrc` + (one-time migrator offered on first run). +2. `package.json` version bump is no longer automatic — it must be listed in + `bump` (the migrator adds it for upgrading users). +3. `tagy.soft` / `method: "soft"` config keys removed; `--soft` flag remains. +4. Published artifact is compiled `dist/` output (TypeScript source). + +## Testing strategy + +- One test file per module (`test/*.test.ts`), Vitest. +- `lib`, `config`, `bump` are pure/IO-via-temp-files → direct unit tests. +- `wizard` and `git` tested with injected mock `prompts` / `shell` so no real + interaction or git commands run. +- `index.ts` orchestration remains the thin, un-unit-tested shell (validated by + the smoke path / manual release). +- CI: `npm test` on PRs and master across a Node matrix (existing `test.yml`), + plus a build step in the publish workflow. + +## Open follow-ups (out of scope for this spec) + +- Removing the remaining runtime deps (`shelljs`, `prompts`, `semver`, + `fs-extra`, `yargs`) toward true zero-deps — separate effort, eased by the + module boundaries above. +- Possible future bundling into a single self-contained file once runtime deps + are gone. From 08ba07e0ff9b07264dafc31bf53253461be44339 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:09:26 +0300 Subject: [PATCH 02/13] refactor: port lib to TypeScript, add tsc toolchain --- lib.js | 145 ------------------------------ package.json | 30 ++++--- src/lib.ts | 84 ++++++++++++++++++ src/types.ts | 14 +++ test/lib.test.js | 227 ----------------------------------------------- test/lib.test.ts | 89 +++++++++++++++++++ tsconfig.json | 17 ++++ 7 files changed, 224 insertions(+), 382 deletions(-) delete mode 100644 lib.js create mode 100644 src/lib.ts create mode 100644 src/types.ts delete mode 100644 test/lib.test.js create mode 100644 test/lib.test.ts create mode 100644 tsconfig.json diff --git a/lib.js b/lib.js deleted file mode 100644 index 9804188..0000000 --- a/lib.js +++ /dev/null @@ -1,145 +0,0 @@ -'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, -} diff --git a/package.json b/package.json index 5543036..c5eb3ad 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,21 @@ "name": "tagy", "version": "1.10.8", "description": "Create a new git tag by following the 'Semantic Versioning' and push it on remote.", - "main": "index.js", + "main": "dist/index.js", + "bin": { + "tagy": "dist/cli.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, "scripts": { + "build": "tsc", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepack": "npm run build" }, "repository": { "type": "git", @@ -24,14 +35,6 @@ "author": "Andrei Surdu", "license": "MIT", "homepage": "https://github.com/awps/tagy#readme", - "bin": { - "tagy": "./cli.js" - }, - "files": [ - "cli.js", - "index.js", - "lib.js" - ], "dependencies": { "fs-extra": "^8.1.0", "prompts": "^2.3.0", @@ -40,6 +43,13 @@ "yargs": "^13.2.4" }, "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/node": "^25.9.3", + "@types/prompts": "^2.4.9", + "@types/semver": "^7.7.1", + "@types/shelljs": "^0.10.0", + "@types/yargs": "^17.0.35", + "typescript": "^6.0.3", "vitest": "^4.1.9" } } diff --git a/src/lib.ts b/src/lib.ts new file mode 100644 index 0000000..67b2cb5 --- /dev/null +++ b/src/lib.ts @@ -0,0 +1,84 @@ +import path from 'path' +import fs from 'fs-extra' +import semver from 'semver' +import type { ReplaceRule } from './types' + +const COLOR_CODES = { + red: [31, 39], green: [32, 39], blue: [34, 39], yellow: [33, 39], bold: [1, 22], +} as const +type StyleName = keyof typeof COLOR_CODES + +export type Color = ((text: unknown) => string) & { [K in StyleName]: Color } +export type Increment = 'patch' | 'minor' | 'major' +export interface ReplaceConfig { files: string[]; from: RegExp; to: string } + +export function createColor( + { isTTY = false, env = {} as Record } = {}, +): Color { + const supportsColor = (() => { + if ('NO_COLOR' in env) return false + if (env.FORCE_COLOR) return true + return Boolean(isTTY) + })() + + const build = (styles: StyleName[]): Color => { + const fn = ((text: unknown) => { + 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)) + }) as Color + ;(Object.keys(COLOR_CODES) as StyleName[]).forEach((name) => { + Object.defineProperty(fn, name, { get: () => build([...styles, name]) }) + }) + return fn + } + + return build([]) +} + +export function substitute(value: unknown, { version, currentTag }: { version: string; currentTag: string }): string { + return String(value).replaceAll('__CURRENT_TAG__', currentTag).replaceAll('__VERSION__', version) +} + +export function buildReplaceConfig( + rule: ReplaceRule, + { version, currentTag, cwd }: { version: string; currentTag: string; cwd: string }, +): ReplaceConfig | null { + 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 }), + } +} + +export function replaceInFiles({ files, from, to }: ReplaceConfig, fsModule: typeof fs = fs): void { + 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) + }) +} + +export function resolveIncrement(args: Record): Increment | null { + if (args.p || args.patch) return 'patch' + if (args.m || args.minor) return 'minor' + if (args.major) return 'major' + return null +} + +export function normalizeCurrentVersion(vv: unknown): string { + 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 +} + +export function nextVersion(current: string, type: Increment): string { + return semver.inc(current, type) as string +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..42c7ed1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,14 @@ +export interface ReplaceRule { + files: string | string[] + from: string + to: string + flags?: string | false +} + +export interface TagyConfig { + branch: string | null + tagPrefix: string + bump: string[] + replace: ReplaceRule[] + autoRelease: boolean +} diff --git a/test/lib.test.js b/test/lib.test.js deleted file mode 100644 index 1ad9c1e..0000000 --- a/test/lib.test.js +++ /dev/null @@ -1,227 +0,0 @@ -import {describe, it, expect, beforeEach, afterEach} from 'vitest' -import {tmpdir} from 'node:os' -import {mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync} from 'node:fs' -import {join, resolve} from 'node:path' - -import { - createColor, - replaceInFiles, - substitute, - buildReplaceConfig, - resolveIncrement, - normalizeCurrentVersion, - nextVersion, - bumpPackageVersion, -} from '../lib.js' - -describe('createColor', () => { - it('emits ANSI codes when FORCE_COLOR is set', () => { - const c = createColor({isTTY: false, env: {FORCE_COLOR: '1'}}) - expect(c.blue('Y')).toBe('\x1b[34mY\x1b[39m') - }) - - it('nests chained styles (red.bold) correctly', () => { - const c = createColor({isTTY: true, env: {}}) - expect(c.red.bold('X')).toBe('\x1b[1m\x1b[31mX\x1b[39m\x1b[22m') - }) - - it('emits codes for a TTY with no env overrides', () => { - const c = createColor({isTTY: true, env: {}}) - expect(c.green('Z')).toBe('\x1b[32mZ\x1b[39m') - }) - - it('returns plain text when NO_COLOR is set (even on a TTY)', () => { - const c = createColor({isTTY: true, env: {NO_COLOR: '1'}}) - expect(c.red.bold('X')).toBe('X') - }) - - it('returns plain text for non-TTY output (piped) with no env', () => { - const c = createColor({isTTY: false, env: {}}) - expect(c.yellow('W')).toBe('W') - }) - - it('NO_COLOR wins over FORCE_COLOR', () => { - const c = createColor({isTTY: true, env: {NO_COLOR: '', FORCE_COLOR: '1'}}) - expect(c.blue('Y')).toBe('Y') - }) - - it('coerces non-string input to a string', () => { - const c = createColor({isTTY: false, env: {}}) - expect(c.red(42)).toBe('42') - }) -}) - -describe('substitute', () => { - it('replaces __VERSION__ and __CURRENT_TAG__ (all occurrences)', () => { - expect(substitute('v __VERSION__ / __CURRENT_TAG__ / __VERSION__', {version: '1.2.4', currentTag: '1.2.3'})) - .toBe('v 1.2.4 / 1.2.3 / 1.2.4') - }) - - it('coerces non-string values', () => { - expect(substitute(123, {version: '1.0.0', currentTag: '0.9.0'})).toBe('123') - }) -}) - -describe('resolveIncrement', () => { - it('maps -p / --patch to patch', () => { - expect(resolveIncrement({p: true})).toBe('patch') - expect(resolveIncrement({patch: true})).toBe('patch') - }) - - it('maps -m / --minor to minor', () => { - expect(resolveIncrement({m: true})).toBe('minor') - expect(resolveIncrement({minor: true})).toBe('minor') - }) - - it('maps --major to major', () => { - expect(resolveIncrement({major: true})).toBe('major') - }) - - it('prioritizes patch over minor over major', () => { - expect(resolveIncrement({p: true, minor: true, major: true})).toBe('patch') - expect(resolveIncrement({minor: true, major: true})).toBe('minor') - }) - - it('returns null when no increment flag is present', () => { - expect(resolveIncrement({info: true})).toBeNull() - expect(resolveIncrement({})).toBeNull() - }) -}) - -describe('normalizeCurrentVersion', () => { - it('defaults missing/empty input to 0.0.0', () => { - expect(normalizeCurrentVersion(undefined)).toBe('0.0.0') - expect(normalizeCurrentVersion('')).toBe('0.0.0') - expect(normalizeCurrentVersion(' ')).toBe('0.0.0') - }) - - it('trims surrounding whitespace/newlines from git output', () => { - expect(normalizeCurrentVersion('1.2.3\n')).toBe('1.2.3') - expect(normalizeCurrentVersion(' 1.2.3 ')).toBe('1.2.3') - }) - - it('passes through a valid version', () => { - expect(normalizeCurrentVersion('2.5.1')).toBe('2.5.1') - }) -}) - -describe('nextVersion', () => { - it('increments patch/minor/major', () => { - expect(nextVersion('1.2.3', 'patch')).toBe('1.2.4') - expect(nextVersion('1.2.3', 'minor')).toBe('1.3.0') - expect(nextVersion('1.2.3', 'major')).toBe('2.0.0') - }) - - it('resets lower components on a higher bump', () => { - expect(nextVersion('1.9.9', 'major')).toBe('2.0.0') - expect(nextVersion('1.9.9', 'minor')).toBe('1.10.0') - }) -}) - -describe('buildReplaceConfig', () => { - const ctx = {version: '1.2.4', currentTag: '1.2.3', cwd: '/proj'} - - it('returns null when files/from/to are incomplete', () => { - expect(buildReplaceConfig({from: 'a', to: 'b'}, ctx)).toBeNull() - expect(buildReplaceConfig({files: 'x', to: 'b'}, ctx)).toBeNull() - expect(buildReplaceConfig({files: 'x', from: 'a'}, ctx)).toBeNull() - }) - - it('resolves a single file relative to cwd', () => { - const conf = buildReplaceConfig({files: 'style.css', from: 'a', to: 'b'}, ctx) - expect(conf.files).toEqual([resolve('/proj/style.css')]) - }) - - it('resolves an array of files', () => { - const conf = buildReplaceConfig({files: ['a.css', 'b.css'], from: 'x', to: 'y'}, ctx) - expect(conf.files).toEqual([resolve('/proj/a.css'), resolve('/proj/b.css')]) - }) - - it('substitutes placeholders in from (regex) and to', () => { - const conf = buildReplaceConfig( - {files: 'f', from: 'Version: __CURRENT_TAG__', to: 'Version: __VERSION__'}, - ctx, - ) - expect(conf.from).toBeInstanceOf(RegExp) - expect(conf.from.source).toBe('Version: 1.2.3') // placeholder substituted into the pattern - expect(conf.from.test('Version: 1.2.3')).toBe(true) - expect(conf.to).toBe('Version: 1.2.4') - }) - - it('defaults to the global flag', () => { - const conf = buildReplaceConfig({files: 'f', from: 'a', to: 'b'}, ctx) - expect(conf.from.flags).toBe('g') - }) - - it('honors a custom flags string', () => { - const conf = buildReplaceConfig({files: 'f', from: 'a', to: 'b', flags: 'gi'}, ctx) - expect(conf.from.flags).toBe('gi') - }) - - it('uses no flags when flags is explicitly false', () => { - const conf = buildReplaceConfig({files: 'f', from: 'a', to: 'b', flags: false}, ctx) - expect(conf.from.flags).toBe('') - }) -}) - -describe('replaceInFiles', () => { - let dir - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'tagy-replace-')) - }) - afterEach(() => { - rmSync(dir, {recursive: true, force: true}) - }) - - it('replaces all matches with a global regex', () => { - const file = join(dir, 'style.css') - writeFileSync(file, 'Version: 1.2.3\nVersion: 1.2.3\nkeep\n') - replaceInFiles({files: [file], from: /Version: \d+\.\d+\.\d+/g, to: 'Version: 1.2.4'}) - expect(readFileSync(file, 'utf8')).toBe('Version: 1.2.4\nVersion: 1.2.4\nkeep\n') - }) - - it('skips files that do not exist without throwing', () => { - const missing = join(dir, 'nope.txt') - expect(() => replaceInFiles({files: [missing], from: /x/g, to: 'y'})).not.toThrow() - expect(existsSync(missing)).toBe(false) - }) - - it('leaves a file untouched when nothing matches', () => { - const file = join(dir, 'a.txt') - writeFileSync(file, 'no match here') - replaceInFiles({files: [file], from: /zzz/g, to: 'q'}) - expect(readFileSync(file, 'utf8')).toBe('no match here') - }) -}) - -describe('bumpPackageVersion', () => { - let dir - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'tagy-bump-')) - }) - afterEach(() => { - rmSync(dir, {recursive: true, force: true}) - }) - - it('writes the new version and preserves other fields + 2-space indent', async () => { - const file = join(dir, 'package.json') - writeFileSync(file, JSON.stringify({name: 'demo', version: '1.0.0', keep: true}, null, 2)) - const result = await bumpPackageVersion('1.0.1', {cwd: dir}) - expect(result).toBe(true) - const written = readFileSync(file, 'utf8') - const parsed = JSON.parse(written) - expect(parsed.version).toBe('1.0.1') - expect(parsed.name).toBe('demo') - expect(parsed.keep).toBe(true) - expect(written).toContain('\n "name"') // 2-space indentation - }) - - it('resolves true (no-op) when there is no package.json', async () => { - await expect(bumpPackageVersion('1.0.1', {cwd: dir})).resolves.toBe(true) - }) - - it('throws a friendly error on malformed package.json', async () => { - writeFileSync(join(dir, 'package.json'), '{ not valid json') - await expect(bumpPackageVersion('1.0.1', {cwd: dir})).rejects.toThrow('Couldn\'t parse "package.json"') - }) -}) diff --git a/test/lib.test.ts b/test/lib.test.ts new file mode 100644 index 0000000..de8c98c --- /dev/null +++ b/test/lib.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + createColor, substitute, buildReplaceConfig, replaceInFiles, + resolveIncrement, normalizeCurrentVersion, nextVersion, +} from '../src/lib' + +describe('createColor', () => { + it('emits ANSI when FORCE_COLOR set', () => { + expect(createColor({ isTTY: false, env: { FORCE_COLOR: '1' } }).blue('Y')).toBe('\x1b[34mY\x1b[39m') + }) + it('nests red.bold', () => { + expect(createColor({ isTTY: true, env: {} }).red.bold('X')).toBe('\x1b[1m\x1b[31mX\x1b[39m\x1b[22m') + }) + it('plain text when NO_COLOR set', () => { + expect(createColor({ isTTY: true, env: { NO_COLOR: '1' } }).red.bold('X')).toBe('X') + }) + it('plain text for non-TTY', () => { + expect(createColor({ isTTY: false, env: {} }).yellow('W')).toBe('W') + }) +}) + +describe('substitute', () => { + it('replaces both placeholders, all occurrences', () => { + expect(substitute('__VERSION__ __CURRENT_TAG__ __VERSION__', { version: '1.2.4', currentTag: '1.2.3' })) + .toBe('1.2.4 1.2.3 1.2.4') + }) +}) + +describe('resolveIncrement', () => { + it('maps flags with patch>minor>major priority', () => { + expect(resolveIncrement({ patch: true })).toBe('patch') + expect(resolveIncrement({ m: true })).toBe('minor') + expect(resolveIncrement({ major: true })).toBe('major') + expect(resolveIncrement({ p: true, major: true })).toBe('patch') + expect(resolveIncrement({})).toBeNull() + }) +}) + +describe('normalizeCurrentVersion', () => { + it('defaults and trims', () => { + expect(normalizeCurrentVersion(undefined)).toBe('0.0.0') + expect(normalizeCurrentVersion(' ')).toBe('0.0.0') + expect(normalizeCurrentVersion('1.2.3\n')).toBe('1.2.3') + }) +}) + +describe('nextVersion', () => { + it('bumps', () => { + expect(nextVersion('1.2.3', 'patch')).toBe('1.2.4') + expect(nextVersion('1.2.3', 'minor')).toBe('1.3.0') + expect(nextVersion('1.9.9', 'major')).toBe('2.0.0') + }) +}) + +describe('buildReplaceConfig', () => { + const ctx = { version: '1.2.4', currentTag: '1.2.3', cwd: '/proj' } + it('returns null when incomplete', () => { + expect(buildReplaceConfig({ from: 'a', to: 'b' } as any, ctx)).toBeNull() + }) + it('resolves files, substitutes, defaults global flag', () => { + const c = buildReplaceConfig({ files: 'f', from: 'V __CURRENT_TAG__', to: 'V __VERSION__' }, ctx)! + expect(c.files).toEqual([resolve('/proj/f')]) + expect(c.from.flags).toBe('g') + expect(c.from.test('V 1.2.3')).toBe(true) + expect(c.to).toBe('V 1.2.4') + }) + it('flags:false -> no flags', () => { + expect(buildReplaceConfig({ files: 'f', from: 'a', to: 'b', flags: false }, ctx)!.from.flags).toBe('') + }) +}) + +describe('replaceInFiles', () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-')) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + it('replaces all matches', () => { + const f = join(dir, 's.css'); writeFileSync(f, 'V 1.2.3\nV 1.2.3\n') + replaceInFiles({ files: [f], from: /V \d+\.\d+\.\d+/g, to: 'V 1.2.4' }) + expect(readFileSync(f, 'utf8')).toBe('V 1.2.4\nV 1.2.4\n') + }) + it('skips missing files', () => { + const f = join(dir, 'no.txt') + expect(() => replaceInFiles({ files: [f], from: /x/g, to: 'y' })).not.toThrow() + expect(existsSync(f)).toBe(false) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..457a80d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "CommonJS", + "moduleResolution": "Node", + "ignoreDeprecations": "6.0", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} From 5e629f0badce3b54db207ba22b87cc975fd513c8 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:15:06 +0300 Subject: [PATCH 03/13] feat: add git module (command builders + shell wrappers) --- src/git.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ test/git.test.ts | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/git.ts create mode 100644 test/git.test.ts diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..1deb5b5 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,58 @@ +import shell from 'shelljs' + +export interface Shell { + exec(command: string, opts?: { silent?: boolean }): { code: number; stdout: string } +} + +export const cmd = { + insideRepo: () => 'git rev-parse --is-inside-work-tree', + currentBranch: () => "git branch | grep \\* | cut -d ' ' -f2", + fetchTags: () => 'git fetch --tags', + latestTag: (prefix: string) => `git tag --sort=v:refname | grep -E '^${prefix}[0-9]' | tail -1`, + commit: (message: string) => `git commit -a -m "${message}"`, + pushBranch: (branch: string) => `git push origin ${branch}`, + createTag: (tag: string) => `git tag ${tag}`, + pushTag: (tag: string) => `git push origin ${tag}`, + deleteTag: (tag: string) => `git tag -d ${tag}`, + pushDeleteTag: (tag: string) => `git push origin :refs/tags/${tag}`, + ghVersion: () => 'gh --version', + ghRelease: (tag: string, title: string, notes: string) => + `gh release create ${tag} --title "${title}" --notes "${notes}"`, +} + +const sh = shell as unknown as Shell + +export function insideRepo(s: Shell = sh): boolean { + return s.exec(cmd.insideRepo(), { silent: true }).code === 0 +} +export function currentBranch(s: Shell = sh): string { + return s.exec(cmd.currentBranch(), { silent: true }).stdout.trim() +} +export function fetchTags(s: Shell = sh): void { + s.exec(cmd.fetchTags(), { silent: true }) +} +export function latestTag(prefix: string, s: Shell = sh): string { + return s.exec(cmd.latestTag(prefix), { silent: true }).stdout.trim() +} +export function commit(message: string, s: Shell = sh): void { + s.exec(cmd.commit(message)) +} +export function pushBranch(branch: string, s: Shell = sh): void { + s.exec(cmd.pushBranch(branch)) +} +export function createTag(tag: string, s: Shell = sh): void { + s.exec(cmd.createTag(tag)) +} +export function pushTag(tag: string, s: Shell = sh): void { + s.exec(cmd.pushTag(tag)) +} +export function deleteTag(tag: string, s: Shell = sh): void { + s.exec(cmd.deleteTag(tag)) + s.exec(cmd.pushDeleteTag(tag)) +} +export function ghAvailable(s: Shell = sh): boolean { + return Boolean(s.exec(cmd.ghVersion(), { silent: true }).stdout) +} +export function ghReleaseCreate(tag: string, title: string, notes: string, s: Shell = sh): void { + s.exec(cmd.ghRelease(tag, title, notes)) +} diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 0000000..94edb24 --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { cmd, insideRepo, currentBranch, latestTag, deleteTag, ghAvailable } from '../src/git' + +function fakeShell(result: { code?: number; stdout?: string }) { + const calls: string[] = [] + return { + calls, + exec(c: string) { calls.push(c); return { code: result.code ?? 0, stdout: result.stdout ?? '' } }, + } +} + +describe('cmd builders', () => { + it('latestTag includes the prefix', () => { + expect(cmd.latestTag('v')).toBe("git tag --sort=v:refname | grep -E '^v[0-9]' | tail -1") + }) + it('commit/tag/push builders', () => { + expect(cmd.commit('Release v1.2.3')).toBe('git commit -a -m "Release v1.2.3"') + expect(cmd.createTag('v1.2.3')).toBe('git tag v1.2.3') + expect(cmd.pushBranch('main')).toBe('git push origin main') + expect(cmd.pushDeleteTag('v1.2.3')).toBe('git push origin :refs/tags/v1.2.3') + }) +}) + +describe('wrappers', () => { + it('insideRepo true on exit 0', () => { + expect(insideRepo(fakeShell({ code: 0 }))).toBe(true) + }) + it('insideRepo false on nonzero', () => { + expect(insideRepo(fakeShell({ code: 128 }))).toBe(false) + }) + it('currentBranch trims stdout', () => { + expect(currentBranch(fakeShell({ stdout: 'main\n' }))).toBe('main') + }) + it('latestTag trims stdout', () => { + expect(latestTag('', fakeShell({ stdout: '1.2.3\n' }))).toBe('1.2.3') + }) + it('deleteTag runs delete then remote-delete', () => { + const sh = fakeShell({}) + deleteTag('1.2.3', sh) + expect(sh.calls).toEqual([cmd.deleteTag('1.2.3'), cmd.pushDeleteTag('1.2.3')]) + }) + it('ghAvailable false when stdout empty', () => { + expect(ghAvailable(fakeShell({ stdout: '' }))).toBe(false) + }) +}) From d4460178ba63522bc48629045745690c0a319442 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:18:06 +0300 Subject: [PATCH 04/13] feat: add bump module (JSON bumpers, replace rules, writeConfig) --- src/bump.ts | 45 ++++++++++++++++++++++++++++++++++++++++ test/bump.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/bump.ts create mode 100644 test/bump.test.ts diff --git a/src/bump.ts b/src/bump.ts new file mode 100644 index 0000000..4eceffd --- /dev/null +++ b/src/bump.ts @@ -0,0 +1,45 @@ +import path from 'path' +import fs from 'fs-extra' +import { buildReplaceConfig, replaceInFiles } from './lib' +import type { ReplaceRule, TagyConfig } from './types' + +export const KNOWN_BUMP_FILES = ['package.json', 'composer.json'] as const + +export function bumpJsonVersion(absFile: string, version: string, fsModule: typeof fs = fs): boolean { + if (!fsModule.existsSync(absFile)) return false + const content = fsModule.readJsonSync(absFile) + content.version = version + fsModule.writeJsonSync(absFile, content, { spaces: 2 }) + return true +} + +export function bumpFiles( + names: string[], + version: string, + { cwd, fs: fsModule = fs }: { cwd: string; fs?: typeof fs }, +): string[] { + const bumped: string[] = [] + names.forEach((name) => { + if (!(KNOWN_BUMP_FILES as readonly string[]).includes(name)) return + if (bumpJsonVersion(path.resolve(cwd, name), version, fsModule)) bumped.push(name) + }) + return bumped +} + +export function applyReplaceRules( + rules: ReplaceRule[], + { version, currentTag, cwd, fs: fsModule = fs }: + { version: string; currentTag: string; cwd: string; fs?: typeof fs }, +): void { + rules.forEach((rule) => { + const conf = buildReplaceConfig(rule, { version, currentTag, cwd }) + if (conf) replaceInFiles(conf, fsModule) + }) +} + +export function writeConfig( + config: TagyConfig, + { cwd, fs: fsModule = fs }: { cwd: string; fs?: typeof fs }, +): void { + fsModule.writeFileSync(path.resolve(cwd, '.tagyrc'), JSON.stringify(config, null, 2) + '\n') +} diff --git a/test/bump.test.ts b/test/bump.test.ts new file mode 100644 index 0000000..4eae6da --- /dev/null +++ b/test/bump.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { KNOWN_BUMP_FILES, bumpFiles, applyReplaceRules, writeConfig } from '../src/bump' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-bump-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('KNOWN_BUMP_FILES', () => { + it('contains the two JSON manifests', () => { + expect([...KNOWN_BUMP_FILES]).toEqual(['package.json', 'composer.json']) + }) +}) + +describe('bumpFiles', () => { + it('bumps existing known JSON files, preserves other fields + indent', () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2)) + const bumped = bumpFiles(['package.json'], '1.0.1', { cwd: dir }) + expect(bumped).toEqual(['package.json']) + const out = readFileSync(join(dir, 'package.json'), 'utf8') + expect(JSON.parse(out)).toMatchObject({ name: 'x', version: '1.0.1' }) + expect(out).toContain('\n "name"') + }) + it('skips files that do not exist', () => { + expect(bumpFiles(['composer.json'], '1.0.1', { cwd: dir })).toEqual([]) + }) + it('ignores unknown file names', () => { + writeFileSync(join(dir, 'random.json'), '{"version":"1.0.0"}') + expect(bumpFiles(['random.json'], '2.0.0', { cwd: dir })).toEqual([]) + }) +}) + +describe('applyReplaceRules', () => { + it('applies regex replacement with placeholders', () => { + const f = join(dir, 'style.css'); writeFileSync(f, 'Version: 1.2.3\n') + applyReplaceRules( + [{ files: 'style.css', from: 'Version: __CURRENT_TAG__', to: 'Version: __VERSION__' }], + { version: '1.2.4', currentTag: '1.2.3', cwd: dir }, + ) + expect(readFileSync(f, 'utf8')).toBe('Version: 1.2.4\n') + }) +}) + +describe('writeConfig', () => { + it('writes .tagyrc as pretty JSON', () => { + writeConfig({ branch: 'main', tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }, { cwd: dir }) + const out = readFileSync(join(dir, '.tagyrc'), 'utf8') + expect(JSON.parse(out)).toEqual({ branch: 'main', tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }) + expect(out.endsWith('\n')).toBe(true) + }) +}) From 725c65048c110e9b855e96f741e805551f1d5b4c Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:21:02 +0300 Subject: [PATCH 05/13] feat: add config module (.tagyrc load/normalize/migrate) --- src/config.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++ test/config.test.ts | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/config.ts create mode 100644 test/config.test.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3a33752 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,67 @@ +import path from 'path' +import fs from 'fs-extra' +import { KNOWN_BUMP_FILES } from './bump' +import type { TagyConfig, ReplaceRule } from './types' + +export const DEFAULT_CONFIG: TagyConfig = { + branch: null, tagPrefix: '', bump: [], replace: [], autoRelease: false, +} + +const KNOWN_KEYS = ['branch', 'tagPrefix', 'bump', 'replace', 'autoRelease'] + +export function normalizeConfig( + raw: Record, + warn: (msg: string) => void = () => {}, +): TagyConfig { + Object.keys(raw).forEach((k) => { + if (!KNOWN_KEYS.includes(k)) warn(`Unknown .tagyrc key ignored: "${k}"`) + }) + + const config: TagyConfig = { + branch: typeof raw.branch === 'string' ? raw.branch : null, + tagPrefix: typeof raw.tagPrefix === 'string' ? raw.tagPrefix : '', + bump: Array.isArray(raw.bump) ? (raw.bump as string[]) : [], + replace: Array.isArray(raw.replace) ? (raw.replace as ReplaceRule[]) : [], + autoRelease: raw.autoRelease === true, + } + + config.bump.forEach((file) => { + if (!(KNOWN_BUMP_FILES as readonly string[]).includes(file)) { + throw new Error(`Unsupported bump file "${file}". Supported: ${KNOWN_BUMP_FILES.join(', ')}`) + } + }) + + return config +} + +export function loadConfig( + { cwd, fs: fsModule = fs, warn }: { cwd: string; fs?: typeof fs; warn?: (m: string) => void }, +): { config: TagyConfig | null; legacy: Record | null } { + for (const name of ['.tagyrc', '.tagyrc.json']) { + const p = path.resolve(cwd, name) + if (fsModule.existsSync(p)) { + const raw = JSON.parse(fsModule.readFileSync(p, 'utf8')) + return { config: normalizeConfig(raw, warn), legacy: null } + } + } + + const pkgPath = path.resolve(cwd, 'package.json') + if (fsModule.existsSync(pkgPath)) { + const pkg = fsModule.readJsonSync(pkgPath) + if (pkg && pkg.tagy && typeof pkg.tagy === 'object') { + return { config: null, legacy: pkg.tagy as Record } + } + } + + return { config: null, legacy: null } +} + +export function migrateLegacy(legacy: Record): TagyConfig { + return { + branch: null, + tagPrefix: typeof legacy.tagPrefix === 'string' ? legacy.tagPrefix : '', + bump: ['package.json'], + replace: Array.isArray(legacy.replace) ? legacy.replace : [], + autoRelease: legacy['auto-release'] === true, + } +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..1c8d1f2 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { normalizeConfig, loadConfig, migrateLegacy } from '../src/config' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-cfg-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('normalizeConfig', () => { + it('fills defaults and coerces types', () => { + expect(normalizeConfig({})).toEqual({ branch: null, tagPrefix: '', bump: [], replace: [], autoRelease: false }) + }) + it('warns on unknown keys', () => { + const warnings: string[] = [] + normalizeConfig({ nope: 1 }, (m) => warnings.push(m)) + expect(warnings[0]).toContain('nope') + }) + it('throws on unsupported bump file', () => { + expect(() => normalizeConfig({ bump: ['Cargo.toml'] })).toThrow('Cargo.toml') + }) +}) + +describe('loadConfig', () => { + it('reads .tagyrc when present', () => { + writeFileSync(join(dir, '.tagyrc'), JSON.stringify({ tagPrefix: 'v', bump: ['package.json'] })) + const { config, legacy } = loadConfig({ cwd: dir }) + expect(legacy).toBeNull() + expect(config).toMatchObject({ tagPrefix: 'v', bump: ['package.json'] }) + }) + it('detects legacy package.json.tagy when no .tagyrc', () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '1.0.0', tagy: { tagPrefix: 'v' } })) + const { config, legacy } = loadConfig({ cwd: dir }) + expect(config).toBeNull() + expect(legacy).toEqual({ tagPrefix: 'v' }) + }) + it('returns nulls when no config anywhere', () => { + expect(loadConfig({ cwd: dir })).toEqual({ config: null, legacy: null }) + }) +}) + +describe('migrateLegacy', () => { + it('maps keys and forces package.json bump', () => { + expect(migrateLegacy({ tagPrefix: 'v', 'auto-release': true, soft: true, replace: [{ files: 'a', from: 'b', to: 'c' }] })) + .toEqual({ branch: null, tagPrefix: 'v', bump: ['package.json'], replace: [{ files: 'a', from: 'b', to: 'c' }], autoRelease: true }) + }) +}) From 9b6a02e0ca5e690c35c2c7dd1f6b4644a5f371cb Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:23:32 +0300 Subject: [PATCH 06/13] refactor: harden config fs injection and migrateLegacy typing - loadConfig: use readFileSync+JSON.parse for package.json instead of fs-extra-only readJsonSync, so the injectable fs contract holds - migrateLegacy: tighten param type to Record - add .tagyrc.json load test case --- src/config.ts | 4 ++-- test/config.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3a33752..f7beac2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,7 +47,7 @@ export function loadConfig( const pkgPath = path.resolve(cwd, 'package.json') if (fsModule.existsSync(pkgPath)) { - const pkg = fsModule.readJsonSync(pkgPath) + const pkg = JSON.parse(fsModule.readFileSync(pkgPath, 'utf8')) if (pkg && pkg.tagy && typeof pkg.tagy === 'object') { return { config: null, legacy: pkg.tagy as Record } } @@ -56,7 +56,7 @@ export function loadConfig( return { config: null, legacy: null } } -export function migrateLegacy(legacy: Record): TagyConfig { +export function migrateLegacy(legacy: Record): TagyConfig { return { branch: null, tagPrefix: typeof legacy.tagPrefix === 'string' ? legacy.tagPrefix : '', diff --git a/test/config.test.ts b/test/config.test.ts index 1c8d1f2..a7f44d1 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -29,6 +29,12 @@ describe('loadConfig', () => { expect(legacy).toBeNull() expect(config).toMatchObject({ tagPrefix: 'v', bump: ['package.json'] }) }) + it('reads .tagyrc.json when .tagyrc absent', () => { + writeFileSync(join(dir, '.tagyrc.json'), JSON.stringify({ tagPrefix: 'v', bump: ['composer.json'] })) + const { config, legacy } = loadConfig({ cwd: dir }) + expect(legacy).toBeNull() + expect(config).toMatchObject({ tagPrefix: 'v', bump: ['composer.json'] }) + }) it('detects legacy package.json.tagy when no .tagyrc', () => { writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '1.0.0', tagy: { tagPrefix: 'v' } })) const { config, legacy } = loadConfig({ cwd: dir }) From 3e6c158bf4728ad18d0e90c91452ff91f3d85cf0 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:25:41 +0300 Subject: [PATCH 07/13] feat: add interactive wizard module --- src/wizard.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++ test/wizard.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/wizard.ts create mode 100644 test/wizard.test.ts diff --git a/src/wizard.ts b/src/wizard.ts new file mode 100644 index 0000000..1c7fafc --- /dev/null +++ b/src/wizard.ts @@ -0,0 +1,47 @@ +import path from 'path' +import fs from 'fs-extra' +import { KNOWN_BUMP_FILES } from './bump' +import type { TagyConfig } from './types' + +type Ask = (question: any) => Promise<{ value: any }> + +export interface WizardResult { config: TagyConfig; save: boolean } + +export function existingBumpFiles(cwd: string, fsModule: typeof fs = fs): string[] { + return (KNOWN_BUMP_FILES as readonly string[]).filter((f) => fsModule.existsSync(path.resolve(cwd, f))) +} + +export async function runWizard( + { cwd, branch, promptsLib, fs: fsModule = fs }: + { cwd: string; branch: string; promptsLib: Ask; fs?: typeof fs }, +): Promise { + const confirmBranch = await promptsLib({ + type: 'confirm', name: 'value', + message: `You're on branch '${branch}'. Tag this branch?`, initial: true, + }) + if (!confirmBranch.value) return null + + const prefixAns = await promptsLib({ + type: 'text', name: 'value', message: 'Tag prefix? (blank for none)', initial: '', + }) + const tagPrefix = String(prefixAns.value || '').trim() + + const choices = existingBumpFiles(cwd, fsModule) + let bump: string[] = [] + if (choices.length) { + const bumpAns = await promptsLib({ + type: 'multiselect', name: 'value', message: 'Bump version in a file?', + choices: choices.map((c) => ({ title: c, value: c })), instructions: false, + }) + bump = Array.isArray(bumpAns.value) ? bumpAns.value : [] + } + + const config: TagyConfig = { branch, tagPrefix, bump, replace: [], autoRelease: false } + + const saveAns = await promptsLib({ + type: 'confirm', name: 'value', + message: 'Save these answers to .tagyrc for next time?', initial: true, + }) + + return { config, save: Boolean(saveAns.value) } +} diff --git a/test/wizard.test.ts b/test/wizard.test.ts new file mode 100644 index 0000000..98915ac --- /dev/null +++ b/test/wizard.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { runWizard, existingBumpFiles } from '../src/wizard' + +// scripted prompts: returns answers in order +function scripted(answers: any[]) { + let i = 0 + return async () => ({ value: answers[i++] }) +} + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tagy-wiz-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('existingBumpFiles', () => { + it('lists only known files that exist', () => { + writeFileSync(join(dir, 'package.json'), '{}') + expect(existingBumpFiles(dir)).toEqual(['package.json']) + }) +}) + +describe('runWizard', () => { + it('returns null when user declines to tag the branch', async () => { + const result = await runWizard({ cwd: dir, branch: 'feature', promptsLib: scripted([false]) as any }) + expect(result).toBeNull() + }) + + it('builds config from answers and save decision', async () => { + writeFileSync(join(dir, 'package.json'), '{}') + // answers: confirm branch=true, prefix='v', bump=['package.json'], save=true + const result = await runWizard({ cwd: dir, branch: 'main', promptsLib: scripted([true, 'v', ['package.json'], true]) as any }) + expect(result).toEqual({ + config: { branch: 'main', tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }, + save: true, + }) + }) + + it('skips bump question when no known files exist', async () => { + // answers: confirm=true, prefix='', save=false (no bump prompt) + const result = await runWizard({ cwd: dir, branch: 'main', promptsLib: scripted([true, '', false]) as any }) + expect(result).toEqual({ + config: { branch: 'main', tagPrefix: '', bump: [], replace: [], autoRelease: false }, + save: false, + }) + }) +}) From c6da4c7199c8cf30e16381a52c1497a2c10c43d9 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:27:45 +0300 Subject: [PATCH 08/13] refactor: make promptsLib optional with prompts-backed default Aligns with the brief's Interfaces block (promptsLib?) and prevents a compile error if index.ts calls runWizard without injecting prompts. --- src/wizard.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/wizard.ts b/src/wizard.ts index 1c7fafc..96c8f58 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -1,10 +1,13 @@ import path from 'path' import fs from 'fs-extra' +import prompts from 'prompts' import { KNOWN_BUMP_FILES } from './bump' import type { TagyConfig } from './types' type Ask = (question: any) => Promise<{ value: any }> +const defaultAsk: Ask = (question) => prompts(question) + export interface WizardResult { config: TagyConfig; save: boolean } export function existingBumpFiles(cwd: string, fsModule: typeof fs = fs): string[] { @@ -12,8 +15,8 @@ export function existingBumpFiles(cwd: string, fsModule: typeof fs = fs): string } export async function runWizard( - { cwd, branch, promptsLib, fs: fsModule = fs }: - { cwd: string; branch: string; promptsLib: Ask; fs?: typeof fs }, + { cwd, branch, promptsLib = defaultAsk, fs: fsModule = fs }: + { cwd: string; branch: string; promptsLib?: Ask; fs?: typeof fs }, ): Promise { const confirmBranch = await promptsLib({ type: 'confirm', name: 'value', From 07d22c0c60f2936951b5d9d99f5584fcd4b3bfa7 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 02:32:29 +0300 Subject: [PATCH 09/13] feat: TypeScript orchestration (index + cli) with .tagyrc, wizard, git-first flow --- cli.js | 6 - index.js | 334 --------------------------------------------------- src/cli.ts | 4 + src/index.ts | 188 +++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 340 deletions(-) delete mode 100755 cli.js delete mode 100755 index.js create mode 100644 src/cli.ts create mode 100644 src/index.ts diff --git a/cli.js b/cli.js deleted file mode 100755 index f286ac7..0000000 --- a/cli.js +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node -'use strict' - -const tagy = require(`./index.js`) - -tagy(); diff --git a/index.js b/index.js deleted file mode 100755 index fef3a24..0000000 --- a/index.js +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env node -'use strict' - -const fs = require('fs-extra') -const path = require('path') -const shell = require("shelljs") -const args = require('yargs').argv -const prompts = require('prompts') -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 () => { - const totalArguments = Object.values(args).length; - - if (totalArguments > 5) { - await console.log(chalk.red.bold('Too many arguments!')) - return; - } - - const haveOption = ( - args.p || - args.m || - args.patch || - args.minor || - args.major || - args.reverse || - args.custom || - args.info - ) - - if (!haveOption || args.h) { - await console.log(chalk.red.bold(`'Please specify the increment type') [-p, -m, --minor, --patch, --major, --reverse, --custom, --info, --soft]' - -Options: --p, --patch # Will increase the version from 1.0.0 to 1.0.1 --m, --minor # Will increase the version from 1.0.0 to 1.1.0 ---major # Will increase the version from 1.0.0 to 2.0.0 ---reverse # Will remove the last tag and revert to previously created one. ---info # Get some info about current project. ---custom # Define the new Semantic version manually. ---soft # Create a soft tag. This will not commit the changes to git or create a new git tag. --h, --help # Show this message. - `)); - await console.log(chalk.blue('Example: ') + chalk.yellow('tagy --patch')) - return; - } - - if (args.m && args.a) { - await console.log(chalk.red.bold('Did you mean `--major`? Try again!')) - return; - } - - if (args.r && args.e) { - await console.log(chalk.red.bold('Did you mean `--reverse`? Try again!')) - return; - } - - // abort if multiple args are passed from this list [p, m, major, minor, patch] - // if in array then abort - const multipleArgs = ['p', 'm', 'major', 'minor', 'patch', 'reverse', 'custom', 'info']; - const multipleArgsPassed = multipleArgs.filter(arg => args[arg]); - - if (multipleArgsPassed.length > 1) { - await console.log(chalk.red.bold('Too many arguments!')) - return; - } - - // read package.json - const pkgPath = path.join(process.cwd(), 'package.json') - - if (!fs.existsSync(pkgPath)) { - await console.log(chalk.red.bold('package.json not found. Make sure that you are in the right directory!')) - return; - } - - // Get package.json configuration - let pkgContent; - - try { - pkgContent = await fs.readJSON(pkgPath); - } catch (err) { - throw new Error(`Couldn't parse "package.json"`) - } - - const tagPrefix = (pkgContent && pkgContent.tagy && pkgContent.tagy.tagPrefix) || ''; - - let vv; - - // This will soft create a tag - // ---------------------------------------------------------------------------- - const isSoft = args.soft || (pkgContent && pkgContent.tagy && (pkgContent.tagy.soft || pkgContent.tagy.method === 'soft')); - - if (pkgContent && pkgContent.tagy && pkgContent.tagy.method === 'soft'){ - console.log(chalk.red(`{"method": "soft"} is deprecated, please use {"soft": true} instead.`)) - } - - if (isSoft) { - console.log(chalk.green('➡️ Soft Processing!')); - } - - let branchName; - - // This will create the tag in git - // ---------------------------------------------------------------------------- - try { - if (!isSoft) { - let currentBranchName = shell.exec("git branch | grep \\* | cut -d ' ' -f2", {silent: true}).stdout; - - if (!currentBranchName) { - return console.log(chalk.red.bold('Can\'t determine the branch name!')) - } - - branchName = currentBranchName.trim(); - - if (!(branchName === 'master' || branchName === 'main')) { - // await console.log(chalk.red.bold('You can create tags only from "master" branch.')) - // return; - - const confirmBranch = await prompts({ - type: 'confirm', - name: 'value', - message: `Current branch name is '${branchName}'. Do you want to tag this branch?`, - initial: false - }); - - if (!confirmBranch.value) { - return console.log(chalk.red.bold('Aborted! Please switch the branch.')) - } - } - - shell.exec('git fetch --tags', {silent: true}); - - // vv = shell.exec('git tag --sort=v:refname | grep -E \'^[0-9]\' | tail -1', {silent: true}).stdout; - vv = shell.exec(`git tag --sort=v:refname | grep -E '^${tagPrefix}[0-9]' | tail -1`, {silent: true}).stdout; - - if (args.info) { - if (!vv) { - await console.log(chalk.blue(`Looks like no tags were created by this moment.`)) - } else { - await console.log(chalk.blue(`Last created tag is: ${vv}`)) - } - - return; - } - } else { - vv = pkgContent.version; - } - - if (args.reverse) { - if (isSoft) { - return console.log(chalk.red(`Can't perform a reverse when the method is soft. Please reverse it manually.`)) - } - - if (!vv) { - await console.log(chalk.blue(`Looks like no tags were created by this moment. Nothing to delete.`)) - } else { - const confirmReverse = await prompts({ - type: 'confirm', - name: 'value', - message: `Are you sure that you want to remove this tag?(${vv.trim()})`, - initial: false - }) - - if (!confirmReverse.value) { - return console.log(chalk.red.bold('Aborted!')) - } - - shell.exec(`git tag -d ${vv}`); - shell.exec(`git push origin :refs/tags/${vv}`); - - await console.log(chalk.blue(`Tag ${vv} --> deleted!.`)) - } - - return; - } - - let currentTag; - - if (args.custom) { - const customVer = await prompts({ - type: 'text', - name: 'value', - message: 'Please enter a custom version. Make sure to be valid according to semver.org standard.', - initial: false, - // validate: val => { - // if (!/\d+\.\d+\.\d+/g.test(val)) { - // return 'Invalid version!'; - // } - // - // return true; - // } - validate: val => { - if (!new RegExp(`^\\d+\\.\\d+\\.\\d+$`).test(val)) { - return 'Invalid version!'; - } - return true; - } - }) - - if (!customVer.value) { - return console.log(chalk.red.bold('Aborted!')) - } - - vv = customVer.value; - - currentTag = vv; - } else { - vv = normalizeCurrentVersion(vv); - - currentTag = vv; - - 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', - name: 'value', - message: `Are you sure that you want to create a major release? Current tag is "${currentTag}" and the next will be "${vv}"`, - initial: false - }) - - if (!confirmMajorRelease.value) { - return console.log(chalk.red.bold('Aborted!')) - } - } - } - - let canCreate - - // Bump the version in package.json - try { - canCreate = await bumpPackageVersion(vv); - } catch (err) { - return console.log(err.message); - } - - // Check for custom config inside of current directory. - const tagyExtraFile = path.resolve(`${process.cwd()}/tagy.js`); - - try { - if (fs.existsSync(tagyExtraFile)) { - console.log('"tagy.js" file is found!'); - const tagyExtra = require(tagyExtraFile) - - await tagyExtra(vv, currentTag, args) - } - } catch (err) { - console.error(err) - } - - // In some configurations, the replacements can be defined in the package.json. - const replacementMethods = pkgContent && pkgContent.tagy && pkgContent.tagy.replace && Array.isArray(pkgContent.tagy.replace) ? pkgContent.tagy.replace : []; - - if (replacementMethods.length > 0) { - console.log(chalk.green('♾️ Replacement methods are found!')); - - for (let i = 0; i < replacementMethods.length; i++) { - const replaceConf = buildReplaceConfig(replacementMethods[i], { - version: vv, - currentTag, - cwd: process.cwd(), - }); - - if (replaceConf) { - replaceInFiles(replaceConf); - } - } - } - - if (canCreate) { - if (!isSoft) { - await shell.exec(`git config --global core.autocrlf true`);// Replace CRLF with LF on Windows OS. - await shell.exec(`git config --global core.safecrlf false`);// Disable CRLF warnings. - // await shell.exec(`git commit -a -m "Release ${vv}"`); - // await shell.exec(`git push origin ${branchName}`); - // await shell.exec(`git tag ${vv}`); - // await shell.exec(`git push origin ${vv}`); - await shell.exec(`git commit -a -m "Release ${tagPrefix}${vv}"`); - await shell.exec(`git push origin ${branchName}`); - await shell.exec(`git tag ${tagPrefix}${vv}`); - await shell.exec(`git push origin ${tagPrefix}${vv}`); - - // Check if github CLI is installed and create a release - const ghInstalled = shell.exec(`gh --version`, {silent: true}).stdout; - - if (ghInstalled) { - const autoRelease = args['auto-release'] || (pkgContent && pkgContent.tagy && pkgContent.tagy['auto-release']); - - let releaseIt = autoRelease; - - if (!autoRelease) { - const ghRelease = await prompts({ - type: 'confirm', - name: 'value', - message: `Do you want to create a release on GitHub?`, - initial: false - }); - - releaseIt = ghRelease.value; - } - - if (releaseIt) { - await shell.exec(`gh release create ${tagPrefix}${vv} --title "${tagPrefix}${vv}" --notes "Release ${tagPrefix}${vv}"`) - } - } - } - - await console.log(chalk.blue(`💥 Tag ${vv} --> created!.`)) - } - } catch (e) { - console.log(e) - } - })() -} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..0f9510b --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import tagy from './index' + +tagy() diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dcb53d3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,188 @@ +import fs from 'fs-extra' +import yargs from 'yargs' +import prompts from 'prompts' +import { + createColor, resolveIncrement, normalizeCurrentVersion, nextVersion, +} from './lib' +import * as git from './git' +import { bumpFiles, applyReplaceRules, writeConfig } from './bump' +import { loadConfig, migrateLegacy } from './config' +import { runWizard } from './wizard' +import type { TagyConfig } from './types' + +export default function (): void { + void (async () => { + const args = yargs(process.argv.slice(2)).argv as Record + const chalk = createColor({ isTTY: Boolean(process.stdout && process.stdout.isTTY), env: process.env }) + const cwd = process.cwd() + const ask = prompts as unknown as (q: any) => Promise<{ value: any }> + + // --- help / no-op --- + const increment = resolveIncrement(args) + const hasAction = increment || args.reverse || args.custom || args.info + if (!hasAction || args.h || args.help) { + console.log(chalk.blue('Usage: ') + chalk.yellow('tagy [-p|-m|--major|--custom|--reverse|--info|--soft|--auto-release]')) + return + } + + // mutually-exclusive increment validation + const exclusive = ['p', 'm', 'major', 'minor', 'patch', 'reverse', 'custom', 'info'] + .filter((k) => args[k]) + if (exclusive.length > 1) { + console.log(chalk.red.bold('Too many arguments! Pick one of patch/minor/major/reverse/custom/info.')) + return + } + + if (!git.insideRepo()) { + console.log(chalk.red.bold('Not a git repository.')) + return + } + + // --- resolve config --- + const { config: loaded, legacy } = loadConfig({ cwd, warn: (m) => console.log(chalk.yellow(m)) }) + let config: TagyConfig | null = loaded + + if (!config && legacy) { + const migrate = await ask({ + type: 'confirm', name: 'value', + message: 'Found legacy tagy config in package.json. Create .tagyrc from it?', initial: true, + }) + if (migrate.value) { + config = migrateLegacy(legacy) + writeConfig(config, { cwd }) + console.log(chalk.green('Created .tagyrc from legacy config.')) + } + } + + const branch = git.currentBranch() + if (!branch) { + console.log(chalk.red.bold("Can't determine the branch name!")) + return + } + + // --- no config at all: wizard --- + let isSoft = Boolean(args.soft) + if (!config) { + const wizard = await runWizard({ cwd, branch, promptsLib: ask }) + if (!wizard) { + console.log(chalk.red.bold('Aborted! Please switch the branch.')) + return + } + config = wizard.config + if (wizard.save) { + writeConfig(config, { cwd }) + console.log(chalk.green('Saved .tagyrc.')) + } + } else { + // config-driven branch check + if (config.branch) { + if (branch !== config.branch) { + console.log(chalk.red.bold(`On '${branch}', but .tagyrc requires '${config.branch}'. Aborted.`)) + return + } + } else if (branch !== 'master' && branch !== 'main') { + const confirmBranch = await ask({ + type: 'confirm', name: 'value', + message: `Current branch is '${branch}'. Tag this branch?`, initial: false, + }) + if (!confirmBranch.value) { + console.log(chalk.red.bold('Aborted! Please switch the branch.')) + return + } + } + } + + const tagPrefix = config.tagPrefix || '' + + // --- current version from latest tag --- + if (!isSoft) git.fetchTags() + let raw = isSoft ? '' : git.latestTag(tagPrefix) + + if (args.info) { + console.log(raw ? chalk.blue(`Last created tag is: ${raw}`) : chalk.blue('No tags created yet.')) + return + } + + if (args.reverse) { + if (isSoft) { console.log(chalk.red("Can't reverse in soft mode.")); return } + if (!raw) { console.log(chalk.blue('No tags to delete.')); return } + const confirmReverse = await ask({ + type: 'confirm', name: 'value', message: `Remove tag ${raw.trim()}?`, initial: false, + }) + if (!confirmReverse.value) { console.log(chalk.red.bold('Aborted!')); return } + git.deleteTag(raw.trim()) + console.log(chalk.blue(`Tag ${raw.trim()} deleted.`)) + return + } + + // --- compute next version --- + let currentTag: string + let version: string + + if (args.custom) { + const customVer = await ask({ + type: 'text', name: 'value', + message: 'Enter a custom version (semver):', + validate: (val: string) => /^\d+\.\d+\.\d+$/.test(val) ? true : 'Invalid version!', + }) + if (!customVer.value) { console.log(chalk.red.bold('Aborted!')); return } + version = customVer.value + currentTag = version + } else { + currentTag = normalizeCurrentVersion(raw) + if (!increment) { console.log(chalk.red('Something went wrong!')); return } + version = nextVersion(currentTag, increment) + if (args.major) { + const confirmMajor = await ask({ + type: 'confirm', name: 'value', + message: `Create a major release? ${currentTag} -> ${version}`, initial: false, + }) + if (!confirmMajor.value) { console.log(chalk.red.bold('Aborted!')); return } + } + } + + // --- apply file changes (bump + replace) --- + const bumped = bumpFiles(config.bump, version, { cwd }) + if (bumped.length) console.log(chalk.green(`Bumped: ${bumped.join(', ')}`)) + if (config.replace.length) applyReplaceRules(config.replace, { version, currentTag, cwd }) + + // --- tagy.js hook (before push) --- + const hookPath = `${cwd}/tagy.js` + if (fs.existsSync(hookPath)) { + try { + const hook = require(hookPath) + await hook(version, currentTag, args) + } catch (err) { + console.error(err) + } + } + + if (isSoft) { + console.log(chalk.green(`Soft: files updated to ${version} (no git changes).`)) + return + } + + // --- git ops --- + git.commit(`Release ${tagPrefix}${version}`) + git.pushBranch(branch) + git.createTag(`${tagPrefix}${version}`) + git.pushTag(`${tagPrefix}${version}`) + + // --- optional GitHub release --- + if (git.ghAvailable()) { + let releaseIt = config.autoRelease || Boolean(args['auto-release']) + if (!releaseIt) { + const ghRelease = await ask({ + type: 'confirm', name: 'value', message: 'Create a GitHub release?', initial: false, + }) + releaseIt = Boolean(ghRelease.value) + } + if (releaseIt) { + const tag = `${tagPrefix}${version}` + git.ghReleaseCreate(tag, tag, `Release ${tag}`) + } + } + + console.log(chalk.blue(`Tag ${tagPrefix}${version} created!`)) + })() +} From 37313098a2bfe7898d223cb56445669740daf048 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 12:00:17 +0300 Subject: [PATCH 10/13] fix: --info/--reverse skip wizard; --custom keeps previous tag as currentTag --- src/index.ts | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index dcb53d3..084f7b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,29 @@ export default function (): void { return } + // Read-only / maintenance commands short-circuit before any config + // resolution or interactive wizard — they only need the tag prefix. + if (args.info || args.reverse) { + const { config: existing } = loadConfig({ cwd }) + const prefix = existing?.tagPrefix || '' + git.fetchTags() + const raw = git.latestTag(prefix) + + if (args.info) { + console.log(raw ? chalk.blue(`Last created tag is: ${raw}`) : chalk.blue('No tags created yet.')) + return + } + + // args.reverse + if (args.soft) { console.log(chalk.red("Can't reverse in soft mode.")); return } + if (!raw) { console.log(chalk.blue('No tags to delete.')); return } + const confirmReverse = await ask({ type: 'confirm', name: 'value', message: `Remove tag ${raw.trim()}?`, initial: false }) + if (!confirmReverse.value) { console.log(chalk.red.bold('Aborted!')); return } + git.deleteTag(raw.trim()) + console.log(chalk.blue(`Tag ${raw.trim()} deleted.`)) + return + } + // --- resolve config --- const { config: loaded, legacy } = loadConfig({ cwd, warn: (m) => console.log(chalk.yellow(m)) }) let config: TagyConfig | null = loaded @@ -98,28 +121,12 @@ export default function (): void { if (!isSoft) git.fetchTags() let raw = isSoft ? '' : git.latestTag(tagPrefix) - if (args.info) { - console.log(raw ? chalk.blue(`Last created tag is: ${raw}`) : chalk.blue('No tags created yet.')) - return - } - - if (args.reverse) { - if (isSoft) { console.log(chalk.red("Can't reverse in soft mode.")); return } - if (!raw) { console.log(chalk.blue('No tags to delete.')); return } - const confirmReverse = await ask({ - type: 'confirm', name: 'value', message: `Remove tag ${raw.trim()}?`, initial: false, - }) - if (!confirmReverse.value) { console.log(chalk.red.bold('Aborted!')); return } - git.deleteTag(raw.trim()) - console.log(chalk.blue(`Tag ${raw.trim()} deleted.`)) - return - } - // --- compute next version --- let currentTag: string let version: string if (args.custom) { + const prevTag = normalizeCurrentVersion(raw) const customVer = await ask({ type: 'text', name: 'value', message: 'Enter a custom version (semver):', @@ -127,7 +134,7 @@ export default function (): void { }) if (!customVer.value) { console.log(chalk.red.bold('Aborted!')); return } version = customVer.value - currentTag = version + currentTag = prevTag } else { currentTag = normalizeCurrentVersion(raw) if (!increment) { console.log(chalk.red('Something went wrong!')); return } From 5d8411af09d7bcd2a9f14c606b2ac59c9ad6eee7 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 12:05:11 +0300 Subject: [PATCH 11/13] ci+docs: build step for publish, typecheck in CI, .tagyrc + v2 architecture docs --- .github/workflows/npmpublish.yml | 1 + .github/workflows/test.yml | 1 + CLAUDE.md | 75 +++++++++---- README.md | 183 ++++++++++++++----------------- 4 files changed, 134 insertions(+), 126 deletions(-) diff --git a/.github/workflows/npmpublish.yml b/.github/workflows/npmpublish.yml index 5f50708..3258b79 100644 --- a/.github/workflows/npmpublish.yml +++ b/.github/workflows/npmpublish.yml @@ -16,6 +16,7 @@ jobs: registry-url: https://registry.npmjs.org/ - run: npm install - run: npm test + - run: npm run build - run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.npm_token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 299661b..43dc749 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,3 +20,4 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm install - run: npm test + - run: npx tsc --noEmit diff --git a/CLAUDE.md b/CLAUDE.md index c064bc3..b0e4e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,41 +4,70 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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. +`tagy` is a globally-installed CLI (`bin: tagy` → `dist/cli.js`) that creates a SemVer git tag, optionally bumps version files, 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. v2 is language-agnostic and works in any git repository. ## 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. +- Build: `npm run build` (tsc, compiles `src/` → `dist/`). Required before running locally. +- Test: `npm test` (Vitest runs directly against `.ts` source, no build needed) or `npm run test:watch`. Run one file: `npx vitest run test/lib.test.ts`; one case: `npx vitest run -t "nextVersion"`. +- Typecheck: `npx tsc --noEmit` (no output files, just type errors). +- Run locally against another project: `node /Users/awps/Projects/MY/tagy/dist/cli.js --patch` from that project's directory (requires a prior `npm run build`). +- Install for local dev: `npm run build && 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`): +The codebase is TypeScript, compiled with plain `tsc`. Source is in `src/*.ts`, compiled to `dist/`. Published artifact is `dist/` only (see `files` in `package.json`). A `prepack` script ensures `dist/` is always built before publishing. + +### Modules + +- **`src/cli.ts`** — 6-line shim with shebang; requires and invokes `index.ts`. Compiles to `dist/cli.js` (the `bin` entry). +- **`src/index.ts`** — CLI orchestration only: wires the modules together and drives the CLI flow. Not unit-tested (thin shell validated manually). +- **`src/lib.ts`** — pure, side-effect-free helpers: `createColor` (chainable zero-dep chalk replacement), `replaceInFiles` (zero-dep replace-in-file), `substitute` (`__VERSION__`/`__CURRENT_TAG__`), `buildReplaceConfig`, `resolveIncrement`, `normalizeCurrentVersion`, `nextVersion`. Dependencies (fs, cwd) are passed as args so they're testable. **Prefer adding new logic here** rather than in the orchestration IIFE. +- **`src/config.ts`** — resolves and parses `.tagyrc` (JSON), validates/normalizes the schema, detects legacy `package.json.tagy` blocks and builds a migration object. Resolution order: `.tagyrc` → `.tagyrc.json` → legacy `package.json.tagy` (migration) → wizard. +- **`src/wizard.ts`** — interactive prompts when no config exists. Asks: branch confirm, tag prefix, which known files to bump (only shows files that exist), save to `.tagyrc`?. Does NOT ask about replace rules. Returns a normalized config object (same shape as `config.ts` output). +- **`src/bump.ts`** — known-file registry for structural version bumps (`package.json`, `composer.json` only); also handles `applyReplaceRules` and `writeConfig` (writes `.tagyrc`). Owns all file writes. +- **`src/git.ts`** — thin shelljs wrapper: `insideRepo`, `currentBranch`, `latestTag`, `fetchTags`, `commit`, `push`, `tag`, `deleteTag`, `ghReleaseCreate`, `ghAvailable`. Takes `shell` as an injected arg for testability. +- **`src/types.ts`** — shared TypeScript types/interfaces. + +### Test files + +One test file per module under `test/`: `lib.test.ts`, `config.test.ts`, `wizard.test.ts`, `bump.test.ts`, `git.test.ts`. Total: 39 tests. Vitest runs them directly against `.ts` source via its esbuild transform. + +## Configuration model (`.tagyrc`) + +Config is a standalone `.tagyrc` JSON file. Schema keys (all optional): + +```jsonc +{ + "branch": null, // null = confirm if not master/main; string = require exact branch + "tagPrefix": "", // e.g. "v" -> tag "v1.2.3", file versions written unprefixed + "bump": [], // structural bumps: ["package.json", "composer.json"] ONLY + "replace": [], // regex rules: { files, from, to, flags } + "autoRelease": false // gh release without prompting +} +``` -- **`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. +**Current version source:** always read from the latest git tag (prefix-aware). File versions are written unprefixed. Default with no/minimal config: verify git repo, tag + push; touch no files. -Execution flow of the `index.js` IIFE: +## Execution flow (`index.ts`) -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 '^[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`. +1. **Arg validation** — rejects too many args or conflicting increment flags. +2. **Config resolution** — `.tagyrc` / `.tagyrc.json` / legacy migration / wizard. +3. **Branch check** — abort if wrong branch (strict mode), or confirm prompt on non-master/main. +4. **Determine current version** — `git.latestTag(prefix)`, normalize via `normalizeCurrentVersion`. +5. **`--info`** short-circuits here. +6. **Compute next version** — `semver.inc()` for patch/minor/major; `--major` and `--reverse` require confirmation prompts; `--custom` prompts for value. +7. **Apply changes** — structural bumps (`bump.ts`); replace rules; `tagy.js` hook (if present). +8. **Git ops** (non-soft only) — commit, push branch, create tag, push tag; `gh release create` (if `autoRelease` or prompted). ### 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`. \ No newline at end of file +- **Soft mode** (`--soft`): apply bumps + replace rules only; never touches git. Config `soft`/`method:"soft"` keys are removed in v2 (CLI flag only). +- **`tagPrefix`** is prepended to the git tag and release name; file versions stay unprefixed. +- **`tagy.js` hook** in the target project runs before `git push`: `module.exports = (newVersion, oldVersion, args) => {...}`. +- **`replace` rules** `from`/`to` support `__VERSION__` and `__CURRENT_TAG__` placeholders; `from` is compiled to a `RegExp`. +- **Supported `bump` files**: `package.json` and `composer.json` only. Other files use `replace` rules. +- **External tooling**: requires `git` on PATH; `gh` on PATH for GitHub releases. diff --git a/README.md b/README.md index 842fb3a..a326c36 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -## tagy +## tagy -An easy way to create Git releases using git tags. -Create a new git tag by following the 'Semantic Versioning' and push it on remote. - > Note: This will also bump the version in `package.json` before pushing it on remote origin. +A general-purpose CLI for creating SemVer git tags. Creates a new git tag, optionally bumps version files, optionally runs custom file replacements, pushes the commit + tag to origin, and optionally creates a GitHub release. + +> tagy is language-agnostic: it works in any git repository, not just Node.js projects. [![NPM](https://nodei.co/npm/tagy.png?compact=true)](https://nodei.co/npm/tagy/) @@ -13,143 +13,120 @@ npm i tagy -g #### Use it in terminal from working directory: ``` -tagy [-p, -m, --minor, --patch, --major, --reverse, --info, --custom, -h] +tagy [-p, -m, --minor, --patch, --major, --reverse, --info, --custom, --soft, --auto-release, -h] ``` ##### Arguments -```sh +```sh -p, --patch # Will increase the version from 1.0.0 to 1.0.1 -m, --minor # Will increase the version from 1.0.0 to 1.1.0 --major # Will increase the version from 1.0.0 to 2.0.0 --reverse # Will remove the last tag and revert to previously created one. --info # Get some info about current project. --custom # Define the new Semantic version manually. ---soft # Create a soft tag. This will not commit the changes to git or create a new git tag. +--soft # File changes only (bump + replace) without touching git or creating a tag. --auto-release # Automatically create a Github release after the tag is created. -h # Show help information. ``` -## `package.json` configuration: +## Configuration: `.tagyrc` -_All parameters are optional._ +tagy reads configuration from a `.tagyrc` file (JSON) in your project root. All keys are optional. -``` -"tagy": { - "tagPrefix": "v", - "soft": true, - "auto-release": true, - "replace": [ - { - "files": "themes/custom/style.css", - "from": "Version: \\d+\\.\\d+\\.\\d+", - "to": "Version: __VERSION__", - "flags": "g" - } - ] +**Resolution order (first match wins):** + +1. `.tagyrc` — primary config file +2. `.tagyrc.json` — alias, identical parsing +3. Legacy `package.json` `tagy` block detected → one-time migration prompt to create `.tagyrc` +4. No config found → **interactive wizard** (asks branch, prefix, files to bump, then offers to save `.tagyrc`) + +**Schema:** + +```jsonc +{ + "branch": null, // null = confirm if not on master/main; string = require this branch + "tagPrefix": "", // e.g. "v" → tag "v1.2.3"; file versions written unprefixed + "bump": [], // structural version bumps: ["package.json", "composer.json"] + "replace": [], // regex replacement rules (see below) + "autoRelease": false // create GitHub release without prompting } ``` -Description of the above parameters: -* `tagPrefix` - (optional) Allows to create releases with a prefix. For example, if you want to create a release with a prefix `v` and the version is `1.0.0`, the tag will be `v1.0.0`. The tag in git will be `v1.0.0`. -* `soft` - (optional) Allows to create a new version which will update only the `package.json` and follow any rules in `tagy.js` file or `package.json`, but will not commit the changes to git or create a new git tag. So basically, it will do only a search and replace in files without affecting the git tags. -* `auto-release` - (optional) Allows to create a Github release directly from terminal after the tag is created (automatically, without confirmation). -* `replace` - (optional) Allows to define custom replacement rules in `package.json` file. For example, if you want to replace the version in a file named `style.css` with the version from `package.json`, add the following in `package.json`: - * `files` - (required) The file or files where the replacement will be done. This can be a string or an array of strings. Relative to `package.json` file! - * `from` - (required) The string or regex to search for. If you define a regex, make sure to escape the special characters and double escape the backslash. - * `to` - (required) The string to replace the matched string or regex of `from`. You can use the `__VERSION__` placeholder to use the new version from `package.json`. - * `flags` - (optional) The flags to use for the regex. Default is `g`. +**Supported `bump` files:** `package.json` and `composer.json` (JSON manifests with a `.version` field). For other files (e.g. `Cargo.toml`, `VERSION`, CSS headers), use `replace` rules instead. +**Default behavior (no/minimal config):** tagy verifies it is inside a git repo, then tags and pushes. It touches **no files** unless `bump` or `replace` are configured. -#### The above `from` and `to` parameters accept 2 types of variables: -* `__VERSION__` - This will be replaced with the new version from `package.json`. -* `__CURRENT_TAG__` - This will be replaced with the current tag from git. +### `replace` rules -## Extend it: +Each rule is an object with: -#### Custom scripts before `git push` is executed. -Create a file in your project directory named `tagy.js` and inside export a module function with some logic. This function will be executed just before the `git push` command is called. -Doing so you have the option to manipulate the files before they are released. -For example: -```js -module.exports = (newVersion, oldVersion, args) => { - console.log('Custom "tagy" scripts can be used before git push'); +```jsonc +{ + "files": "path/to/file", // string or array of strings, relative to project root + "from": "Version: \\d+\\.\\d+\\.\\d+", // regex pattern (special chars must be escaped) + "to": "Version: __VERSION__", // replacement string + "flags": "g" // optional regex flags (default: "g") } ``` -A real example, replacing the version in a css file. -``` -const path = require('path'); -const replace = require('replace-in-file'); - -module.exports = (newVersion, oldVersion, args) => { - replace.sync({ - files: path.resolve(__dirname, 'src/style.css'), - from: /Version: \d+\.\d+\.\d+/g, - to: `Version: ${newVersion}`, - }); +The `from` and `to` values support two placeholders: +- `__VERSION__` — replaced with the new version (unprefixed) +- `__CURRENT_TAG__` — replaced with the current git tag (with prefix) + +**Example `.tagyrc`:** + +```json +{ + "tagPrefix": "v", + "bump": ["package.json"], + "replace": [ + { + "files": "themes/custom/style.css", + "from": "Version: \\d+\\.\\d+\\.\\d+", + "to": "Version: __VERSION__", + "flags": "g" + } + ], + "autoRelease": false } ``` -## New in version 1.8 -### Soft tag: +### Interactive wizard -A soft tag will allow to create a new version which will update only the `package.json` and follow any rules in `tagy.js` file, -but will not commit the changes to git or create a new git tag. +When no `.tagyrc` is found, tagy runs an interactive wizard that asks: -So basically, it will do only a search and replace in files without affecting the git tags. - -To enable this, add the following in `package.json`: -``` -"tagy": { - "method": "soft" -} -``` +1. Confirm the current branch +2. Tag prefix (blank for none) +3. Which known version file(s) to bump (only shows files that exist in your project) +4. Whether to save answers to `.tagyrc` for next time -## New in version 1.9 -### Tag Prefix: +Replace rules are not asked in the wizard (advanced feature — edit `.tagyrc` by hand). -This allows to create releases with a prefix. +### Legacy migration -For example, if you want to create a release with a prefix `v` and the version is `1.0.0`, the tag will be `v1.0.0`. +If your project has a `tagy` block in `package.json` (v1 config), tagy will detect it on first run and offer to create a `.tagyrc` from it automatically. The migrator maps: -To enable this, add the following in `package.json`: -``` -"tagy": { - "tagPrefix": "v" -} -``` +| `package.json.tagy` | `.tagyrc` | +|---|---| +| `tagPrefix` | `tagPrefix` | +| `replace` | `replace` (unchanged) | +| `auto-release` | `autoRelease` | +| `soft` / `method: "soft"` | dropped (use `--soft` flag instead) | +| (implicit package.json bump) | `bump: ["package.json"]` | -## New in version 1.10 -### Replacements from `package.json`: +## Extend it: `tagy.js` hook -This allows to define custom replacement rules in `package.json` file. +Create a `tagy.js` file in your project root and export a function. It runs just before `git push`, after all file bumps and replacements. -For example, if you want to replace the version in a file named `style.css` with the version from `package.json`, add the following in `package.json`: -``` -"tagy": { - "replace": [ - { - "files": "themes/custom/style.css", - "from": "Version: \\d+\\.\\d+\\.\\d+", - "to": "Version: __VERSION__", - "flags": "g" - } - ] +```js +module.exports = (newVersion, oldVersion, args) => { + console.log('Custom scripts before git push'); } ``` -In the above example we replace the version from style.css with the new version from `package.json` file. - -**This is an array of objects, so you can define multiple replacements.** - -### Other changes in version 1.10 include: -* Added `--soft` argument to create a soft tag directly from terminal. -* Deprecated `{"method": "soft"}` in `package.json` file. Use `{"soft": true}` instead. - - -## New in version 1.10.1 -* Added Github release prompt. This will allow to create a Github release directly from terminal after the tag is created. +## Breaking changes from v1 -## New in version 1.10.3 -* Added `--auto-release` argument to create a Github release directly from terminal after the tag is created (automatically, without confirmation). -* Added `auto-release` option in `package.json` file. This will allow to create a Github release directly from terminal after the tag is created (automatically, without confirmation). +1. Config moved from `package.json.tagy` to `.tagyrc` (one-time migration offered on first run). +2. `package.json` version bump is no longer automatic — add `"bump": ["package.json"]` to your `.tagyrc`. +3. `tagy.soft` / `method: "soft"` config keys removed; use the `--soft` CLI flag. +4. Published artifact is compiled `dist/` output (TypeScript source not included). From 4311dda7b80b6cf172ca02b58f41c90873442510 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 12:09:00 +0300 Subject: [PATCH 12/13] docs: correct execution-flow (--info/--reverse short-circuit before config); clarify branch:null --- CLAUDE.md | 10 +++++----- README.md | 2 +- docs/superpowers/specs/2026-06-18-tagy-v2-design.md | 2 ++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0e4e3e..df2c9f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,11 +55,11 @@ Config is a standalone `.tagyrc` JSON file. Schema keys (all optional): ## Execution flow (`index.ts`) 1. **Arg validation** — rejects too many args or conflicting increment flags. -2. **Config resolution** — `.tagyrc` / `.tagyrc.json` / legacy migration / wizard. -3. **Branch check** — abort if wrong branch (strict mode), or confirm prompt on non-master/main. -4. **Determine current version** — `git.latestTag(prefix)`, normalize via `normalizeCurrentVersion`. -5. **`--info`** short-circuits here. -6. **Compute next version** — `semver.inc()` for patch/minor/major; `--major` and `--reverse` require confirmation prompts; `--custom` prompts for value. +2. **`--info` / `--reverse` short-circuit here** — read-only/maintenance commands run before config resolution and never launch the wizard. They read the tag prefix from `.tagyrc` if it exists (else none), then `git.fetchTags()` + `git.latestTag(prefix)`. `--info` prints the latest tag; `--reverse` deletes/pushes-deletion of the last tag after a confirm prompt (blocked in `--soft`). +3. **Config resolution** — `.tagyrc` / `.tagyrc.json` / legacy migration / wizard. +4. **Branch check** — abort if wrong branch (strict mode), or confirm prompt on non-master/main. +5. **Determine current version** — `git.latestTag(prefix)`, normalize via `normalizeCurrentVersion`. +6. **Compute next version** — `semver.inc()` for patch/minor/major (`--major` requires a confirmation prompt); `--custom` prompts for a value. 7. **Apply changes** — structural bumps (`bump.ts`); replace rules; `tagy.js` hook (if present). 8. **Git ops** (non-soft only) — commit, push branch, create tag, push tag; `gh release create` (if `autoRelease` or prompted). diff --git a/README.md b/README.md index a326c36..8c7d0b8 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ tagy reads configuration from a `.tagyrc` file (JSON) in your project root. All ```jsonc { - "branch": null, // null = confirm if not on master/main; string = require this branch + "branch": null, // null = silent on master/main, confirm prompt on any other branch; string = require this exact branch "tagPrefix": "", // e.g. "v" → tag "v1.2.3"; file versions written unprefixed "bump": [], // structural version bumps: ["package.json", "composer.json"] "replace": [], // regex replacement rules (see below) diff --git a/docs/superpowers/specs/2026-06-18-tagy-v2-design.md b/docs/superpowers/specs/2026-06-18-tagy-v2-design.md index 6f87997..d02ad22 100644 --- a/docs/superpowers/specs/2026-06-18-tagy-v2-design.md +++ b/docs/superpowers/specs/2026-06-18-tagy-v2-design.md @@ -129,6 +129,8 @@ silently dropping that behavior on upgrade. ## Execution flow +> Refinement adopted during implementation: `--info` and `--reverse` short-circuit *before* config resolution / the wizard — a read-only `--info` must never launch the wizard. They read `tagPrefix` from `.tagyrc` if present, otherwise use no prefix. + ### Path 1 — `.tagyrc` present (non-interactive) ``` From 7b2fe3b47318156c5c9098846576034cc85a6d72 Mon Sep 17 00:00:00 2001 From: Andrei Surdu Date: Thu, 18 Jun 2026 18:11:24 +0300 Subject: [PATCH 13/13] refactor: wizard branch:null default + soft skips branch confirm; harmonize bump fs; restore lib tests --- src/bump.ts | 9 +++++++-- src/index.ts | 2 +- src/wizard.ts | 18 ++++++++++-------- test/bump.test.ts | 6 +++++- test/lib.test.ts | 14 ++++++++++++++ test/wizard.test.ts | 17 +++++++++++++++-- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/bump.ts b/src/bump.ts index 4eceffd..db9e935 100644 --- a/src/bump.ts +++ b/src/bump.ts @@ -7,9 +7,14 @@ export const KNOWN_BUMP_FILES = ['package.json', 'composer.json'] as const export function bumpJsonVersion(absFile: string, version: string, fsModule: typeof fs = fs): boolean { if (!fsModule.existsSync(absFile)) return false - const content = fsModule.readJsonSync(absFile) + let content: any + try { + content = JSON.parse(fsModule.readFileSync(absFile, 'utf8')) + } catch { + throw new Error(`Couldn't parse "${absFile}"`) + } content.version = version - fsModule.writeJsonSync(absFile, content, { spaces: 2 }) + fsModule.writeFileSync(absFile, JSON.stringify(content, null, 2) + '\n') return true } diff --git a/src/index.ts b/src/index.ts index 084f7b9..145cca1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +86,7 @@ export default function (): void { // --- no config at all: wizard --- let isSoft = Boolean(args.soft) if (!config) { - const wizard = await runWizard({ cwd, branch, promptsLib: ask }) + const wizard = await runWizard({ cwd, branch, promptsLib: ask, skipBranchConfirm: isSoft }) if (!wizard) { console.log(chalk.red.bold('Aborted! Please switch the branch.')) return diff --git a/src/wizard.ts b/src/wizard.ts index 96c8f58..80525a2 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -15,14 +15,16 @@ export function existingBumpFiles(cwd: string, fsModule: typeof fs = fs): string } export async function runWizard( - { cwd, branch, promptsLib = defaultAsk, fs: fsModule = fs }: - { cwd: string; branch: string; promptsLib?: Ask; fs?: typeof fs }, + { cwd, branch, promptsLib = defaultAsk, fs: fsModule = fs, skipBranchConfirm = false }: + { cwd: string; branch: string; promptsLib?: Ask; fs?: typeof fs; skipBranchConfirm?: boolean }, ): Promise { - const confirmBranch = await promptsLib({ - type: 'confirm', name: 'value', - message: `You're on branch '${branch}'. Tag this branch?`, initial: true, - }) - if (!confirmBranch.value) return null + if (!skipBranchConfirm) { + const confirmBranch = await promptsLib({ + type: 'confirm', name: 'value', + message: `You're on branch '${branch}'. Tag this branch?`, initial: true, + }) + if (!confirmBranch.value) return null + } const prefixAns = await promptsLib({ type: 'text', name: 'value', message: 'Tag prefix? (blank for none)', initial: '', @@ -39,7 +41,7 @@ export async function runWizard( bump = Array.isArray(bumpAns.value) ? bumpAns.value : [] } - const config: TagyConfig = { branch, tagPrefix, bump, replace: [], autoRelease: false } + const config: TagyConfig = { branch: null, tagPrefix, bump, replace: [], autoRelease: false } const saveAns = await promptsLib({ type: 'confirm', name: 'value', diff --git a/test/bump.test.ts b/test/bump.test.ts index 4eae6da..09f1f3a 100644 --- a/test/bump.test.ts +++ b/test/bump.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { tmpdir } from 'node:os' -import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs' +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { KNOWN_BUMP_FILES, bumpFiles, applyReplaceRules, writeConfig } from '../src/bump' @@ -30,6 +30,10 @@ describe('bumpFiles', () => { writeFileSync(join(dir, 'random.json'), '{"version":"1.0.0"}') expect(bumpFiles(['random.json'], '2.0.0', { cwd: dir })).toEqual([]) }) + it('throws a friendly error on malformed JSON', () => { + writeFileSync(join(dir, 'package.json'), '{ bad json') + expect(() => bumpFiles(['package.json'], '1.0.1', { cwd: dir })).toThrow("Couldn't parse") + }) }) describe('applyReplaceRules', () => { diff --git a/test/lib.test.ts b/test/lib.test.ts index de8c98c..b048e4f 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -20,6 +20,12 @@ describe('createColor', () => { it('plain text for non-TTY', () => { expect(createColor({ isTTY: false, env: {} }).yellow('W')).toBe('W') }) + it('NO_COLOR wins over FORCE_COLOR', () => { + expect(createColor({ isTTY: true, env: { NO_COLOR: '', FORCE_COLOR: '1' } }).blue('Y')).toBe('Y') + }) + it('coerces non-string input to a string', () => { + expect(createColor({ isTTY: false, env: {} }).red(42 as any)).toBe('42') + }) }) describe('substitute', () => { @@ -27,6 +33,9 @@ describe('substitute', () => { expect(substitute('__VERSION__ __CURRENT_TAG__ __VERSION__', { version: '1.2.4', currentTag: '1.2.3' })) .toBe('1.2.4 1.2.3 1.2.4') }) + it('coerces non-string values', () => { + expect(substitute(123 as any, { version: '1.0.0', currentTag: '0.9.0' })).toBe('123') + }) }) describe('resolveIncrement', () => { @@ -70,6 +79,11 @@ describe('buildReplaceConfig', () => { it('flags:false -> no flags', () => { expect(buildReplaceConfig({ files: 'f', from: 'a', to: 'b', flags: false }, ctx)!.from.flags).toBe('') }) + it('resolves an array of files and honors a custom flags string', () => { + const c = buildReplaceConfig({ files: ['a.css', 'b.css'], from: 'a', to: 'b', flags: 'gi' }, { version: '1.2.4', currentTag: '1.2.3', cwd: '/proj' })! + expect(c.files).toEqual([resolve('/proj/a.css'), resolve('/proj/b.css')]) + expect(c.from.flags).toBe('gi') + }) }) describe('replaceInFiles', () => { diff --git a/test/wizard.test.ts b/test/wizard.test.ts index 98915ac..eeb1941 100644 --- a/test/wizard.test.ts +++ b/test/wizard.test.ts @@ -32,7 +32,7 @@ describe('runWizard', () => { // answers: confirm branch=true, prefix='v', bump=['package.json'], save=true const result = await runWizard({ cwd: dir, branch: 'main', promptsLib: scripted([true, 'v', ['package.json'], true]) as any }) expect(result).toEqual({ - config: { branch: 'main', tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }, + config: { branch: null, tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }, save: true, }) }) @@ -41,8 +41,21 @@ describe('runWizard', () => { // answers: confirm=true, prefix='', save=false (no bump prompt) const result = await runWizard({ cwd: dir, branch: 'main', promptsLib: scripted([true, '', false]) as any }) expect(result).toEqual({ - config: { branch: 'main', tagPrefix: '', bump: [], replace: [], autoRelease: false }, + config: { branch: null, tagPrefix: '', bump: [], replace: [], autoRelease: false }, save: false, }) }) + + it('skipBranchConfirm: true skips branch prompt, still returns valid config', async () => { + writeFileSync(join(dir, 'package.json'), '{}') + // First scripted answer would decline branch (false), but it is never consumed. + // With skipBranchConfirm=true, prompts start at prefix='v', bump=['package.json'], save=true + const result = await runWizard({ + cwd: dir, branch: 'feature', skipBranchConfirm: true, + promptsLib: scripted(['v', ['package.json'], true]) as any, + }) + expect(result).not.toBeNull() + expect(result!.config).toEqual({ branch: null, tagPrefix: 'v', bump: ['package.json'], replace: [], autoRelease: false }) + expect(result!.save).toBe(true) + }) })