-
-
Notifications
You must be signed in to change notification settings - Fork 3
feat(anchor-pnpm): new CLI for managing pnpm-workspace.yaml anchors #293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <name> <version>` — append a new `&<name> <version>` entry. | ||
| - `anchor-pnpm update <name> <version>` — set the scalar value of an existing anchor (all `*<name>` aliases follow automatically). | ||
| - `anchor-pnpm remove <name>` — 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix outdated Line 34 says Suggested doc correction-- `anchor-pnpm sync` — _scheduled, not yet implemented._ Auto-detect catalog groups with repeated versions and convert them to anchor+alias pairs.
+- `anchor-pnpm sync` — detect catalog groups with repeated versions and convert them to anchor+alias pairs (`--apply` writes the proposal).🤖 Prompt for AI Agents |
||
|
|
||
| ## 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 | ||
| ``` | ||
|
Comment on lines
+38
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a language to the fenced code block. Line 38 opens a fence without a language ( Suggested lint-safe fence-```
+```text
--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🧰 Tools🪛 markdownlint-cli2 (0.22.1)[warning] 38-38: Fenced code blocks should have a language specified (MD040, fenced-code-language) 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| /** | ||
| * Generated by tsnapi — public API snapshot of `@stephansama/anchor-pnpm/cli` | ||
| */ | ||
| /* no exports */ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| /** | ||
| * Generated by tsnapi — public API snapshot of `@stephansama/anchor-pnpm/cli` | ||
| */ | ||
| /* no exports */ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Document.Parsed>; | ||
| export declare function remove(_: Document, _: string, _?: { | ||
| force?: boolean; | ||
| }): { | ||
| dangling: string[]; | ||
| removed: boolean; | ||
| }; | ||
| export declare function resolveWorkspacePath(_?: string): Promise<string>; | ||
| export declare function saveWorkspace(_: string, _: Document): Promise<void>; | ||
| export declare function sync(_: Document): SyncProposal[]; | ||
| export declare function update(_: Document, _: string, _: string): { | ||
| updated: boolean; | ||
| }; | ||
| // #endregion |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| { | ||
| "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", | ||
| "cleye": "catalog:cli", | ||
| "yaml": "catalog:", | ||
| "zod": "catalog:schema" | ||
| }, | ||
| "devDependencies": { | ||
| "tsdown": "catalog:" | ||
| }, | ||
| "packageManager": "pnpm@10.29.3", | ||
| "engines": { | ||
| "node": ">=24" | ||
| }, | ||
| "publishConfig": { | ||
| "access": "public", | ||
| "provenance": true | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
|
Comment on lines
+38
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In It's safer to verify that the anchor exists and has the expected version before applying aliases. export function applySyncProposal(
document: Document,
proposal: SyncProposal,
): void {
const { added } = add(document, proposal.anchorName, proposal.version);
if (!added) {
const existing = list(document).find((e) => e.name === proposal.anchorName);
if (existing?.value !== proposal.version) return;
}
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 }; | ||
|
Comment on lines
+79
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent forced removal from mutating aliases when the anchor is missing. On Line 79–82, aliases are inlined before confirming the anchor exists. If the anchor is absent, Line 87 returns Safe ordering fix 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 };
}
+ const scalar = findAnchor(seq, name);
+ if (!scalar) return { dangling, removed: false };
+
if (dangling.length > 0 && options.force) {
- const scalar = findAnchor(seq, name);
- replaceAliasesWithLiteral(document, name, scalar?.value);
+ 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 };
}🤖 Prompt for AI Agents |
||
| } | ||
|
Comment on lines
+66
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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 index = seq.items.findIndex(
(item) => isScalar(item) && item.anchor === name,
);
if (index === -1) return { dangling: [], removed: false };
const scalar = seq.items[index] as Scalar;
const dangling = collectAliasPaths(document, name);
if (dangling.length > 0 && !options.force) {
return { dangling, removed: false };
}
if (dangling.length > 0 && options.force) {
replaceAliasesWithLiteral(document, name, scalar.value);
}
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<string, string[]>(); | ||
|
|
||
| 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, | ||
| }); | ||
|
Comment on lines
+116
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Line 119 uses Direction for deterministic non-conflicting names proposals.push({
- anchorName: catalogName,
+ anchorName: `${catalogName}_${version.replace(/[^A-Za-z0-9_-]/g, "_")}`,
catalog: catalogName,
keys,
version,
});🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| return proposals; | ||
| } | ||
|
Comment on lines
+92
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The I suggest tracking existing anchor names and ensuring each proposal gets a unique name. export function sync(document: Document): SyncProposal[] {
const catalogs = document.get(CATALOGS_KEY);
if (!isMap(catalogs)) return [];
const proposals: SyncProposal[] = [];
const existingAnchors = new Set(list(document).map((e) => e.name));
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<string, string[]>();
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;
let anchorName = catalogName;
let counter = 1;
while (existingAnchors.has(anchorName)) {
anchorName = `${catalogName}-${counter++}`;
}
existingAnchors.add(anchorName);
proposals.push({
anchorName,
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); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The README states that
anchor-pnpm syncis "not yet implemented", but the implementation is included in this pull request. This should be updated to reflect the current status.