diff --git a/.changeset/tender-keys-act.md b/.changeset/tender-keys-act.md new file mode 100644 index 00000000..43154f01 --- /dev/null +++ b/.changeset/tender-keys-act.md @@ -0,0 +1,5 @@ +--- +"@gql.tada/cli-utils": minor +--- + +Add `--tsconfig` option to the `check` command, update its log output, and add support for GitHub Actions annotations to it. diff --git a/packages/cli-utils/src/commands/check/index.ts b/packages/cli-utils/src/commands/check/index.ts index 38f0b3cc..68bd4a71 100644 --- a/packages/cli-utils/src/commands/check/index.ts +++ b/packages/cli-utils/src/commands/check/index.ts @@ -1,10 +1,16 @@ import * as t from 'typanion'; import { Command, Option } from 'clipanion'; + +import { initTTY } from '../../term'; import { run } from './runner'; export class CheckCommand extends Command { static paths = [['check']]; + tsconfig = Option.String('--tsconfig,-c', { + description: 'Specify the `tsconfig.json` used to read', + }); + failOnWarn = Option.Boolean('--fail-on-warn,-w', false, { description: 'Triggers an error and a non-zero exit code if any warnings have been reported', }); @@ -13,12 +19,16 @@ export class CheckCommand extends Command { Option.String('--level,-l', { description: 'The minimum severity of diagnostics to display (info, warn, error)', validator: t.isOneOf([t.isLiteral('info'), t.isLiteral('warn'), t.isLiteral('error')]), - }) || 'error'; + }) || 'info'; async execute() { - await run({ - failOnWarn: this.failOnWarn, - minSeverity: this.minSeverity, - }); + const result = await initTTY().start( + run({ + failOnWarn: this.failOnWarn, + minSeverity: this.minSeverity, + tsconfig: this.tsconfig, + }) + ); + return process.exitCode || (typeof result === 'object' ? result.exit : 0); } } diff --git a/packages/cli-utils/src/commands/check/logger.ts b/packages/cli-utils/src/commands/check/logger.ts new file mode 100644 index 00000000..0a1d4cb4 --- /dev/null +++ b/packages/cli-utils/src/commands/check/logger.ts @@ -0,0 +1,127 @@ +import { pipe, interval, map } from 'wonka'; + +import * as path from 'node:path'; +import * as t from '../../term'; +import type { DiagnosticMessage } from './types'; +import type { SeveritySummary } from './types'; + +const CWD = process.cwd(); + +export function code(text: string) { + return t.text`${t.cmd(t.CSI.Style, t.Style.Underline)}${text}${t.cmd( + t.CSI.Style, + t.Style.NoUnderline + )}`; +} + +export function diagnosticFile(filePath: string) { + const relativePath = path.relative(CWD, filePath); + if (!relativePath.startsWith('..')) filePath = relativePath; + return t.text([ + t.cmd(t.CSI.Style, t.Style.Underline), + filePath, + t.cmd(t.CSI.Style, t.Style.NoUnderline), + '\n', + ]); +} + +export function diagnosticMessage(message: DiagnosticMessage) { + const indent = t.Chars.Space.repeat(2); + + let color = t.Style.Foreground; + if (message.severity === 'info') { + color = t.Style.BrightBlue; + } else if (message.severity === 'warn') { + color = t.Style.BrightYellow; + } else if (message.severity === 'error') { + color = t.Style.BrightRed; + } + + let text = message.message.trim(); + if (text.includes('\n')) { + text = text.split('\n').join(t.text([t.Chars.Newline, indent, t.Chars.Tab, t.Chars.Tab])); + } + + return t.text([ + indent, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `${message.line}:${message.col}`, + t.Chars.Tab, + t.cmd(t.CSI.Style, color), + message.severity, + t.Chars.Tab, + t.cmd(t.CSI.Style, t.Style.Foreground), + text, + t.Chars.Newline, + ]); +} + +export function infoSummary(summary: SeveritySummary) { + const { info, error, warn } = summary; + let out = ''; + if (info) { + out += t.text([t.cmd(t.CSI.Style, t.Style.Blue), t.Icons.Info, ` ${info} notices\n`]); + } + if (error || warn) { + out += t.text([ + t.cmd(t.CSI.Style, t.Style.BrightYellow), + t.Icons.Warning, + ` ${error + warn} problems (${error} errors, ${warn} warnings)\n`, + ]); + } else { + out += t.text([t.cmd(t.CSI.Style, t.Style.BrightGreen), t.Icons.Tick, ` No problems found\n`]); + } + return out; +} + +export function problemsSummary(summary: SeveritySummary) { + const { info, error, warn } = summary; + let out = ''; + if (info) { + out += t.text([t.cmd(t.CSI.Style, t.Style.Blue), t.Icons.Info, ` ${info} notices\n`]); + } + out += t.text([ + t.cmd(t.CSI.Style, t.Style.Red), + t.Icons.Cross, + ` ${error + warn} problems (${error} errors, ${warn} warnings)\n`, + ]); + return t.error(out); +} + +export function diagnosticMessageGithub(message: DiagnosticMessage): void { + const kind = + message.severity === 'warn' ? 'warning' : message.severity === 'error' ? 'error' : 'notice'; + t.githubAnnotation(kind, message.message, { + file: message.file, + line: message.line, + col: message.col, + }); +} + +export function runningDiagnostics(file: number, ofFiles?: number) { + const progress = ofFiles ? `(${file}/${ofFiles})` : `(${file})`; + return pipe( + interval(150), + map((state) => { + return t.text([ + t.cmd(t.CSI.Style, t.Style.Magenta), + t.dotSpinner[state % t.dotSpinner.length], + ' ', + t.cmd(t.CSI.Style, t.Style.Foreground), + `Checking files${t.Chars.Ellipsis} `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + progress, + ]); + }) + ); +} + +export function errorMessage(message: string) { + return t.error([ + '\n', + t.cmd(t.CSI.Style, [t.Style.Red, t.Style.Invert]), + ` ${t.Icons.Warning} Error `, + t.cmd(t.CSI.Style, t.Style.NoInvert), + `\n${message.trim()}\n`, + ]); +} diff --git a/packages/cli-utils/src/commands/check/runner.ts b/packages/cli-utils/src/commands/check/runner.ts index 8dd80ab9..088a2ab3 100644 --- a/packages/cli-utils/src/commands/check/runner.ts +++ b/packages/cli-utils/src/commands/check/runner.ts @@ -1,15 +1,22 @@ -import { Project, ts } from 'ts-morph'; -import { init, getGraphQLDiagnostics } from '@0no-co/graphqlsp/api'; -import { load, resolveTypeScriptRootDir } from '@gql.tada/internal'; import path from 'node:path'; -import type { GraphQLSPConfig } from '../../lsp'; import { getGraphQLSPConfig } from '../../lsp'; import { getTsConfig } from '../../tsconfig'; -import { createPluginInfo } from '../../ts/project'; - -type Severity = 'error' | 'warn' | 'info'; -const severities: Severity[] = ['error', 'warn', 'info']; +import * as logger from './logger'; + +import type { ComposeInput } from '../../term'; +import type { Severity, SeveritySummary } from './types'; + +const isMinSeverity = (severity: Severity, minSeverity: Severity) => { + switch (severity) { + case 'info': + return minSeverity !== 'warn' && minSeverity !== 'error'; + case 'warn': + return minSeverity !== 'error'; + case 'error': + return true; + } +}; export interface FormattedDisplayableDiagnostic { severity: Severity; @@ -22,145 +29,76 @@ export interface FormattedDisplayableDiagnostic { export interface Options { failOnWarn: boolean | undefined; minSeverity: Severity; + tsconfig: string | undefined; } -export async function run(opts: Options) { - const tsConfig = await getTsConfig(); - if (!tsConfig) { - return; +export async function* run(opts: Options): AsyncIterable { + const CWD = process.cwd(); + const { runDiagnostics } = await import('./thread'); + + const tsconfig = await getTsConfig(opts.tsconfig); + if (!tsconfig) { + const relative = opts.tsconfig + ? logger.code(path.relative(process.cwd(), opts.tsconfig)) + : 'the current working directory'; + throw logger.errorMessage( + `The ${logger.code('tsconfig.json')} file at ${relative} could not be loaded.\n` + ); } - const config = getGraphQLSPConfig(tsConfig); + const config = getGraphQLSPConfig(tsconfig); if (!config) { - return; - } - - const result = (await runDiagnostics(config)) || []; - const errorDiagnostics = result.filter((d) => d.severity === 'error'); - const warnDiagnostics = result.filter((d) => d.severity === 'warn'); - const infoDiagnostics = result.filter((d) => d.severity === 'info'); - - const minSeverityForReport = severities.indexOf(opts.minSeverity); - if ( - errorDiagnostics.length === 0 && - warnDiagnostics.length === 0 && - infoDiagnostics.length === 0 - ) { - // eslint-disable-next-line no-console - console.log('No issues found! 🎉'); - process.exit(0); - } else { - // TODO: report a summary at the top and then a list of files with diagnostics sorted by severity. - const errorReport = errorDiagnostics.length - ? `Found ${errorDiagnostics.length} Errors:\n${constructDiagnosticsPerFile(errorDiagnostics)}` - : ``; - const warningsReport = - minSeverityForReport >= severities.indexOf('warn') && warnDiagnostics.length - ? `Found ${warnDiagnostics.length} Warnings:\n${constructDiagnosticsPerFile( - warnDiagnostics - )}` - : ``; - const suggestionsReport = - minSeverityForReport >= severities.indexOf('info') && - infoDiagnostics.length && - warnDiagnostics.length && - errorDiagnostics.length - ? `Found ${infoDiagnostics.length} Suggestions:\n${constructDiagnosticsPerFile( - infoDiagnostics - )}` - : ``; - // eslint-disable-next-line no-console - console.log(`${errorReport}${warningsReport}${suggestionsReport}`); - if (errorDiagnostics.length || (opts.failOnWarn && warnDiagnostics.length)) { - process.exit(1); - } else { - process.exit(0); - } + throw logger.errorMessage( + `No ${logger.code('"@0no-co/graphqlsp"')} plugin was found in your ${logger.code( + 'tsconfig.json' + )}.\n` + ); } -} -async function runDiagnostics(config: GraphQLSPConfig): Promise { - // TODO: leverage ts-morph tsconfig resolver - const projectName = path.resolve(process.cwd(), 'tsconfig.json'); - const rootPath = (await resolveTypeScriptRootDir(projectName)) || path.dirname(projectName); - const project = new Project({ - tsConfigFilePath: projectName, - }); + let tsconfigPath = opts.tsconfig || CWD; + tsconfigPath = + path.extname(tsconfigPath) !== '.json' + ? path.resolve(CWD, tsconfigPath, 'tsconfig.json') + : path.resolve(CWD, tsconfigPath); - init({ - typescript: ts as any, - }); + const summary: SeveritySummary = { warn: 0, error: 0, info: 0 }; + const minSeverity = opts.minSeverity; + const generator = runDiagnostics({ tsconfigPath, config }); - const pluginCreateInfo = createPluginInfo(project, config, projectName); + let totalFileCount = 0; + let fileCount = 0; - const sourceFiles = project.getSourceFiles(); - const loader = load({ origin: config.schema, rootPath }); - let schema; try { - const loaderResult = await loader.load(); - schema = loaderResult && loaderResult.schema; - if (!schema) { - throw new Error(`Failed to load schema`); + for await (const signal of generator) { + if (signal.kind === 'FILE_COUNT') { + totalFileCount = signal.fileCount; + continue; + } + + let buffer = ''; + for (const message of signal.messages) { + summary[message.severity]++; + if (isMinSeverity(message.severity, minSeverity)) { + buffer += logger.diagnosticMessage(message); + logger.diagnosticMessageGithub(message); + } + } + if (buffer) { + yield logger.diagnosticFile(signal.filePath) + buffer + '\n'; + } + + yield logger.runningDiagnostics(++fileCount, totalFileCount); } - } catch (error) { - throw new Error(`Failed to load schema: ${error}`); + } catch (error: any) { + throw logger.errorMessage(error.message || `${error}`); } - return sourceFiles.flatMap((sourceFile) => { - const diag = - getGraphQLDiagnostics( - sourceFile.getFilePath(), - { current: schema, version: 1 }, - pluginCreateInfo - ) || []; - return diag.map((diag) => { - const text = diag.file && diag.file.getText(); - const start = diag.start; - const [line, col] = getLineCol(text || '', start || 0); - return { - severity: (diag.category === ts.DiagnosticCategory.Error - ? 'error' - : diag.category === ts.DiagnosticCategory.Warning - ? 'warn' - : 'info') as Severity, - message: diag.messageText as string, - file: diag.file && diag.file.fileName, - line, - col, - }; - }); - }); -} + // Reset notice count if it's outside of min severity + if (minSeverity !== 'info') summary.info = 0; -function getLineCol(text: string, start: number): [number, number] { - if (!text || !start) return [0, 0]; - - let counter = 0; - const parts = text.split('\n'); - for (let i = 0; i <= parts.length; i++) { - const line = parts[i]; - if (counter + line.length > start) { - return [i + 1, start + 1 - counter]; - } else { - counter = counter + (line.length + 1); - continue; - } + if ((opts.failOnWarn && summary.warn) || summary.error) { + throw logger.problemsSummary(summary); + } else { + yield logger.infoSummary(summary); } - - return [0, 0]; -} - -function constructDiagnosticsPerFile(diagnostics: FormattedDisplayableDiagnostic[]): string { - const diagnosticsByFile = diagnostics.reduce>((acc, diag) => { - const file = diag.file || ''; - if (!acc[file]) { - acc[file] = []; - } - acc[file].push(`[${diag.line}:${diag.col}] ${diag.message}`); - return acc; - }, {}); - - return Object.entries(diagnosticsByFile).reduce((acc, [fileName, diagnostics]) => { - return `${acc}\n${fileName}\n${diagnostics.join('\n')}\n`; - }, ''); } diff --git a/packages/cli-utils/src/commands/check/thread.ts b/packages/cli-utils/src/commands/check/thread.ts new file mode 100644 index 00000000..6f35204c --- /dev/null +++ b/packages/cli-utils/src/commands/check/thread.ts @@ -0,0 +1,97 @@ +import * as path from 'node:path'; +import { Project, ts } from 'ts-morph'; + +import { load, resolveTypeScriptRootDir } from '@gql.tada/internal'; +import { init, getGraphQLDiagnostics } from '@0no-co/graphqlsp/api'; + +import type { GraphQLSPConfig } from '../../lsp'; +import { createPluginInfo } from '../../ts/project'; +import { expose } from '../../threads'; + +import type { Severity, DiagnosticMessage, DiagnosticSignal } from './types'; + +const getLineCol = (text: string, start: number | undefined): [number, number] => { + if (text && start) { + let counter = 0; + const parts = text.split('\n'); + for (let i = 0; i <= parts.length; i++) { + const line = parts[i]; + if (counter + line.length > start) { + return [i + 1, start + 1 - counter]; + } else { + counter = counter + (line.length + 1); + continue; + } + } + } + return [0, 0]; +}; + +const loadSchema = async (rootPath: string, config: GraphQLSPConfig) => { + const loader = load({ origin: config.schema, rootPath }); + const result = await loader.load(); + if (!result) throw new Error('Failed to load schema'); + return { current: result.schema, version: 1 }; +}; + +export interface DiagnosticsParams { + config: GraphQLSPConfig; + tsconfigPath: string; +} + +async function* _runDiagnostics( + params: DiagnosticsParams +): AsyncIterableIterator { + init({ typescript: ts as any }); + const projectPath = path.dirname(params.tsconfigPath); + const rootPath = (await resolveTypeScriptRootDir(params.tsconfigPath)) || params.tsconfigPath; + const schemaRef = await loadSchema(rootPath, params.config); + const project = new Project({ tsConfigFilePath: params.tsconfigPath }); + const pluginInfo = createPluginInfo(project, params.config, projectPath); + const sourceFiles = project.getSourceFiles(); + + yield { + kind: 'FILE_COUNT', + fileCount: sourceFiles.length, + }; + + for (const sourceFile of sourceFiles) { + const filePath = sourceFile.getFilePath(); + const diagnostics = getGraphQLDiagnostics(filePath, schemaRef, pluginInfo); + const messages: DiagnosticMessage[] = []; + + if (diagnostics && diagnostics.length) { + const sourceText = sourceFile.getText(); + for (const diagnostic of diagnostics) { + if ( + !('messageText' in diagnostic) || + typeof diagnostic.messageText !== 'string' || + !diagnostic.file + ) + continue; + let severity: Severity = 'info'; + if (diagnostic.category === ts.DiagnosticCategory.Error) { + severity = 'error'; + } else if (diagnostic.category === ts.DiagnosticCategory.Warning) { + severity = 'warn'; + } + const [line, col] = getLineCol(sourceText, diagnostic.start); + messages.push({ + severity, + message: diagnostic.messageText, + file: diagnostic.file.fileName, + line, + col, + }); + } + } + + yield { + kind: 'FILE_DIAGNOSTICS', + filePath, + messages, + }; + } +} + +export const runDiagnostics = expose(_runDiagnostics); diff --git a/packages/cli-utils/src/commands/check/types.ts b/packages/cli-utils/src/commands/check/types.ts new file mode 100644 index 00000000..f18347f7 --- /dev/null +++ b/packages/cli-utils/src/commands/check/types.ts @@ -0,0 +1,24 @@ +export type Severity = 'error' | 'warn' | 'info'; + +export type SeveritySummary = Record; + +export interface DiagnosticMessage { + severity: Severity; + message: string; + file: string; + line: number; + col: number; +} + +export interface FileDiagnosticsSignal { + kind: 'FILE_DIAGNOSTICS'; + filePath: string; + messages: DiagnosticMessage[]; +} + +export interface FileCountSignal { + kind: 'FILE_COUNT'; + fileCount: number; +} + +export type DiagnosticSignal = FileDiagnosticsSignal | FileCountSignal; diff --git a/packages/cli-utils/src/commands/doctor/index.ts b/packages/cli-utils/src/commands/doctor/index.ts index 5ae796e4..27b27b05 100644 --- a/packages/cli-utils/src/commands/doctor/index.ts +++ b/packages/cli-utils/src/commands/doctor/index.ts @@ -7,6 +7,6 @@ export class DoctorCommand extends Command { async execute() { const result = await initTTY().start(run()); - return typeof result === 'object' ? result.exit : 0; + return process.exitCode || (typeof result === 'object' ? result.exit : 0); } } diff --git a/packages/cli-utils/src/commands/doctor/logger.ts b/packages/cli-utils/src/commands/doctor/logger.ts index 6143085e..1d5b596f 100644 --- a/packages/cli-utils/src/commands/doctor/logger.ts +++ b/packages/cli-utils/src/commands/doctor/logger.ts @@ -96,7 +96,7 @@ export function runningTask(description: string) { t.circleSpinner[state % t.circleSpinner.length], ' ', t.cmd(t.CSI.Style, t.Style.Foreground), - description, + description.trim(), ]); }) ); diff --git a/packages/cli-utils/src/term/github.ts b/packages/cli-utils/src/term/github.ts new file mode 100644 index 00000000..0e01657f --- /dev/null +++ b/packages/cli-utils/src/term/github.ts @@ -0,0 +1,46 @@ +export const isGithubCI = !!process.env.GITHUB_ACTIONS; + +export interface AnnotationProperties { + title?: string; + file?: string; + line?: number; + endLine?: number; + col?: number; + endColumn?: number; +} + +const toCommandValue = (input: unknown): string => + typeof input == 'string' || input == null + ? '' + (input ? '' + input : '') + : JSON.stringify(input); + +const escapeData = (input: unknown) => + toCommandValue(input).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + +const escapeProperty = (input: unknown): string => + toCommandValue(input) + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A') + .replace(/:/g, '%3A') + .replace(/,/g, '%2C'); + +export function githubAnnotation( + kind: 'error' | 'warning' | 'notice', + message: string, + properties?: AnnotationProperties +) { + if (isGithubCI) { + let out = `::${kind}`; + let propsOut = ''; + if (properties) { + for (const key in properties) { + if (properties) propsOut += ','; + if (properties[key]) propsOut += `${key}=${escapeProperty(properties[key])}`; + } + } + if (propsOut) out += ` ${propsOut}`; + out += `::${escapeData(message)}\n`; + process.stdout.write(out); + } +} diff --git a/packages/cli-utils/src/term/index.ts b/packages/cli-utils/src/term/index.ts index b0f6852d..eb2fc929 100644 --- a/packages/cli-utils/src/term/index.ts +++ b/packages/cli-utils/src/term/index.ts @@ -3,3 +3,4 @@ export { CLIError, text, error, compose } from './write'; export * from './csi'; export * from './symbols'; export * from './tty'; +export * from './github'; diff --git a/packages/cli-utils/src/term/symbols.ts b/packages/cli-utils/src/term/symbols.ts index e0915732..76ecde9f 100644 --- a/packages/cli-utils/src/term/symbols.ts +++ b/packages/cli-utils/src/term/symbols.ts @@ -3,6 +3,7 @@ export const enum Chars { Newline = '\x0a', Tab = '\x09', Space = ' ', + Ellipsis = '…', } export const enum Box { @@ -136,7 +137,7 @@ export const enum Heart { export const enum Icons { Tick = '✓', TickSwoosh = '✔', - Cross = '×', + Cross = '✖', CrossSwoosh = '✘', Home = '⌂', Note = '♪', diff --git a/packages/cli-utils/src/term/tty.ts b/packages/cli-utils/src/term/tty.ts index 2a7985c4..f63c2218 100644 --- a/packages/cli-utils/src/term/tty.ts +++ b/packages/cli-utils/src/term/tty.ts @@ -19,6 +19,7 @@ import { emitKeypressEvents } from 'node:readline'; import type { ComposeInput, CLIError } from './write'; import { text, compose } from './write'; import { cmd, _setColor, CSI, Mode, PrivateMode } from './csi'; +import { isGithubCI } from './github'; export interface KeypressEvent { data?: string; @@ -79,7 +80,10 @@ export function initTTY(): TTY { let isTTY = process.env.TERM !== 'dumb' && !process.env.CI; let pipeTo: WriteStream | null = null; let output: WriteStream = process.stdout; - if (!output.isTTY && process.stderr.isTTY) { + if (isGithubCI) { + output = process.stderr; + if (!output.isTTY) pipeTo = process.stdout; + } else if (!output.isTTY && process.stderr.isTTY) { output = process.stderr; pipeTo = process.stdout; } else { @@ -88,7 +92,7 @@ export function initTTY(): TTY { const hasColorArg = process.argv.includes('--color'); const hasColorEnv = 'FORCE_COLOR' in process.env || (!process.env.NO_COLOR && !process.env.CI); - _setColor((isTTY && hasColorEnv) || hasColorArg); + _setColor((isTTY && hasColorEnv) || hasColorArg || isGithubCI); function _start() { _setColor((isTTY && hasColorEnv) || hasColorArg); diff --git a/packages/cli-utils/src/term/write.ts b/packages/cli-utils/src/term/write.ts index da388de1..f2ac514c 100644 --- a/packages/cli-utils/src/term/write.ts +++ b/packages/cli-utils/src/term/write.ts @@ -5,17 +5,17 @@ import { fromAsyncIterable, fromValue, concatMap, - sample, + never, merge, takeUntil, + takeLast, filter, share, - take, scan, map, } from 'wonka'; -import { cmd, CSI, Style } from './csi'; +import { cmd, CSI, EraseLine, Style } from './csi'; const ansiRegex = /([\x1B\x9B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><])/g; @@ -27,10 +27,11 @@ export class CLIError extends Error { constructor(message: string, exitCode?: number) { super(stripAnsi(message)); this.output = message; - this.exit = exitCode || 1; + this.exit = exitCode == null ? 0 : 1; } toString() { + if (this.exit) process.exitCode = this.exit; return this.output; } } @@ -64,13 +65,16 @@ function error(arg: readonly string[] | string | number, ...input: readonly stri } function clear(text: string) { - if (!text) return ''; let lines = 0; for (let index = 0; index < text.length; index++) if (text.charCodeAt(index) === 10 /*'\n'*/) lines++; - return ( - (lines > 0 ? cmd(CSI.PrevLine, lines) : cmd(CSI.ToColumn, 1)) + cmd(CSI.DeleteLines, lines + 1) - ); + if (lines) { + return cmd(CSI.PrevLine, lines) + cmd(CSI.DeleteLines, lines + 1); + } else if (stripAnsi(text)) { + return cmd(CSI.EraseLine, EraseLine.Backward) + cmd(CSI.ToColumn, 1); + } else { + return ''; + } } type ComposeInput = undefined | string | CLIError | Source | AsyncIterable; @@ -84,33 +88,45 @@ async function* convertError(outputs: AsyncIterable): AsyncIterabl } function compose(outputs: AsyncIterable): Source { - const outputs$ = share(fromAsyncIterable(convertError(outputs))); const reset = cmd(CSI.Style, [Style.Reset, Style.NoInvert]); + const outputs$ = pipe( + fromAsyncIterable(convertError(outputs)), + concatMap((output) => { + return typeof output === 'object' && !(output instanceof CLIError) + ? compose(output) + : fromValue(output); + }), + filter((x: T): x is Exclude => x != null), + share + ); + return pipe( outputs$, - filter((x: T): x is Exclude => x != null), concatMap((output) => { - if (typeof output === 'object' && !(output instanceof CLIError)) { - return compose(output); - } - const output$ = share( - typeof output === 'string' || output instanceof CLIError ? fromValue(output) : output + const output$ = pipe( + typeof output === 'string' || output instanceof CLIError + ? fromValue(output) + : merge([output, never]), + takeUntil(outputs$), + share ); return pipe( merge([ pipe( output$, - sample(outputs$), - map((output) => (typeof output === 'string' && output.endsWith('\n') ? output : '')), - take(1) + takeLast(1), + map((output) => (typeof output === 'string' && !output.endsWith('\n') ? '' : output)) ), - pipe(output$, takeUntil(outputs$)), + output$, ]), scan((prev: CLIError | string, output) => { - return typeof output === 'string' ? clear('' + prev) + output + reset : output; + return typeof output === 'string' + ? clear(typeof prev === 'string' ? prev : '') + output + reset + : output; }, '') ); - }) + }), + takeUntil(pipe(outputs$, takeLast(1))) ); } diff --git a/packages/cli-utils/src/threads/index.ts b/packages/cli-utils/src/threads/index.ts index 8d800e39..091c8ca2 100644 --- a/packages/cli-utils/src/threads/index.ts +++ b/packages/cli-utils/src/threads/index.ts @@ -61,9 +61,14 @@ export interface Generator { } function main(url: string | URL): Generator { - const worker = new Worker(url, workerOpts); + let worker: Worker; let ids = 0; return (...args: Args) => { + if (!worker) { + worker = new Worker(url, workerOpts); + worker.unref(); + } + const id = ++ids | 0; const buffer: ThreadMessage[] = []; @@ -79,7 +84,6 @@ function main(url: string | URL): Generator