Skip to content

Commit

Permalink
refactor: Convert lib/config to TypeScript
Browse files Browse the repository at this point in the history
  • Loading branch information
sjinks committed Jun 1, 2023
1 parent 0b2d49e commit 30aaa3e
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 37 deletions.
2 changes: 0 additions & 2 deletions src/lib/cli/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @flow

/**
* External dependencies
*/
Expand Down
19 changes: 19 additions & 0 deletions src/lib/config/software.generated.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Types from '../../../graphqlTypes';

export type UpdateSoftwareSettingsMutationVariables = Types.Exact<{
appId: Types.Scalars['Int']['input'];
envId: Types.Scalars['Int']['input'];
component: Types.Scalars['String']['input'];
version: Types.Scalars['String']['input'];
}>;


export type UpdateSoftwareSettingsMutation = { __typename?: 'Mutation', updateSoftwareSettings?: { __typename?: 'AppEnvironmentSoftwareSettings', php?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null, wordpress?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null, muplugins?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null, nodejs?: { __typename?: 'AppEnvironmentSoftwareSettingsSoftware' } | null } | null };

export type UpdateJobQueryVariables = Types.Exact<{
appId: Types.Scalars['Int']['input'];
envId: Types.Scalars['Int']['input'];
}>;


export type UpdateJobQuery = { __typename?: 'Query', app?: { __typename?: 'App', environments?: Array<{ __typename?: 'AppEnvironment', jobs?: Array<{ __typename?: 'Job', type?: string | null, completedAt?: string | null, createdAt?: string | null, inProgressLock?: boolean | null, progress?: { __typename?: 'JobProgress', status?: string | null, steps?: Array<{ __typename?: 'JobProgressStep', step?: string | null, name?: string | null, status?: string | null } | null> | null } | null } | { __typename?: 'PrimaryDomainSwitchJob', type?: string | null, completedAt?: string | null, createdAt?: string | null, inProgressLock?: boolean | null, progress?: { __typename?: 'JobProgress', status?: string | null, steps?: Array<{ __typename?: 'JobProgressStep', step?: string | null, name?: string | null, status?: string | null } | null> | null } | null } | null> | null } | null> | null } | null };
120 changes: 85 additions & 35 deletions src/lib/config/software.js → src/lib/config/software.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// @flow

/**
* External dependencies
*/
import { setTimeout } from 'node:timers/promises';
import { Select, Confirm } from 'enquirer';
import gql from 'graphql-tag';
import debugLib from 'debug';
Expand All @@ -13,6 +12,8 @@ import debugLib from 'debug';
import { isAppNodejs, isAppWordPress } from '../app';
import API from '../api';
import UserError from '../user-error';
import { UpdateJobQueryVariables } from './software.generated';
import { JobInterface, Query } from '../../graphqlTypes';

const UPDATE_PROGRESS_POLL_INTERVAL = 5;
const debug = debugLib( '@automattic/vip:bin:config-software' );
Expand Down Expand Up @@ -131,11 +132,44 @@ const COMPONENT_NAMES = {
nodejs: 'Node.js',
};

type ComponentName = keyof typeof COMPONENT_NAMES;

const MANAGED_OPTION_KEY = 'managed_latest';

