From 000944f0fbd56cfe1355c12581a59f18c14110af Mon Sep 17 00:00:00 2001 From: stephansama Date: Sun, 17 May 2026 22:52:48 -0400 Subject: [PATCH 1/3] feat(core): add anchor-pnpm to readme --- README.md | 1 + core/anchor-pnpm/README.md | 42 ++++ .../__snapshots__/tsnapi/cli.snapshot.d.ts | 4 + .../__snapshots__/tsnapi/cli.snapshot.js | 4 + .../__snapshots__/tsnapi/index.snapshot.d.ts | 22 ++ .../__snapshots__/tsnapi/index.snapshot.js | 13 ++ core/anchor-pnpm/package.json | 65 ++++++ core/anchor-pnpm/src/anchors.ts | 217 ++++++++++++++++++ core/anchor-pnpm/src/cli.ts | 198 ++++++++++++++++ core/anchor-pnpm/src/index.ts | 6 + core/anchor-pnpm/src/workspace.ts | 26 +++ core/anchor-pnpm/tsconfig.json | 7 + core/anchor-pnpm/tsdown.config.ts | 16 ++ pnpm-lock.yaml | 22 ++ 14 files changed, 643 insertions(+) create mode 100644 core/anchor-pnpm/README.md create mode 100644 core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.d.ts create mode 100644 core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.js create mode 100644 core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.d.ts create mode 100644 core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.js create mode 100644 core/anchor-pnpm/package.json create mode 100644 core/anchor-pnpm/src/anchors.ts create mode 100644 core/anchor-pnpm/src/cli.ts create mode 100644 core/anchor-pnpm/src/index.ts create mode 100644 core/anchor-pnpm/src/workspace.ts create mode 100644 core/anchor-pnpm/tsconfig.json create mode 100644 core/anchor-pnpm/tsdown.config.ts diff --git a/README.md b/README.md index 8d59946c..0a05510b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ All packages are packaged underneath the `@stephansama` scope (for example: `@st | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | [ai-commit-msg](core/ai-commit-msg/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fai-commit-msg?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/ai-commit-msg?labelColor=211F1F) | generate commit messages using ai | | [alfred-kaomoji](core/alfred-kaomoji/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Falfred-kaomoji?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/alfred-kaomoji?labelColor=211F1F) | Alfred Kaomoji Picker | +| [anchor-pnpm](core/anchor-pnpm/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fanchor-pnpm?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/anchor-pnpm?labelColor=211F1F) | Manage YAML anchor declarations in pnpm-workspace.yaml | | [astro-iconify-svgmap](core/astro-iconify-svgmap/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fastro-iconify-svgmap?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/astro-iconify-svgmap?labelColor=211F1F) | Astro integration for generating iconify svgmaps for ssg sites | | [auto-readme](core/auto-readme/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fauto-readme?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/auto-readme?labelColor=211F1F) | Generate lists and tables for your README automagically based on your repository and comments | | [catppuccin-jsonresume-theme](core/catppuccin-jsonresume-theme/README.md) | ![npm version image](https://img.shields.io/npm/v/%40stephansama%2Fcatppuccin-jsonresume-theme?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F) | ![npm downloads](https://img.shields.io/npm/dw/@stephansama/catppuccin-jsonresume-theme?labelColor=211F1F) | theme for resume cli website | diff --git a/core/anchor-pnpm/README.md b/core/anchor-pnpm/README.md new file mode 100644 index 00000000..f4f4a00c --- /dev/null +++ b/core/anchor-pnpm/README.md @@ -0,0 +1,42 @@ +# @stephansama/anchor-pnpm + +CLI for managing YAML anchor declarations in `pnpm-workspace.yaml`. + +Many pnpm catalog setups repeat the same version string across multiple packages in a catalog group: + +```yaml +catalogs: + alpine: + alpinejs: 3.15.8 + "@alpinejs/focus": 3.15.8 +``` + +Sharing the version via a YAML anchor keeps them in lock-step: + +```yaml +__versions: + - &alpine 3.15.8 + +catalogs: + alpine: + alpinejs: *alpine + "@alpinejs/focus": *alpine +``` + +`anchor-pnpm` automates creating and maintaining that `__versions` block while preserving the surrounding YAML (anchors, comments, ordering) via the `yaml` package's Document API. + +## Commands + +- `anchor-pnpm list` — print every anchor name and resolved version. +- `anchor-pnpm add ` — append a new `& ` entry. +- `anchor-pnpm update ` — set the scalar value of an existing anchor (all `*` aliases follow automatically). +- `anchor-pnpm remove ` — remove the anchor; errors if aliases still reference it (pass `--force` to inline them first). +- `anchor-pnpm sync` — _scheduled, not yet implemented._ Auto-detect catalog groups with repeated versions and convert them to anchor+alias pairs. + +## Global flags + +``` +--workspace, -w Path to pnpm-workspace.yaml (default: auto-detect via @manypkg/find-root) +--dry-run, -d Print the rewritten YAML to stdout instead of writing +--verbose, -v Show detailed output +``` diff --git a/core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.d.ts b/core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.d.ts new file mode 100644 index 00000000..745de720 --- /dev/null +++ b/core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.d.ts @@ -0,0 +1,4 @@ +/** + * Generated by tsnapi — public API snapshot of `@stephansama/anchor-pnpm/cli` + */ +/* no exports */ \ No newline at end of file diff --git a/core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.js b/core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.js new file mode 100644 index 00000000..745de720 --- /dev/null +++ b/core/anchor-pnpm/__snapshots__/tsnapi/cli.snapshot.js @@ -0,0 +1,4 @@ +/** + * Generated by tsnapi — public API snapshot of `@stephansama/anchor-pnpm/cli` + */ +/* no exports */ \ No newline at end of file diff --git a/core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.d.ts b/core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.d.ts new file mode 100644 index 00000000..2e1afc9a --- /dev/null +++ b/core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.d.ts @@ -0,0 +1,22 @@ +/** + * Generated by tsnapi — public API snapshot of `@stephansama/anchor-pnpm` + */ +// #region Functions +export declare function add(_: Document, _: string, _: string): { + added: boolean; +}; +export declare function list(_: Document): AnchorEntry[]; +export declare function loadWorkspace(_: string): Promise; +export declare function remove(_: Document, _: string, _?: { + force?: boolean; +}): { + dangling: string[]; + removed: boolean; +}; +export declare function resolveWorkspacePath(_?: string): Promise; +export declare function saveWorkspace(_: string, _: Document): Promise; +export declare function sync(_: Document): SyncProposal[]; +export declare function update(_: Document, _: string, _: string): { + updated: boolean; +}; +// #endregion \ No newline at end of file diff --git a/core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.js b/core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.js new file mode 100644 index 00000000..9fcc0347 --- /dev/null +++ b/core/anchor-pnpm/__snapshots__/tsnapi/index.snapshot.js @@ -0,0 +1,13 @@ +/** + * Generated by tsnapi — public API snapshot of `@stephansama/anchor-pnpm` + */ +// #region Functions +export function add(_, _, _) {} +export function list(_) {} +export async function loadWorkspace(_) {} +export function remove(_, _, _) {} +export async function resolveWorkspacePath(_) {} +export async function saveWorkspace(_, _) {} +export function sync(_) {} +export function update(_, _, _) {} +// #endregion \ No newline at end of file diff --git a/core/anchor-pnpm/package.json b/core/anchor-pnpm/package.json new file mode 100644 index 00000000..88ae7a12 --- /dev/null +++ b/core/anchor-pnpm/package.json @@ -0,0 +1,65 @@ +{ + "name": "@stephansama/anchor-pnpm", + "version": "0.1.0", + "description": "Manage YAML anchor declarations in pnpm-workspace.yaml", + "keywords": [ + "anchor", + "anchor-pnpm", + "cli", + "pnpm", + "tanstack-intent", + "workspace", + "yaml" + ], + "homepage": "https://packages.stephansama.info/api/@stephansama/anchor-pnpm", + "repository": { + "type": "git", + "url": "git+https://github.com/stephansama/packages.git", + "directory": "core/anchor-pnpm" + }, + "license": "MIT", + "author": { + "name": "Stephan Randle", + "email": "stephanrandle.dev@gmail.com", + "url": "https://stephansama.info" + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": "./dist/index.mjs", + "./cli": "./dist/cli.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "bin": { + "anchor-pnpm": "./dist/cli.mjs" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "build:snapshot": "tsdown -u", + "dev": "tsdown --watch", + "lint": "eslint ./src/ --pass-on-no-patterns --no-error-on-unmatched-pattern", + "lint:fix": "eslint ./src/ --pass-on-no-patterns --no-error-on-unmatched-pattern --fix" + }, + "dependencies": { + "@manypkg/find-root": "catalog:manypkg", + "yaml": "catalog:", + "yargs": "catalog:cli", + "zod": "catalog:schema" + }, + "devDependencies": { + "@types/yargs": "catalog:", + "tsdown": "catalog:" + }, + "packageManager": "pnpm@10.29.3", + "engines": { + "node": ">=24" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/core/anchor-pnpm/src/anchors.ts b/core/anchor-pnpm/src/anchors.ts new file mode 100644 index 00000000..8078c671 --- /dev/null +++ b/core/anchor-pnpm/src/anchors.ts @@ -0,0 +1,217 @@ +import { + Alias, + Document, + isAlias, + isMap, + isPair, + isScalar, + isSeq, + Scalar, + YAMLSeq, +} from "yaml"; + +const VERSIONS_KEY = "__versions"; +const CATALOGS_KEY = "catalogs"; + +export type AnchorEntry = { name: string; value: string }; + +export type SyncProposal = { + anchorName: string; + catalog: string; + keys: string[]; + version: string; +}; + +export function add( + document: Document, + name: string, + value: string, +): { added: boolean } { + const seq = ensureVersionsSeq(document); + if (findAnchor(seq, name)) return { added: false }; + const scalar = new Scalar(value); + scalar.anchor = name; + seq.add(scalar); + return { added: true }; +} + +export function applySyncProposal( + document: Document, + proposal: SyncProposal, +): void { + add(document, proposal.anchorName, proposal.version); + const catalogs = document.get(CATALOGS_KEY); + if (!isMap(catalogs)) return; + const group = catalogs.get(proposal.catalog); + if (!isMap(group)) return; + + for (const entry of group.items) { + if (!isPair(entry) || !isScalar(entry.key)) continue; + if (!proposal.keys.includes(String(entry.key.value))) continue; + entry.value = new Alias(proposal.anchorName); + } +} + +export function list(document: Document): AnchorEntry[] { + const seq = getVersionsSeq(document); + if (!seq) return []; + const entries: AnchorEntry[] = []; + for (const item of seq.items) { + if (!isScalar(item) || !item.anchor) continue; + entries.push({ name: item.anchor, value: String(item.value) }); + } + return entries; +} + +export function remove( + document: Document, + name: string, + options: { force?: boolean } = {}, +): { dangling: string[]; removed: boolean } { + const seq = getVersionsSeq(document); + if (!seq) return { dangling: [], removed: false }; + + const dangling = collectAliasPaths(document, name); + if (dangling.length > 0 && !options.force) { + return { dangling, removed: false }; + } + + if (dangling.length > 0 && options.force) { + const scalar = findAnchor(seq, name); + replaceAliasesWithLiteral(document, name, scalar?.value); + } + + const index = seq.items.findIndex( + (item) => isScalar(item) && item.anchor === name, + ); + if (index === -1) return { dangling: [], removed: false }; + seq.items.splice(index, 1); + return { dangling: [], removed: true }; +} + +export function sync(document: Document): SyncProposal[] { + const catalogs = document.get(CATALOGS_KEY); + if (!isMap(catalogs)) return []; + + const proposals: SyncProposal[] = []; + + for (const pair of catalogs.items) { + if (!isPair(pair) || !isScalar(pair.key) || !isMap(pair.value)) + continue; + const catalogName = String(pair.key.value); + const versionToKeys = new Map(); + + for (const entry of pair.value.items) { + if (!isPair(entry) || !isScalar(entry.key)) continue; + const value = entry.value; + if (!isScalar(value) || value.anchor) continue; + const versionString = String(value.value); + const keyString = String(entry.key.value); + versionToKeys.set(versionString, [ + ...(versionToKeys.get(versionString) ?? []), + keyString, + ]); + } + + for (const [version, keys] of versionToKeys) { + if (keys.length < 2) continue; + proposals.push({ + anchorName: catalogName, + catalog: catalogName, + keys, + version, + }); + } + } + + return proposals; +} + +export function update( + document: Document, + name: string, + value: string, +): { updated: boolean } { + const seq = getVersionsSeq(document); + if (!seq) return { updated: false }; + const scalar = findAnchor(seq, name); + if (!scalar) return { updated: false }; + scalar.value = value; + return { updated: true }; +} + +function collectAliasPaths(document: Document, name: string): string[] { + const paths: string[] = []; + function walk(node: unknown, trail: string[]): void { + if (isAlias(node)) { + if (node.source === name) paths.push(trail.join(".") || "(root)"); + return; + } + if (isMap(node)) { + for (const pair of node.items) { + if (!isPair(pair) || !isScalar(pair.key)) continue; + walk(pair.value, [...trail, String(pair.key.value)]); + } + return; + } + if (isSeq(node)) { + let index = 0; + for (const item of node.items) { + walk(item, [...trail, String(index++)]); + } + } + } + walk(document.contents, []); + return paths; +} + +function ensureVersionsSeq(document: Document): YAMLSeq { + const existing = getVersionsSeq(document); + if (existing) return existing; + const seq = new YAMLSeq(); + document.set(VERSIONS_KEY, seq); + return seq; +} + +function findAnchor(seq: YAMLSeq, name: string): Scalar | undefined { + for (const item of seq.items) { + if (isScalar(item) && item.anchor === name) return item; + } + return undefined; +} + +function getVersionsSeq(document: Document): undefined | YAMLSeq { + const node = document.get(VERSIONS_KEY); + return isSeq(node) ? node : undefined; +} + +function replaceAliasesWithLiteral( + document: Document, + name: string, + literal: unknown, +): void { + function visit(node: unknown): void { + if (isMap(node)) { + for (const pair of node.items) { + if (!isPair(pair)) continue; + if (isAlias(pair.value) && pair.value.source === name) { + pair.value = new Scalar(literal); + } else { + visit(pair.value); + } + } + return; + } + if (isSeq(node)) { + for (let index = 0; index < node.items.length; index++) { + const item = node.items[index]; + if (isAlias(item) && item.source === name) { + node.items[index] = new Scalar(literal); + } else { + visit(item); + } + } + } + } + visit(document.contents); +} diff --git a/core/anchor-pnpm/src/cli.ts b/core/anchor-pnpm/src/cli.ts new file mode 100644 index 00000000..cd86e363 --- /dev/null +++ b/core/anchor-pnpm/src/cli.ts @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +import { add, applySyncProposal, list, remove, sync, update } from "./anchors"; +import { + loadWorkspace, + resolveWorkspacePath, + saveWorkspace, +} from "./workspace"; + +type GlobalOptions = { + dryRun?: boolean; + verbose?: boolean; + workspace?: string; +}; + +await yargs(hideBin(process.argv)) + .scriptName("anchor-pnpm") + .usage("$0 [options]") + .option("workspace", { + alias: "w", + description: "Path to pnpm-workspace.yaml (default: auto-detect)", + type: "string", + }) + .option("dry-run", { + alias: "d", + default: false, + description: "Preview the rewritten YAML without writing", + type: "boolean", + }) + .option("verbose", { + alias: "v", + default: false, + description: "Show detailed output", + type: "boolean", + }) + .command( + "list", + "Print every anchor name and its resolved version.", + (yargv) => yargv, + async (argv) => { + const filepath = await resolveWorkspacePath( + (argv as GlobalOptions).workspace, + ); + const document = await loadWorkspace(filepath); + const entries = list(document); + if (entries.length === 0) { + // eslint-disable-next-line no-console + console.log("(no anchors)"); + return; + } + const longest = Math.max( + ...entries.map((entry) => entry.name.length), + ); + for (const entry of entries) { + // eslint-disable-next-line no-console + console.log(` &${entry.name.padEnd(longest)} ${entry.value}`); + } + }, + ) + .command( + "add ", + "Append `- & ` to __versions.", + (yargv) => + yargv + .positional("name", { demandOption: true, type: "string" }) + .positional("version", { demandOption: true, type: "string" }), + async (argv) => { + const options = argv as GlobalOptions & { + name: string; + version: string; + }; + const filepath = await resolveWorkspacePath(options.workspace); + const document = await loadWorkspace(filepath); + const result = add(document, options.name, options.version); + if (!result.added) { + throw new Error(`anchor &${options.name} already exists`); + } + await commit(filepath, document, options); + }, + ) + .command( + "update ", + "Set the scalar value of an existing & entry.", + (yargv) => + yargv + .positional("name", { demandOption: true, type: "string" }) + .positional("version", { demandOption: true, type: "string" }), + async (argv) => { + const options = argv as GlobalOptions & { + name: string; + version: string; + }; + const filepath = await resolveWorkspacePath(options.workspace); + const document = await loadWorkspace(filepath); + const result = update(document, options.name, options.version); + if (!result.updated) { + throw new Error( + `anchor &${options.name} not found in __versions`, + ); + } + await commit(filepath, document, options); + }, + ) + .command( + "remove ", + "Remove the & entry from __versions.", + (yargv) => + yargv + .positional("name", { demandOption: true, type: "string" }) + .option("force", { + alias: "f", + default: false, + description: "Inline alias references before removing", + type: "boolean", + }), + async (argv) => { + const options = argv as GlobalOptions & { + force?: boolean; + name: string; + }; + const filepath = await resolveWorkspacePath(options.workspace); + const document = await loadWorkspace(filepath); + const result = remove(document, options.name, { + force: options.force, + }); + if (!result.removed && result.dangling.length > 0) { + throw new Error( + `anchor &${options.name} still has alias references at: ${result.dangling.join(", ")}. Pass --force to inline them.`, + ); + } + if (!result.removed) { + throw new Error(`anchor &${options.name} not found`); + } + await commit(filepath, document, options); + }, + ) + .command( + "sync", + "Detect repeated catalog versions and propose anchor+alias pairs.", + (yargv) => + yargv.option("apply", { + default: false, + description: "Apply all proposals (otherwise prints only)", + type: "boolean", + }), + async (argv) => { + const options = argv as GlobalOptions & { apply?: boolean }; + const filepath = await resolveWorkspacePath(options.workspace); + const document = await loadWorkspace(filepath); + const proposals = sync(document); + if (proposals.length === 0) { + // eslint-disable-next-line no-console + console.log("No catalog groups with repeated versions."); + return; + } + for (const proposal of proposals) { + // eslint-disable-next-line no-console + console.log( + `&${proposal.anchorName} = ${proposal.version} (catalog: ${proposal.catalog}, keys: ${proposal.keys.join(", ")})`, + ); + } + if (!options.apply) { + // eslint-disable-next-line no-console + console.log( + "\nDry run. Pass --apply to write these anchors + aliases.", + ); + return; + } + for (const proposal of proposals) + applySyncProposal(document, proposal); + await commit(filepath, document, options); + }, + ) + .demandCommand(1) + .strict() + .help("h") + .alias("h", "help") + .parseAsync(); + +async function commit( + filepath: string, + document: Awaited>, + options: GlobalOptions, +): Promise { + if (options.dryRun) { + // eslint-disable-next-line no-console + console.log(document.toString()); + return; + } + await saveWorkspace(filepath, document); + if (options.verbose) { + // eslint-disable-next-line no-console + console.log(`Wrote ${filepath}`); + } +} diff --git a/core/anchor-pnpm/src/index.ts b/core/anchor-pnpm/src/index.ts new file mode 100644 index 00000000..48932f8b --- /dev/null +++ b/core/anchor-pnpm/src/index.ts @@ -0,0 +1,6 @@ +export { add, list, remove, sync, update } from "./anchors"; +export { + loadWorkspace, + resolveWorkspacePath, + saveWorkspace, +} from "./workspace"; diff --git a/core/anchor-pnpm/src/workspace.ts b/core/anchor-pnpm/src/workspace.ts new file mode 100644 index 00000000..62a797aa --- /dev/null +++ b/core/anchor-pnpm/src/workspace.ts @@ -0,0 +1,26 @@ +import { findRoot } from "@manypkg/find-root"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { Document, parseDocument } from "yaml"; + +export const WORKSPACE_FILE = "pnpm-workspace.yaml"; + +export async function loadWorkspace( + filepath: string, +): Promise { + const source = await fs.readFile(filepath, "utf8"); + return parseDocument(source); +} + +export async function resolveWorkspacePath(override?: string): Promise { + if (override) return path.resolve(override); + const { rootDir } = await findRoot(process.cwd()); + return path.join(rootDir, WORKSPACE_FILE); +} + +export async function saveWorkspace( + filepath: string, + document: Document, +): Promise { + await fs.writeFile(filepath, document.toString(), "utf8"); +} diff --git a/core/anchor-pnpm/tsconfig.json b/core/anchor-pnpm/tsconfig.json new file mode 100644 index 00000000..79101018 --- /dev/null +++ b/core/anchor-pnpm/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": ["../../tsconfig.base.json"], + "include": ["./src/**/*", "tsdown.config.ts"], + "compilerOptions": { + "composite": true + } +} diff --git a/core/anchor-pnpm/tsdown.config.ts b/core/anchor-pnpm/tsdown.config.ts new file mode 100644 index 00000000..3384ab49 --- /dev/null +++ b/core/anchor-pnpm/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsdown"; +import ApiSnapshot from "tsnapi/rolldown"; + +export default defineConfig({ + attw: { profile: "esm-only" }, + deps: { skipNodeModulesBundle: true }, + dts: true, + entry: { + cli: "./src/cli.ts", + index: "./src/index.ts", + }, + exports: { bin: true, enabled: true }, + format: "esm", + plugins: [ApiSnapshot()], + target: "esnext", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39a0cdbc..9a71a93d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -793,6 +793,28 @@ importers: specifier: 'catalog:' version: 2.1.0 + core/anchor-pnpm: + dependencies: + '@manypkg/find-root': + specifier: catalog:manypkg + version: 3.1.0 + yaml: + specifier: 'catalog:' + version: 2.8.2 + yargs: + specifier: catalog:cli + version: 18.0.0 + zod: + specifier: catalog:schema + version: 4.2.1 + devDependencies: + '@types/yargs': + specifier: 'catalog:' + version: 17.0.35 + tsdown: + specifier: 'catalog:' + version: 0.21.10(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(publint@0.3.18)(synckit@0.11.12)(typescript@5.9.3) + core/astro-iconify-svgmap: devDependencies: '@iconify/types': From ee2dc6c684c7eba84894d06055a43ffe62f05761 Mon Sep 17 00:00:00 2001 From: stephansama Date: Sun, 17 May 2026 22:53:39 -0400 Subject: [PATCH 2/3] test(anchor): add test coverage for anchor operations --- core/anchor-pnpm/test/anchors.test.ts | 101 ++++++++++++++++++++++++++ core/anchor-pnpm/vitest.config.ts | 8 ++ 2 files changed, 109 insertions(+) create mode 100644 core/anchor-pnpm/test/anchors.test.ts create mode 100644 core/anchor-pnpm/vitest.config.ts diff --git a/core/anchor-pnpm/test/anchors.test.ts b/core/anchor-pnpm/test/anchors.test.ts new file mode 100644 index 00000000..5d058c04 --- /dev/null +++ b/core/anchor-pnpm/test/anchors.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { parseDocument } from "yaml"; + +import { + add, + applySyncProposal, + list, + remove, + sync, + update, +} from "../src/anchors"; + +const SAMPLE = `__versions: + - &vitest 4.1.0 + +catalogs: + vitest: + vitest: *vitest + "@vitest/ui": *vitest + alpine: + alpinejs: 3.15.8 + "@alpinejs/focus": 3.15.8 +`; + +describe("anchors", () => { + it("lists existing anchor entries", () => { + const document = parseDocument(SAMPLE); + expect(list(document)).toEqual([{ name: "vitest", value: "4.1.0" }]); + }); + + it("adds a new anchor to __versions", () => { + const document = parseDocument(SAMPLE); + const result = add(document, "alpine", "3.15.8"); + expect(result.added).toBe(true); + expect(list(document)).toContainEqual({ + name: "alpine", + value: "3.15.8", + }); + }); + + it("returns added=false when the anchor already exists", () => { + const document = parseDocument(SAMPLE); + expect(add(document, "vitest", "4.2.0").added).toBe(false); + }); + + it("updates the scalar value of an existing anchor", () => { + const document = parseDocument(SAMPLE); + const result = update(document, "vitest", "4.2.0"); + expect(result.updated).toBe(true); + expect(document.toString()).toContain("&vitest 4.2.0"); + }); + + it("refuses to remove an anchor with dangling aliases", () => { + const document = parseDocument(SAMPLE); + const result = remove(document, "vitest"); + expect(result.removed).toBe(false); + expect(result.dangling.length).toBeGreaterThan(0); + }); + + it("force-removes by inlining aliases with literal values", () => { + const document = parseDocument(SAMPLE); + const result = remove(document, "vitest", { force: true }); + expect(result.removed).toBe(true); + const output = document.toString(); + expect(output).not.toContain("&vitest"); + expect(output).not.toContain("*vitest"); + expect(output).toContain("vitest: 4.1.0"); + }); + + it("detects repeated catalog versions via sync", () => { + const document = parseDocument(SAMPLE); + const proposals = sync(document); + expect(proposals).toEqual([ + expect.objectContaining({ + anchorName: "alpine", + catalog: "alpine", + version: "3.15.8", + }), + ]); + }); + + it("applySyncProposal writes the anchor and replaces literals with aliases", () => { + const document = parseDocument(SAMPLE); + applySyncProposal(document, { + anchorName: "alpine", + catalog: "alpine", + keys: ["alpinejs", "@alpinejs/focus"], + version: "3.15.8", + }); + const output = document.toString(); + expect(output).toContain("&alpine 3.15.8"); + expect(output).toContain("alpinejs: *alpine"); + }); + + it("round-trips: serializing a document with existing aliases preserves them", () => { + const document = parseDocument(SAMPLE); + const output = document.toString(); + expect(output).toContain("&vitest"); + expect(output).toContain("*vitest"); + }); +}); diff --git a/core/anchor-pnpm/vitest.config.ts b/core/anchor-pnpm/vitest.config.ts new file mode 100644 index 00000000..77330a54 --- /dev/null +++ b/core/anchor-pnpm/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["test/**/*.test.ts"], + }, +}); From 6484ff6016d9e4a1acf8e9817dd959b30ac0e615 Mon Sep 17 00:00:00 2001 From: stephansama Date: Sun, 17 May 2026 23:30:18 -0400 Subject: [PATCH 3/3] refactor(cli): replace yargs with cleye for command parsing --- core/anchor-pnpm/package.json | 3 +- core/anchor-pnpm/src/cli.ts | 318 +++++++++++++++++----------------- pnpm-lock.yaml | 9 +- 3 files changed, 160 insertions(+), 170 deletions(-) diff --git a/core/anchor-pnpm/package.json b/core/anchor-pnpm/package.json index 88ae7a12..d20f99c0 100644 --- a/core/anchor-pnpm/package.json +++ b/core/anchor-pnpm/package.json @@ -46,12 +46,11 @@ }, "dependencies": { "@manypkg/find-root": "catalog:manypkg", + "cleye": "catalog:cli", "yaml": "catalog:", - "yargs": "catalog:cli", "zod": "catalog:schema" }, "devDependencies": { - "@types/yargs": "catalog:", "tsdown": "catalog:" }, "packageManager": "pnpm@10.29.3", diff --git a/core/anchor-pnpm/src/cli.ts b/core/anchor-pnpm/src/cli.ts index cd86e363..397fc9b3 100644 --- a/core/anchor-pnpm/src/cli.ts +++ b/core/anchor-pnpm/src/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node -import yargs from "yargs"; -import { hideBin } from "yargs/helpers"; +import { cli, command } from "cleye"; import { add, applySyncProposal, list, remove, sync, update } from "./anchors"; import { @@ -10,188 +9,183 @@ import { saveWorkspace, } from "./workspace"; -type GlobalOptions = { - dryRun?: boolean; - verbose?: boolean; - workspace?: string; +type GlobalFlags = { + dryRun: boolean; + verbose: boolean; + workspace: string | undefined; }; -await yargs(hideBin(process.argv)) - .scriptName("anchor-pnpm") - .usage("$0 [options]") - .option("workspace", { - alias: "w", - description: "Path to pnpm-workspace.yaml (default: auto-detect)", - type: "string", - }) - .option("dry-run", { +const globalFlags = { + dryRun: { alias: "d", default: false, description: "Preview the rewritten YAML without writing", - type: "boolean", - }) - .option("verbose", { + type: Boolean, + }, + verbose: { alias: "v", default: false, description: "Show detailed output", - type: "boolean", - }) - .command( - "list", - "Print every anchor name and its resolved version.", - (yargv) => yargv, - async (argv) => { - const filepath = await resolveWorkspacePath( - (argv as GlobalOptions).workspace, - ); - const document = await loadWorkspace(filepath); - const entries = list(document); - if (entries.length === 0) { - // eslint-disable-next-line no-console - console.log("(no anchors)"); - return; - } - const longest = Math.max( - ...entries.map((entry) => entry.name.length), - ); - for (const entry of entries) { - // eslint-disable-next-line no-console - console.log(` &${entry.name.padEnd(longest)} ${entry.value}`); - } - }, - ) - .command( - "add ", - "Append `- & ` to __versions.", - (yargv) => - yargv - .positional("name", { demandOption: true, type: "string" }) - .positional("version", { demandOption: true, type: "string" }), - async (argv) => { - const options = argv as GlobalOptions & { - name: string; - version: string; - }; - const filepath = await resolveWorkspacePath(options.workspace); - const document = await loadWorkspace(filepath); - const result = add(document, options.name, options.version); - if (!result.added) { - throw new Error(`anchor &${options.name} already exists`); - } - await commit(filepath, document, options); - }, - ) - .command( - "update ", - "Set the scalar value of an existing & entry.", - (yargv) => - yargv - .positional("name", { demandOption: true, type: "string" }) - .positional("version", { demandOption: true, type: "string" }), - async (argv) => { - const options = argv as GlobalOptions & { - name: string; - version: string; - }; - const filepath = await resolveWorkspacePath(options.workspace); - const document = await loadWorkspace(filepath); - const result = update(document, options.name, options.version); - if (!result.updated) { - throw new Error( - `anchor &${options.name} not found in __versions`, - ); - } - await commit(filepath, document, options); - }, - ) - .command( - "remove ", - "Remove the & entry from __versions.", - (yargv) => - yargv - .positional("name", { demandOption: true, type: "string" }) - .option("force", { - alias: "f", - default: false, - description: "Inline alias references before removing", - type: "boolean", - }), - async (argv) => { - const options = argv as GlobalOptions & { - force?: boolean; - name: string; - }; - const filepath = await resolveWorkspacePath(options.workspace); - const document = await loadWorkspace(filepath); - const result = remove(document, options.name, { - force: options.force, - }); - if (!result.removed && result.dangling.length > 0) { - throw new Error( - `anchor &${options.name} still has alias references at: ${result.dangling.join(", ")}. Pass --force to inline them.`, - ); - } - if (!result.removed) { - throw new Error(`anchor &${options.name} not found`); - } - await commit(filepath, document, options); + type: Boolean, + }, + workspace: { + alias: "w", + description: "Path to pnpm-workspace.yaml (default: auto-detect)", + type: String, + }, +} as const; + +const listCommand = command( + { + flags: globalFlags, + name: "list", + }, + async (argv) => { + const filepath = await resolveWorkspacePath(argv.flags.workspace); + const document = await loadWorkspace(filepath); + const entries = list(document); + if (entries.length === 0) { + // eslint-disable-next-line no-console + console.log("(no anchors)"); + return; + } + const longest = Math.max(...entries.map((entry) => entry.name.length)); + for (const entry of entries) { + // eslint-disable-next-line no-console + console.log(` &${entry.name.padEnd(longest)} ${entry.value}`); + } + }, +); + +const addCommand = command( + { + flags: globalFlags, + name: "add", + parameters: ["", ""], + }, + async (argv) => { + const { name, version } = argv._; + const filepath = await resolveWorkspacePath(argv.flags.workspace); + const document = await loadWorkspace(filepath); + const result = add(document, name, version); + if (!result.added) { + throw new Error(`anchor &${name} already exists`); + } + await commit(filepath, document, argv.flags); + }, +); + +const updateCommand = command( + { + flags: globalFlags, + name: "update", + parameters: ["", ""], + }, + async (argv) => { + const { name, version } = argv._; + const filepath = await resolveWorkspacePath(argv.flags.workspace); + const document = await loadWorkspace(filepath); + const result = update(document, name, version); + if (!result.updated) { + throw new Error(`anchor &${name} not found in __versions`); + } + await commit(filepath, document, argv.flags); + }, +); + +const removeCommand = command( + { + flags: { + ...globalFlags, + force: { + alias: "f", + default: false, + description: "Inline alias references before removing", + type: Boolean, + }, }, - ) - .command( - "sync", - "Detect repeated catalog versions and propose anchor+alias pairs.", - (yargv) => - yargv.option("apply", { + name: "remove", + parameters: [""], + }, + async (argv) => { + const { name } = argv._; + const filepath = await resolveWorkspacePath(argv.flags.workspace); + const document = await loadWorkspace(filepath); + const result = remove(document, name, { force: argv.flags.force }); + if (!result.removed && result.dangling.length > 0) { + throw new Error( + `anchor &${name} still has alias references at: ${result.dangling.join(", ")}. Pass --force to inline them.`, + ); + } + if (!result.removed) { + throw new Error(`anchor &${name} not found`); + } + await commit(filepath, document, argv.flags); + }, +); + +const syncCommand = command( + { + flags: { + ...globalFlags, + apply: { default: false, description: "Apply all proposals (otherwise prints only)", - type: "boolean", - }), - async (argv) => { - const options = argv as GlobalOptions & { apply?: boolean }; - const filepath = await resolveWorkspacePath(options.workspace); - const document = await loadWorkspace(filepath); - const proposals = sync(document); - if (proposals.length === 0) { - // eslint-disable-next-line no-console - console.log("No catalog groups with repeated versions."); - return; - } - for (const proposal of proposals) { - // eslint-disable-next-line no-console - console.log( - `&${proposal.anchorName} = ${proposal.version} (catalog: ${proposal.catalog}, keys: ${proposal.keys.join(", ")})`, - ); - } - if (!options.apply) { - // eslint-disable-next-line no-console - console.log( - "\nDry run. Pass --apply to write these anchors + aliases.", - ); - return; - } - for (const proposal of proposals) - applySyncProposal(document, proposal); - await commit(filepath, document, options); + type: Boolean, + }, }, - ) - .demandCommand(1) - .strict() - .help("h") - .alias("h", "help") - .parseAsync(); + name: "sync", + }, + async (argv) => { + const filepath = await resolveWorkspacePath(argv.flags.workspace); + const document = await loadWorkspace(filepath); + const proposals = sync(document); + if (proposals.length === 0) { + // eslint-disable-next-line no-console + console.log("No catalog groups with repeated versions."); + return; + } + for (const proposal of proposals) { + // eslint-disable-next-line no-console + console.log( + `&${proposal.anchorName} = ${proposal.version} (catalog: ${proposal.catalog}, keys: ${proposal.keys.join(", ")})`, + ); + } + if (!argv.flags.apply) { + // eslint-disable-next-line no-console + console.log( + "\nDry run. Pass --apply to write these anchors + aliases.", + ); + return; + } + for (const proposal of proposals) applySyncProposal(document, proposal); + await commit(filepath, document, argv.flags); + }, +); + +void cli({ + commands: [ + listCommand, + addCommand, + updateCommand, + removeCommand, + syncCommand, + ], + name: "anchor-pnpm", +}); async function commit( filepath: string, document: Awaited>, - options: GlobalOptions, + flags: GlobalFlags, ): Promise { - if (options.dryRun) { + if (flags.dryRun) { // eslint-disable-next-line no-console console.log(document.toString()); return; } await saveWorkspace(filepath, document); - if (options.verbose) { + if (flags.verbose) { // eslint-disable-next-line no-console console.log(`Wrote ${filepath}`); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a71a93d..726413bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -798,19 +798,16 @@ importers: '@manypkg/find-root': specifier: catalog:manypkg version: 3.1.0 + cleye: + specifier: catalog:cli + version: 2.6.0 yaml: specifier: 'catalog:' version: 2.8.2 - yargs: - specifier: catalog:cli - version: 18.0.0 zod: specifier: catalog:schema version: 4.2.1 devDependencies: - '@types/yargs': - specifier: 'catalog:' - version: 17.0.35 tsdown: specifier: 'catalog:' version: 0.21.10(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(publint@0.3.18)(synckit@0.11.12)(typescript@5.9.3)