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
5 changes: 5 additions & 0 deletions .changeset/lemon-dryers-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pull-up/cli': patch
---

Add YAML configuration and JSON Schema support.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.yarn
/schema.json
70 changes: 53 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,49 +43,85 @@ pnpm add -D @pull-up/cli

## Configuration

Create a config file in your project root:
Create `pullup.yml` or `pullup.yaml` in your project root:

```ts
import { defineConfig, codeownersJob } from '@pull-up/cli';

export default defineConfig(codeownersJob());
// or export default defineConfig([codeownersJob()]);
```yaml
jobs:
codeowners:
type: codeowners
```

`jobs` maps job identifiers to their settings. YAML configuration is validated before jobs run; invalid settings cause the command to exit with code 1.

`pullup.yaml` is searched before `pullup.yml`.

### Built-in Jobs

#### `codeownersJob`
#### `codeowners`

Collects `CODEOWNERS` files from your monorepo and merges them into a single file.

```yaml
jobs:
codeowners:
type: codeowners
input:
- '**/CODEOWNERS'
- '!**/fixtures/**'
output: .github/CODEOWNERS
```

| Field | Type | Required | Default |
| -------- | ------------ | -------- | -------------------- |
| `type` | `codeowners` | Yes | |
| `input` | `string[]` | No | `['**/CODEOWNERS']` |
| `output` | `string` | No | `.github/CODEOWNERS` |

Input patterns and the output path are relative to the repository root. Prefix an input pattern with `!` to exclude matching files. The output file is automatically excluded from the input.

### Editor Support

The package includes `schema.json` for autocomplete and validation in editors that support YAML Language Server. In VS Code, install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) and add this line at the top of your config:

```yaml
# yaml-language-server: $schema=./node_modules/@pull-up/cli/schema.json
```

The path is relative to the YAML file. For Yarn Plug'n'Play or a remote schema, use `https://unpkg.com/@pull-up/cli@<version>/schema.json`, replacing `<version>` with your installed package version.

### JavaScript and TypeScript Configuration

Use `pullup.config.ts`, `pullup.config.js`, `pullup.config.cjs`, or `pullup.config.mjs` for a JavaScript or TypeScript configuration:

```ts
codeownersJob({
from: ['**/CODEOWNERS'], // default
output: '.github/CODEOWNERS', // default
});
import { defineConfig, codeownersJob } from '@pull-up/cli';

export default defineConfig(codeownersJob());
```

### Custom Jobs
The `package.json` `pullup` field and `.pulluprc` files are also supported. These formats are searched before `pullup.yaml` and `pullup.yml`.

You can define custom jobs using `defineJob`:
#### Custom Jobs

YAML currently supports built-in jobs only. To define a custom job, use `defineJob` in a JavaScript or TypeScript config:

```ts
import { defineConfig, defineJob } from '@pull-up/cli';

const myJob = defineJob({
name: 'my-job',
from: ['packages/*/config.json'],
input: ['packages/*/config.json'],
output: 'merged-config.json',
transform: (sources, context) => {
// sources: array of { path, contents }
// context: { root, outputPath, existingContents }
transform: (sources) => {
return JSON.stringify(sources.map((s) => JSON.parse(s.contents)));
},
});

export default defineConfig([myJob()]);
```

Transform functions receive source files as `{ path, contents }` objects and a context containing `rootDir` and `outputPath`. They can return a string or a promise of a string.

## Usage

### Sync
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"url": "git+https://github.com/toss/pull-up.git"
},
"files": [
"dist"
"dist",
"schema.json"
],
"scripts": {
"build": "tsdown",
Expand All @@ -49,6 +50,7 @@
"@eslint/js": "^9.39.2",
"@fixture-kit/core": "^1.0.1",
"@types/node": "^24.10.0",
"@valibot/to-json-schema": "^1.8.0",
"eslint": "^9.7.0",
"eslint-plugin-import-x": "^4.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
Expand All @@ -64,7 +66,8 @@
"clipanion": "^4.0.0-rc.4",
"cosmiconfig": "^9.0.0",
"fast-glob": "^3.3.3",
"find-up": "^8.0.0"
"find-up": "^8.0.0",
"valibot": "^1.5.0"
},
"engines": {
"node": ">=22.8.0"
Expand Down
57 changes: 57 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"type": "object",
"properties": {
"jobs": {
"type": "object",
"propertyNames": {
"type": "string",
"not": {
"enum": [
"constructor",
"prototype",
"__proto__"
]
}
},
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"const": "codeowners"
},
"input": {
"type": "array",
"items": {
"type": "string"
},
"description": "Input file paths for the job",
"examples": [
[
"**/CODEOWNERS",
"!**/fixtures/**"
]
]
},
"output": {
"type": "string",
"description": "Output file path for the job",
"examples": [
"CODEOWNERS",
".github/CODEOWNERS",
"docs/CODEOWNERS"
]
}
},
"required": [
"type"
],
"additionalProperties": false
}
}
},
"required": [
"jobs"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
2 changes: 1 addition & 1 deletion src/cli/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { styleText } from 'node:util';

import { Command, Option } from 'clipanion';

import { resolveConfig } from '../../core/resolve-config';
import { resolveConfig } from '../../core/config/resolve-config';
import { runJob } from '../../core/run-job';
import { resolveRepositoryRoot } from '../utils';

Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { styleText } from 'node:util';

import { Command, Option } from 'clipanion';

import { resolveConfig } from '../../core/resolve-config';
import { resolveConfig } from '../../core/config/resolve-config';
import { runJob } from '../../core/run-job';
import { resolveRepositoryRoot } from '../utils';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Job } from './types';
import type { Job } from '../types';

export function defineConfig(config: Job | Job[]) {
if (Array.isArray(config)) {
Expand Down
1 change: 1 addition & 0 deletions src/core/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './define-config';
156 changes: 156 additions & 0 deletions src/core/config/resolve-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Fixture } from '@fixture-kit/core';

import { resolveConfig } from './resolve-config';

const customJobs = [{ name: 'custom-job', input: ['packages/*/config.json'], output: 'merged-config.json' }];
const jsonConfig = JSON.stringify(customJobs);
const esmConfig = `export default ${jsonConfig};`;
const tsConfig = `export default ${jsonConfig} as const;`;
const cjsConfig = `module.exports = ${jsonConfig};`;
const rcConfig = `- name: custom-job
input:
- 'packages/*/config.json'
output: merged-config.json
`;
const defaultYamlConfig = `jobs:
owners:
type: codeowners
`;

describe('resolveConfig', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it.each(['pullup.yaml', 'pullup.yml'])('resolves default codeowners options from %s', async (filename) => {
await using fixture = await Fixture.create({ [filename]: defaultYamlConfig });

expect(await resolveConfig(fixture.root)).toEqual([
{
name: 'codeowners',
input: ['**/CODEOWNERS'],
output: '.github/CODEOWNERS',
transform: expect.any(Function),
},
]);
});

it('resolves explicit input and output from pullup.yaml', async () => {
await using fixture = await Fixture.create({
'pullup.yaml': `jobs:
packages:
type: codeowners
input:
- 'packages/**/CODEOWNERS'
- '!packages/fixtures/**'
output: .github/CODEOWNERS
docs:
type: codeowners
input:
- 'docs/**/CODEOWNERS'
output: docs/CODEOWNERS
`,
});

expect(await resolveConfig(fixture.root)).toEqual([
{
name: 'codeowners',
input: ['packages/**/CODEOWNERS', '!packages/fixtures/**'],
output: '.github/CODEOWNERS',
transform: expect.any(Function),
},
{
name: 'codeowners',
input: ['docs/**/CODEOWNERS'],
output: 'docs/CODEOWNERS',
transform: expect.any(Function),
},
]);
});

it('returns no jobs for an empty jobs mapping in pullup.yml', async () => {
await using fixture = await Fixture.create({ 'pullup.yml': 'jobs: {}\n' });

expect(await resolveConfig(fixture.root)).toEqual([]);
});

it.each([
['pullup.config.js', esmConfig],
['pullup.config.ts', tsConfig],
['pullup.config.cjs', cjsConfig],
['pullup.config.mjs', esmConfig],
['.pulluprc.js', esmConfig],
['.pulluprc.ts', tsConfig],
['.pulluprc.cjs', cjsConfig],
['.pulluprc.mjs', esmConfig],
['.pulluprc', rcConfig],
['.pulluprc.json', jsonConfig],
['.pulluprc.yaml', rcConfig],
['.pulluprc.yml', rcConfig],
])('resolves jobs from %s', async (filename, contents) => {
await using fixture = await Fixture.create({
'package.json': JSON.stringify({ type: 'module' }),
[filename]: contents,
});

expect(await resolveConfig(fixture.root)).toEqual(customJobs);
});

it('resolves jobs from package.json#pullup', async () => {
await using fixture = await Fixture.create({ 'package.json': JSON.stringify({ pullup: customJobs }) });

expect(await resolveConfig(fixture.root)).toEqual(customJobs);
});

it('resolves jobs from .config/pulluprc', async () => {
await using fixture = await Fixture.create({ '.config': { pulluprc: rcConfig } });

expect(await resolveConfig(fixture.root)).toEqual(customJobs);
});

it('returns no jobs when no config exists', async () => {
await using fixture = await Fixture.create({});

expect(await resolveConfig(fixture.root)).toEqual([]);
});

it('prefers pullup.yaml over pullup.yml', async () => {
await using fixture = await Fixture.create({
'pullup.yaml': `${defaultYamlConfig} output: yaml/CODEOWNERS\n`,
'pullup.yml': `${defaultYamlConfig} output: yml/CODEOWNERS\n`,
});

expect(await resolveConfig(fixture.root)).toMatchObject([{ name: 'codeowners', output: 'yaml/CODEOWNERS' }]);
});

it('prefers pullup.config.js over pullup.yml', async () => {
await using fixture = await Fixture.create({
'package.json': JSON.stringify({ type: 'module' }),
'pullup.config.js': esmConfig,
'pullup.yml': 'jobs: []\n',
});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

expect(await resolveConfig(fixture.root)).toEqual(customJobs);
expect(warn).not.toHaveBeenCalled();
});

it('prefers package.json over pullup.config.js', async () => {
await using fixture = await Fixture.create({
'package.json': JSON.stringify({ type: 'module', pullup: customJobs }),
'pullup.config.js': 'export default [{ name: "other-job", input: [], output: "other.json" }];',
});

expect(await resolveConfig(fixture.root)).toEqual(customJobs);
});

it('uses cosmiconfig.searchPlaces from package.json', async () => {
await using fixture = await Fixture.create({
'package.json': JSON.stringify({ cosmiconfig: { searchPlaces: ['pullup.yml'], mergeSearchPlaces: false } }),
'pullup.yml': `${defaultYamlConfig} output: custom/CODEOWNERS\n`,
});
vi.spyOn(process, 'cwd').mockReturnValue(fixture.root);

expect(await resolveConfig(fixture.root)).toMatchObject([{ name: 'codeowners', output: 'custom/CODEOWNERS' }]);
});
});
Loading
Loading