Initial commit

This commit is contained in:
Devin Zuczek
2026-06-08 18:23:40 -04:00
commit 8e644a5c20
141 changed files with 21414 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import 'zx/globals'
import { program } from '@commander-js/extra-typings'
import { catchProcessError } from '@jahands/cli-tools/proc'
import { buildCmd } from '../cmd/build.cmd'
import { checkCmd } from '../cmd/check.cmd'
import { ciCmd } from '../cmd/ci.cmd'
import { devCmd } from '../cmd/dev.cmd'
import { fixCmd } from '../cmd/fix.cmd'
import { shfmtCmd } from '../cmd/shfmt.cmd'
import { updateCmd } from '../cmd/update.cmd'
program
.name('runx')
.description('A CLI for scripts that automate this repo')
// While `packages/tools/bin` scripts work well for simple tasks,
// a typescript CLI is nicer for more complex things.
.addCommand(fixCmd)
.addCommand(buildCmd)
.addCommand(checkCmd)
.addCommand(devCmd)
.addCommand(ciCmd)
.addCommand(updateCmd)
.addCommand(shfmtCmd)
// Don't hang for unresolved promises
.hook('postAction', () => process.exit(0))
.parseAsync()
.catch(catchProcessError())
+215
View File
@@ -0,0 +1,215 @@
import { Command } from '@commander-js/extra-typings'
import { validateArg } from '@jahands/cli-tools/args'
import * as esbuild from 'esbuild'
import pMap from 'p-map'
import { match } from 'ts-pattern'
import { z } from 'zod'
import { TSHelpers } from '../tsconfig'
import type { CompilerOptions as TSCompilerOptions } from 'typescript'
export const buildCmd = new Command('build').description('Scripts to build things')
type Format = z.infer<typeof Format>
const Format = z.enum(['esm', 'cjs'])
buildCmd
.command('tsc')
.description('Build a library with tsc')
.argument('<entrypoint...>', 'Entrypoint(s) for the program')
.option('-r, --root-dir <string>', 'Root dir for tsc to use (overrides tsconfig.json)')
.action(async (entrypoints, { rootDir }) => {
const tsHelpers = await new TSHelpers().init()
await $`rm -rf ./dist`
const tsOptions: TSCompilerOptions = {
...tsHelpers.getTSConfig(),
...(rootDir !== undefined && { rootDir }),
}
tsHelpers.ts.createProgram(entrypoints, tsOptions).emit()
})
buildCmd
.command('bundle-lib')
.alias('lib')
.description('Bundle library with esbuild')
.argument('<entrypoints...>', 'Entrypoint(s) of the app. e.g. src/index.ts')
.option('-d, --root-dir <string>', 'Root directory to look for entrypoints')
.option('-f, --format <format...>', 'Formats to use (options: esm, cjs)', ['esm'])
.option('--no-minify', `Don't minify output`)
.option(
'--sourcemap <string>',
`Include sourcemaps in the output. (options: both, linked, inline, external, true, false)`,
validateArg(z.union([z.enum(['both', 'linked', 'inline', 'external']), z.coerce.boolean()])),
'both'
)
.option(
'--platform <string>',
'Optional platform to target. (options: cloudflare_workers,browser, node, neutral)',
validateArg(z.enum(['cloudflare_workers', 'browser', 'node', 'neutral'])),
'cloudflare_workers'
)
.option('--no-types', `Don't include .d.ts types in output (usually not recommended)`)
.action(
async (entryPoints, { format: moduleFormats, platform, rootDir, minify, sourcemap, types }) => {
entryPoints = z
.string()
.array()
.min(1)
.decode(entryPoints)
.map((d) => path.join(rootDir ?? '.', d))
const formats = Format.array().parse(moduleFormats)
await fs.rm('./dist/', { force: true, recursive: true })
const maybeOutputTypes = types
? $({
stdio: 'inherit',
})`runx build bundle-lib-build-types ${entryPoints}`
: undefined
await Promise.all([
maybeOutputTypes,
...formats.map(async (outFormat) => {
type Config = {
format: Format
outExt: string
}
const { format, outExt } = match<'esm' | 'cjs', Config>(outFormat)
.with('esm', () => ({
format: 'esm',
outExt: '.mjs',
}))
.with('cjs', () => ({
format: 'cjs',
outExt: '.cjs',
}))
.exhaustive()
const external: string[] = []
if (platform === 'cloudflare_workers') {
external.push('node:events', 'node:async_hooks', 'node:buffer', 'cloudflare:test')
}
const opts: esbuild.BuildOptions = {
entryPoints,
outdir: './dist/',
logLevel: 'warning',
outExtension: {
'.js': outExt,
},
target: 'es2022',
bundle: true,
minify,
format,
sourcemap,
treeShaking: true,
external,
}
if (platform !== 'cloudflare_workers') {
opts.platform = platform
}
await esbuild.build(opts)
}),
])
}
)
buildCmd
.command('bundle-lib-build-types')
.description('Separate command to build types (so that we can run them concurrently)')
.argument('<entrypoints...>', 'Entrypoint(s) of the app. e.g. src/index.ts')
.action(async (entryPoints) => {
const tsHelpers = await new TSHelpers().init()
const { ts } = tsHelpers
z.string().array().min(1).decode(entryPoints)
const tsCompOpts = {
...tsHelpers.getTSConfig(),
declaration: true,
declarationMap: true,
emitDeclarationOnly: true,
noEmit: false,
outDir: './dist/',
} satisfies TSCompilerOptions
const program = ts.createProgram(entryPoints, tsCompOpts)
program.emit()
})
buildCmd
.command('bun')
.description('Bundle with Bun')
.argument('<entrypoints...>', 'Entrypoint(s) of the app. e.g. src/index.ts')
.option('-f, --format <format...>', 'Formats to use (options: esm, cjs)', ['esm'])
.option('--no-minify', `Don't minify output`)
.option('--no-sourcemap', `Don't include sourcemaps`)
.action(async (entryPoints, { format, minify, sourcemap }) => {
await fs.rm('./dist/', { force: true, recursive: true })
const formats = await z
.array(Format)
.parseAsync(format)
.catch((e) => {
throw new Error(`Invalid format: ${z.prettifyError(e)}`)
})
await Promise.all([
$({
stdio: 'inherit',
})`runx build bundle-lib-build-types ${entryPoints}`,
...formats.map(async (fmt) => {
const distDir = `./dist/${fmt}`
await Bun.build({
entrypoints: entryPoints,
outdir: distDir,
target: 'node',
minify,
format: fmt,
})
const outExt = match(fmt)
.with('esm', () => '.mjs')
.with('cjs', () => '.cjs')
.exhaustive()
// change output files to mjs/cjs
await pMap(await glob(`${distDir}/**/*.js`), async (file) => {
await fs.rename(file, file.replace(/\.js$/, outExt))
})
}),
])
const cleanupSourcemaps = async () => {
if (sourcemap === false) {
const files = await glob('dist/**/*.map')
await Promise.all(files.map((file) => fs.rm(file)))
}
}
// executables don't need declaration files
const cleanupBin = async () => {
const files = await glob('dist/bin/*.d.ts')
await Promise.all(files.map((file) => fs.rm(file)))
}
await Promise.all([cleanupSourcemaps(), cleanupBin()])
// check if bin is empty
const files = await glob('dist/bin/*')
if (files.length === 0) {
await fs.rm('dist/bin', { recursive: true })
}
})
+163
View File
@@ -0,0 +1,163 @@
import { Command } from '@commander-js/extra-typings'
import Table from 'cli-table3'
import { getRepoRoot } from '../path'
import { getOutcome, SHFMT_SKIPPED_EXIT_CODE } from '../proc'
export const checkCmd = new Command('check')
.description(
'Check for issues with deps/lint/types/format. If no options are provided, all checks are run.'
)
.option('-r, --root', 'Run checks from root of repo. Defaults to cwd', false)
.option('-d, --deps', 'Check for dependency issues with Syncpack')
.option('-l, --lint', 'Check for oxlint issues')
.option('-t, --types', 'Check for TypeScript issues')
.option(
'-f, --format',
'Check for formatting issues with prettier. Also checks shell scripts if shfmt and rg (ripgrep) are available'
)
.option('--continue', 'Use --continue when executing turbo commands', false)
.action(async ({ root, deps, lint, types, format, continue: useContinue }) => {
const repoRoot = getRepoRoot()
if (root) {
cd(repoRoot)
}
// Run all if none are selected
if (!deps && !lint && !types && !format) {
deps = true
lint = true
types = true
format = true
}
const cwd = process.cwd()
const runFromRoot = cwd === repoRoot
const cwdName = path.basename(cwd)
const turboFlags: string[] = []
if (useContinue) {
turboFlags.push('--continue')
}
const checks = {
deps: ['syncpack', 'lint'],
// oxlint can be run from anywhere and it'll automatically only lint the current dir and children
lint: ['run-oxlint'],
types: ['turbo', ...turboFlags, 'check:types'],
format: ['prettier', '.', '--cache', '--check', '--log-level=warn'],
formatShell: ['runx', 'shfmt', 'check', '--skip-if-unavailable'],
workersTypes: ['turbo', ...turboFlags, 'check:workers-types'],
} as const satisfies { [key: string]: string[] }
type TableRow = [string, string, string, string]
const table = new Table({
head: [
chalk.whiteBright('Name'),
chalk.whiteBright('Command'),
chalk.whiteBright('Outcome'),
chalk.whiteBright('Ran From'),
] satisfies TableRow,
})
$.stdio = 'inherit'
$.verbose = true
$.nothrow = true
let didErr = false
function getAndCheckOutcome({
exitCode,
skippedCode,
}: {
exitCode: number | null
skippedCode?: number
}): string {
if (exitCode !== 0 && exitCode !== skippedCode) {
didErr = true
}
return getOutcome({ exitCode, skippedCode })
}
if (deps) {
const exitCode = await $({
cwd: repoRoot, // Must be run from root
})`${checks.deps}`.exitCode
table.push([
'deps',
checks.deps.join(' '),
getAndCheckOutcome({ exitCode }),
'Root',
] satisfies TableRow)
}
if (lint) {
const exitCode = await $`${checks.lint}`.exitCode
table.push([
'lint',
checks.lint.join(' '),
getAndCheckOutcome({ exitCode }),
runFromRoot ? 'Root' : `cwd (${cwdName})`,
] satisfies TableRow)
}
if (types) {
const exitCode = await $`${checks.types}`.exitCode
table.push([
'types',
checks.types.join(' '),
getAndCheckOutcome({ exitCode }),
runFromRoot ? 'Root' : `cwd (${cwdName})`,
] satisfies TableRow)
}
if (format) {
echo(chalk.dim('checking formatting with prettier (and shfmt if available)...'))
const [prettierProc, shfmtProc] = await Promise.all([
$({
cwd: repoRoot, // Must be run from root
})`${checks.format}`,
$({
cwd: repoRoot, // Must be run from root
})`${checks.formatShell}`,
])
table.push(
[
'format',
checks.format.join(' '),
getAndCheckOutcome({ exitCode: prettierProc.exitCode }),
'Root',
] satisfies TableRow,
[
'format shell',
checks.formatShell.join(' '),
getAndCheckOutcome({
exitCode: shfmtProc.exitCode,
skippedCode: SHFMT_SKIPPED_EXIT_CODE,
}),
'Root',
] satisfies TableRow
)
const workersTypesExitCode = await $({
cwd: repoRoot, // Must be run from root
})`${checks.workersTypes}`.exitCode
table.push([
'workers types',
checks.workersTypes.join(' '),
getAndCheckOutcome({ exitCode: workersTypesExitCode }),
'Root',
] satisfies TableRow)
}
echo(table.toString())
if (didErr) {
process.exit(1)
}
})
+22
View File
@@ -0,0 +1,22 @@
import { Command } from '@commander-js/extra-typings'
import type { Options as ZXOptions } from 'zx'
export const ciCmd = new Command('ci').description('Scripts used in CI')
function opts(): Partial<ZXOptions> {
return {
verbose: true,
env: {
FORCE_COLOR: '1',
...process.env,
},
}
}
ciCmd
.command('check')
.description('Run CI checks')
.action(async () => {
await $(opts())`bun turbo check:ci`
})
+39
View File
@@ -0,0 +1,39 @@
import { Command } from '@commander-js/extra-typings'
import { getRepoRoot } from '../path'
export const devCmd = new Command('dev')
.description(
'Run development server for Workers projects, etc. Use --runx-help to see all options.'
)
.argument(
'[args...]',
'Arguments to pass to the dev script. May need to use -- to pass options to the dev script.'
)
.allowUnknownOption()
// allow passing --help to the dev script
.helpOption('--runx-help')
.action(async (args) => {
const cwd = process.cwd()
const repoRoot = getRepoRoot()
const isRepoRoot = cwd === repoRoot
const [hasDevScript, hasWranglerJsonc] = await Promise.all([
fs
.readJson('./package.json')
.then((packageJson) => packageJson.scripts?.dev !== undefined)
.catch(() => {
return false
}),
fs.pathExists('./wrangler.jsonc'),
])
$.stdio = 'inherit'
if (!isRepoRoot && (hasWranglerJsonc || hasDevScript)) {
await $`pnpm dev ${args}`
} else {
const argsWithSeparator = args.length > 0 ? ['--', ...args] : args
await $`pnpm turbo dev ${argsWithSeparator}`
}
})
+144
View File
@@ -0,0 +1,144 @@
import { Command } from '@commander-js/extra-typings'
import Table from 'cli-table3'
import { getRepoRoot } from '../path'
import { getOutcome, SHFMT_SKIPPED_EXIT_CODE } from '../proc'
export const fixCmd = new Command('fix')
.description('Fix deps/lint/format issues. If no options are provided, all fixes are run.')
.option('-r, --root', 'Run fixes from root of repo. Defaults to cwd', false)
.option('-d, --deps', 'Fix dependency versions with syncpack')
.option('-l, --lint', 'Fix oxlint issues')
.option('-f, --format', 'Format code with prettier')
.option(
'-w, --workers-types',
'Generate Workers runtime types (worker-configuration.d.ts) via wrangler types'
)
.action(async ({ root, deps, lint, format, workersTypes }) => {
const repoRoot = getRepoRoot()
if (root) {
cd(repoRoot)
}
// Run all if none are selected
if (!deps && !lint && !format && !workersTypes) {
deps = true
lint = true
format = true
workersTypes = true
}
const cwd = process.cwd()
const runFromRoot = cwd === repoRoot
const cwdName = path.basename(cwd)
const fixes = {
deps: ['run-fix-deps'],
lint: ['run-oxlint', '--fix'],
workersTypes: ['turbo', 'fix:workers-types'],
format: ['prettier', '.', '--cache', '--write', '--log-level=warn'],
formatShell: ['runx', 'shfmt', 'fix', '--skip-if-unavailable'],
} as const satisfies { [key: string]: string[] }
type TableRow = [string, string, string, string]
const table = new Table({
head: [
chalk.whiteBright('Name'),
chalk.whiteBright('Command'),
chalk.whiteBright('Outcome'),
chalk.whiteBright('Ran From'),
] satisfies TableRow,
})
$.stdio = 'inherit'
$.verbose = true
$.nothrow = true
let didErr = false
function getAndCheckOutcome({
exitCode,
skippedCode,
}: {
exitCode: number | null
skippedCode?: number
}): string {
if (exitCode !== 0 && exitCode !== skippedCode) {
didErr = true
}
return getOutcome({ exitCode, skippedCode })
}
if (deps) {
const exitCode = await $({
cwd: repoRoot, // Must be run from root
})`${fixes.deps}`.exitCode
table.push([
'deps',
fixes.deps.join(' '),
getAndCheckOutcome({ exitCode }),
'Root',
] satisfies TableRow)
}
if (lint) {
const exitCode = await $`${fixes.lint}`.exitCode
table.push([
'lint',
fixes.lint.join(' '),
getAndCheckOutcome({ exitCode }),
runFromRoot ? 'Root' : `cwd (${cwdName})`,
] satisfies TableRow)
}
if (workersTypes) {
const exitCode = await $({
cwd: repoRoot, // Must be run from root
})`${fixes.workersTypes}`.exitCode
table.push([
'workers types',
fixes.workersTypes.join(' '),
getAndCheckOutcome({ exitCode }),
runFromRoot ? 'Root' : `cwd (${cwdName})`,
] satisfies TableRow)
}
if (format) {
echo(chalk.dim('formatting with prettier (and shfmt if available)...'))
const [prettierProc, shfmtProc] = await Promise.all([
$({
cwd: repoRoot, // Must be run from root
})`${fixes.format}`,
$({
cwd: repoRoot, // Must be run from root
})`${fixes.formatShell}`,
])
table.push(
[
'format',
fixes.format.join(' '),
getAndCheckOutcome({ exitCode: prettierProc.exitCode }),
'Root',
] satisfies TableRow,
[
'format shell',
fixes.formatShell.join(' '),
getAndCheckOutcome({
exitCode: shfmtProc.exitCode,
skippedCode: SHFMT_SKIPPED_EXIT_CODE,
}),
'Root',
] satisfies TableRow
)
}
echo(table.toString())
if (didErr) {
process.exit(1)
}
})
+80
View File
@@ -0,0 +1,80 @@
import { Command } from '@commander-js/extra-typings'
import { SHFMT_SKIPPED_EXIT_CODE } from '../proc'
export const shfmtCmd = new Command('shfmt').description('Format shell scripts with shfmt')
shfmtCmd
.command('fix')
.description('Format shell scripts with shfmt')
.option(
'--skip-if-unavailable',
'Only run if shfmt and ripgrep are available, otherwise skip with a warning.',
false
)
.action(async ({ skipIfUnavailable }) => {
const mode: Mode = 'write'
await checkShfmtAvailable(skipIfUnavailable, mode)
await runShfmt(mode)
})
shfmtCmd
.command('check')
.description('Check shell scripts with shfmt')
.option(
'--skip-if-unavailable',
'Only run if shfmt and ripgrep are available, otherwise skip with a warning.',
false
)
.action(async ({ skipIfUnavailable }) => {
const mode: Mode = 'diff'
await checkShfmtAvailable(skipIfUnavailable, mode)
await runShfmt(mode)
})
type Mode = 'write' | 'diff'
async function runShfmt(mode: Mode) {
await Promise.all([
$`rg --files-with-matches '^#!.*\\b(sh|bash|zsh|fish|dash|ksh|csh)\\b' -g '!*.*' .`
.pipe($`xargs shfmt --case-indent ${`--${mode}`}`)
.pipe(process.stderr),
$({
nothrow: true, // may not be any .sh files
})`rg --files-with-matches '^#!.*\\b(sh|bash|zsh|fish|dash|ksh|csh)\\b' -g '*.sh' .`
.pipe($`xargs shfmt --case-indent ${`--${mode}`}`)
.pipe(process.stderr),
])
}
async function checkShfmtAvailable(skipIfUnavailable: boolean, mode: Mode): Promise<void> {
const [shfmtExit, rgExit] = await Promise.all([
which('shfmt', { nothrow: true }),
which('rg', { nothrow: true }),
])
const missing: string[] = []
if (shfmtExit === null) {
missing.push('shfmt')
}
if (rgExit === null) {
missing.push('rg (ripgrep)')
}
if (missing.length > 0) {
const missingStr = `${missing.join(' and ')} ${missing.length === 1 ? 'is' : 'are'} unavailable`
if (skipIfUnavailable) {
echo(chalk.yellow(`warning: ${missingStr}, skipping shell formatting`))
process.exit(SHFMT_SKIPPED_EXIT_CODE)
} else {
const action = mode === 'write' ? 'fix' : 'check'
echo(chalk.red(`error: ${missingStr}, unable to ${action} shell formatting`))
process.exit(1)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Command } from '@commander-js/extra-typings'
import { getRepoRoot } from '../path'
import { updatePnpm } from '../update-pnpm'
export const updateCmd = new Command('update')
.description('Update things in the repo')
.hook('preAction', () => {
cd(getRepoRoot())
$.verbose = true
$.stdio = 'inherit'
$.env.FORCE_COLOR = '1'
})
updateCmd
.command('deps')
.description('Update dependencies via syncpack')
.action(async () => {
await $`syncpack update`
// Run fix if there are any changes
const status = await $({
stdio: 'pipe',
})`git status --porcelain`.text()
if (status.includes('package.json') || status.includes('pnpm-lock.yaml')) {
await $`just fix --deps`
}
})
updateCmd
.command('pnpm')
.description('Update pnpm version')
.action(async () => {
await updatePnpm()
})
updateCmd
.command('turbo')
.description('Update turbo version (must have clean working tree)')
.action(async () => {
await $`pnpm dlx @turbo/codemod@latest update`
})
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { getRepoRoot } from './path'
describe('getRepoRoot()', () => {
it('should return the root of the repo', () => {
expect(getRepoRoot()).toBe(path.resolve(__dirname, '../../..'))
})
})
+29
View File
@@ -0,0 +1,29 @@
import * as find from 'empathic/find'
import * as pkg from 'empathic/package'
import memoizeOne from 'memoize-one'
import { z } from 'zod'
export const getRepoRoot = memoizeOne(() => {
const pnpmLock = z
.string()
.trim()
.startsWith('/')
.endsWith('/pnpm-lock.yaml')
.parse(find.up('pnpm-lock.yaml'))
return path.dirname(pnpmLock)
})
/**
* Get the package name of the nearest package.json
*/
export const getPackageName = memoizeOne(async (): Promise<string> => {
const pkgJsonPath = pkg.up()
if (!pkgJsonPath) {
throw new Error(`unable to locate package.json from ${process.cwd()}`)
}
const pkgJson = z.object({ name: z.string() }).safeParse(await fs.readJson(pkgJsonPath))
if (!pkgJson.success) {
throw new Error(`unable to parse package.json: ${pkgJsonPath}`)
}
return pkgJson.data.name
})
+63
View File
@@ -0,0 +1,63 @@
import { z } from 'zod'
/**
* Represents a pakcage.json file
*/
export const PackageJson = z.object({
name: z.string().optional(),
version: z.string().optional(),
description: z.string().optional(),
type: z.enum(['module', 'commonjs']).optional(),
module: z.string().optional(),
main: z.string().optional(),
types: z.string().optional(),
scripts: z.record(z.string(), z.string()).optional(),
dependencies: z.record(z.string(), z.string()).optional(),
devDependencies: z.record(z.string(), z.string()).optional(),
peerDependencies: z.record(z.string(), z.string()).optional(),
optionalDependencies: z.record(z.string(), z.string()).optional(),
packageManager: z.string().optional(),
engines: z
.object({
node: z.string().optional(),
npm: z.string().optional(),
pnpm: z.string().optional(),
})
.optional(),
repository: z
.union([
z.string(),
z.object({
type: z.string(),
url: z.string(),
}),
])
.optional(),
keywords: z.array(z.string()).optional(),
author: z
.union([
z.string(),
z.object({
name: z.string(),
email: z.string().optional(),
url: z.string().optional(),
}),
])
.optional(),
license: z.string().optional(),
bugs: z
.union([
z.string(),
z.object({
url: z.string().optional(),
email: z.string().optional(),
}),
])
.optional(),
homepage: z.string().optional(),
private: z.boolean().optional(),
publishConfig: z.record(z.string(), z.unknown()).optional(),
workspaces: z.array(z.string()).optional(),
})
export type PackageJson = z.infer<typeof PackageJson>
+20
View File
@@ -0,0 +1,20 @@
export function getOutcome({
exitCode,
skippedCode,
}: {
exitCode: number | null
skippedCode?: number
}) {
if (exitCode === 0) {
return chalk.green('Success!')
} else if (exitCode === skippedCode) {
return chalk.yellow('Skipped')
} else {
return chalk.red(`Failed with code: ${exitCode}`)
}
}
/**
* Non-zero exit code used to indicate "skipped due to shfmt/rg unavailable"
*/
export const SHFMT_SKIPPED_EXIT_CODE = 113
+4
View File
@@ -0,0 +1,4 @@
// runx uses zx/globals imported in bin/runx.cmd.ts
// This import ensures that tests work without
// needing to import this manually.
import 'zx/globals'
+66
View File
@@ -0,0 +1,66 @@
import path from 'node:path'
import { inspect } from 'node:util'
import type {
createProgram,
parseJsonConfigFileContent,
readConfigFile,
sys,
CompilerOptions as TSCompilerOptions,
} from 'typescript'
export type { TSCompilerOptions }
interface TSModule {
readConfigFile: typeof readConfigFile
parseJsonConfigFileContent: typeof parseJsonConfigFileContent
sys: typeof sys
createProgram: typeof createProgram
}
/**
* TypeScript helpers. This is a class so that we can dynamically import the TypeScript module
* to reduce runx start time for commands that don't use the typescript package.
*
* @example
*
* ```ts
* const tsHelpers = await new TSHelpers().init()
* const { ts } = tsHelpers
* const tsConfig = tsHelpers.getTSConfig()
* ts.createProgram(entryPoints, tsConfig).emit()
* ```
*/
export class TSHelpers {
#ts: TSModule | undefined
public get ts(): TSModule {
if (!this.#ts) {
throw new Error('TSHelpers not initialized. Call init() first.')
}
return this.#ts
}
async init(): Promise<TSHelpers> {
this.#ts = (await import('typescript')) as TSModule
return this
}
getTSConfig(configPath = 'tsconfig.json'): TSCompilerOptions {
const absolutePath = path.resolve(configPath)
const configFile = this.ts.readConfigFile(absolutePath, (p) => this.ts.sys.readFile(p))
if (configFile.error) {
throw new Error(`Failed to read tsconfig: ${inspect(configFile.error)}`)
}
const parsed = this.ts.parseJsonConfigFileContent(
configFile.config,
this.ts.sys,
path.dirname(absolutePath)
)
if (parsed.errors.length > 0) {
throw new Error(`Failed to parse tsconfig: ${inspect(parsed.errors)}`)
}
return parsed.options
}
}
+221
View File
@@ -0,0 +1,221 @@
import { cliError } from '@jahands/cli-tools'
import Table from 'cli-table3'
import * as toml from 'smol-toml'
import { match } from 'ts-pattern'
import { z } from 'zod'
import { getRepoRoot } from './path'
import { PackageJson } from './pkg'
type UpdateResult = {
type: string
updated: number
failed: number
files: string[]
errors: string[]
}
export async function updatePnpm() {
const $$ = $({
verbose: false,
stdio: 'pipe',
})
const repoRoot = getRepoRoot()
cd(repoRoot)
const miseAvailable = await which('mise')
echo(chalk.white(`Checking for pnpm updates...`))
const res = await fetch('https://registry.npmjs.org/pnpm')
if (!res.ok) {
throw cliError(`Failed to fetch pnpm registry: ${res.status}`)
}
const body = await res.json()
const pnpm = NpmRegistryPnpmResponse.parse(body)
const latest = pnpm['dist-tags'].latest
echo(chalk.blue(`Latest pnpm version: ${latest}`))
const results: UpdateResult[] = []
if (miseAvailable) {
const miseResult = await updateMiseTomlFiles(repoRoot, latest)
results.push(miseResult)
}
const packageJsonResult = await updatePackageJsonFiles(repoRoot, latest)
results.push(packageJsonResult)
if (results.some((r) => r.updated > 0)) {
echo(chalk.blue(`Fixing formatting...`))
await $$`runx fix --format`
}
// Display summary table
const table = new Table({
head: [
chalk.whiteBright('File Type'),
chalk.whiteBright('Updated'),
chalk.whiteBright('Failed'),
chalk.whiteBright('Status'),
],
})
let totalUpdated = 0
let totalFailed = 0
for (const result of results) {
totalUpdated += result.updated
totalFailed += result.failed
const status = match(result)
.when(
(r) => r.failed > 0,
() => chalk.red('Failed')
)
.when(
(r) => r.updated > 0,
() => chalk.green('Updated')
)
.otherwise(() => chalk.gray('No changes'))
table.push([result.type, result.updated.toString(), result.failed.toString(), status])
}
// Show detailed errors if any
for (const result of results) {
if (result.errors.length > 0) {
echo(chalk.red(`\nErrors in ${result.type}:`))
for (const error of result.errors) {
echo(chalk.red(` ${error}`))
}
}
}
if (totalUpdated > 0) {
if (miseAvailable) {
echo(chalk.blue('\nRunning mise up...'))
await $$`mise up`
}
echo(chalk.blue(`Fixing formatting...`))
await $$`runx fix --format`
echo(chalk.greenBright(`\nSuccessfully updated pnpm to ${latest}`))
} else if (totalFailed > 0) {
echo(chalk.red(`\nFailed to update some files. See errors above.`))
process.exit(1)
} else {
echo(chalk.green(`\nNo pnpm updates needed, already on ${latest}`))
}
echo('\n' + table.toString())
}
const Semver = z.string().regex(/^\d+\.\d+\.\d+$/)
const NpmRegistryPnpmResponse = z.object({
_id: z.literal('pnpm'),
name: z.literal('pnpm'),
'dist-tags': z.object({
latest: Semver.describe('pnpm latest version, e.g. 9.5.0'),
}),
})
type MiseToml = z.infer<typeof MiseToml>
const MiseToml = z
.object({
tools: z
.object({
pnpm: Semver.describe('pnpm version, e.g. 9.5.0').optional(),
})
.catchall(z.string()),
alias: z.record(z.string(), z.string()).optional(),
})
.loose()
/**
* Update package.json files with the new pnpm version
* @param repoRoot - The root directory of the repository
* @param newVersion - The new pnpm version
* @returns UpdateResult with details about the operation
*/
async function updatePackageJsonFiles(repoRoot: string, newVersion: string): Promise<UpdateResult> {
const result: UpdateResult = {
type: 'package.json',
updated: 0,
failed: 0,
files: [],
errors: [],
}
const packageJsonFiles = await glob('**/package.json', {
cwd: repoRoot,
gitignore: true,
ignore: ['**/node_modules/**', 'turbo/generators/**'],
})
for (const file of packageJsonFiles) {
const filePath = `${repoRoot}/${file}`
try {
const packageJson = PackageJson.loose().parse(await Bun.file(filePath).json())
if (packageJson.packageManager?.startsWith('pnpm@')) {
const currentVersion = packageJson.packageManager.replace('pnpm@', '')
if (currentVersion !== newVersion) {
echo(chalk.blue(`Updating ${file} packageManager to pnpm@${newVersion}`))
packageJson.packageManager = `pnpm@${newVersion}`
await Bun.file(filePath).write(JSON.stringify(packageJson, null, 2) + '\n')
result.updated++
result.files.push(file)
}
}
} catch (e) {
result.failed++
result.errors.push(`Failed to update ${file}: ${e instanceof Error ? e.message : String(e)}`)
}
}
return result
}
/**
* Update mise.toml and .mise.toml files with the new pnpm version
* @param repoRoot - The root directory of the repository
* @param newVersion - The new pnpm version
* @returns UpdateResult with details about the operation
*/
async function updateMiseTomlFiles(repoRoot: string, newVersion: string): Promise<UpdateResult> {
const result: UpdateResult = {
type: 'mise.toml',
updated: 0,
failed: 0,
files: [],
errors: [],
}
const miseTomlFiles = await glob('**/{.mise.toml,mise.toml}', {
cwd: repoRoot,
gitignore: true,
ignore: ['**/node_modules/**'],
})
for (const file of miseTomlFiles) {
const filePath = `${repoRoot}/${file}`
try {
const miseToml = MiseToml.parse(toml.parse(await Bun.file(filePath).text()))
if (miseToml.tools.pnpm && miseToml.tools.pnpm !== newVersion) {
echo(chalk.blue(`Updating ${file} to pnpm@${newVersion}`))
miseToml.tools.pnpm = newVersion
const miseString = toml.stringify(miseToml) + '\n'
await Bun.file(filePath).write(miseString.replaceAll('"', "'"))
result.updated++
result.files.push(file)
}
} catch (e) {
result.failed++
result.errors.push(`Failed to update ${file}: ${e instanceof Error ? e.message : String(e)}`)
}
}
return result
}