Compare commits

...

5 Commits

Author SHA1 Message Date
Johann150 f245b6e517
server: remove direct chalk dependency
ci/woodpecker/push/lint-backend Pipeline failed Details
ci/woodpecker/push/build Pipeline was successful Details
ci/woodpecker/push/lint-sw Pipeline failed Details
ci/woodpecker/push/lint-foundkey-js Pipeline was successful Details
ci/woodpecker/push/lint-client Pipeline failed Details
ci/woodpecker/push/test Pipeline failed Details
Colouring of logs is not important to me because if I view them in journalctl
there are no colours anyway.
2024-04-01 19:10:55 +02:00
Johann150 c9759a3a79
add a bit of guidance for startup 2024-04-01 19:06:37 +02:00
Johann150 a7a663e939
server: make some sensible sub-loggers 2024-04-01 18:35:00 +02:00
Johann150 7458550f7a
server: fix some TS warnings 2024-04-01 18:26:15 +02:00
Johann150 5444ca9aca
server: fix restarted job retaining the right mode
When a web or queue worker exited unexpectedly, the new restarted worker would
not have any mode set and so would try to do web and queue worker stuff at the
same time, which was not the intended behaviour.

Changelog: Fixed
2024-04-01 17:55:13 +02:00
25 changed files with 85 additions and 144 deletions

View File

@ -7,6 +7,10 @@ Look further up in the section to find the "base path" it is relative to.
All the backend code is in `/packages/backend/src`.
The backend is started via `index.ts` which in turn starts `boot/index.ts`.
In the "boot" code is where the process is forked from the main process into additional and separate worker and frontend processes.
If you look into your operating system's process overview or similar, you might be able to see that the processes rename themselves accordingly.
### Database
For connecting to the database an ORM (objectrelational mapping) is used.

View File

@ -36,8 +36,6 @@
"bull": "4.8.4",
"cacheable-lookup": "6.0.4",
"cbor": "8.1.0",
"chalk": "5.0.1",
"chalk-template": "0.4.0",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",

View File