const _optionsForVersion = softwareSettings => {
interface SoftwareSetting {
options: Option[];
current: {
version: string;
};
pinned: boolean;
name: string;
slug: string;
}

type SoftwareSettings = Record<ComponentName, SoftwareSetting>;

interface Option {
deprecated: boolean;
unstable: boolean;
version: string;
}

interface VersionChoice {
message: string;
value: string;
disabled?: boolean;
}

interface VersionChoices {
managed: VersionChoice[];
supported: VersionChoice[];
test: VersionChoice[];
deprecated: VersionChoice[];
}

const _optionsForVersion = ( softwareSettings: SoftwareSetting ): VersionChoice[] => {
const { options, current, pinned, slug } = softwareSettings;
const versionChoices = {
const versionChoices: VersionChoices = {
managed: [],
supported: [],
test: [],
Expand Down Expand Up @@ -188,8 +222,8 @@ const _optionsForVersion = softwareSettings => {
} );
};

const _processComponent = async ( appTypeId: number, userProvidedComponent: string | undefined ) => {
const validComponents = [];
const _processComponent = ( appTypeId: number, userProvidedComponent?: ComponentName ): Promise<ComponentName> => {
const validComponents: ComponentName[] = [];
if ( isAppWordPress( appTypeId ) ) {
validComponents.push( 'wordpress', 'php', 'muplugins' );
} else if ( isAppNodejs( appTypeId ) ) {
Expand All @@ -200,39 +234,42 @@ const _processComponent = async ( appTypeId: number, userProvidedComponent: stri
if ( ! validComponents.includes( userProvidedComponent ) ) {
throw new UserError( `Component ${ userProvidedComponent } is not supported. Use one of: ${ validComponents.join( ',' ) }` );

Check warning on line 235 in src/lib/config/software.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected an error object to be thrown
}
return userProvidedComponent;

return Promise.resolve( userProvidedComponent );
}

if ( validComponents.length === 0 ) {
throw new UserError( 'No components are supported for this application' );

Check warning on line 242 in src/lib/config/software.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected an error object to be thrown
}

if ( validComponents.length === 1 ) {
return validComponents[ 0 ];
return Promise.resolve( validComponents[ 0 ] );
}

const choices = validComponents.map( item => ( {
message: COMPONENT_NAMES[ item ],
value: item,
} ) );

const select = new Select( {
message: 'Component to update',
choices,
} );
return select.run().catch( () => {
throw new UserError( 'Command cancelled by user.' );

Check warning on line 259 in src/lib/config/software.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected an error object to be thrown
} );
} ) as Promise<ComponentName>;
};

const _processComponentVersion = async ( softwareSettings, component: string, userProvidedVersion: string | undefined ) => {
const _processComponentVersion = ( softwareSettings: SoftwareSettings, component: ComponentName, userProvidedVersion?: string ): Promise<string> => {
const versionChoices = _optionsForVersion( softwareSettings[ component ] );

if ( userProvidedVersion ) {
const validValues = versionChoices.map( item => item.value );
if ( ! validValues.includes( userProvidedVersion ) ) {
throw new UserError( `Version ${ userProvidedVersion } is not supported for ${ COMPONENT_NAMES[ component ] }. Use one of: ${ validValues.join( ',' ) }` );

Check warning on line 269 in src/lib/config/software.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected an error object to be thrown
}
return userProvidedVersion;

return Promise.resolve( userProvidedVersion );
}

const versionSelect = new Select( {
Expand All @@ -241,25 +278,26 @@ const _processComponentVersion = async ( softwareSettings, component: string, us
} );
return versionSelect.run().catch( () => {
throw new UserError( 'Command cancelled by user.' );

Check warning on line 280 in src/lib/config/software.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected an error object to be thrown
} );
} ) ;
};

interface UpdateData {
component: string,
version: string,
component: ComponentName;
version: string;
}

export interface UpdatePromptOptions {
component?: string,
version?: string,
force?: boolean,
component?: ComponentName;
version?: string;
force?: boolean;
}

export const promptForUpdate = async ( appTypeId: number, opts: UpdatePromptOptions, softwareSettings ): Promise<UpdateData> => {
export const promptForUpdate = async ( appTypeId: number, opts: UpdatePromptOptions, softwareSettings: SoftwareSettings ): Promise<UpdateData> => {
const component = await _processComponent( appTypeId, opts.component );
const version = await _processComponentVersion( softwareSettings, component, opts.version );

const confirm = opts.force || await new Confirm( {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const confirm: boolean = opts.force || await new Confirm( { // NOSONAR
message: `Are you sure you want to upgrade ${ COMPONENT_NAMES[ component ] } to ${ version }?`,
} ).run().catch( () => {
throw new UserError( 'Command cancelled by user.' );

Check warning on line 303 in src/lib/config/software.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected an error object to be thrown
Expand Down Expand Up @@ -289,62 +327,74 @@ export const triggerUpdate = async ( variables: TrigerUpdateOptions ) => {
return api.mutate( { mutation: updateSoftwareMutation, variables } );
};

const _getLatestJob = async ( appId: number, envId: number ) => {
const _getLatestJob = async ( appId: number, envId: number ): Promise<JobInterface | null> => {
const api = await API();
const result = await api.query( { query: updateJobQuery, variables: { appId, envId }, fetchPolicy: 'network-only' } );
const jobs = result?.data?.app?.environments[ 0 ].jobs || [];
const result = await api.query<Query, UpdateJobQueryVariables>( { query: updateJobQuery, variables: { appId, envId }, fetchPolicy: 'network-only' } );
const jobs = result.data.app?.environments?.[0]?.jobs ?? [];

if ( jobs.length ) {
return jobs.reduce( ( prev, current ) => ( prev.createdAt > current.createdAt ) ? prev : current );
return jobs.reduce( ( prev, current ) => ( ( prev?.createdAt || '' ) > ( current?.createdAt || '' ) ) ? prev : current );
}
return null;
};

const _getCompletedJob = async ( appId: number, envId: number ) => {
const _getCompletedJob = async ( appId: number, envId: number ): Promise<JobInterface | null> => {
const latestJob = await _getLatestJob( appId, envId );
debug( 'Latest job result:', latestJob );

if ( ! latestJob || ! latestJob.inProgressLock ) {
if ( ! latestJob?.inProgressLock ) {
return latestJob;
}

debug( `Sleep for ${ UPDATE_PROGRESS_POLL_INTERVAL } seconds` );
await new Promise( resolve => setTimeout( resolve, UPDATE_PROGRESS_POLL_INTERVAL * 1000 ) );

await setTimeout( UPDATE_PROGRESS_POLL_INTERVAL * 1000 );
return _getCompletedJob( appId, envId );
};

interface UpdateResult {
ok: boolean;
errorMessage?: string;
interface UpdateResultSuccess {
ok: true;
}

interface UpdateResultError {
ok: false;
errorMessage: string;
}

type UpdateResult = UpdateResultSuccess | UpdateResultError;

export const getUpdateResult = async ( appId: number, envId: number ): Promise<UpdateResult> => {
debug( 'Getting update result', { appId, envId } );

const completedJob = await _getCompletedJob( appId, envId );

const success = ! completedJob || completedJob?.progress?.status === 'success';
const success = ! completedJob || completedJob.progress?.status === 'success';
if ( success ) {
return {
ok: true,
};
}

const failedStep = completedJob?.progress?.steps?.find( step => step.status === 'failed' );
const error = failedStep ? `Failed during step: ${ failedStep.name }` : 'Software update failed';
const failedStep = completedJob.progress?.steps?.find( step => step?.status === 'failed' );
const error = failedStep ? `Failed during step: ${ failedStep.name! }` : 'Software update failed';
return {
ok: false,
errorMessage: error,
};
};

export const formatSoftwareSettings = ( softwareSetting: SoftwareSettings, includes: string[], format: string ) => {
interface FormatSoftwareSettingsResult {
name: string;
slug: string;
version: string;
available_versions?: string | string[];
}

export const formatSoftwareSettings = ( softwareSetting: SoftwareSetting, includes: string[], format: string ): FormatSoftwareSettingsResult => {
let version = softwareSetting.current.version;
if ( softwareSetting.slug === 'wordpress' && ! softwareSetting.pinned ) {
version += ' (managed updates)';
}
const result = {
const result: FormatSoftwareSettingsResult = {
name: softwareSetting.name,
slug: softwareSetting.slug,
version,
Expand Down
21 changes: 21 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ArrayPromptOptions } from 'enquirer';

// These types are not 100% correct, but they are close enough for now.
declare module 'enquirer' {
interface ConfirmOption {
name?: string | (() => string);
message: string | (() => string);
initial?: boolean | (() => boolean);
actions?: {'ctrl': {[key: string]: string}}
}

class Confirm extends NodeJS.EventEmitter {
constructor(option: ConfirmOption);
run: () => Promise<boolean>;
}

class Select extends NodeJS.EventEmitter {
constructor(option: ArrayPromptOptions);
run: () => Promise<string>;
}
}

0 comments on commit 30aaa3e

Please sign in to comment.