JamieMason/syncpack

View on GitHub
src/bin-update/effects.ts

Summary

Maintainability
A
0 mins
Test Coverage
F
0%
import * as Schema from '@effect/schema/Schema';
import chalk from 'chalk-template';
import { Data, Effect, identity, pipe } from 'effect';
import https from 'https';
import ora, { type Ora } from 'ora';
import { EOL } from 'os';
import prompts from 'prompts';
import type { ReleaseType } from 'semver';
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 type { Instance } from '../get-instances/instance.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';

type ReleasesByType = Record<ReleaseType, Releases[]>;

/** full release history from the npm registry for a given package */
class Releases extends Data.TaggedClass('Releases')<{
  instance: Instance;
  versions: {
    all: string[];
    latest: string;
  };
  repoUrl: string | undefined;
}> {}

// https://github.com/terkelg/prompts?tab=readme-ov-file#prompts
class PromptCancelled extends Data.TaggedClass('PromptCancelled')<{
  name: string;
}> {}

class HttpError extends Data.TaggedClass('HttpError')<{
  error: string;
}> {}

class NpmRegistryError extends Data.TaggedClass('NpmRegistryError')<{
  error: string;
}> {}

/** the API client for the terminal spinner */
let spinner: Ora | null = 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<string>();

/** names of instances most recently finished being fetched from npm */
const mostRecent = new RingBuffer<string>(5);

/** page size when prompting */
const optionsPerPage = 50;

/** instance names in `inFlight` are formatted for display */
function format(instance: Instance) {
  return chalk`{gray ${instance.name}}`;
}

/** we need to remove colours when sorting loading status output */
function stripAnsi(str: string) {
  // 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: Instance, totalCount: number) {
    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: Instance, versions?: Releases['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 ${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: 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: Instance, latest: string) {
    outdatedCount++;
    mostRecent.push(
      chalk`${instance.name} {gray {red ${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: Instance): Effect.Effect<Releases, HttpError | NpmRegistryError> {
    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(Schema.String, 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: Releases[]): Effect.Effect<void, PromptCancelled> {
    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 as ReleaseType].length > 0)
            .map((type) => ({
              title: chalk`${releasesByType[type as ReleaseType].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: (): Promise<string[]> =>
                  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: ReleaseType,
  doState: { releasesByType: ReleasesByType; releaseTypeAnswers: string[] },
): Effect.Effect<Releases[], PromptCancelled> {
  const { releasesByType, releaseTypeAnswers } = doState;
  const prop = `${releaseType}Answers`;
  const releases = releasesByType[releaseType];
  return releaseTypeAnswers.includes(releaseType)
    ? pipe(
        Effect.tryPromise({
          try: (): Promise<Record<string, unknown>> =>
            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 ${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: Releases[]): Effect.Effect<ReleasesByType> {
  return Effect.succeed(
    releases.reduce(
      (releasesByType: 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: string): Effect.Effect<unknown, HttpError | NpmRegistryError> {
  return pipe(
    Effect.async<string, HttpError>((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}` }),
      }),
    ),
  );
}