Created
July 25, 2025 09:29
-
-
Save schickling/b4d86a9ebf0c2a5b1393e16dcf543e97 to your computer and use it in GitHub Desktop.
TypeScript script for intelligent dependency management in monorepos with Expo constraints
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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