diff --git a/scripts/release-notes/package.json b/scripts/release-notes/package.json index 32a90c9a..bf4765d7 100644 --- a/scripts/release-notes/package.json +++ b/scripts/release-notes/package.json @@ -1,5 +1,6 @@ { "private": true, + "type": "module", "dependencies": { "@anthropic-ai/sdk": "^0.25.0", "@octokit/rest": "^21.0.2", diff --git a/scripts/release-notes/src/legacy.ts b/scripts/release-notes/src/legacy.ts new file mode 100644 index 00000000..9bf023f8 --- /dev/null +++ b/scripts/release-notes/src/legacy.ts @@ -0,0 +1,305 @@ +import { Anthropic } from '@anthropic-ai/sdk' +import { verifyEnvVars } from '@radashi-org/common/verifyEnvVars.ts' +import { execa } from 'execa' +import type mri from 'mri' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { uid } from 'radashi/random/uid.ts' +import { dedent } from 'radashi/string/dedent.ts' + +export async function legacyGenerateReleaseNotes(argv: mri.Argv) { + const outFile = argv.o || argv.output + const limit = argv.limit ? +argv.limit : Number.POSITIVE_INFINITY + + if (!outFile && !argv.publish) { + console.error('Error: No --output file or --publish flag provided') + process.exit(1) + } else if (outFile && argv.publish) { + console.error('Error: Cannot use --output file and --publish flag together') + process.exit(1) + } + + const { anthropicApiKey, githubToken } = verifyEnvVars({ + anthropicApiKey: 'ANTHROPIC_API_KEY', + githubToken: !!argv.publish && 'GITHUB_TOKEN', + }) + + const version: string = JSON.parse( + fs.readFileSync('package.json', 'utf8'), + ).version + + log('Generating release notes for v' + version) + + /** + * If no commit ref is provided, use the latest two tags. + */ + let commitRange: string + if (argv._.length) { + commitRange = 'v' + version + '..' + argv._[0] + } else { + const tags = await execa('git', [ + 'tag', + '--format=%(refname:short)', + '--sort=-version:refname', + '-n', + 'v*', + ]).then(result => + result.stdout + .split('\n') + .filter(tag => !tag.includes('-')) + .slice(0, 2), + ) + + const [currentVersion, previousVersion] = tags + commitRange = previousVersion + '..' + currentVersion + } + + const commits = await execa('git', [ + 'log', + '--format=%H %s', + commitRange, + ]).then(result => + result.stdout + .trim() + .split('\n') + .map(line => { + const [, sha, message] = /^(\w+) (.+)$/.exec(line)! + return { sha, message, diff: '' } + }), + ) + + log('Grouping commits and fetching diffs...') + + const sections = getSections() + for (const commit of commits) { + for (const section of sections) { + if ( + section.match.test(commit.message) && + !section.exclude?.test(commit.message) + ) { + const diff = await execa('git', [ + 'log', + '-p', + commit.sha + '^..' + commit.sha, + 'src', + 'docs', + ]) + commit.diff = diff.stdout + section.commits ??= [] + section.commits.push(commit) + } + } + } + + const anthropic = new Anthropic({ + apiKey: anthropicApiKey, + }) + + for (const section of sections) { + if (!section.commits?.length) { + continue + } + + log('Generating release notes for', section.name) + + section.commits.length = Math.min(section.commits.length, limit) + section.notes = '' + + const chunkSize = 4 + + for (let offset = 0; offset < section.commits.length; offset += chunkSize) { + log(`${section.commits.length - offset} commits left`) + + const linkLocation = + section.noun === 'feature' || section.noun === 'fix' + ? 'immediately after the heading' + : 'at the end' + + const rules = [ + `You're tasked with writing in-depth release notes (using Markdown) in a professional tone.`, + 'Never converse with me.', + 'Always mention every change I give you.', + `Always link to the relevant PR (or the commit if there's no PR) ${linkLocation} of each ${section.noun} in a format like "[→ PR #110](…)" or "[→ commit {short-hash}](…)". The GitHub URL is "https://github.com/radashi-org/radashi".`, + `Never include headings like "Release Notes" or "v1.0.0".`, + ...section.rules(section.noun), + ] + + log('Sending request to Anthropic...') + + const response = await anthropic.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 4096, + messages: [ + { + role: 'user', + content: dedent` + - ${rules.join('\n- ')} + + The following changes are from \`git log -p\`: + + + ${section.commits + .slice(offset, offset + chunkSize) + .map(commit => commit.diff) + .join('\n\n')} + + `, + }, + ], + }) + + const [message] = response.content + if (message.type !== 'text') { + console.error('Expected a text message, got:', message) + process.exit(1) + } + + section.notes += message.text.trim() + '\n\n' + } + } + + let notes = sections + .filter(section => section.notes) + .map(section => `## ${section.name}\n\n${section.notes}`) + .join('\n\n') + + const tmpFile = path.join(os.tmpdir(), 'release-notes.' + uid(20) + '.md') + fs.writeFileSync(tmpFile, notes) + + try { + const editor = await getPreferredEditor() + log('Opening', tmpFile, 'with', editor) + + // Open the generated release notes in the user's preferred text editor + await execa(editor, [tmpFile], { stdio: 'inherit' }) + + // Read the potentially modified content after the editor is closed + notes = fs.readFileSync(tmpFile, 'utf-8') + } finally { + fs.unlinkSync(tmpFile) + } + + if (argv.publish) { + const { Octokit } = await import('@octokit/rest') + + const octokit = new Octokit({ + auth: githubToken, + }) + + log('Publishing release notes for version', version) + + try { + await octokit.rest.repos.createRelease({ + owner: 'radashi-org', + repo: 'radashi', + tag_name: `v${version}`, + name: `v${version}`, + body: notes, + draft: !!argv.draft, + prerelease: !!argv.prerelease, + }) + + log('Successfully published release notes to GitHub') + } catch (error) { + console.error('Failed to publish release notes:', error) + process.exit(1) + } + } else { + fs.writeFileSync(outFile, notes) + log('Saved release notes to', path.resolve(outFile)) + } +} + +function getSections(): Section[] { + const getFormattingRules = (noun: string) => [ + `Use an H4 (####) for the heading of each ${noun}.`, + 'Headings must be in sentence case.', + noun === 'feature' && + `Each heading must describe what the ${noun} enables, not simply what the change is (e.g. "Allow throttled function to be triggered immediately" instead of "Add trigger method to throttle function").`, + 'Be concise but not vague.', + 'Omit prefixes like "Fix:" from headings.', + `The paragraph(s) after each heading must describe the ${noun} in more detail (but be brief where possible).`, + ] + + const getCodeExampleRules = (noun: string) => [ + `Every ${noun} needs a concise code example to showcase it.`, + 'Never preface examples with "Example:" or similar.', + dedent` + In each example, import the functions or types like this: + \`\`\`ts + import { sum } from 'radashi' + \`\`\` + `, + ] + + const getBulletedListRules = (noun: string) => [ + `Describe each ${noun} in a bulleted list, without being vague.`, + 'Never use headings.', + 'Only give me the bulleted list. No prefacing like “Here are the changes” or similar.', + ] + + return [ + { + name: 'Features', + match: /^feat/, + exclude: /\((types|perf)\)/, + noun: 'feature', + rules: noun => [ + ...getFormattingRules(noun), + ...getCodeExampleRules(noun), + ], + }, + { + name: 'Bug Fixes', + match: /^fix/, + exclude: /\((types|perf)\)/, + noun: 'fix', + rules: noun => [ + ...getFormattingRules(noun), + ...getCodeExampleRules(noun), + ], + }, + { + name: 'Performance', + match: /^(perf|\w+\(perf\))/, + noun: 'improvement', + rules: noun => [...getBulletedListRules(noun)], + }, + { + name: 'Types', + match: /^(fix|feat)\(types\)/, + noun: 'change', + rules: noun => [...getBulletedListRules(noun)], + }, + ] +} + +function log(message: string, ...args: any[]) { + console.log('• ' + message, ...args) +} + +async function getPreferredEditor() { + const { stdout: gitEditor } = await execa('git', [ + 'config', + '--global', + 'core.editor', + ]) + return gitEditor.trim() || process.env.EDITOR || 'nano' +} + +type Section = { + name: string + match: RegExp + exclude?: RegExp + noun: string + rules: (noun: string) => (string | false)[] + commits?: Commit[] + notes?: string +} + +type Commit = { + sha: string + message: string + diff: string +} diff --git a/scripts/release-notes/src/main.ts b/scripts/release-notes/src/main.ts index e024977f..c012b9e4 100644 --- a/scripts/release-notes/src/main.ts +++ b/scripts/release-notes/src/main.ts @@ -1,308 +1,19 @@ -import { Anthropic } from '@anthropic-ai/sdk' -import { verifyEnvVars } from '@radashi-org/common/verifyEnvVars.ts' -import { execa } from 'execa' import mri from 'mri' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { uid } from 'radashi/random/uid.ts' -import { dedent } from 'radashi/string/dedent.ts' main() async function main() { const argv = mri(process.argv.slice(2)) - const outFile = argv.o || argv.output - const limit = argv.limit ? +argv.limit : Number.POSITIVE_INFINITY - if (!outFile && !argv.publish) { - console.error('Error: No --output file or --publish flag provided') - process.exit(1) - } else if (outFile && argv.publish) { - console.error('Error: Cannot use --output file and --publish flag together') - process.exit(1) + if (argv._[0] === 'minor') { + const { generateNextMinorReleaseNotes } = await import('./next-minor.ts') + return generateNextMinorReleaseNotes(argv) } - const { anthropicApiKey, githubToken } = verifyEnvVars({ - anthropicApiKey: 'ANTHROPIC_API_KEY', - githubToken: !!argv.publish && 'GITHUB_TOKEN', - }) - - const version: string = JSON.parse( - fs.readFileSync('package.json', 'utf8'), - ).version - - log('Generating release notes for v' + version) - - /** - * If no commit ref is provided, use the latest two tags. - */ - let commitRange: string - if (argv._.length) { - commitRange = 'v' + version + '..' + argv._[0] - } else { - const tags = await execa('git', [ - 'tag', - '--format=%(refname:short)', - '--sort=-version:refname', - '-n', - 'v*', - ]).then(result => - result.stdout - .split('\n') - .filter(tag => !tag.includes('-')) - .slice(0, 2), - ) - - const [currentVersion, previousVersion] = tags - commitRange = previousVersion + '..' + currentVersion - } - - const commits = await execa('git', [ - 'log', - '--format=%H %s', - commitRange, - ]).then(result => - result.stdout - .trim() - .split('\n') - .map(line => { - const [, sha, message] = /^(\w+) (.+)$/.exec(line)! - return { sha, message, diff: '' } - }), - ) - - log('Grouping commits and fetching diffs...') - - const sections = getSections() - for (const commit of commits) { - for (const section of sections) { - if ( - section.match.test(commit.message) && - !section.exclude?.test(commit.message) - ) { - const diff = await execa('git', [ - 'log', - '-p', - commit.sha + '^..' + commit.sha, - 'src', - 'docs', - ]) - commit.diff = diff.stdout - section.commits ??= [] - section.commits.push(commit) - } - } - } - - const anthropic = new Anthropic({ - apiKey: anthropicApiKey, - }) - - for (const section of sections) { - if (!section.commits?.length) { - continue - } - - log('Generating release notes for', section.name) - - section.commits.length = Math.min(section.commits.length, limit) - section.notes = '' - - const chunkSize = 4 - - for (let offset = 0; offset < section.commits.length; offset += chunkSize) { - log(`${section.commits.length - offset} commits left`) - - const linkLocation = - section.noun === 'feature' || section.noun === 'fix' - ? 'immediately after the heading' - : 'at the end' - - const rules = [ - `You're tasked with writing in-depth release notes (using Markdown) in a professional tone.`, - 'Never converse with me.', - 'Always mention every change I give you.', - `Always link to the relevant PR (or the commit if there's no PR) ${linkLocation} of each ${section.noun} in a format like "[→ PR #110](…)" or "[→ commit {short-hash}](…)". The GitHub URL is "https://github.com/radashi-org/radashi".`, - `Never include headings like "Release Notes" or "v1.0.0".`, - ...section.rules(section.noun), - ] - - log('Sending request to Anthropic...') - - const response = await anthropic.messages.create({ - model: 'claude-3-haiku-20240307', - max_tokens: 4096, - messages: [ - { - role: 'user', - content: dedent` - - ${rules.join('\n- ')} - - The following changes are from \`git log -p\`: - - - ${section.commits - .slice(offset, offset + chunkSize) - .map(commit => commit.diff) - .join('\n\n')} - - `, - }, - ], - }) - - const [message] = response.content - if (message.type !== 'text') { - console.error('Expected a text message, got:', message) - process.exit(1) - } - - section.notes += message.text.trim() + '\n\n' - } + if (argv._[0] === 'legacy') { + const { legacyGenerateReleaseNotes } = await import('./legacy.ts') + return legacyGenerateReleaseNotes(argv) } - let notes = sections - .filter(section => section.notes) - .map(section => `## ${section.name}\n\n${section.notes}`) - .join('\n\n') - - const tmpFile = path.join(os.tmpdir(), 'release-notes.' + uid(20) + '.md') - fs.writeFileSync(tmpFile, notes) - - try { - const editor = await getPreferredEditor() - log('Opening', tmpFile, 'with', editor) - - // Open the generated release notes in the user's preferred text editor - await execa(editor, [tmpFile], { stdio: 'inherit' }) - - // Read the potentially modified content after the editor is closed - notes = fs.readFileSync(tmpFile, 'utf-8') - } finally { - fs.unlinkSync(tmpFile) - } - - if (argv.publish) { - const { Octokit } = await import('@octokit/rest') - - const octokit = new Octokit({ - auth: githubToken, - }) - - log('Publishing release notes for version', version) - - try { - await octokit.rest.repos.createRelease({ - owner: 'radashi-org', - repo: 'radashi', - tag_name: `v${version}`, - name: `v${version}`, - body: notes, - draft: !!argv.draft, - prerelease: !!argv.prerelease, - }) - - log('Successfully published release notes to GitHub') - } catch (error) { - console.error('Failed to publish release notes:', error) - process.exit(1) - } - } else { - fs.writeFileSync(outFile, notes) - log('Saved release notes to', path.resolve(outFile)) - } -} - -function getSections(): Section[] { - const getFormattingRules = (noun: string) => [ - `Use an H4 (####) for the heading of each ${noun}.`, - 'Headings must be in sentence case.', - noun === 'feature' && - `Each heading must describe what the ${noun} enables, not simply what the change is (e.g. "Allow throttled function to be triggered immediately" instead of "Add trigger method to throttle function").`, - 'Be concise but not vague.', - 'Omit prefixes like "Fix:" from headings.', - `The paragraph(s) after each heading must describe the ${noun} in more detail (but be brief where possible).`, - ] - - const getCodeExampleRules = (noun: string) => [ - `Every ${noun} needs a concise code example to showcase it.`, - 'Never preface examples with "Example:" or similar.', - dedent` - In each example, import the functions or types like this: - \`\`\`ts - import { sum } from 'radashi' - \`\`\` - `, - ] - - const getBulletedListRules = (noun: string) => [ - `Describe each ${noun} in a bulleted list, without being vague.`, - 'Never use headings.', - 'Only give me the bulleted list. No prefacing like “Here are the changes” or similar.', - ] - - return [ - { - name: 'Features', - match: /^feat/, - exclude: /\((types|perf)\)/, - noun: 'feature', - rules: noun => [ - ...getFormattingRules(noun), - ...getCodeExampleRules(noun), - ], - }, - { - name: 'Bug Fixes', - match: /^fix/, - exclude: /\((types|perf)\)/, - noun: 'fix', - rules: noun => [ - ...getFormattingRules(noun), - ...getCodeExampleRules(noun), - ], - }, - { - name: 'Performance', - match: /^(perf|\w+\(perf\))/, - noun: 'improvement', - rules: noun => [...getBulletedListRules(noun)], - }, - { - name: 'Types', - match: /^(fix|feat)\(types\)/, - noun: 'change', - rules: noun => [...getBulletedListRules(noun)], - }, - ] -} - -function log(message: string, ...args: any[]) { - console.log('• ' + message, ...args) -} - -async function getPreferredEditor() { - const { stdout: gitEditor } = await execa('git', [ - 'config', - '--global', - 'core.editor', - ]) - return gitEditor.trim() || process.env.EDITOR || 'nano' -} - -type Section = { - name: string - match: RegExp - exclude?: RegExp - noun: string - rules: (noun: string) => (string | false)[] - commits?: Commit[] - notes?: string -} - -type Commit = { - sha: string - message: string - diff: string + throw new Error('Expected one of: minor, legacy') } diff --git a/scripts/release-notes/src/next-minor.ts b/scripts/release-notes/src/next-minor.ts new file mode 100644 index 00000000..85d26889 --- /dev/null +++ b/scripts/release-notes/src/next-minor.ts @@ -0,0 +1,259 @@ +import { Anthropic } from '@anthropic-ai/sdk' +import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest' +import { verifyEnvVars } from '@radashi-org/common/verifyEnvVars.ts' +import { execa } from 'execa' +import type mri from 'mri' +import fs from 'node:fs' +import { dedent } from 'radashi/string/dedent.ts' +import { isNumber } from 'radashi/typed/isNumber.ts' + +export async function generateNextMinorReleaseNotes(argv: mri.Argv) { + const { anthropicApiKey, githubToken } = verifyEnvVars({ + anthropicApiKey: 'ANTHROPIC_API_KEY', + githubToken: 'GITHUB_TOKEN', + }) + + const version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version + const [major, prevMinor] = version.split('.') + + const nextVersion = `${major}.${+prevMinor + 1}.0` + + const content = fs.readFileSync('.github/next-minor.md', 'utf8') + const lines = content.split('\n') + + interface Change { + section: string + title: string + url: string + } + + const changes: Change[] = [] + + let section: string | null = null + let title: string | null = null + + for (const line of lines) { + if (line.startsWith('## ')) { + section = line.slice(2).trim() + } else if (section && line.startsWith('#### ')) { + title = line.slice(4).trim() + } else if (section && title && line.startsWith('http')) { + changes.push({ + section, + title, + url: line.trim(), + }) + title = null + } + } + + let octokit: Octokit | undefined + + type OctokitCommitArray = + RestEndpointMethodTypes['pulls']['listCommits']['response']['data'] + + const cachedCommits = new Map() + + async function addAuthor( + prNumber: number, + authors: Set, + [, name, email]: string[], + ) { + if (!email) { + if (name) { + authors.add(name) + } + return + } + + let username: string | undefined + + if (email.endsWith('@users.noreply.github.com')) { + username = email.split('@')[0].replace(/^\d+\+/, '') + } else if (!githubToken) { + throw new Error('Cannot look up author without GitHub token') + } else { + octokit ||= new Octokit({ auth: githubToken }) + + let commits = cachedCommits.get(prNumber) + if (!commits) { + const response = await octokit.rest.pulls.listCommits({ + owner: 'radashi-org', + repo: 'radashi', + pull_number: prNumber, + }) + commits = response.data + cachedCommits.set(prNumber, commits) + if (argv.debug) { + console.dir(commits, { depth: null }) + } + } + + username = commits.find(commit => commit.commit.author?.email === email) + ?.author?.login + + if (!username) { + authors.add(name) + return + } + } + + authors.add(`[${name}](https://github.com/${username})`) + } + + const anthropic = new Anthropic({ + apiKey: anthropicApiKey, + }) + + let notes = '' + + section = null + + for (const change of changes) { + const prNumber = change.url.match(/\/pull\/(\d+)/)?.[1] + if (!prNumber) { + throw new Error(`Expected PR number in ${change.url}`) + } + + if (argv.debug && isNumber(argv.debug) && argv.debug !== +prNumber) { + continue + } + + const { stdout: commit } = await execa('git', [ + 'log', + '--format=%H', + '--grep', + `(#${prNumber})$`, + '-n', + '1', + ]) + + if (!commit) { + throw new Error(`Expected commit for PR ${prNumber}`) + } + + const { stdout: diff } = await execa('git', [ + 'show', + `${commit}`, + '--', + 'src', + 'docs', + ]) + + const authors = new Set() + await addAuthor( + +prNumber, + authors, + diff.match(/^Author: +([^<]+) +<([^>]+)>/m) || [], + ) + for (const coAuthor of diff.matchAll( + /^ *Co-authored-by: +([^<]+) +<([^>]+)>/gm, + )) { + await addAuthor(+prNumber, authors, coAuthor) + } + + if (argv.debug) { + console.log({ prNumber, commit, authors }) + console.log( + diff + .replace( + /^([-+].+?)([ \t]+)$/gm, + (_, content, whitespace) => content + '█'.repeat(whitespace.length), + ) + .replace(/^(\+[^+].*)/gm, '\x1b[32m$1\x1b[0m') + .replace(/^(-[^-].*)/gm, '\x1b[31m$1\x1b[0m'), + ) + // process.exit(0) + } + + if (section !== change.section) { + section = change.section + notes += `## ${section}\n\n` + } + + let prompt: string + + switch (section) { + case 'New Functions': + notes += `### Add \`${change.title}\` function [→ PR #${prNumber}](${change.url})\n\n` + prompt = dedent` + Briefly explain the new function's purpose in the first paragraph. + If the function isn't simple, you may list more details (i.e. limitations, + options, etc.) in a bulleted list after the first paragraph. Try to be concise. + You don't have to mention every detail, as we link to the documentation page. + ` + break + case 'New Features': + notes += `### ${change.title} [→ PR #${prNumber}](${change.url})\n\n` + prompt = dedent` + Briefly explain what the new feature is in the first paragraph. + If the feature isn't simple, you may list more details (i.e. limitations, + options, etc.) in a bulleted list after the first paragraph. Try to be concise. + ` + break + default: + throw new Error(`Unknown section: ${section}`) + } + + prompt = dedent` + ${prompt} + Finally, write a code block containing an example of how to use the new + function. Keep the example straight-forward and add comments if necessary. + Use \`import * as _ from 'radashi'\` to import the function. + Do not include headings anywhere in your response. + + Use the git diff below to write the release notes. + + + ${diff} + + ` + + const response = await anthropic.messages.create({ + model: 'claude-3-5-haiku-latest', + max_tokens: 4096, + messages: [ + { + role: 'user', + content: prompt, + }, + ], + }) + + const textBlock = response.content.find(block => block.type === 'text')! + notes += textBlock.text + '\n\n' + + const renderCommaSeparatedList = (items: T[]) => + items.length === 1 + ? items[0] + : items.slice(0, -1).join(', ') + + (items.length > 2 ? ',' : '') + + ` and ${items.at(-1)}` + + notes += `Thanks to ${renderCommaSeparatedList([ + ...authors, + ])} for their work on this feature!\n\n` + + if (section === 'New Functions') { + const { stdout: addedFiles } = await execa('git', [ + 'diff-tree', + '--no-commit-id', + '--name-only', + '-r', + '--diff-filter=A', + commit, + '--', + 'src', + ]) + + const slug = addedFiles + .split('\n')[0] + .replace(/^src\//, '') + .replace(/\.ts$/, '') + + notes += `🔗 [Docs](https://radashi.js.org/reference/${slug}) / [Source](https://github.com/radashi-org/radashi/blob/main/src/${slug}.ts) / [Tests](https://github.com/radashi-org/radashi/blob/main/tests/${slug}.test.ts)\n\n` + } + + fs.writeFileSync('notes.md', notes) + } +} diff --git a/scripts/run.js b/scripts/run.js index 115968d5..f8bd4d1a 100644 --- a/scripts/run.js +++ b/scripts/run.js @@ -18,6 +18,8 @@ async function main([command, ...argv]) { process.exit(1) } + let forceTSX = false + // Only a few environment variables are exposed to install/postinstall // scripts when installing dependencies from NPM. const strictEnv = pick(process.env, [ @@ -32,10 +34,6 @@ async function main([command, ...argv]) { const __dirname = path.dirname(__filename) async function installDependencies(pkgDir) { - if (fs.existsSync(path.join(pkgDir, 'node_modules'))) { - return - } - const pkgPath = path.join(pkgDir, 'package.json') const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) @@ -43,6 +41,16 @@ async function main([command, ...argv]) { return } + // Since modules in Radashi may import "radashi" (which resolves to the "dist" folder), forcing + // the use of TSX helps us avoid resolution errors. + if ('radashi' in pkg.dependencies) { + forceTSX = true + } + + if (fs.existsSync(path.join(pkgDir, 'node_modules'))) { + return + } + console.warn( `> Installing dependencies for ${path.relative(process.cwd(), pkgDir)}`, ) @@ -83,7 +91,7 @@ async function main([command, ...argv]) { let runner let runnerArgs - if (major < 22 || (major === 22 && minor < 6)) { + if (forceTSX || process.env.CI || major < 22 || (major === 22 && minor < 6)) { const tsxSpecifier = 'tsx@4.19.1' if (process.env.CI) { console.warn(`> pnpm add -g ${tsxSpecifier}`)