UNPKG

syncpack

Version:

Consistent dependency versions in large JavaScript Monorepos

282 lines (281 loc) 12.6 kB
import https from 'node:https'; import { EOL } from 'node:os'; import chalk from 'chalk-template'; import { Data, Effect, Schema, identity, pipe } from 'effect'; import ora from 'ora'; import prompts from 'prompts'; import { diff } from 'semver'; import gtr from 'semver/ranges/gtr.js'; import { isArray } from 'tightrope/guard/is-array.js'; import { isEmptyObject } from 'tightrope/guard/is-empty-object.js'; import { ICON } from '../constants.js'; import { formatRepositoryUrl } from '../lib/format-repository-url.js'; import { RingBuffer } from '../lib/ring-buffer.js'; import { setSemverRange } from '../lib/set-semver-range.js'; import { Specifier } from '../specifier/index.js'; /** full release history from the npm registry for a given package */ class Releases extends Data.TaggedClass('Releases') { } // https://github.com/terkelg/prompts?tab=readme-ov-file#prompts class PromptCancelled extends Data.TaggedClass('PromptCancelled') { } class HttpError extends Data.TaggedClass('HttpError') { } class NpmRegistryError extends Data.TaggedClass('NpmRegistryError') { } /** the API client for the terminal spinner */ let spinner = null; /** how many HTTP requests have been sent */ let fetchedCount = 0; /** how many instances have updates available */ let outdatedCount = 0; /** names of instances currently being fetched from npm */ const inFlight = new Set(); /** names of instances most recently finished being fetched from npm */ const mostRecent = new RingBuffer(5); /** page size when prompting */ const optionsPerPage = 50; /** instance names in `inFlight` are formatted for display */ function format(instance) { return chalk `{gray ${instance.name}}`; } /** we need to remove colours when sorting loading status output */ function stripAnsi(str) { // eslint-disable-next-line no-control-regex const ansiChars = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; return str.replace(ansiChars, ''); } export const updateEffects = { onFetchAllStart() { if (!spinner) { spinner = ora().start(); } fetchedCount = 0; return Effect.void; }, onFetchStart(instance, totalCount) { inFlight.add(format(instance)); fetchedCount++; if (spinner) { const indent = `${EOL} `; const progress = new Set([ ...mostRecent.filter(Boolean), ...inFlight.values(), ]); const sortedProgress = Array.from(progress).sort((a, b) => stripAnsi(a).localeCompare(stripAnsi(b))); const suffixText = sortedProgress.join(indent); spinner.text = chalk `${outdatedCount} updates found in ${fetchedCount}/${totalCount} dependencies${indent}${suffixText}`; } return Effect.void; }, onFetchEnd(instance, versions) { inFlight.delete(format(instance)); const latest = versions?.latest; if (latest) { if (gtr(latest, String(instance.rawSpecifier.raw), true)) { outdatedCount++; mostRecent.push(chalk `${instance.name} {gray {red ${String(instance.rawSpecifier.raw)}} ${ICON.rightArrow}} {green ${latest}}`); } else { mostRecent.push(chalk `{green ${instance.name}}`); } } return Effect.void; }, /** After checking the registry, store this instance known to be up to date */ onUpToDate(instance) { mostRecent.push(chalk `{green ${instance.name}}`); return Effect.void; }, /** After checking the registry, store this instance known to have newer versions available */ onOutdated(instance, latest) { outdatedCount++; mostRecent.push(chalk `${instance.name} {gray {red ${String(instance.rawSpecifier.raw)}} ${ICON.rightArrow}} {green ${latest}}`); return Effect.void; }, /** As the last request completes, remove the progress information */ onFetchAllEnd() { if (spinner) { spinner.stop(); } spinner = null; fetchedCount = 0; return Effect.void; }, /** Fetch available versions for a given package from the npm registry */ fetchLatestVersions(instance) { return pipe(fetchJson(`https://registry.npmjs.org/${instance.name}`), // parse and validate the specific data we expect Effect.flatMap(Schema.decodeUnknownEither(Schema.Struct({ 'dist-tags': Schema.Struct({ latest: Schema.String }), time: Schema.Record({ key: Schema.String, value: Schema.String }), homepage: Schema.optional(Schema.String), repository: Schema.optional(Schema.Union(Schema.String, Schema.Struct({ url: Schema.optional(Schema.String) }))), }))), // transform it into something more appropriate Effect.map(struct => { const rawRepoUrl = typeof struct.repository === 'object' ? struct.repository.url : struct.repository; return new Releases({ instance, versions: { all: Object.keys(struct.time).filter(key => key !== 'modified' && key !== 'created'), latest: struct['dist-tags'].latest, }, repoUrl: formatRepositoryUrl(rawRepoUrl), }); }), // hide ParseErrors and just treat them as another kind of NpmRegistryError Effect.catchTags({ ParseError: () => Effect.fail(new NpmRegistryError({ error: `Invalid response for ${instance.name}`, })), })); }, /** Given responses from npm, ask the user which they want */ promptForUpdates(outdated) { return pipe(Effect.Do, Effect.bind('releasesByType', () => groupByReleaseType(outdated)), // Create choices to ask if they want major, minor, patch etc Effect.bind('releaseTypeQuestions', ({ releasesByType }) => Effect.succeed(Object.keys(releasesByType) .filter(type => releasesByType[type].length > 0) .map(type => ({ title: chalk `${releasesByType[type].length} ${type}`, selected: true, value: type, })))), // Ask which release types (major, minor, patch etc) they want Effect.bind('releaseTypeAnswers', ({ releaseTypeQuestions }) => releaseTypeQuestions.length > 0 ? pipe(Effect.tryPromise({ try: () => prompts({ name: 'releaseTypeAnswers', type: 'multiselect', instructions: true, message: `${outdated.length} updates are available`, choices: releaseTypeQuestions, }).then(res => res?.releaseTypeAnswers || []), catch: identity, }), Effect.catchAll(() => pipe(Effect.logError('Error when prompting for releaseTypeAnswers'), Effect.map(() => [])))) : Effect.succeed([])), // For each chosen release type, list the available updates to choose from Effect.bind('prepatchAnswers', doState => promptForReleaseType('prepatch', doState)), Effect.bind('patchAnswers', doState => promptForReleaseType('patch', doState)), Effect.bind('preminorAnswers', doState => promptForReleaseType('preminor', doState)), Effect.bind('minorAnswers', doState => promptForReleaseType('minor', doState)), Effect.bind('premajorAnswers', doState => promptForReleaseType('premajor', doState)), Effect.bind('majorAnswers', doState => promptForReleaseType('major', doState)), Effect.bind('prereleaseAnswers', doState => promptForReleaseType('prerelease', doState)), /** Apply every update to the package.json files */ Effect.flatMap(doState => pipe([ ...doState.prepatchAnswers, ...doState.patchAnswers, ...doState.preminorAnswers, ...doState.minorAnswers, ...doState.premajorAnswers, ...doState.majorAnswers, ...doState.prereleaseAnswers, ], Effect.forEach(({ instance, versions }) => pipe(instance.semverGroup.getFixed(Specifier.create(instance, versions.latest)), Effect.flatMap(latestWithRange => instance.write(latestWithRange.raw)), Effect.catchTag('NonSemverError', Effect.logError))), Effect.flatMap(() => Effect.void)))); }, }; function promptForReleaseType(releaseType, doState) { const { releasesByType, releaseTypeAnswers } = doState; const prop = `${releaseType}Answers`; const releases = releasesByType[releaseType]; return releaseTypeAnswers.includes(releaseType) ? pipe(Effect.tryPromise({ try: () => prompts({ name: prop, type: 'multiselect', instructions: false, // @ts-expect-error optionsPerPage *does* exist https://github.com/terkelg/prompts#options-7 optionsPerPage, message: `${releases.length} ${releaseType} updates`, choices: releases.map(updateable => { const spacingValue = 50 - updateable.instance.name.length - String(updateable.instance.rawSpecifier).length - updateable.versions.latest.length; const spacing = Array.from({ length: spacingValue }) .fill(' ') .join(''); const repoUrl = updateable.repoUrl ? chalk `${spacing} {white - ${updateable.repoUrl}}` : ''; return { title: chalk `${updateable.instance.name} {gray ${String(updateable.instance.rawSpecifier.raw)} ${ICON.rightArrow}} {green ${updateable.versions.latest}} ${repoUrl}`, selected: true, value: updateable, }; }), }), catch: identity, }), // Paper over errors in terkelg/prompts for now Effect.catchAll(() => pipe(Effect.logError(`terkelg/prompts errored while prompting for ${prop}`), Effect.map(() => ({ [prop]: [] })))), // In terkelg/prompts, an empty object means that the user cancelled via // ctrl+c or the escape key etc. Handle this case so we can skip any // remaining steps. Effect.flatMap(res => isEmptyObject(res) ? Effect.fail(new PromptCancelled({ name: releaseType })) : Effect.succeed(isArray(res?.[prop]) ? res?.[prop] : []))) : Effect.succeed([]); } function groupByReleaseType(releases) { return Effect.succeed(releases.reduce((releasesByType, release) => { const previous = setSemverRange('', String(release.instance.rawSpecifier.raw)); const latest = release.versions.latest; try { const type = diff(previous, latest); if (type && releasesByType[type]) { releasesByType[type].push(release); } } catch { // } return releasesByType; }, { prepatch: [], patch: [], preminor: [], minor: [], premajor: [], major: [], prerelease: [], })); } // @TODO: add a cache with a short TTL on disk in $TMPDIR function fetchJson(url) { return pipe(Effect.async(resume => { // setTimeout( // () => { // resume( // Effect.succeed( // JSON.stringify({ // 'dist-tags': { latest: '3.1.1' }, // 'time': { // '0.3.1': new Date().toJSON(), // }, // }), // ), // ); // }, // Math.floor(Math.random() * 500) + 1, // ); https .get(url, res => { let body = ''; res.setEncoding('utf8'); res.on('data', chunk => { body = `${body}${chunk}`; }); res.on('end', () => { resume(Effect.succeed(body)); }); }) .on('error', err => { resume(Effect.fail(new HttpError({ error: `Node https threw on ${url}: ${String(err)}`, }))); }); }), Effect.flatMap(body => Effect.try({ try: () => JSON.parse(body), catch: () => new NpmRegistryError({ error: `JSON.parse threw on response from ${url}`, }), }))); }