diff --git a/.changeset/strange-ways-poke.md b/.changeset/strange-ways-poke.md new file mode 100644 index 00000000..fb0a72ab --- /dev/null +++ b/.changeset/strange-ways-poke.md @@ -0,0 +1,5 @@ +--- +"@gql.tada/cli-utils": patch +--- + +Improve log output of `doctor` command. diff --git a/packages/cli-utils/LICENSE.md b/packages/cli-utils/LICENSE.md index 7dff7938..2010c09d 100644 --- a/packages/cli-utils/LICENSE.md +++ b/packages/cli-utils/LICENSE.md @@ -82,6 +82,30 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## @0no-co/graphql.web + +MIT License + +Copyright (c) 0no.co + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## @clack/prompts MIT License @@ -132,6 +156,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## wonka + +MIT License + +Copyright (c) 0no.co + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## cross-spawn The MIT License (MIT) diff --git a/packages/cli-utils/package.json b/packages/cli-utils/package.json index 29dce3bb..e07cf572 100644 --- a/packages/cli-utils/package.json +++ b/packages/cli-utils/package.json @@ -48,13 +48,14 @@ "sade": "^1.8.1", "semiver": "^1.1.0", "type-fest": "^4.10.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "wonka": "^6.3.4" }, "dependencies": { - "@gql.tada/internal": "workspace:*", "@0no-co/graphqlsp": "^1.9.1", - "ts-morph": "~22.0.0", - "graphql": "^16.8.1" + "@gql.tada/internal": "workspace:*", + "graphql": "^16.8.1", + "ts-morph": "~22.0.0" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/packages/cli-utils/src/commands/doctor.ts b/packages/cli-utils/src/commands/doctor.ts index a6979975..3f0b3ef3 100644 --- a/packages/cli-utils/src/commands/doctor.ts +++ b/packages/cli-utils/src/commands/doctor.ts @@ -6,7 +6,24 @@ import type { TsConfigJson } from 'type-fest'; import { resolveTypeScriptRootDir } from '@gql.tada/internal'; import { existsSync } from 'node:fs'; -import { getGraphQLSPConfig } from '../lsp'; +import { initTTY } from '../term'; +import * as logger from '../loggers/check'; + +// NOTE: Currently, most tasks in this command complete too quickly +// We slow them down to make the CLI output easier to follow along to +const delay = (ms = 700) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const enum Messages { + TITLE = 'Doctor', + DESCRIPTION = 'Detects problems with your setup', + CHECK_TS_VERSION = 'Checking TypeScript version', + CHECK_DEPENDENCIES = 'Checking installed dependencies', + CHECK_TSCONFIG = 'Checking tsconfig.json', + CHECK_SCHEMA = 'Checking schema', +} const MINIMUM_VERSIONS = { typescript: '4.1.0', @@ -15,6 +32,14 @@ const MINIMUM_VERSIONS = { }; export async function executeTadaDoctor() { + await initTTY().start(run()); +} + +async function* run() { + yield logger.title(Messages.TITLE, Messages.DESCRIPTION); + yield logger.runningTask(Messages.CHECK_TS_VERSION); + await delay(); + // Check TypeScript version const cwd = process.cwd(); const packageJsonPath = path.resolve(cwd, 'package.json'); @@ -22,14 +47,16 @@ export async function executeTadaDoctor() { dependencies: Record; devDependencies: Record; }; + try { const file = path.resolve(packageJsonPath); packageJsonContents = JSON.parse(await fs.readFile(file, 'utf-8')); - } catch (error) { - console.error( - 'Failed to read package.json in current working directory, try running the doctor command in your workspace folder.' + } catch (_error) { + yield logger.failedTask(Messages.CHECK_TS_VERSION); + throw logger.errorMessage( + `A ${logger.code('package.json')} file was not found in the current working directory.\n` + + logger.hint('Try running the doctor command in your workspace folder.') ); - return; } const deps = Object.entries({ @@ -39,96 +66,189 @@ export async function executeTadaDoctor() { const typeScriptVersion = deps.find((x) => x[0] === 'typescript'); if (!typeScriptVersion) { - console.error('Failed to find a "typescript" installation, try installing one.'); - return; + yield logger.failedTask(Messages.CHECK_TS_VERSION); + throw logger.errorMessage( + `A version of ${logger.code('typescript')} was not found in your dependencies.\n` + + logger.hint(`Is ${logger.code('typescript')} installed in this package?`) + ); } else if (semiver(typeScriptVersion[1], MINIMUM_VERSIONS.typescript) === -1) { // TypeScript version lower than v4.1 which is when they introduced template lits - console.error( - `Found an outdated "TypeScript" version, gql.tada requires at least ${MINIMUM_VERSIONS.typescript}.` + yield logger.failedTask(Messages.CHECK_TS_VERSION); + throw logger.errorMessage( + `The version of ${logger.code('typescript')} in your dependencies is out of date.\n` + + logger.hint( + `${logger.code('gql.tada')} requires at least ${logger.bold(MINIMUM_VERSIONS.typescript)}` + ) ); - return; } - const gqlspVersion = deps.find((x) => x[0] === "'@0no-co/graphqlsp'"); + yield logger.completedTask(Messages.CHECK_TS_VERSION); + yield logger.runningTask(Messages.CHECK_DEPENDENCIES); + await delay(); + + const gqlspVersion = deps.find((x) => x[0] === '@0no-co/graphqlsp'); if (!gqlspVersion) { - console.error('Failed to find a "@0no-co/graphqlsp" installation, try installing one.'); - return; + yield logger.failedTask(Messages.CHECK_DEPENDENCIES); + throw logger.errorMessage( + `A version of ${logger.code('@0no-co/graphqlsp')} was not found in your dependencies.\n` + + logger.hint(`Is ${logger.code('@0no-co/graphqlsp')} installed?`) + ); } else if (semiver(gqlspVersion[1], MINIMUM_VERSIONS.lsp) === -1) { - console.error( - `Found an outdated "@0no-co/graphqlsp" version, gql.tada requires at least ${MINIMUM_VERSIONS.lsp}.` + yield logger.failedTask(Messages.CHECK_DEPENDENCIES); + throw logger.errorMessage( + `The version of ${logger.code('@0no-co/graphqlsp')} in your dependencies is out of date.\n` + + logger.hint( + `${logger.code('gql.tada')} requires at least ${logger.bold(MINIMUM_VERSIONS.lsp)}` + ) ); - return; } - const gqlTadaVersion = deps.find((x) => x[0] === "'gql.tada'"); + const gqlTadaVersion = deps.find((x) => x[0] === 'gql.tada'); if (!gqlTadaVersion) { - console.error('Failed to find a "gql.tada" installation, try installing one.'); - return; + yield logger.failedTask(Messages.CHECK_DEPENDENCIES); + throw logger.errorMessage( + `A version of ${logger.code('gql.tada')} was not found in your dependencies.\n` + + logger.hint(`Is ${logger.code('gql.tada')} installed?`) + ); } else if (semiver(gqlTadaVersion[1], '1.0.0') === -1) { - console.error( - `Found an outdated "gql.tada" version, gql.tada requires at least ${MINIMUM_VERSIONS.tada}.` + yield logger.failedTask(Messages.CHECK_DEPENDENCIES); + throw logger.errorMessage( + `The version of ${logger.code('gql.tada')} in your dependencies is out of date.\n` + + logger.hint( + `It's recommended to upgrade ${logger.code('gql.tada')} to at least ${logger.bold( + MINIMUM_VERSIONS.lsp + )}` + ) ); - return; } + yield logger.completedTask(Messages.CHECK_DEPENDENCIES); + yield logger.runningTask(Messages.CHECK_TSCONFIG); + await delay(); + const tsconfigpath = path.resolve(cwd, 'tsconfig.json'); - const root = (await resolveTypeScriptRootDir(tsconfigpath)) || cwd; let tsconfigContents: string; try { - const file = path.resolve(root, 'tsconfig.json'); - tsconfigContents = await fs.readFile(file, 'utf-8'); - } catch (error) { - console.error( - 'Failed to read tsconfig.json in current working directory, try adding a "tsconfig.json".' + tsconfigContents = await fs.readFile(tsconfigpath, 'utf-8'); + } catch (_error) { + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `A ${logger.code('tsconfig.json')} file was not found in the current working directory.\n` + + logger.hint( + `Set up a new ${logger.code('tsconfig.json')} containing ${logger.code( + '@0no-co/graphqlp' + )}.` + ) ); - return; } let tsConfig: TsConfigJson; try { tsConfig = parse(tsconfigContents) as TsConfigJson; - } catch (err) { - console.error('Unable to parse tsconfig.json in current working directory.', err); - return; + } catch (error: any) { + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `Your ${logger.code('tsconfig.json')} file could not be parsed.\n` + + logger.console(error.message) + ); + } + + let root: string; + try { + root = (await resolveTypeScriptRootDir(tsconfigpath)) || cwd; + } catch (error: any) { + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `Failed to resolve a ${logger.code('"extends"')} reference in your ${logger.code( + 'tsconfig.json' + )}.\n` + logger.console(error.message) + ); + } + + if (root !== cwd) { + try { + tsconfigContents = await fs.readFile(path.resolve(root, 'tsconfig.json'), 'utf-8'); + tsConfig = parse(tsconfigContents) as TsConfigJson; + } catch (error: any) { + const relative = path.relative(process.cwd(), root); + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `The ${logger.code('tsconfig.json')} file at ${logger.code( + relative + )} could not be loaded.\n` + logger.console(error.message) + ); + } } // Check GraphQLSP version, later on we can check if a ts version is > 5.5.0 to use gql.tada/lsp instead of // the LSP package. - const config = getGraphQLSPConfig(tsConfig); + const config = + tsConfig && + tsConfig.compilerOptions && + tsConfig.compilerOptions.plugins && + (tsConfig.compilerOptions.plugins.find( + (plugin) => plugin.name === '@0no-co/graphqlsp' || plugin.name === 'gql.tada/lsp' + ) as any); if (!config) { - console.error(`Missing a "@0no-co/graphqlsp" plugin in your tsconfig.`); - return; + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `No ${logger.code('"@0no-co/graphqlsp"')} plugin was found in your ${logger.code( + 'tsconfig.json' + )}.\n` + logger.hint(`Have you set up ${logger.code('"@0no-co/graphqlsp"')} yet?`) + ); } // TODO: this is optional I guess with the CLI being there and all if (!config.tadaOutputLocation) { - console.error(`Missing a "tadaOutputLocation" setting in your GraphQLSP configuration.`); - return; + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `No ${logger.code('"tadaOutputLocation"')} option was found in your configuration.\n` + + logger.hint( + `Have you chosen an output path for ${logger.code('gql.tada')}'s declaration file yet?` + ) + ); } if (!config.schema) { - console.error(`Missing a "schema" setting in your GraphQLSP configuration.`); - return; + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `No ${logger.code('"schema"')} option was found in your configuration.\n` + + logger.hint(`Have you specified your SDL file or URL in your configuration yet?`) + ); + } + + yield logger.completedTask(Messages.CHECK_TSCONFIG); + yield logger.runningTask(Messages.CHECK_SCHEMA); + await delay(); + + // TODO: This doesn't match laoders. Should we just use loaders here? + const isFile = + typeof config.schema === 'string' && + (config.schema.endsWith('.json') || config.schema.endsWith('.graphql')); + if (isFile) { + const resolvedFile = path.resolve(root, config.schema as string); + if (!existsSync(resolvedFile)) { + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `Could not find the SDL file that ${logger.code('"schema"')} is specifying.\n` + + logger.hint(`Have you specified a valid SDL file in your configuration?`) + ); + } } else { - const isFile = - typeof config.schema === 'string' && - (config.schema.endsWith('.json') || config.schema.endsWith('.graphql')); - if (isFile) { - const resolvedFile = path.resolve(root, config.schema as string); - if (!existsSync(resolvedFile)) { - console.error(`The schema setting does not point at an existing file "${resolvedFile}"`); - return; - } - } else { - try { - typeof config.schema === 'string' ? new URL(config.schema) : new URL(config.schema.url); - } catch (e) { - console.error( - `The schema setting does not point at a valid URL "${JSON.stringify(config.schema)}"` - ); - return; - } + try { + typeof config.schema === 'string' ? new URL(config.schema) : new URL(config.schema.url); + } catch (_error) { + yield logger.failedTask(Messages.CHECK_TSCONFIG); + throw logger.errorMessage( + `The ${logger.code('"schema"')} option is neither a valid URL nor a valid file.\n` + + logger.hint(`Have you specified a valid URL in your configuration?`) + ); } } + + yield logger.completedTask(Messages.CHECK_SCHEMA, true); + await delay(); + + yield logger.success(); } diff --git a/packages/cli-utils/src/loggers/check.ts b/packages/cli-utils/src/loggers/check.ts new file mode 100644 index 00000000..2bfa9087 --- /dev/null +++ b/packages/cli-utils/src/loggers/check.ts @@ -0,0 +1,124 @@ +import { pipe, interval, map } from 'wonka'; + +import * as t from '../term'; + +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 bold(text: string) { + return t.text`${t.cmd(t.CSI.Style, t.Style.Bold)}${text}${t.cmd(t.CSI.Style, t.Style.Normal)}`; +} + +export function hint(text: string) { + return t.text([ + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `${t.HeavyBox.BottomLeft} `, + t.cmd(t.CSI.Style, t.Style.BrightBlue), + `${t.Icons.Info} `, + t.cmd(t.CSI.Style, t.Style.Blue), + text, + ]); +} + +export function console(error: any) { + return t.text([ + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `${t.HeavyBox.BottomLeft} `, + error && error instanceof Error ? error.message : `${error}`, + ]); +} + +export function emptyLine() { + return t.text([t.cmd(t.CSI.Style, t.Style.BrightBlack), t.HeavyBox.Vertical, '\n']); +} + +export function title(title: string, description?: string) { + let out = t.text([ + t.cmd(t.CSI.Style, t.Style.BrightBlack), + t.HeavyBox.TopLeft, + ' ', + t.cmd(t.CSI.Style, [t.Style.Magenta, t.Style.Invert]), + ` ${title.trim()} `, + t.cmd(t.CSI.Style, [t.Style.NoInvert]), + '\n', + ]); + if (description) { + out += t.text([ + t.cmd(t.CSI.Style, t.Style.BrightBlack), + t.HeavyBox.Vertical, + ` ${description}\n`, + ]); + } + return out; +} + +export function completedTask(description: string, isLast = false) { + return t.text([ + emptyLine(), + t.cmd(t.CSI.Style, t.Style.BrightBlack), + isLast ? t.HeavyBox.BottomLeft : t.HeavyBox.VerticalRight, + ' ', + t.cmd(t.CSI.Style, t.Style.Green), + t.Icons.TickSwoosh, + ' ', + t.cmd(t.CSI.Style, t.Style.Foreground), + description, + '\n', + ]); +} + +export function failedTask(description: string) { + return t.text([ + emptyLine(), + t.cmd(t.CSI.Style, t.Style.BrightBlack), + t.HeavyBox.BottomLeft, + ' ', + t.cmd(t.CSI.Style, t.Style.BrightRed), + t.Icons.CrossSwoosh, + ' ', + t.cmd(t.CSI.Style, t.Style.Foreground), + description, + '\n', + ]); +} + +export function runningTask(description: string) { + return pipe( + interval(150), + map((state) => { + return t.text([ + emptyLine(), + t.cmd(t.CSI.Style, t.Style.Magenta), + t.circleSpinner[state % t.circleSpinner.length], + ' ', + t.cmd(t.CSI.Style, t.Style.Foreground), + description, + ]); + }) + ); +} + +export function success() { + return t.text([ + '\n', + t.cmd(t.CSI.Style, [t.Style.Green, t.Style.Invert]), + ' Done ', + t.cmd(t.CSI.Style, t.Style.NoInvert), + t.Chars.Space, + 'You are all set and ready to go.\n', + ]); +} + +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/term/csi.ts b/packages/cli-utils/src/term/csi.ts new file mode 100644 index 00000000..67b06219 --- /dev/null +++ b/packages/cli-utils/src/term/csi.ts @@ -0,0 +1,284 @@ +let hasColor = false; + +export function _setColor(color: boolean) { + hasColor = color; +} + +// See: http://xtermjs.org/docs/api/vtfeatures/#csi +const enum CSI { + InsertChars = '@', + ScrollLeft = 'SP@', + Up = 'A', + ScrollRight = 'SPA', + Down = 'B', + Forward = 'C', + Backward = 'D', + NextLine = 'E', + PrevLine = 'F', + ToColumn = 'G', + ToPosition = 'H', + Tab = 'I', + Erase = 'J', + EraseLine = 'K', + InsertLines = 'L', + DeleteLines = 'M', + DeleteChars = 'P', + ScrollUp = 'S', + ScrollDown = 'T', + EraseChars = 'X', + TabBackwards = 'Z', + RepeatChar = 'b', + ToRow = 'd', + DownLine = 'e', + TabClear = 'g', + SetMode = 'h', + UnsetMode = 'l', + SetPrivateMode = '?h', + UnsetPrivateMode = '?l', + ResetPrivateMode = '?r', + Style = 'm', + Reset = '!p', + Protect = '"q', + Cursor = 'SPq', + SetMargin = 'r', + SaveCursor = 's', + RestoreCursor = 'u', + InsertColumns = "'}", + DeleteColumns = "'~", +} + +const enum TabClear { + Current = 0, + All = 3, +} + +const enum Erase { + Forward = 0, + Backward = 1, + All = 2, + Scrollback = 3, +} + +const enum EraseLine { + Forward = 0, + Backward = 1, + All = 2, +} + +const enum Protect { + Insert = 4, + AutomaticNewline = 20, +} + +const enum Mode { + Insert = 4, + AutomaticNewline = 20, +} + +const enum PrivateMode { + AppCursorKeys = 1, + USASCII = 2, + Column132 = 3, + OriginMode = 6, + AutoWrap = 7, + AutoRepeat = 8, + X10Mouse = 9, + BlinkCursor = 12, + ShowCursor = 25, + ReverseWrapAround = 45, + AlternativeScreenBuffer = 47, + AppKeypad = 66, + X11Mouse = 1000, + CellMotionMouseTracking = 1002, + AllMotionMouseTracking = 1003, + FocusEvents = 1004, + Utf8Mouse = 1005, + SGRMouse = 1006, + UrxvtMouse = 1015, + SGRPixelsMouse = 1016, + SaveCursor = 1048, + BracketedPaste = 2004, +} + +const enum Cursor { + Empty = 0, + Block = 1, + BlinkBlock = 2, + Underline = 3, + BlinkUnderline = 4, + Bar = 5, + BlinkBar = 6, +} + +const enum Style { + Reset = 0, + Bold = 1, + Faint = 2, + Italic = 3, + Underline = 4, + Blink = 5, + RapidBlink = 6, + Invert = 7, + Invisible = 8, + Strikethrough = 9, + DoubleUnderlined = 21, + Normal = 22, // No Bold, No Faint + NoItalic = 23, + NoUnderline = 24, + NoBlink = 25, + NoInvert = 27, + Visible = 28, + NoStrikethrough = 29, + + Black = 30, + Red = 31, + Green = 32, + Yellow = 33, + Blue = 34, + Magenta = 35, + Cyan = 36, + White = 37, + Foreground = 39, + + OnBlack = 40, + OnRed = 41, + OnGreen = 42, + OnYellow = 43, + OnBlue = 44, + OnMagenta = 45, + OnCyan = 46, + OnWhite = 47, + OnBackground = 49, + + BrightBlack = 90, + BrightRed = 91, + BrightGreen = 92, + BrightYellow = 93, + BrightBlue = 94, + BrightMagenta = 95, + BrightCyan = 96, + BrightWhite = 97, + + OnBrightBlack = 100, + OnBrightRed = 101, + OnBrightGreen = 102, + OnBrightYellow = 103, + OnBrightBlue = 104, + OnBrightMagenta = 105, + OnBrightCyan = 106, + OnBrightWhite = 107, + + DoubleUnderline = '4:2', + CurlyUnderline = '4:3', + DottedUnderline = '4:4', + DashedUnderline = '4:5', +} + +type escapeCSI = '\x1B['; +const escapeCSI = '\x1B['; + +type CommandNoParam = CSI.Reset | CSI.SaveCursor | CSI.RestoreCursor | CSI.ResetPrivateMode; + +type CommandSingleParam = + | CSI.ScrollLeft + | CSI.ScrollRight + | CSI.ScrollUp + | CSI.ScrollDown + | CSI.Up + | CSI.Down + | CSI.Backward + | CSI.Forward + | CSI.PrevLine + | CSI.NextLine + | CSI.DownLine + | CSI.ToColumn + | CSI.ToRow + | CSI.Tab + | CSI.TabBackwards + | CSI.InsertChars + | CSI.InsertLines + | CSI.InsertColumns + | CSI.DeleteChars + | CSI.DeleteLines + | CSI.DeleteColumns + | CSI.RepeatChar; + +type CommandParam = + | number + | TabClear + | Erase + | EraseLine + | Protect + | Cursor + | Mode + | PrivateMode + | Style + | readonly Mode[] + | readonly PrivateMode[] + | readonly Style[]; + +function cmd(code: CommandNoParam): `${escapeCSI}${CommandNoParam}`; +function cmd( + code: CommandSingleParam, + count?: number +): `${escapeCSI}${number}${CommandSingleParam}`; +function cmd(code: CSI.TabClear, mode?: TabClear): `${escapeCSI}${TabClear}${CSI.TabClear}`; +function cmd(code: CSI.Erase, mode?: Erase): `${escapeCSI}${Erase}${CSI.Erase}`; +function cmd(code: CSI.EraseLine, mode?: EraseLine): `${escapeCSI}${EraseLine}${CSI.EraseLine}`; +function cmd(code: CSI.Protect, mode?: Protect): `${escapeCSI}${Protect}${CSI.Protect}`; +function cmd(code: CSI.Cursor, style: Cursor): `${escapeCSI}${Cursor}${CSI.Cursor}`; + +function cmd( + code: CSI.ToPosition, + row: number, + column: number +): `${escapeCSI}${number};${number}${CSI.ToPosition}`; + +function cmd( + code: CSI.SetMargin, + top: number, + bottom: number +): `${escapeCSI}${number};${number}${CSI.SetMargin}`; + +function cmd(code: CSI.SetMode, modes: Mode | readonly Mode[]): `${escapeCSI}${Mode}${CSI.SetMode}`; + +function cmd( + code: CSI.SetPrivateMode, + modes: PrivateMode | readonly PrivateMode[] +): `${escapeCSI}?${PrivateMode}${CSI.SetMode}`; + +function cmd( + code: CSI.UnsetMode, + modes: Mode | readonly Mode[] +): `${escapeCSI}${Mode}${CSI.UnsetMode}`; + +function cmd( + code: CSI.UnsetPrivateMode, + modes: PrivateMode | readonly PrivateMode[] +): `${escapeCSI}?${PrivateMode}${CSI.UnsetMode}`; + +function cmd(code: CSI.Style, styles: Style | readonly Style[]): `${escapeCSI}${Style}${CSI.Style}`; + +function cmd(code: CSI, a?: CommandParam, b?: number): cmdCode { + if (!hasColor && code === CSI.Style) return ''; + let out = escapeCSI; + if (code === CSI.SetPrivateMode) { + out += '?'; + code = CSI.SetMode; + } else if (code === CSI.UnsetPrivateMode) { + out += '?'; + code = CSI.UnsetMode; + } + if (Array.isArray(a)) { + out += a.join(';'); + } else if (a != null) { + out += `${a}`; + if (b != null) out += `;${b}`; + } + out += code; + return out as cmdCode; +} + +export type cmdCode = `${escapeCSI}${string}${CSI}` | ''; + +export { cmd, CSI, TabClear, Erase, EraseLine, Protect, Mode, PrivateMode, Cursor, Style }; diff --git a/packages/cli-utils/src/term/index.ts b/packages/cli-utils/src/term/index.ts new file mode 100644 index 00000000..b0f6852d --- /dev/null +++ b/packages/cli-utils/src/term/index.ts @@ -0,0 +1,5 @@ +export type { ComposeInput } from './write'; +export { CLIError, text, error, compose } from './write'; +export * from './csi'; +export * from './symbols'; +export * from './tty'; diff --git a/packages/cli-utils/src/term/symbols.ts b/packages/cli-utils/src/term/symbols.ts new file mode 100644 index 00000000..e0915732 --- /dev/null +++ b/packages/cli-utils/src/term/symbols.ts @@ -0,0 +1,155 @@ +export const enum Chars { + Bell = '\x07', + Newline = '\x0a', + Tab = '\x09', + Space = ' ', +} + +export const enum Box { + TopLeft = '┌', + TopRight = '┐', + BottomLeft = '└', + BottomRight = '┘', + Vertical = '│', + VerticalRight = '├', + VerticalLeft = '┤', + Horizontal = '─', + HorizontalDown = '┬', + HorizontalUp = '┴', + Cross = '┼', +} + +export const enum HeavyBox { + TopLeft = '┏', + TopRight = '┓', + BottomLeft = '┗', + BottomRight = '┛', + Vertical = '┃', + VerticalRight = '┣', + VerticalLeft = '┫', + Horizontal = '━', + HorizontalDown = '┳', + HorizontalUp = '┻', + Cross = '╋', +} + +export const enum DoubleBox { + TopLeft = '╔', + TopRight = '╗', + BottomLeft = '╚', + BottomRight = '╝', + Vertical = '║', + VerticalRight = '╠', + VerticalLeft = '╣', + Horizontal = '═', + HorizontalDown = '╦', + HorizontalUp = '╩', + Cross = '╬', +} + +export enum RoundedBox { + TopLeft = '╭', + TopRight = '╮', + BottomLeft = '╰', + BottomRight = '╯', + Vertical = '│', + VerticalRight = '├', + VerticalLeft = '┤', + Horizontal = '─', + HorizontalDown = '┬', + HorizontalUp = '┴', + Cross = '┼', +} + +export const enum Arrow { + Right = '→', + Left = '←', + Up = '↑', + Down = '↓', + LeftRight = '↔', + UpDown = '↕', +} + +export const enum Triangle { + Right = '▶', + Left = '◀', + Up = '▲', + Down = '▼', +} + +export const enum SmallTriangle { + Right = '▸', + Left = '◂', + Up = '▴', + Down = '▾', +} + +export const enum Line { + DiagonalCross = '╳', + Diagonalbackwards = '╲', + Diagonal = '╱', + VerticalDashed = '┆', + VerticalDashedHeavy = '┇', + Vertical = '│', + VerticalHeavy = '┃', + HorizontalDashed = '┄', + HorizontalDashedHeavy = '┅', + Horizontal = '─', + HorizontalHeavy = '━', +} + +export const enum Circle { + Filled = '●', + Outline = '◯', + OutlineFilled = '◉', + Dotted = '◌', + Doubled = '◎', + Small = '•', + HalfLeft = '◐', + HalfTop = '◓', + HalfRight = '◑', + HalfBottom = '◒', +} + +export const enum Chevron { + Default = '', + Small = '›', + Heavy = '❯', +} + +export const enum Diamond { + Default = '◆', + Outline = '◇', +} + +export const enum Square { + Default = '■', + Outline = '☐', + Crossed = '☒', +} + +export const enum Heart { + Default = '❤︎', + Outline = '♥', +} + +export const enum Icons { + Tick = '✓', + TickSwoosh = '✔', + Cross = '×', + CrossSwoosh = '✘', + Home = '⌂', + Note = '♪', + Warning = '⚠', + Info = 'ℹ', + Star = '★', +} + +export const enum Shade { + Light = '░', + Medium = '▒', + Heavy = '▓', +} + +export const dotSpinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +export const circleSpinner = ['◐', '◓', '◑', '◒']; diff --git a/packages/cli-utils/src/term/tty.ts b/packages/cli-utils/src/term/tty.ts new file mode 100644 index 00000000..a3d99aac --- /dev/null +++ b/packages/cli-utils/src/term/tty.ts @@ -0,0 +1,174 @@ +import { + fromValue, + make, + concat, + pipe, + filter, + share, + takeUntil, + toPromise, + onPush, + onStart, + onEnd, +} from 'wonka'; + +import type { Source } from 'wonka'; +import type { WriteStream, ReadStream } from 'node:tty'; +import { emitKeypressEvents } from 'node:readline'; + +import type { ComposeInput } from './write'; +import { text, compose } from './write'; +import { cmd, _setColor, CSI, Mode, PrivateMode } from './csi'; + +export interface KeypressEvent { + data?: string; + sequence: string; + name: string; + ctrl: boolean; + meta: boolean; + shift: boolean; +} + +export interface TTY { + output: WriteStream; + pipeTo: WriteStream | null; + inputSource: Source; + cancelSource: Source; + + write(input: readonly string[], ...args: readonly string[]): void; + write(...input: readonly string[]): void; + + start(outputs: AsyncIterable): Promise; + + mode(...modes: readonly (Mode | PrivateMode)[]): void; + modeOff(...modes: readonly (Mode | PrivateMode)[]): void; +} + +function fromReadStream(stream: ReadStream): Source { + return make((observer) => { + function onKeypress(data: string | undefined, event: KeypressEvent) { + switch (event.name) { + case 'c': + case 'd': + case 'x': + if (event.ctrl) cleanup(); + case 'escape': + cleanup(); + default: + observer.next({ ...event, data }); + } + } + + function cleanup() { + observer.complete(); + stream.removeListener('keypress', onKeypress); + stream.setRawMode(false); + stream.unref(); + } + + emitKeypressEvents(stream); + stream.setRawMode(true); + stream.setEncoding('utf8'); + stream.resume(); + stream.addListener('keypress', onKeypress); + return cleanup; + }); +} + +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) { + output = process.stderr; + pipeTo = process.stdout; + } else { + isTTY = output.isTTY; + } + + const hasColorArg = process.argv.includes('--color'); + const hasColorEnv = 'FORCE_COLOR' in process.env || (!process.env.NO_COLOR && !process.env.CI); + _setColor((isTTY && hasColorEnv) || hasColorArg); + + function _start() { + _setColor((isTTY && hasColorEnv) || hasColorArg); + if (isTTY) { + output.write(cmd(CSI.UnsetPrivateMode, PrivateMode.ShowCursor)); + } + } + + function _end() { + if (isTTY) { + output.write( + cmd(CSI.Reset) + + cmd(CSI.ResetPrivateMode) + + cmd(CSI.SetPrivateMode, PrivateMode.ShowCursor) + + '\n' + ); + } + } + + const inputSource = pipe(fromReadStream(process.stdin), onStart(_start), onEnd(_end), share); + + const cancelSource = pipe( + concat([ + pipe( + inputSource, + filter(() => false) + ), + fromValue(null), + ]), + share + ); + + function write(...input: any[]) { + output.write(text(...input)); + } + + function start(outputs: AsyncIterable) { + return pipe(compose(outputs), onPush(write), takeUntil(cancelSource), toPromise); + } + + function mode(...modes: readonly (Mode | PrivateMode)[]): void { + if (isTTY) { + const normalModes: Mode[] = []; + const privateModes: PrivateMode[] = []; + for (const mode of modes) { + if (mode === Mode.Insert || mode === Mode.AutomaticNewline) { + normalModes.push(mode); + } else { + privateModes.push(mode); + } + } + if (normalModes.length) output.write(cmd(CSI.SetMode, normalModes)); + if (privateModes.length) output.write(cmd(CSI.SetPrivateMode, privateModes)); + } + } + + function modeOff(...modes: readonly (Mode | PrivateMode)[]): void { + if (isTTY) { + const normalModes: Mode[] = []; + const privateModes: PrivateMode[] = []; + for (const mode of modes) { + if (mode === Mode.Insert || mode === Mode.AutomaticNewline) { + normalModes.push(mode); + } else { + privateModes.push(mode); + } + } + if (normalModes.length) output.write(cmd(CSI.UnsetMode, normalModes)); + if (privateModes.length) output.write(cmd(CSI.UnsetPrivateMode, privateModes)); + } + } + + return { + output, + pipeTo, + inputSource, + cancelSource, + write, + start, + mode, + modeOff, + }; +} diff --git a/packages/cli-utils/src/term/write.ts b/packages/cli-utils/src/term/write.ts new file mode 100644 index 00000000..1ede7933 --- /dev/null +++ b/packages/cli-utils/src/term/write.ts @@ -0,0 +1,105 @@ +import type { Source } from 'wonka'; + +import { + pipe, + fromAsyncIterable, + fromValue, + concatMap, + sample, + merge, + takeUntil, + filter, + share, + take, + scan, + map, +} from 'wonka'; + +import { cmd, CSI, Style } from './csi'; + +const ansiRegex = /([\x1B\x9B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><])/g; + +export const stripAnsi = (input: string) => input.replace(ansiRegex, ''); + +export class CLIError extends Error { + output: string; + constructor(message: string) { + super(stripAnsi(message)); + this.output = message; + } +} + +function text(input: readonly string[], ...args: readonly string[]): string; +function text(...input: readonly string[]): string; + +function text(input: string | readonly string[], ...args: readonly string[]): string { + let out = ''; + if (Array.isArray(input)) { + let argIndex = 0; + for (let index = 0; index < input.length; index++) { + out += input[index]; + if (argIndex < args.length) out += args[argIndex++]; + } + } else if (typeof input === 'string') { + out += input; + for (const arg of args) out += arg; + } + return out; +} + +function error(input: readonly string[], ...args: readonly string[]): CLIError; +function error(...input: readonly string[]): CLIError; + +function error(...input: any[]): CLIError { + return new CLIError(text(...input)); +} + +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) + ); +} + +type ComposeInput = undefined | string | Source | AsyncIterable; + +async function* convertError(outputs: AsyncIterable): AsyncIterable { + try { + yield* outputs; + } catch (error) { + yield error instanceof CLIError ? error.output : '' + error; + } +} + +function compose(outputs: AsyncIterable): Source { + const outputs$ = share(fromAsyncIterable(convertError(outputs))); + const reset = cmd(CSI.Style, [Style.Reset, Style.NoInvert]); + return pipe( + outputs$, + filter((x: T): x is Exclude => x != null), + concatMap((output) => { + if (typeof output === 'object') { + return compose(output); + } + const output$ = share(typeof output === 'string' ? fromValue(output) : output); + return pipe( + merge([ + pipe( + output$, + sample(outputs$), + map((output) => (output.endsWith('\n') ? output : '')), + take(1) + ), + pipe(output$, takeUntil(outputs$)), + ]), + scan((prev, output) => clear(prev) + output + reset, '') + ); + }) + ); +} + +export type { ComposeInput }; +export { text, error, compose, clear }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c322184b..85e0516b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: typescript: specifier: ^5.3.3 version: 5.4.2 + wonka: + specifier: ^6.3.4 + version: 6.3.4 packages/internal: dependencies: