diff --git a/.changeset/lemon-dryers-drive.md b/.changeset/lemon-dryers-drive.md new file mode 100644 index 0000000..99ef9c0 --- /dev/null +++ b/.changeset/lemon-dryers-drive.md @@ -0,0 +1,5 @@ +--- +'@pull-up/cli': patch +--- + +Add YAML configuration and JSON Schema support. diff --git a/.prettierignore b/.prettierignore index 61c3bc7..d05becf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ .yarn +/schema.json diff --git a/README.md b/README.md index 190d982..7cedf11 100644 --- a/README.md +++ b/README.md @@ -43,42 +43,76 @@ 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@/schema.json`, replacing `` 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))); }, }); @@ -86,6 +120,8 @@ const myJob = defineJob({ 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 diff --git a/package.json b/package.json index c1f7c3e..2fda3f3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "url": "git+https://github.com/toss/pull-up.git" }, "files": [ - "dist" + "dist", + "schema.json" ], "scripts": { "build": "tsdown", @@ -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", @@ -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" diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..51c659a --- /dev/null +++ b/schema.json @@ -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#" +} diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index dc4ac0d..a937256 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -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'; diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 31fbcdd..491f8e9 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -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'; diff --git a/src/core/define-config.ts b/src/core/config/define-config.ts similarity index 77% rename from src/core/define-config.ts rename to src/core/config/define-config.ts index 0e365f6..c95f7ce 100644 --- a/src/core/define-config.ts +++ b/src/core/config/define-config.ts @@ -1,4 +1,4 @@ -import type { Job } from './types'; +import type { Job } from '../types'; export function defineConfig(config: Job | Job[]) { if (Array.isArray(config)) { diff --git a/src/core/config/index.ts b/src/core/config/index.ts new file mode 100644 index 0000000..35809ee --- /dev/null +++ b/src/core/config/index.ts @@ -0,0 +1 @@ +export * from './define-config'; diff --git a/src/core/config/resolve-config.spec.ts b/src/core/config/resolve-config.spec.ts new file mode 100644 index 0000000..719e6d7 --- /dev/null +++ b/src/core/config/resolve-config.spec.ts @@ -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' }]); + }); +}); diff --git a/src/core/config/resolve-config.ts b/src/core/config/resolve-config.ts new file mode 100644 index 0000000..574328f --- /dev/null +++ b/src/core/config/resolve-config.ts @@ -0,0 +1,36 @@ +import path from 'node:path'; + +import { cosmiconfig, getDefaultSearchPlaces } from 'cosmiconfig'; +import * as v from 'valibot'; + +import { codeownersJob } from '../jobs'; +import type { Job } from '../types'; +import { type Config, ConfigSchema } from './schema'; + +export async function resolveConfig(cwd: string): Promise { + const explorer = cosmiconfig('pullup', { + searchPlaces: [...getDefaultSearchPlaces('pullup'), 'pullup.yaml', 'pullup.yml'], + }); + const result = await explorer.search(cwd); + + if (result == null) { + return []; + } + + if (['pullup.yaml', 'pullup.yml'].includes(path.basename(result.filepath))) { + return toJobs(v.parse(ConfigSchema, result.config)); + } + + return result.config as Job[]; +} + +function toJobs(config: Config): Job[] { + return Object.values(config.jobs).map((job) => { + switch (job.type) { + case 'codeowners': + return codeownersJob({ input: job.input, output: job.output }); + default: + throw new Error(`Unknown job type: ${job.type}`); + } + }); +} diff --git a/src/core/config/schema.spec.ts b/src/core/config/schema.spec.ts new file mode 100644 index 0000000..0de3df4 --- /dev/null +++ b/src/core/config/schema.spec.ts @@ -0,0 +1,64 @@ +import * as v from 'valibot'; + +import { ConfigSchema } from './schema'; + +describe('ConfigSchema', () => { + it.each([ + { name: 'a job with default options', config: { jobs: { owners: { type: 'codeowners' } } } }, + { + name: 'named jobs with explicit options', + config: { + jobs: { + packages: { + type: 'codeowners', + input: ['packages/**/CODEOWNERS', '!packages/fixtures/**'], + output: '.github/CODEOWNERS', + }, + docs: { type: 'codeowners', input: ['docs/**/CODEOWNERS'], output: 'docs/CODEOWNERS' }, + }, + }, + }, + { name: 'an empty jobs mapping', config: { jobs: {} } }, + ])('accepts $name', ({ config }) => { + expect(v.parse(ConfigSchema, config)).toEqual(config); + }); + + it.each(['constructor-job', 'prototype-job', '__proto__-job', 'toString'])('accepts job key %s', (key) => { + const config = { jobs: { [key]: { type: 'codeowners' } } }; + + expect(v.parse(ConfigSchema, config)).toEqual(config); + }); + + it.each([ + { name: 'missing jobs', config: {} }, + { name: 'null jobs', config: { jobs: null } }, + { name: 'scalar jobs', config: { jobs: 'owners' } }, + { name: 'an empty jobs array', config: { jobs: [] } }, + { name: 'a non-empty jobs array', config: { jobs: [{ type: 'codeowners' }] } }, + { name: 'a missing job type', config: { jobs: { owners: {} } } }, + { name: 'an unsupported job type', config: { jobs: { owners: { type: 'custom' } } } }, + { name: 'a scalar input', config: { jobs: { owners: { type: 'codeowners', input: '**/CODEOWNERS' } } } }, + { name: 'a non-string input pattern', config: { jobs: { owners: { type: 'codeowners', input: [123] } } } }, + { name: 'an output array', config: { jobs: { owners: { type: 'codeowners', output: ['CODEOWNERS'] } } } }, + { name: 'a null output', config: { jobs: { owners: { type: 'codeowners', output: null } } } }, + { name: 'an unknown root property', config: { jobs: {}, extra: true } }, + { + name: 'an unknown job property', + config: { jobs: { owners: { type: 'codeowners', from: ['**/CODEOWNERS'] } } }, + }, + ])('rejects $name', ({ config }) => { + expect(v.safeParse(ConfigSchema, config).success).toBe(false); + }); + + describe.each(['constructor', 'prototype', '__proto__'])('job key %s', (key) => { + it.each([ + { name: 'a valid job', job: { type: 'codeowners' } }, + { name: 'an invalid job', job: { type: 'unsupported', input: 123 } }, + ])('rejects $name instead of discarding it', ({ job }) => { + const result = v.safeParse(ConfigSchema, { jobs: { owners: { type: 'codeowners' }, [key]: job } }); + + expect(result.success).toBe(false); + expect(result.issues?.[0].message).toBe('jobs must not use constructor, prototype, or __proto__ as keys.'); + }); + }); +}); diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts new file mode 100644 index 0000000..f88d52d --- /dev/null +++ b/src/core/config/schema.ts @@ -0,0 +1,34 @@ +import * as v from 'valibot'; + +const JobKeySchema = v.pipe(v.string(), v.notValues(['constructor', 'prototype', '__proto__'])); + +const CodeownersJobSchema = v.strictObject({ + type: v.literal('codeowners'), + input: v.pipe( + v.optional(v.array(v.string())), + v.metadata({ description: 'Input file paths for the job', examples: [['**/CODEOWNERS', '!**/fixtures/**']] }), + ), + output: v.pipe( + v.optional(v.string()), + v.metadata({ + description: 'Output file path for the job', + examples: ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS'], + }), + ), +}); + +export const ConfigSchema = v.strictObject({ + jobs: v.pipe( + v.custom>( + (input) => input !== null && typeof input === 'object' && !Array.isArray(input), + 'jobs must be an object.', + ), + v.check( + (jobs) => Object.keys(jobs).every((key) => v.is(JobKeySchema, key)), + 'jobs must not use constructor, prototype, or __proto__ as keys.', + ), + v.record(JobKeySchema, CodeownersJobSchema), + ), +}); + +export type Config = v.InferOutput; diff --git a/src/core/index.ts b/src/core/index.ts index 236be71..5b4d646 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,4 @@ -export type * from './types'; +export * from './config'; export * from './define-job'; export * from './jobs'; -export * from './define-config'; +export type * from './types'; diff --git a/src/core/resolve-config.ts b/src/core/resolve-config.ts deleted file mode 100644 index 91bcd90..0000000 --- a/src/core/resolve-config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { cosmiconfig } from 'cosmiconfig'; - -import type { Job } from './types'; - -export async function resolveConfig(cwd: string): Promise { - const explorer = cosmiconfig('pullup'); - const result = await explorer.search(cwd); - - if (result == null) { - return []; - } - - return result.config as Job[]; -} diff --git a/tests/config-cli.spec.ts b/tests/config-cli.spec.ts new file mode 100644 index 0000000..44dfb14 --- /dev/null +++ b/tests/config-cli.spec.ts @@ -0,0 +1,145 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { Fixture } from '@fixture-kit/core'; + +const execFileAsync = promisify(execFile); +const CLI_PATH = fileURLToPath(new URL('../dist/bin/index.mjs', import.meta.url)); +const existingContents = '* @existing-team\n'; +const generatedContents = 'custom generated output\n'; +const scriptConfig = `export default [ + { name: 'custom', input: [], output: '.github/CODEOWNERS', transform: () => ${JSON.stringify(generatedContents)} }, +]; +`; +const config = `jobs: + codeowners: + type: codeowners + input: + - '**/CODEOWNERS' + - '!**/fixtures/**' + output: .github/CODEOWNERS +`; + +function runCli(command: string, rootDir: string) { + return execFileAsync(process.execPath, [CLI_PATH, command], { + cwd: rootDir, + timeout: 10_000, + }).then( + (result) => ({ ...result, code: 0 }), + (error: { code: number; stdout: string; stderr: string }) => error, + ); +} + +describe('YAML config CLI', () => { + it.each(['pullup.yaml', 'pullup.yml'])('syncs and checks a codeowners job from %s', async (filename) => { + await using fixture = await Fixture.create({ + '.git': {}, + [filename]: config, + '.github': { CODEOWNERS: existingContents }, + CODEOWNERS: '* @root-team\n', + packages: { + web: { CODEOWNERS: '*.ts @frontend-team\n' }, + fixtures: { CODEOWNERS: '* @ignored-team\n' }, + }, + }); + const outputPath = path.join(fixture.root, '.github/CODEOWNERS'); + + const sync = await runCli('sync', fixture.root); + + expect(sync.code).toBe(0); + expect(sync.stderr).toBe(''); + expect(sync.stdout).toContain('codeowners synced'); + expect(await readFile(outputPath, 'utf8')).toBe('* @root-team\n/packages/web/**/*.ts @frontend-team\n'); + + const check = await runCli('check', fixture.root); + + expect(check.code).toBe(0); + expect(check.stderr).toBe(''); + expect(check.stdout).toContain('All files are up to date'); + }); + + describe.each(['sync', 'check'])('%s', (command) => { + it.each([ + { name: 'an empty jobs array', config: 'jobs: []\n' }, + { name: 'a nonempty jobs array', config: 'jobs:\n - type: codeowners\n' }, + ])('rejects $name without changing the output', async (scenario) => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.yml': scenario.config, + '.github': { CODEOWNERS: existingContents }, + CODEOWNERS: '* @source-team\n', + }); + + const result = await runCli(command, fixture.root); + const output = result.stdout + result.stderr; + + expect(result.code).toBe(1); + expect(output).toContain('jobs must be an object.'); + expect(output).not.toContain('synced'); + expect(output).not.toContain('All files are up to date'); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); + + it.each( + ['constructor', 'prototype', '__proto__'].flatMap((key) => [ + { + name: `a ${key} job`, + config: `jobs:\n ${key}:\n type: codeowners\n`, + }, + { + name: `a ${key} job alongside an owners job`, + config: `jobs:\n owners:\n type: codeowners\n output: .github/CODEOWNERS\n ${key}:\n type: codeowners\n`, + }, + ]), + )('rejects $name without changing the output', async (scenario) => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.yml': scenario.config, + '.github': { CODEOWNERS: existingContents }, + CODEOWNERS: '* @source-team\n', + }); + + const result = await runCli(command, fixture.root); + const output = result.stdout + result.stderr; + + expect(result.code).toBe(1); + expect(output).toContain('jobs must not use constructor, prototype, or __proto__ as keys.'); + expect(output).not.toContain('synced'); + expect(output).not.toContain('All files are up to date'); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); + + it('succeeds without a config and leaves the output unchanged', async () => { + await using fixture = await Fixture.create({ + '.git': {}, + '.github': { CODEOWNERS: existingContents }, + }); + + const result = await runCli(command, fixture.root); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain(`No jobs found to ${command}`); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); + + it('uses pullup.config.mjs before pullup.yml', async () => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.config.mjs': scriptConfig, + 'pullup.yml': 'jobs: []\n', + '.github': { CODEOWNERS: generatedContents }, + }); + + const result = await runCli(command, fixture.root); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain(command === 'sync' ? 'custom synced' : 'All files are up to date'); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(generatedContents); + }); + }); +}); diff --git a/tests/config-schema.spec.ts b/tests/config-schema.spec.ts new file mode 100644 index 0000000..751c823 --- /dev/null +++ b/tests/config-schema.spec.ts @@ -0,0 +1,32 @@ +import { readFile } from 'node:fs/promises'; + +describe('generated config schema', () => { + it('describes job mappings and their fields for YAML editors', async () => { + const schema = JSON.parse(await readFile(new URL('../schema.json', import.meta.url), 'utf8')); + + expect(schema).toMatchObject({ + 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' } }, + output: { type: 'string' }, + }, + required: ['type'], + additionalProperties: false, + }, + }, + }, + required: ['jobs'], + additionalProperties: false, + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 01c8c8d..1bb730a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "module": "esnext", "target": "esnext", "moduleResolution": "bundler", + "noEmit": true, + "allowImportingTsExtensions": true, "moduleDetection": "force", "resolveJsonModule": true, "isolatedModules": true, diff --git a/tsdown.config.ts b/tsdown.config.ts index cabae2d..8f975c1 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,5 +1,19 @@ +import { writeFile } from 'node:fs/promises'; + +import { toJsonSchema } from '@valibot/to-json-schema'; import { defineConfig } from 'tsdown'; +import { ConfigSchema } from './src/core/config/schema.ts'; + +async function generateSchema() { + const schema = toJsonSchema(ConfigSchema, { + target: 'draft-07', + typeMode: 'output', + }); + + await writeFile('./schema.json', `${JSON.stringify(schema, null, 2)}\n`); +} + export default defineConfig([ { entry: 'src/cli/index.ts', @@ -13,5 +27,8 @@ export default defineConfig([ entry: 'src/core/index.ts', dts: true, outDir: 'dist/core', + hooks: { + 'build:done': generateSchema, + }, }, ]); diff --git a/yarn.lock b/yarn.lock index 6127f38..419d2b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -678,6 +678,7 @@ __metadata: "@eslint/js": "npm:^9.39.2" "@fixture-kit/core": "npm:^1.0.1" "@types/node": "npm:^24.10.0" + "@valibot/to-json-schema": "npm:^1.8.0" clipanion: "npm:^4.0.0-rc.4" cosmiconfig: "npm:^9.0.0" eslint: "npm:^9.7.0" @@ -690,6 +691,7 @@ __metadata: tsdown: "npm:^0.19.0" typescript: "npm:^5.9.2" typescript-eslint: "npm:^8.27.0" + valibot: "npm:^1.5.0" vite: "npm:^8.3.1" vitest: "npm:^5.0.2" bin: @@ -1351,6 +1353,15 @@ __metadata: languageName: node linkType: hard +"@valibot/to-json-schema@npm:^1.8.0": + version: 1.8.0 + resolution: "@valibot/to-json-schema@npm:1.8.0" + peerDependencies: + valibot: ^1.5.0 + checksum: 10c0/8206940f4b892a34c2d78448541de926234bd97741ad72f4b75ce22bbe56172e46492d02efaf71d75cfc71cec6fa0c55d2edbd977aa4fd2cb2dc236f87005c04 + languageName: node + linkType: hard + "@vitest/mocker@npm:5.0.2": version: 5.0.2 resolution: "@vitest/mocker@npm:5.0.2" @@ -3729,6 +3740,18 @@ __metadata: languageName: node linkType: hard +"valibot@npm:^1.5.0": + version: 1.5.0 + resolution: "valibot@npm:1.5.0" + peerDependencies: + typescript: ">=5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/d49dab2b2fcbb477c894def73466de598b602859a47ab92b26d387c4d2a472cbfd4ef57b49eea91aeeff143c38f5a9523914dc5a73469ec93c7cfd4326eb5fbf + languageName: node + linkType: hard + "vite@npm:^8.3.1": version: 8.3.1 resolution: "vite@npm:8.3.1"