@ -1,5 +1,4 @@
import cluster from 'node:cluster';
import chalk from 'chalk';
import Xev from 'xev';
import Logger from '@/services/logger.js';
@ -10,8 +9,8 @@ import 'reflect-metadata';
import { masterMain } from './master.js';
import { workerMain } from './worker.js';
const logger = new Logger('core', 'cyan');
const clusterLogger = logger.createSubLogger('cluster', 'orange', false);
const logger = new Logger('core');
const clusterLogger = logger.createSubLogger('cluster', false);
const ev = new Xev();
/**
@ -57,14 +56,6 @@ cluster.on('online', worker => {
clusterLogger.debug(`Process is now online: [${worker.id}]`);
});
// Listen for dying workers
cluster.on('exit', worker => {
// Replace the dead worker,
// we're not sentimental
clusterLogger.error(chalk.red(`[${worker.id}] died :(`));
cluster.fork();
});
// Display detail of unhandled promise rejection
if (envOption.logLevel !== LOG_LEVELS.quiet) {
process.on('unhandledRejection', console.dir);

View File

@ -3,8 +3,6 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as os from 'node:os';
import cluster from 'node:cluster';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import semver from 'semver';
import Logger from '@/services/logger.js';
@ -19,29 +17,27 @@ const _dirname = dirname(_filename);
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta', false);
const themeColor = chalk.hex('#86b300');
const logger = new Logger('core');
const bootLogger = logger.createSubLogger('boot', false);
function greet(): void {
if (envOption.logLevel !== LOG_LEVELS.quiet) {
//#region FoundKey logo
console.log(themeColor(' ___ _ _ __ '));
console.log(themeColor(' | __|__ _ _ _ _ __| | |/ /___ _ _ '));
console.log(themeColor(' | _/ _ \\ || | \' \\/ _` | \' </ -_) || |'));
console.log(themeColor(' |_|\\___/\\_,_|_||_\\__,_|_|\\_\\___|\\_, |'));
console.log(themeColor(' |__/ '));
console.log(' ___ _ _ __ ');
console.log(' | __|__ _ _ _ _ __| | |/ /___ _ _ ');
console.log(' | _/ _ \\ || | \' \\/ _` | \' </ -_) || |');
console.log(' |_|\\___/\\_,_|_||_\\__,_|_|\\_\\___|\\_, |');
console.log(' |__/ ');
//#endregion
console.log(' FoundKey is an open-source decentralized microblogging platform.');
console.log('');
console.log(chalkTemplate`--- ${os.hostname()} {gray (PID: ${process.pid.toString()})} ---`);
console.log(`--- ${os.hostname()} (PID: ${process.pid.toString()}) ---`);
}
bootLogger.info('Welcome to FoundKey!');
bootLogger.info(`FoundKey v${meta.version}`, true);
bootLogger.info(`FoundKey v${meta.version}`);
}
/**
@ -59,7 +55,7 @@ export async function masterMain(): Promise<void> {
config = loadConfigBoot();
await connectDb();
} catch (e) {
bootLogger.error('Fatal error occurred during initialization', true);
bootLogger.error('Fatal error occurred during initialization');
process.exit(1);
}
@ -69,7 +65,7 @@ export async function masterMain(): Promise<void> {
await spawnWorkers(config.clusterLimits);
}
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, true);
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`);
if (!envOption.noDaemons) {
import('../daemons/server-stats.js').then(x => x.serverStats());
@ -84,7 +80,7 @@ function showEnvironment(): void {
if (env !== 'production') {
logger.warn('The environment is not in production mode.');
logger.warn('DO NOT USE FOR PRODUCTION PURPOSE!', true);
logger.warn('DO NOT USE FOR PRODUCTION PURPOSE!');
}
}
@ -109,7 +105,7 @@ function loadConfigBoot(): Config {
} catch (exception) {
const e = exception as Partial<NodeJS.ErrnoException> | Error;
if ('code' in e && e.code === 'ENOENT') {
configLogger.error('Configuration file not found', true);
configLogger.error('Configuration file not found');
process.exit(1);
} else if (e instanceof Error) {
configLogger.error(e.message);
@ -133,7 +129,7 @@ async function connectDb(): Promise<void> {
const v = await db.query('SHOW server_version').then(x => x[0].server_version);
dbLogger.succ(`Connected: v${v}`);
} catch (e) {
dbLogger.error('Cannot connect', true);
dbLogger.error('Cannot connect');
dbLogger.error(e as Error | string);
process.exit(1);
}
@ -168,6 +164,10 @@ async function spawnWorkers(clusterLimits: Required<Config['clusterLimits']>): P
function spawnWorker(mode: 'web' | 'queue'): Promise<void> {
return new Promise(res => {
const worker = cluster.fork({ mode });
worker.on('exit', async (code, signal) => {
logger.error(mode + ' worker died, restarting...');
await spawnWorker(mode);
});
worker.on('message', message => {
switch (message) {
case 'listenFailed':

View File

@ -1,7 +1,7 @@
import Logger from '@/services/logger.js';
import config from './index.js';
const logger = new Logger('config:redis', 'gray', false);
const logger = new Logger('config:redis', false);
function getRedisFamily(family?: string | number): number {
const familyMap = {

View File

@ -72,7 +72,7 @@ import { getRedisOptions } from '@/config/redis.js';
import { dbLogger } from './logger.js';
import { redisClient } from './redis.js';
const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false);
const sqlLogger = dbLogger.createSubLogger('sql', false);
class MyCustomLogger implements Logger {
private highlight(sql: string): string {

View File

@ -1,7 +1,6 @@
import * as fs from 'node:fs';
import * as stream from 'node:stream';
import * as util from 'node:util';
import chalk from 'chalk';
import got, * as Got from 'got';
import { SECOND, MINUTE } from '@/const.js';
import config from '@/config/index.js';
@ -13,7 +12,7 @@ const pipeline = util.promisify(stream.pipeline);
export async function downloadUrl(url: string, path: string): Promise<void> {
const logger = new Logger('download');
logger.info(`Downloading ${chalk.cyan(url)} ...`);
logger.info(`Downloading ${url} ...`);
const timeout = 30 * SECOND;
const operationTimeout = MINUTE;
@ -65,5 +64,5 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
}
}
logger.succ(`Download finished: ${chalk.cyan(url)}`);
logger.succ(`Download finished: ${url}`);
}

View File

@ -1,3 +1,3 @@
import Logger from '@/services/logger.js';
export const queueLogger = new Logger('queue', 'orange');
export const queueLogger = new Logger('queue');

View File

@ -2,7 +2,6 @@ import { URL } from 'node:url';
import Bull from 'bull';
import { request } from '@/remote/activitypub/request.js';
import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc.js';
import Logger from '@/services/logger.js';
import { Instances } from '@/models/index.js';
import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js';
import { toPuny } from '@/misc/convert-host.js';
@ -11,8 +10,6 @@ import { getUserKeypair } from '@/misc/keypair-store.js';
import { shouldSkipInstance } from '@/misc/skipped-instances.js';
import { DeliverJobData } from '@/queue/types.js';
const logger = new Logger('deliver');
export default async (job: Bull.Job<DeliverJobData>) => {
const { host } = new URL(job.data.to);
const puny = toPuny(host);

View File

@ -45,6 +45,6 @@ export default async function cleanRemoteFiles(job: Bull.Job<Record<string, unkn
job.progress(deletedCount / total);
}
logger.succ('All cahced remote files has been deleted.');
logger.succ('All cached remote files have been deleted.');
done();
}

View File

@ -1,3 +1,3 @@
import { remoteLogger } from '../logger.js';
export const apLogger = remoteLogger.createSubLogger('ap', 'magenta');
export const apLogger = remoteLogger.createSubLogger('ap');

View File

@ -1,3 +1,3 @@
import Logger from '@/services/logger.js';
export const remoteLogger = new Logger('remote', 'cyan');
export const remoteLogger = new Logger('remote');

View File

@ -1,5 +1,4 @@
import { URL } from 'node:url';
import chalk from 'chalk';
import { IsNull } from 'typeorm';
import { DAY } from '@/const.js';
import { isSelfHost, toPuny } from '@/misc/convert-host.js';
@ -46,7 +45,7 @@ export async function resolveUser(username: string, idnHost: string | null, reso
if (user == null) {
const self = await resolveSelf(acctLower);
logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
logger.succ(`return new remote user: ${acctLower}`);
return await createPerson(self, resolver);
}
@ -101,16 +100,16 @@ export async function resolveUser(username: string, idnHost: string | null, reso
* Gets the Webfinger href matching rel="self".
*/
async function resolveSelf(acctLower: string): string {
logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
logger.info(`WebFinger for ${acctLower}`);
// get webfinger response for user
const finger = await webFinger(acctLower).catch(e => {
logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ e.statusCode || e.message }`);
logger.error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`);
throw new Error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`);
});
// try to find the rel="self" link
const self = finger.links.find(link => link.rel?.toLowerCase() === 'self');
if (!self?.href) {
logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`);
logger.error(`Failed to WebFinger for ${acctLower}: self link not found`);
throw new Error('self link not found');
}
return self.href;

View File

@ -1,10 +1,10 @@
import Limiter from 'ratelimiter';
import Logger from '@/services/logger.js';
import { redisClient } from '@/db/redis.js';
import { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
import { apiLogger } from './logger.js';
const logger = new Logger('limiter');
const logger = apiLogger.createSubLogger('limiter');
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((resolve, reject) => {
if (process.env.NODE_ENV === 'test') resolve();

View File

@ -13,10 +13,10 @@ import { readNotification } from '@/server/api/common/read-notification.js';
import { channels } from './channels/index.js';
import Channel from './channel.js';
import { StreamEventEmitter, StreamMessages } from './types.js';
import Logger from '@/services/logger.js';
import { apiLogger } from '@/server/api/logger.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
const logger = new Logger('streaming');
const logger = apiLogger.createSubLogger('streaming');
/**
* Main stream connection

View File

@ -29,7 +29,7 @@ import proxyServer from './proxy/index.js';
import webServer from './web/index.js';
import { initializeStreamingServer } from './api/streaming.js';
export const serverLogger = new Logger('server', 'gray', false);
export const serverLogger = new Logger('server', false);
// Init app
const app = new Koa();

View File

@ -15,7 +15,7 @@ export default async function(blocker: User, blockee: User) {
});
if (blocking == null) {
logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした');
logger.warn('Unblock requested but not blocked');
return;
}

View File

@ -12,7 +12,7 @@ import { getChartInsertLock } from '@/misc/app-lock.js';
import { db } from '@/db/postgre.js';
import Logger from '../logger.js';
const logger = new Logger('chart', 'white', process.env.NODE_ENV !== 'test');
const logger = new Logger('chart', process.env.NODE_ENV !== 'test');
const columnPrefix = '___' as const;
const uniqueTempColumnPrefix = 'unique_temp___' as const;

View File

@ -25,7 +25,7 @@ import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } fro
import { InternalStorage } from './internal-storage.js';
import { getS3 } from './s3.js';
const logger = driveLogger.createSubLogger('register', 'yellow');
const logger = driveLogger.createSubLogger('register');
/***
* Save file
@ -230,7 +230,7 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
logger.debug('web image not created (not an required image)');
}
} catch (err) {
logger.warn('web image not created (an error occured)', err as Error);
logger.warn('web image not created (an error occured)');
}
} else {
if (satisfyWebpublic) logger.info('web image not created (original satisfies webpublic)');

View File

@ -1,3 +1,3 @@
import Logger from '../logger.js';
export const driveLogger = new Logger('drive', 'blue');
export const driveLogger = new Logger('drive');

View File

@ -9,7 +9,7 @@ import { Instances } from '@/models/index.js';
import { getFetchInstanceMetadataLock } from '@/misc/app-lock.js';
import Logger from './logger.js';
const logger = new Logger('metadata', 'cyan');
const logger = new Logger('metadata');
export async function fetchInstanceMetadata(instance: Instance, force = false): Promise<void> {
const unlock = await getFetchInstanceMetadataLock(instance.host);

View File

@ -1,15 +1,7 @@
import cluster from 'node:cluster';
import chalk from 'chalk';
import convertColor from 'color-convert';
import { format as dateFormat } from 'date-fns';
import config from '@/config/index.js';
import { envOption, LOG_LEVELS } from '@/env.js';
import type { KEYWORD } from 'color-convert/conversions.js';
type Domain = {
name: string;
color?: KEYWORD;
};
export type Level = LOG_LEVELS[keyof LOG_LEVELS];
@ -17,7 +9,7 @@ export type Level = LOG_LEVELS[keyof LOG_LEVELS];
* Class that facilitates recording log messages to the console.
*/
export default class Logger {
private domain: Domain;
private domain: string;
private parentLogger: Logger | null = null;
private store: boolean;
/**
@ -28,14 +20,10 @@ export default class Logger {
/**
* Create a logger instance.
* @param domain Logging domain
* @param color Log message color
* @param store Whether to store messages
*/
constructor(domain: string, color?: KEYWORD, store = true, minLevel?: Level) {
this.domain = {
name: domain,
color,
};
constructor(domain: string, store = true, minLevel?: Level) {
this.domain = domain;
this.store = store;
this.minLevel = minLevel ?? envOption.logLevel;
}
@ -43,12 +31,11 @@ export default class Logger {
/**
* Create a child logger instance.
* @param domain Logging domain
* @param color Log message color
* @param store Whether to store messages
* @returns A Logger instance whose parent logger is this instance.
*/
public createSubLogger(domain: string, color?: KEYWORD, store = true, minLevel?: Level): Logger {
const logger = new Logger(domain, color, store, minLevel);
public createSubLogger(domain: string, store = true, minLevel?: Level): Logger {
const logger = new Logger(domain, store, minLevel);
logger.parentLogger = this;
return logger;
}
@ -58,10 +45,9 @@ export default class Logger {
* @param level Indicates the level of this particular message. If it is
* less than the minimum level configured, the message will be discarded.
* @param message The message to be logged.
* @param important Whether to highlight this message as especially important.
* @param subDomains Names of sub-loggers to be added.
*/
private log(level: Level, message: string, important = false, subDomains: Domain[] = [], _store = true): void {
private log(level: Level, message: string, subDomains: Domain[] = [], _store = true): void {
const store = _store && this.store;
// Check against the configured log level.
@ -70,66 +56,52 @@ export default class Logger {
// If this logger has a parent logger, delegate the actual logging to it,
// so the parent domain(s) will be logged properly.
if (this.parentLogger) {
this.parentLogger.log(level, message, important, [this.domain].concat(subDomains), store);
this.parentLogger.log(level, message, [this.domain].concat(subDomains), store);
return;
}
const time = dateFormat(new Date(), 'HH:mm:ss');
const worker = cluster.isPrimary ? '*' : cluster.worker?.id;
const domains = [this.domain].concat(subDomains).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name));
const domains = [this.domain].concat(subDomains);
let levelDisplay;
let messageDisplay;
switch (level) {
case LOG_LEVELS.error:
if (important) {
levelDisplay = chalk.bgRed.white('ERR ');
} else {
levelDisplay = chalk.red('ERR ');
}
messageDisplay = chalk.red(message);
levelDisplay = 'ERR ';
break;
case LOG_LEVELS.warning:
levelDisplay = chalk.yellow('WARN');
messageDisplay = chalk.yellow(message);
levelDisplay = 'WARN';
break;
case LOG_LEVELS.success:
if (important) {
levelDisplay = chalk.bgGreen.white('DONE');
} else {
levelDisplay = chalk.green('DONE');
}
messageDisplay = chalk.green(message);
levelDisplay = 'DONE';
break;
case LOG_LEVELS.info:
levelDisplay = chalk.blue('INFO');
messageDisplay = message;
levelDisplay = 'INFO';
break;
case LOG_LEVELS.debug: default:
levelDisplay = chalk.gray('VERB');
messageDisplay = chalk.gray(message);
case LOG_LEVELS.debug:
default:
levelDisplay = 'VERB';
break;
}
let log = `${levelDisplay} ${worker}\t[${domains.join(' ')}]\t${messageDisplay}`;
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
let log = `${levelDisplay} ${worker}\t[${domains.join(' ')}]\t${message}`;
if (envOption.withLogTime) log = time + ' ' + log;
console.log(important ? chalk.bold(log) : log);
console.log(log);
}
/**
* Log an error message.
* Use in situations where execution cannot be continued.
* @param err Error or string containing an error message
* @param important Whether this error is important
*/
public error(err: string | Error, important = false): void {
public error(err: string | Error): void {
if (err instanceof Error) {
this.log(LOG_LEVELS.error, err.toString(), important);
this.log(LOG_LEVELS.error, err.toString());
} else if (typeof err === 'object') {
this.log(LOG_LEVELS.error, `${(err as any).message || (err as any).name || err}`, important);
this.log(LOG_LEVELS.error, `${(err as any).message || (err as any).name || err}`);
} else {
this.log(LOG_LEVELS.error, `${err}`, important);
this.log(LOG_LEVELS.error, `${err}`);
}
}
@ -137,39 +109,35 @@ export default class Logger {
* Log a warning message.
* Use in situations where execution can continue but needs to be improved.
* @param message Warning message
* @param important Whether this warning is important
*/
public warn(message: string, important = false): void {
this.log(LOG_LEVELS.warning, message, important);
public warn(message: string): void {
this.log(LOG_LEVELS.warning, message);
}
/**
* Log a success message.
* Use in situations where something has been successfully done.
* @param message Success message
* @param important Whether this success message is important
*/
public succ(message: string, important = false): void {
this.log(LOG_LEVELS.success, message, important);
public succ(message: string): void {
this.log(LOG_LEVELS.success, message);
}
/**
* Log a debug message.
* Use for debugging (information needed by developers but not required by users).
* @param message Debug message
* @param important Whether this debug message is important
*/
public debug(message: string, important = false): void {
this.log(LOG_LEVELS.debug, message, important);
public debug(message: string): void {
this.log(LOG_LEVELS.debug, message);
}
/**
* Log an informational message.
* Use when something needs to be logged but doesn't fit into other levels.
* @param message Info message
* @param important Whether this info message is important
*/
public info(message: string, important = false): void {
this.log(LOG_LEVELS.info, message, important);
public info(message: string): void {
this.log(LOG_LEVELS.info, message);
}
}

View File

@ -193,13 +193,15 @@ export async function sideEffects(user: User, note: Note, silent = false, create
}
// Word mute
mutedWordsCache.fetch('').then(us => {
for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
mutedWordsCache.fetch('').then(users => {
if (users == null) return;
for (const user of users) {
checkWordMute(note, { id: user.userId }, user.mutedWords).then(shouldMute => {
if (shouldMute) {
MutedNotes.insert({
id: genId(),
userId: u.userId,
userId: user.userId,
noteId: note.id,
reason: 'word',
});

View File

@ -1,5 +1,6 @@
import { IsNull } from 'typeorm';
import { ILocalUser, User } from '@/models/entities/user.js';
import { UserPublickey } from '@/models/entities/user-publickey.js';
import { Users, UserPublickeys } from '@/models/index.js';
import { Cache } from '@/misc/cache.js';
import { subscriber } from '@/db/redis.js';

View File

@ -3675,8 +3675,6 @@ __metadata:
bull: 4.8.4
cacheable-lookup: 6.0.4
cbor: 8.1.0
chalk: 5.0.1
chalk-template: 0.4.0
cli-highlight: 2.1.11
color-convert: 2.0.1
content-disposition: 0.5.4
@ -4344,15 +4342,6 @@ __metadata:
languageName: node
linkType: hard
"chalk-template@npm:0.4.0":
version: 0.4.0
resolution: "chalk-template@npm:0.4.0"
dependencies:
chalk: ^4.1.2
checksum: 6c706802a79a7963cbce18f022b046fe86e438a67843151868852f80ea7346e975a6a9749991601e7e5d3b6a6c4852a04c53dc966a9a3d04031bd0e0ed53c819
languageName: node
linkType: hard
"chalk@npm:4.0.0":
version: 4.0.0
resolution: "chalk@npm:4.0.0"
@ -4363,13 +4352,6 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:5.0.1":
version: 5.0.1
resolution: "chalk@npm:5.0.1"
checksum: 7b45300372b908f0471fbf7389ce2f5de8d85bb949026fd51a1b95b10d0ed32c7ed5aab36dd5e9d2bf3191867909b4404cef75c5f4d2d1daeeacd301dd280b76
languageName: node
linkType: hard
"chalk@npm:^1.1.3":
version: 1.1.3
resolution: "chalk@npm:1.1.3"
@ -4394,7 +4376,7 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2":
"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
dependencies: