Skip to content

Instantly share code, notes, and snippets.

@schickling
Created July 25, 2025 09:29
Show Gist options
  • Save schickling/b4d86a9ebf0c2a5b1393e16dcf543e97 to your computer and use it in GitHub Desktop.
Save schickling/b4d86a9ebf0c2a5b1393e16dcf543e97 to your computer and use it in GitHub Desktop.
TypeScript script for intelligent dependency management in monorepos with Expo constraints
#!/usr/bin/env bun
import { Effect, Logger, LogLevel } from '@livestore/utils/effect'
import { Cli, PlatformNode } from '@livestore/utils/node'
import { cmd, cmdText } from '@livestore/utils-dev/node'
import { Schema } from '@livestore/utils/effect'
// Types for our dependency update workflow
const PackageUpdate = Schema.Struct({
name: Schema.String,
currentVersion: Schema.String,
targetVersion: Schema.String,
})
const PackageFileUpdates = Schema.Record({ key: Schema.String, value: Schema.String })
const NCUOutput = Schema.Record({ key: Schema.String, value: PackageFileUpdates })
const ExpoConstraints = Schema.Record({ key: Schema.String, value: Schema.String })
interface UpdateResult {
packageFile: string
updates: Array<typeof PackageUpdate.Type>
success: boolean
error?: string
}
// Core Effects for dependency management
const discoverUpdates = (target: string) =>
Effect.gen(function* () {
yield* Effect.log(`Discovering available updates (target: ${target})...`)
const ncuCommand = `bunx npm-check-updates --deep --jsonUpgraded --packageManager pnpm${target !== 'latest' ? ` --target ${target}` : ''}`
const ncuOutput = yield* cmdText(ncuCommand).pipe(
Effect.catchAll((error) =>
Effect.fail(new Error(`Failed to run npm-check-updates: ${error}`))
)
)
const parsedOutput = yield* Effect.try({
try: () => JSON.parse(ncuOutput),
catch: (error) => new Error(`Failed to parse NCU output: ${error}`)
})
const validated = yield* Schema.decodeUnknown(NCUOutput)(parsedOutput)
const totalUpdates = Object.values(validated)
.reduce((sum, updates) => sum + Object.keys(updates).length, 0)
yield* Effect.log(`Found ${totalUpdates} packages that can be updated across ${Object.keys(validated).length} package.json files`)
return validated
})
const fetchExpoConstraints = () =>
Effect.gen(function* () {
yield* Effect.log('Fetching Expo SDK constraints...')
// Get current Expo SDK version
const expoVersion = yield* cmdText('pnpm view expo version').pipe(
Effect.map(version => version.trim().replace(/(\d+\.\d+)\.\d+/, '$1.0')),
Effect.catchAll((error) =>
Effect.fail(new Error(`Failed to get Expo version: ${error}`))
)
)
yield* Effect.log(`Using Expo SDK: ${expoVersion}`)
// Fetch constraints from Expo API
const apiUrl = `https://api.expo.dev/v2/sdks/${expoVersion}/native-modules`
const constraintsOutput = yield* cmdText(`curl -s ${apiUrl}`).pipe(
Effect.catchAll((error) =>
Effect.fail(new Error(`Failed to fetch Expo constraints: ${error}`))
)
)
const apiResponse = yield* Effect.try({
try: () => JSON.parse(constraintsOutput),
catch: (error) => new Error(`Failed to parse Expo API response: ${error}`)
})
// Transform to package -> version mapping
const constraints = apiResponse.data.reduce((acc: Record<string, string>, item: any) => {
acc[item.npmPackage] = item.versionRange
return acc
}, {})
const validated = yield* Schema.decodeUnknown(ExpoConstraints)(constraints)
yield* Effect.log(`Retrieved constraints for ${Object.keys(validated).length} Expo-managed packages`)
return validated
})
const filterPackages = (ncuOutput: typeof NCUOutput.Type, expoConstraints: typeof ExpoConstraints.Type, options: { expoOnly?: boolean }) =>
Effect.gen(function* () {
yield* Effect.log('Filtering packages based on Expo constraints...')
const filtered: Record<string, Record<string, string>> = {}
const expoPackages: Record<string, Record<string, string>> = {}
let totalNonExpo = 0
let totalExpo = 0
for (const [packageFile, updates] of Object.entries(ncuOutput)) {
const nonExpoUpdates: Record<string, string> = {}
const expoUpdates: Record<string, string> = {}
for (const [pkg, version] of Object.entries(updates)) {
if (expoConstraints[pkg]) {
expoUpdates[pkg] = version
totalExpo++
} else {
nonExpoUpdates[pkg] = version
totalNonExpo++
}
}
if (Object.keys(nonExpoUpdates).length > 0) {
filtered[packageFile] = nonExpoUpdates
}
if (Object.keys(expoUpdates).length > 0) {
expoPackages[packageFile] = expoUpdates
}
}
yield* Effect.log(`Filtered: ${totalNonExpo} non-Expo packages, ${totalExpo} Expo packages`)
return options.expoOnly
? { filtered: expoPackages, expoPackages: {} }
: { filtered, expoPackages }
})
const executeUpdates = (filteredUpdates: Record<string, Record<string, string>>, dryRun: boolean) =>
Effect.gen(function* () {
const results: UpdateResult[] = []
for (const [packageFile, updates] of Object.entries(filteredUpdates)) {
if (Object.keys(updates).length === 0) continue
const dir = packageFile === 'package.json' ? '.' : packageFile.replace('/package.json', '')
const packages = Object.entries(updates).map(([pkg, version]) => `${pkg}@${version}`).join(' ')
yield* Effect.log(`${dryRun ? '[DRY RUN] ' : ''}Updating ${dir}: ${packages}`)
if (!dryRun) {
const updateResult = yield* cmd(`pnpm update ${packages}`, { cwd: dir }).pipe(
Effect.map(() => ({ success: true } as const)),
Effect.catchAll((error) => Effect.succeed({ success: false, error: String(error) } as const))
)
results.push({
packageFile,
updates: Object.entries(updates).map(([name, targetVersion]) => ({
name,
currentVersion: 'unknown', // We'd need to track this separately
targetVersion
})),
...updateResult
})
}
}
return results
})
const showExpoRecommendations = (expoPackages: Record<string, Record<string, string>>, expoConstraints: typeof ExpoConstraints.Type) =>
Effect.gen(function* () {
if (Object.keys(expoPackages).length === 0) {
yield* Effect.log('No Expo packages need manual updates')
return
}
yield* Effect.log('\n=== Expo Packages Requiring Manual Updates ===')
for (const [packageFile, updates] of Object.entries(expoPackages)) {
yield* Effect.log(`\n${packageFile}:`)
for (const pkg of Object.keys(updates)) {
const recommendedVersion = expoConstraints[pkg] || 'unknown'
yield* Effect.log(` ${pkg}: ${recommendedVersion}`)
}
}
yield* Effect.log('\nManually update these packages in the respective package.json files.')
})
// Main command
export const updateDepsCommand = Cli.Command.make(
'update-deps',
{
dryRun: Cli.Options.boolean('dry-run').pipe(Cli.Options.withDefault(false)),
target: Cli.Options.text('target').pipe(Cli.Options.withDefault('minor')),
expoOnly: Cli.Options.boolean('expo-only').pipe(Cli.Options.withDefault(false)),
validate: Cli.Options.boolean('validate').pipe(Cli.Options.withDefault(true)),
},
Effect.fn(function* ({ dryRun, target, expoOnly, validate }) {
yield* Effect.log('๐Ÿ”„ Starting dependency update workflow...')
// Validate target option
const validTargets = ['latest', 'minor', 'patch']
if (!validTargets.includes(target)) {
yield* Effect.fail(new Error(`Invalid target: ${target}. Must be one of: ${validTargets.join(', ')}`))
}
// Step 1: Discover available updates
const ncuOutput = yield* discoverUpdates(target)
// Step 2: Fetch Expo constraints
const expoConstraints = yield* fetchExpoConstraints()
// Step 3: Filter packages
const { filtered, expoPackages } = yield* filterPackages(ncuOutput, expoConstraints, { expoOnly })
// Step 4: Execute updates
if (Object.keys(filtered).length > 0) {
yield* executeUpdates(filtered, dryRun)
} else {
yield* Effect.log('No packages to update after filtering')
}
// Step 5: Show Expo recommendations
yield* showExpoRecommendations(expoPackages, expoConstraints)
// Step 6: Validation (if not dry run and validate enabled)
if (!dryRun && validate) {
yield* Effect.log('\n๐Ÿ” Running validation...')
yield* cmd('syncpack lint').pipe(
Effect.catchAll((error) => Effect.logWarning(`Syncpack validation failed: ${error}`))
)
yield* cmd('syncpack fix-mismatches').pipe(
Effect.catchAll((error) => Effect.logWarning(`Syncpack fix failed: ${error}`))
)
// Check Expo examples
const expoExamples = yield* cmdText('find examples -name "expo" -type d -o -name "*expo*" -type d').pipe(
Effect.map(output => output.trim().split('\n').filter(Boolean)),
Effect.catchAll(() => Effect.succeed([]))
)
for (const exampleDir of expoExamples) {
yield* cmd('expo install --check', { cwd: exampleDir }).pipe(
Effect.catchAll((error) => Effect.logWarning(`Expo check failed for ${exampleDir}: ${error}`))
)
}
}
yield* Effect.log('โœ… Dependency update workflow completed!')
})
)
if (import.meta.main) {
const cli = Cli.Command.run(updateDepsCommand, {
name: 'update-deps',
version: '1.0.0',
})
cli(process.argv).pipe(
Effect.annotateLogs({ thread: 'update-deps' }),
Logger.withMinimumLogLevel(LogLevel.Info),
Effect.provide(PlatformNode.NodeContext.layer),
PlatformNode.NodeRuntime.runMain,
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment