From 4bfe8cd09d66964f29ab96f32b2737c2a66a554c Mon Sep 17 00:00:00 2001 From: hoyeon Date: Thu, 1 Oct 2026 21:23:14 +0900 Subject: [PATCH 1/4] test: cover repository-relative CODEOWNERS paths (#17) The runtime fix shipped in #16. Extend CLI regressions to check and --cwd, and build the CLI once for all integration suites. --- package.json | 2 ++ tests/build-cli.ts | 9 +++++++++ tests/codeowners-cli.spec.ts | 30 +++++++++++++++++++++--------- vitest.config.ts | 15 +++++++++++++++ 4 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 tests/build-cli.ts diff --git a/package.json b/package.json index 2be5898..9279a81 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "scripts": { "build": "tsdown", "test": "vitest run", + "test:unit": "vitest run --project unit", + "test:integration": "vitest run --project integration", "dev": "tsdown --watch", "prepack": "yarn build", "lint": "eslint .", diff --git a/tests/build-cli.ts b/tests/build-cli.ts new file mode 100644 index 0000000..7707b0e --- /dev/null +++ b/tests/build-cli.ts @@ -0,0 +1,9 @@ +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +export default async function buildCli() { + await promisify(execFile)('yarn', ['build'], { + cwd: fileURLToPath(new URL('../', import.meta.url)), + }); +} diff --git a/tests/codeowners-cli.spec.ts b/tests/codeowners-cli.spec.ts index 3085007..b2e53ae 100644 --- a/tests/codeowners-cli.spec.ts +++ b/tests/codeowners-cli.spec.ts @@ -13,10 +13,6 @@ const config = `import { codeownersJob } from ${JSON.stringify(CORE_URL)}; export default [codeownersJob()]; `; -beforeAll(async () => { - await execFileAsync('yarn', ['build'], { cwd: fileURLToPath(new URL('../', import.meta.url)) }); -}); - describe('codeowners-cli', () => { it.each([ { name: 'the repository root', cwd: 'repo', explicitRoot: false }, @@ -26,7 +22,13 @@ describe('codeowners-cli', () => { cwd: 'other-repo', explicitRoot: true, }, - ])('sync resolves CODEOWNERS paths from $name', async (scenario) => { + { + name: 'a subdirectory selected with --cwd', + cwd: 'other-repo', + explicitRoot: false, + explicitCwd: true, + }, + ])('sync and check resolve CODEOWNERS paths from $name', async (scenario) => { await using fixture = await Fixture.fromDirectory(fileURLToPath(new URL('../fixtures/', import.meta.url))); const rootDir = path.join(fixture.root, 'repo'); const subdirectory = path.join(rootDir, 'services/auth'); @@ -40,13 +42,16 @@ describe('codeowners-cli', () => { for (const directory of [rootDir, subdirectory, otherRoot]) { await writeFile(path.join(directory, 'pullup.config.mjs'), config); } - const args = [CLI_PATH, 'sync']; - if (scenario.explicitRoot) args.push('--root', rootDir); + const options: string[] = []; + if (scenario.explicitRoot) options.push('--root', rootDir); + if (scenario.explicitCwd === true) options.push('--cwd', subdirectory); - await execFileAsync(process.execPath, args, { + const sync = await execFileAsync(process.execPath, [CLI_PATH, 'sync', ...options], { cwd: path.join(fixture.root, scenario.cwd), timeout: 10_000, }); + expect(sync.stdout).toContain('codeowners synced'); + expect(sync.stderr).toBe(''); expect(await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8')).toBe( '/ @root-team\n' + @@ -63,7 +68,14 @@ describe('codeowners-cli', () => { '/services/cart/ @cart-team\n' + '/tools/catalog-cli/ @tools-team\n', ); - if (scenario.explicitRoot) { + const check = await execFileAsync(process.execPath, [CLI_PATH, 'check', ...options], { + cwd: path.join(fixture.root, scenario.cwd), + timeout: 10_000, + }); + expect(check.stdout).toContain('All files are up to date'); + expect(check.stderr).toBe(''); + + if (scenario.explicitRoot || scenario.explicitCwd === true) { await expect(readFile(path.join(otherRoot, '.github/CODEOWNERS'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT', }); diff --git a/vitest.config.ts b/vitest.config.ts index f14cad8..a5f24cc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,20 @@ export default defineConfig({ test: { globals: true, environment: 'node', + projects: [ + { + test: { + name: 'unit', + include: ['src/**/*.spec.ts'], + }, + }, + { + test: { + name: 'integration', + include: ['tests/**/*.spec.ts'], + globalSetup: './tests/build-cli.ts', + }, + }, + ], }, }); From 80b38cc9c480c65f033728aa807fd75afcc3635f Mon Sep 17 00:00:00 2001 From: hoyeon Date: Thu, 1 Oct 2026 21:24:22 +0900 Subject: [PATCH 2/4] fix: report output read errors before syncing (#18) --- .changeset/output-read-errors.md | 5 ++ src/core/utils.spec.ts | 41 +++++++++++++ src/core/utils.ts | 9 ++- tests/output-read-errors.spec.ts | 100 +++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 .changeset/output-read-errors.md create mode 100644 src/core/utils.spec.ts create mode 100644 tests/output-read-errors.spec.ts diff --git a/.changeset/output-read-errors.md b/.changeset/output-read-errors.md new file mode 100644 index 0000000..9614998 --- /dev/null +++ b/.changeset/output-read-errors.md @@ -0,0 +1,5 @@ +--- +'@pull-up/cli': patch +--- + +Report output read failures with their file path instead of treating unreadable files as missing. Check no longer suggests syncing on read errors, and sync stops before overwriting any outputs. diff --git a/src/core/utils.spec.ts b/src/core/utils.spec.ts new file mode 100644 index 0000000..cd8627f --- /dev/null +++ b/src/core/utils.spec.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises'; + +import { readFileOrNull } from './utils'; + +describe('readFileOrNull', () => { + const outputPath = '/repository/.github/CODEOWNERS'; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns existing file contents, including an empty file', async () => { + const readFile = vi.spyOn(fs, 'readFile').mockResolvedValueOnce('contents').mockResolvedValueOnce(''); + + expect(await readFileOrNull(outputPath)).toBe('contents'); + expect(await readFileOrNull(outputPath)).toBe(''); + expect(readFile).toHaveBeenCalledWith(outputPath, 'utf-8'); + }); + + it('returns null for a missing file', async () => { + vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('No such file'), { code: 'ENOENT' })); + + expect(await readFileOrNull(outputPath)).toBeNull(); + }); + + it.each(['EISDIR', 'EACCES', 'EIO'])('propagates %s with the output path', async (code) => { + const error = Object.assign(new Error(`${code}: read failed`), { code, syscall: 'read' }); + vi.spyOn(fs, 'readFile').mockRejectedValue(error); + + await expect(readFileOrNull(outputPath)).rejects.toMatchObject({ + cause: error, + message: expect.stringContaining(outputPath), + }); + }); + + it('does not hide unexpected read failures', async () => { + vi.spyOn(fs, 'readFile').mockRejectedValue(new Error('Unexpected read failure')); + + await expect(readFileOrNull(outputPath)).rejects.toThrow('Unexpected read failure'); + }); +}); diff --git a/src/core/utils.ts b/src/core/utils.ts index 4dd90d2..96c9bf5 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -3,7 +3,12 @@ import fs from 'node:fs/promises'; export async function readFileOrNull(absPath: string): Promise { try { return await fs.readFile(absPath, 'utf-8'); - } catch { - return null; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return null; + } + + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read file "${absPath}": ${message}`, { cause: error }); } } diff --git a/tests/output-read-errors.spec.ts b/tests/output-read-errors.spec.ts new file mode 100644 index 0000000..27cb19a --- /dev/null +++ b/tests/output-read-errors.spec.ts @@ -0,0 +1,100 @@ +import { execFile } from 'node:child_process'; +import { chmod, mkdir, readFile, stat, writeFile } 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 fixtureDirectory = fileURLToPath(new URL('../fixtures/repo/', import.meta.url)); +const output = '.github/CODEOWNERS'; +const existingContents = 'existing output\n'; +const config = `export default [ + { name: 'writable', input: [], output: 'writable.txt', transform: () => 'updated output\\n' }, + { name: 'unreadable', input: [], output: '${output}', transform: () => 'generated output\\n' }, +]; +`; + +async function prepareRepository(rootDir: string) { + await mkdir(path.join(rootDir, '.git')); + await mkdir(path.join(rootDir, '.github'), { recursive: true }); + await writeFile(path.join(rootDir, 'pullup.config.mjs'), config); + await writeFile(path.join(rootDir, 'writable.txt'), existingContents); +} + +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('output read errors', () => { + it.each(['check', 'sync'])('%s reports an output directory without changing other outputs', async (command) => { + await using fixture = await Fixture.fromDirectory(fixtureDirectory); + await prepareRepository(fixture.root); + const outputPath = path.join(fixture.root, output); + await mkdir(outputPath); + + const result = await runCli(command, fixture.root); + + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).toContain('EISDIR'); + expect(result.stdout + result.stderr).toContain(outputPath); + expect(result.stdout + result.stderr).not.toContain('outdated'); + expect(result.stdout + result.stderr).not.toContain('pullup sync'); + expect(result.stdout + result.stderr).not.toContain('synced'); + expect((await stat(outputPath)).isDirectory()).toBe(true); + expect(await readFile(path.join(fixture.root, 'writable.txt'), 'utf8')).toBe(existingContents); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0).each(['check', 'sync'])( + '%s reports an unreadable but writable output without overwriting it', + async (command) => { + await using fixture = await Fixture.fromDirectory(fixtureDirectory); + await prepareRepository(fixture.root); + const outputPath = path.join(fixture.root, output); + await writeFile(outputPath, existingContents); + await chmod(outputPath, 0o200); + + try { + const result = await runCli(command, fixture.root); + + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).toContain('EACCES'); + expect(result.stdout + result.stderr).toContain(outputPath); + expect(result.stdout + result.stderr).not.toContain('outdated'); + expect(result.stdout + result.stderr).not.toContain('pullup sync'); + expect(result.stdout + result.stderr).not.toContain('synced'); + expect(await readFile(path.join(fixture.root, 'writable.txt'), 'utf8')).toBe(existingContents); + } finally { + await chmod(outputPath, 0o600); + } + + expect(await readFile(outputPath, 'utf8')).toBe(existingContents); + }, + ); + + it('still creates missing outputs and checks their generated contents', async () => { + await using fixture = await Fixture.fromDirectory(fixtureDirectory); + await prepareRepository(fixture.root); + + const sync = await runCli('sync', fixture.root); + + expect(sync.code).toBe(0); + expect(sync.stderr).toBe(''); + expect(sync.stdout).toContain('unreadable synced'); + expect(await readFile(path.join(fixture.root, output), 'utf8')).toBe('generated output\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'); + }); +}); From 4c178f3cf2af3d873e5374bc0b0d24b83d5895d3 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Thu, 1 Oct 2026 21:25:58 +0900 Subject: [PATCH 3/4] fix: preserve CODEOWNERS pattern matching scope (#20) --- .changeset/codeowners-pattern-scope.md | 5 + .../jobs/codeowners/codeowners-job.spec.ts | 39 +++++++- src/core/jobs/codeowners/codeowners-job.ts | 10 +- .../jobs/codeowners/pattern-scope.spec.ts | 97 +++++++++++++++++++ tests/codeowners-cli.spec.ts | 32 +++++- 5 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 .changeset/codeowners-pattern-scope.md create mode 100644 src/core/jobs/codeowners/pattern-scope.spec.ts diff --git a/.changeset/codeowners-pattern-scope.md b/.changeset/codeowners-pattern-scope.md new file mode 100644 index 0000000..949f640 --- /dev/null +++ b/.changeset/codeowners-pattern-scope.md @@ -0,0 +1,5 @@ +--- +'@pull-up/cli': patch +--- + +Preserve CODEOWNERS pattern scope when merging files. Unanchored patterns such as `*.ts` and `docs/` now match at every depth within their source directory, while anchored patterns and root-level rules retain their meaning. diff --git a/src/core/jobs/codeowners/codeowners-job.spec.ts b/src/core/jobs/codeowners/codeowners-job.spec.ts index a173677..7a01eb5 100644 --- a/src/core/jobs/codeowners/codeowners-job.spec.ts +++ b/src/core/jobs/codeowners/codeowners-job.spec.ts @@ -18,6 +18,41 @@ async function readFixtureFiles(rootDir: string, filePaths: string[]) { } describe('codeownersJob', () => { + it.each([ + ['*.ts', '/packages/web/**/*.ts'], + ['/*.ts', '/packages/web/*.ts'], + ['docs/', '/packages/web/**/docs/'], + ['docs', '/packages/web/**/docs'], + ['/docs/', '/packages/web/docs/'], + ['src/*.ts', '/packages/web/src/*.ts'], + ['/src/*.ts', '/packages/web/src/*.ts'], + ['src/docs/', '/packages/web/src/docs/'], + ['**/docs/', '/packages/web/**/docs/'], + ['src/**/index.ts', '/packages/web/src/**/index.ts'], + ['*', '/packages/web/'], + ])('preserves the scope of %s when relocating a nested CODEOWNERS file', async (pattern, expected) => { + const rootDir = process.cwd(); + const result = await codeownersJob().transform( + [{ path: 'packages/web/CODEOWNERS', contents: `${pattern} @frontend-team\n` }], + { rootDir, outputPath: path.join(rootDir, '.github/CODEOWNERS') }, + ); + + expect(result).toBe(`${expected} @frontend-team\n`); + }); + + it.each(['*', '*.ts', '/*.ts', 'docs/', '/docs/', 'src/*.ts', '**/docs/'])( + 'preserves the root-level pattern %s verbatim', + async (pattern) => { + const rootDir = process.cwd(); + const result = await codeownersJob().transform([{ path: 'CODEOWNERS', contents: `${pattern} @root-team\n` }], { + rootDir, + outputPath: path.join(rootDir, '.github/CODEOWNERS'), + }); + + expect(result).toBe(`${pattern} @root-team\n`); + }, + ); + it('merges parent CODEOWNERS before nested files', async () => { await using fixture = await Fixture.fromDirectory(fixtureDirectory); const rootDir = fixture.root; @@ -57,7 +92,7 @@ describe('codeownersJob', () => { }); expect(result).toBe( - '/ @root-team\n' + + '* @root-team\n' + '/docs/ @docs-team\n' + '/services/auth/ @auth-team\n' + '/services/auth/login/ @login-team\n' + @@ -106,7 +141,7 @@ describe('codeownersJob', () => { }); expect(result).toBe( - '/ @root-team\n' + + '* @root-team\n' + '/docs/ @docs-team\n' + '/services/auth/ @auth-team\n' + '/services/auth/login/ @login-team\n' + diff --git a/src/core/jobs/codeowners/codeowners-job.ts b/src/core/jobs/codeowners/codeowners-job.ts index 587de7b..07642be 100644 --- a/src/core/jobs/codeowners/codeowners-job.ts +++ b/src/core/jobs/codeowners/codeowners-job.ts @@ -65,8 +65,14 @@ const sortByDirectory = (inputFiles: Source[], rootDir: string) => }); const toAbsolutePattern = (pattern: string, baseDir: string) => { - const base = baseDir !== '' ? `/${baseDir}` : ''; - return pattern === '*' ? `${base}/` : `${base}/${stripLeadingSlash(pattern)}`; + if (baseDir === '') return pattern; + + const base = `/${baseDir}`; + if (pattern === '*') return `${base}/`; + + // Only a leading or interior slash anchors a pattern; a trailing slash marks a directory. + const isAnchored = pattern.startsWith('/') || pattern.replace(/\/$/, '').includes('/'); + return isAnchored ? `${base}/${stripLeadingSlash(pattern)}` : `${base}/**/${pattern}`; }; const stripLeadingSlash = (text: string) => text.replace(/^\//, ''); diff --git a/src/core/jobs/codeowners/pattern-scope.spec.ts b/src/core/jobs/codeowners/pattern-scope.spec.ts new file mode 100644 index 0000000..a579fb6 --- /dev/null +++ b/src/core/jobs/codeowners/pattern-scope.spec.ts @@ -0,0 +1,97 @@ +import { spawnSync } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { devNull } from 'node:os'; +import path from 'node:path'; + +import { Fixture } from '@fixture-kit/core'; + +import { codeownersJob } from './codeowners-job'; + +const sourceDir = 'packages/web'; +const sourceFiles = [ + 'index.ts', + 'index.js', + 'src/index.ts', + 'src/nested/index.ts', + 'lib/src/index.ts', + 'docs/readme.md', + 'docs/deep/readme.md', + 'src/docs/readme.md', + 'src/docs/deep/readme.md', + 'file-only/docs', +]; +const outsideFiles = [ + 'index.ts', + 'docs/readme.md', + 'packages/mobile/src/index.ts', + 'packages/web-other/docs/readme.md', +]; +const allFiles = [...sourceFiles.map((file) => `${sourceDir}/${file}`), ...outsideFiles]; + +function matchedFiles(rootDir: string) { + const result = spawnSync( + 'git', + [ + '-c', + `core.excludesFile=${devNull}`, + '-c', + 'core.ignoreCase=false', + 'check-ignore', + '--no-index', + '--', + ...allFiles, + ], + { cwd: rootDir, encoding: 'utf8' }, + ); + expect(result.error).toBeUndefined(); + expect([0, 1]).toContain(result.status); + return result.stdout.trim().split('\n').filter(Boolean).sort(); +} + +// CODEOWNERS follows gitignore's pattern scoping for these supported patterns. +// Compare relocation against Git itself rather than a copy of the rewrite logic. +// Negation, character ranges, and Git's excluded-directory precedence are not tested here. +describe('CODEOWNERS pattern scope', () => { + it.each([ + '*.ts', + '/*.ts', + 'docs/', + '/docs/', + 'docs', + 'src/*.ts', + '/src/*.ts', + 'src/docs/', + '**/docs/', + 'src/**/index.ts', + '*', + ])('preserves files matched by %s without escaping the source subtree', async (pattern) => { + await using fixture = await Fixture.create({}); + const rootDir = fixture.root; + const init = spawnSync('git', ['init', '--quiet', '--template='], { cwd: rootDir, encoding: 'utf8' }); + expect(init.error).toBeUndefined(); + expect(init.status).toBe(0); + await Promise.all( + allFiles.map(async (file) => { + const filePath = path.join(rootDir, file); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, ''); + }), + ); + + const originalIgnore = path.join(rootDir, sourceDir, '.gitignore'); + await writeFile(originalIgnore, `${pattern}\n`); + const originalMatches = matchedFiles(rootDir); + expect(originalMatches.length).toBeGreaterThan(0); + await rm(originalIgnore); + + const result = await codeownersJob().transform( + [{ path: `${sourceDir}/CODEOWNERS`, contents: `${pattern} @frontend-team\n` }], + { rootDir, outputPath: path.join(rootDir, '.github/CODEOWNERS') }, + ); + await writeFile(path.join(rootDir, '.gitignore'), `${result.trim().split(' ')[0]}\n`); + const relocatedMatches = matchedFiles(rootDir); + + expect(relocatedMatches).toEqual(originalMatches); + expect(relocatedMatches.every((file) => file.startsWith(`${sourceDir}/`))).toBe(true); + }); +}); diff --git a/tests/codeowners-cli.spec.ts b/tests/codeowners-cli.spec.ts index b2e53ae..0e577ab 100644 --- a/tests/codeowners-cli.spec.ts +++ b/tests/codeowners-cli.spec.ts @@ -54,7 +54,7 @@ describe('codeowners-cli', () => { expect(sync.stderr).toBe(''); expect(await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8')).toBe( - '/ @root-team\n' + + '* @root-team\n' + '/docs/ @docs-team\n' + '/services/ads/ @ads-team\n' + '/services/ads/ads-platform/ @platform-team\n' + @@ -109,4 +109,34 @@ export default [codeownersJob({ input: ${JSON.stringify(scenario.input)} })]; '/services/auth/ @auth-team\n' + '/services/auth/login/ @login-team\n', ); }); + + it('sync preserves unanchored patterns from root and nested CODEOWNERS files', async () => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.config.mjs': config, + CODEOWNERS: '*.js @root-team\n', + packages: { + web: { + CODEOWNERS: '*.ts @frontend-team\ndocs/ @docs-team\n/docs/ @local-docs-team\nsrc/*.ts @source-team\n', + }, + }, + }); + + const sync = await execFileAsync(process.execPath, [CLI_PATH, 'sync'], { cwd: fixture.root, timeout: 10_000 }); + + expect(sync.stdout).toContain('codeowners synced'); + expect(sync.stderr).toBe(''); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe( + '*.js @root-team\n' + + '/packages/web/**/*.ts @frontend-team\n' + + '/packages/web/**/docs/ @docs-team\n' + + '/packages/web/docs/ @local-docs-team\n' + + '/packages/web/src/*.ts @source-team\n', + ); + + const check = await execFileAsync(process.execPath, [CLI_PATH, 'check'], { cwd: fixture.root, timeout: 10_000 }); + + expect(check.stdout).toContain('All files are up to date'); + expect(check.stderr).toBe(''); + }); }); From fd4ff382c0e5c3741fc42abd1c27dfc1214ccb53 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Thu, 1 Oct 2026 21:45:43 +0900 Subject: [PATCH 4/4] chore: require Node.js 22+ and remove picocolors --- package.json | 6 ++++-- src/cli/commands/check.ts | 9 +++++---- src/cli/commands/sync.ts | 16 ++++++++-------- yarn.lock | 1 - 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 9279a81..8e213f4 100644 --- a/package.json +++ b/package.json @@ -64,8 +64,10 @@ "clipanion": "^4.0.0-rc.4", "cosmiconfig": "^9.0.0", "fast-glob": "^3.3.3", - "find-up": "^8.0.0", - "picocolors": "^1.1.1" + "find-up": "^8.0.0" + }, + "engines": { + "node": ">=22.8.0" }, "packageManager": "yarn@4.12.0" } diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 7f79931..dc4ac0d 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -1,5 +1,6 @@ +import { styleText } from 'node:util'; + import { Command, Option } from 'clipanion'; -import pc from 'picocolors'; import { resolveConfig } from '../../core/resolve-config'; import { runJob } from '../../core/run-job'; @@ -27,7 +28,7 @@ export class CheckCommand extends Command { const [repoRoot, jobs] = await Promise.all([resolveRepositoryRoot(cwd, this.root), resolveConfig(cwd)]); if (jobs.length === 0) { - console.log(pc.yellow('✘ No jobs found to check')); + console.log(styleText('yellow', '✘ No jobs found to check')); return; } @@ -35,11 +36,11 @@ export class CheckCommand extends Command { for (const { isSame, jobInfo } of results) { if (!isSame) { - console.error(pc.red(`✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`)); + console.error(styleText('red', `✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`)); process.exit(1); } } - console.log(pc.green('✔ All files are up to date')); + console.log(styleText('green', '✔ All files are up to date')); } } diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index f0d9d97..31fbcdd 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -1,8 +1,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { styleText } from 'node:util'; import { Command, Option } from 'clipanion'; -import pc from 'picocolors'; import { resolveConfig } from '../../core/resolve-config'; import { runJob } from '../../core/run-job'; @@ -36,7 +36,7 @@ export class SyncCommand extends Command { const jobs = await resolveConfig(cwd); if (jobs.length === 0) { - console.log(pc.yellow('✘ No jobs found to sync')); + console.log(styleText('yellow', '✘ No jobs found to sync')); return; } @@ -44,13 +44,13 @@ export class SyncCommand extends Command { for (const { jobInfo, generated, isSame } of results) { if (this.dryRun === true) { - console.log(pc.cyan(`┌─ [Job] ${jobInfo.name}`)); - console.log(`${pc.cyan('│')} ${pc.dim(`Output: ${jobInfo.output}`)}`); - console.log(`${pc.cyan('│')}`); + console.log(styleText('cyan', `┌─ [Job] ${jobInfo.name}`)); + console.log(`${styleText('cyan', '│')} ${styleText('dim', `Output: ${jobInfo.output}`)}`); + console.log(`${styleText('cyan', '│')}`); generated.contents.split('\n').forEach((line) => { - console.log(`${pc.cyan('│')} ${line}`); + console.log(`${styleText('cyan', '│')} ${line}`); }); - console.log(`${pc.cyan('└─')}`); + console.log(`${styleText('cyan', '└─')}`); continue; } @@ -61,7 +61,7 @@ export class SyncCommand extends Command { await fs.writeFile(outputPath, generated.contents); } - console.log(pc.green(`✔ ${jobInfo.name} synced`)); + console.log(styleText('green', `✔ ${jobInfo.name} synced`)); } } } diff --git a/yarn.lock b/yarn.lock index cbd0ee4..6127f38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -686,7 +686,6 @@ __metadata: fast-glob: "npm:^3.3.3" find-up: "npm:^8.0.0" globals: "npm:^17.0.0" - picocolors: "npm:^1.1.1" prettier: "npm:^3.8.0" tsdown: "npm:^0.19.0" typescript: "npm:^5.9.2"