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/.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/package.json b/package.json index 2be5898..8e213f4 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 .", @@ -62,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/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/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/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..0e577ab 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,16 +42,19 @@ 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' + + '* @root-team\n' + '/docs/ @docs-team\n' + '/services/ads/ @ads-team\n' + '/services/ads/ads-platform/ @platform-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', }); @@ -97,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(''); + }); }); 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'); + }); +}); 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', + }, + }, + ], }, }); 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"