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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@
.pnp.*
dist

.nx/cache
.nx/workspace-data
*.tgz
4 changes: 0 additions & 4 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"search.exclude": {
Expand Down
109 changes: 108 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,108 @@
# Codeowners-kit
# 💪 Pull up

Collect scattered config files from your monorepo and generate them where your systems expect.

## The Problem

Many tools require config files in specific locations:

- GitHub reads `CODEOWNERS` from `.github/CODEOWNERS`
- GitHub Actions workflows must live in `.github/workflows/`
- And more...

In a monorepo, each package has its own context. But these systems only look at root-level paths. You end up with a single massive file that every team has to edit, leading to merge conflicts and unclear ownership.

## The Solution

**Pull up** lets you keep config files next to the code they describe, then collects and generates them to the locations your systems expect.

```
packages/
core/
CODEOWNERS # * @core-team
web/
CODEOWNERS # * @frontend-team
api/
CODEOWNERS # * @backend-team

↓ pullup sync

.github/
CODEOWNERS # All entries merged with correct paths
```

## Installation

```bash
npm install -D pull-up
# or
yarn add -D pull-up
# or
pnpm add -D pull-up
```

## Usage

### Sync

Generate files from scattered sources:

```bash
pullup sync
```

Preview changes without writing:

```bash
pullup sync --dry-run
```

### Check

Verify generated files are up to date (useful in CI):

```bash
pullup check
```

## Example: CODEOWNERS

Place `CODEOWNERS` files in each package:

```
# packages/core/CODEOWNERS
* @core-team
```

```
# packages/web/CODEOWNERS
* @frontend-team
*.css @design-team
```

Run `pullup sync --rule codeowners` to generate `.github/CODEOWNERS`:

```
# @generated by pullup - do not edit manually

/packages/core/ @core-team
/packages/web/ @frontend-team
/packages/web/*.css @design-team

# @end-generated
```

Existing manual entries in the file are preserved outside the generated markers.

## CLI Options

| Option | Description |
| --------------- | -------------------------------------- |
| `--rule <name>` | Run specific rule(s) only (repeatable) |
| `--root <path>` | Repository root path |
| `--cwd <path>` | Working directory |
| `--dry-run` | Preview without writing (sync only) |

## License

MIT
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default defineConfig(
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: ["./tsconfig.json", "./packages/**/tsconfig.json"],
project: ["./tsconfig.json"],
},
},
rules: {
Expand Down
14 changes: 0 additions & 14 deletions nx.json

This file was deleted.

35 changes: 26 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
{
"name": "codeowners-kit-monorepo",
"private": true,
"workspaces": [
"packages/*"
"name": "pull-up",
"type": "module",
"version": "0.0.1-alpha.0",
"bin": {
"pullup": "dist/index.mjs"
},
"main": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build:all": "nx run-many -t build",
"typecheck:all": "nx run-many -t typecheck"
"build": "tsdown",
"dev": "tsdown --watch",
"pullup": "yarn build && node dist/index.mjs",
"prepack": "yarn build",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@nx/js": "^22.3.3",
"eslint": "^9.39.2",
"@types/node": "^24.10.0",
"eslint": "^9.7.0",
"eslint-plugin-import-x": "^4.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^17.0.0",
"nx": "^22.3.3",
"prettier": "^3.8.0",
"tsdown": "^0.19.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.27.0"
},
"dependencies": {
"clipanion": "^4.0.0-rc.4",
"fast-glob": "^3.3.3",
"find-up": "^8.0.0",
"picocolors": "^1.1.1"
},
"packageManager": "yarn@4.12.0"
}
Binary file added package.tgz
Binary file not shown.
23 changes: 0 additions & 23 deletions packages/core/package.json

This file was deleted.

1 change: 0 additions & 1 deletion packages/core/src/index.ts

This file was deleted.

4 changes: 0 additions & 4 deletions packages/core/tsconfig.json

This file was deleted.

5 changes: 0 additions & 5 deletions packages/core/tsdown.config.ts

This file was deleted.

95 changes: 95 additions & 0 deletions src/cli/commands/check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Command, Option } from "clipanion";
import pc from "picocolors";
import { resolveRule, Rule } from "../../core";
import fs from "node:fs/promises";
import { defaultRules } from "../constants";
import { getRepositoryRoot } from "../utils";
import path from "node:path";

export class CheckCommand extends Command {
static paths = [["check"]];
static usage = Command.Usage({
description: "Check if generated files are up to date",
examples: [["Check all rules", "pullup check"]],
});

root = Option.String("--root", {
description: "The path to the repository root",
required: false,
});

cwd = Option.String("--cwd", {
description: "The path to the working directory",
required: false,
});

rules = Option.Array("--rule", {
description: "The rules to check",
required: false,
});

async execute() {
const repoRoot = await this.resolveRoot();
const resolvedRules = this.resolveRules();

if (resolvedRules.length === 0) {
console.log(pc.yellow("✘ No rules found to check"));
return;
}

const results = await Promise.all(
resolvedRules.map(([name, rule]) => this.checkRule(name, rule, repoRoot)),
);

const failures = results.filter((r) => !r.ok);

if (failures.length > 0) {
failures.forEach((f) =>
console.error(
pc.red(`✘ ${f.name} is outdated. Run 'pullup sync' to update.`),
),
);
process.exit(1);
}

console.log(pc.green("✔ All files are up to date"));
}

private async resolveRoot(): Promise<string> {
if (this.root != null) return this.root;

const cwd = this.cwd ?? process.cwd();
const root = await getRepositoryRoot(cwd);

if (root == null) throw new Error("Repository root not found");
return root;
}

private async checkRule(
name: string,
rule: Rule,
repoRoot: string,
): Promise<{ name: string; ok: boolean }> {
const outputPath = path.resolve(repoRoot, rule.output);
const existing = await readFileOrNull(outputPath);
const resolved = await resolveRule(rule, repoRoot);

return { name, ok: existing === resolved.contents };
}

private resolveRules(): [string, Rule][] {
const ruleKeys =
this.rules != null ? this.rules : Object.keys(defaultRules);
return Object.entries(defaultRules).filter(([name]) =>
ruleKeys.includes(name),
);
}
}

async function readFileOrNull(absPath: string): Promise<string | null> {
try {
return await fs.readFile(absPath, "utf-8");
} catch {
return null;
}
}
2 changes: 2 additions & 0 deletions src/cli/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./check.js";
export * from "./sync.js";
Loading