From 36adc7547dd66349dc3498ab235d34f54524bbce Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Mon, 15 Apr 2024 10:34:27 +0100 Subject: [PATCH] feat(cli-utils): Complete CLI output, logging, and config refactor (#200) --- .changeset/mighty-taxis-end.md | 5 + .changeset/thin-bikes-help.md | 5 + .changeset/twelve-kiwis-fail.md | 5 + packages/cli-utils/LICENSE.md | 26 --- packages/cli-utils/package.json | 1 - .../cli-utils/src/commands/check/logger.ts | 34 +-- .../cli-utils/src/commands/check/runner.ts | 45 ++-- .../cli-utils/src/commands/check/thread.ts | 61 +++--- .../cli-utils/src/commands/check/types.ts | 2 + .../cli-utils/src/commands/doctor/logger.ts | 22 +- .../cli-utils/src/commands/doctor/runner.ts | 143 ++++--------- .../src/commands/generate-output/index.ts | 14 +- .../src/commands/generate-output/logger.ts | 10 + .../src/commands/generate-output/runner.ts | 94 +++++---- .../src/commands/generate-persisted/index.ts | 25 ++- .../src/commands/generate-persisted/logger.ts | 97 +++++++++ .../src/commands/generate-persisted/runner.ts | 198 +++++++----------- .../src/commands/generate-persisted/thread.ts | 130 ++++++++++++ .../src/commands/generate-persisted/types.ts | 20 ++ .../src/commands/generate-schema/index.ts | 18 +- .../src/commands/generate-schema/logger.ts | 10 + .../src/commands/generate-schema/runner.ts | 73 ++++--- .../cli-utils/src/commands/init/runner.ts | 8 +- .../cli-utils/src/commands/shared/index.ts | 2 + .../cli-utils/src/commands/shared/logger.ts | 75 +++++++ .../cli-utils/src/commands/shared/utils.ts | 57 +++++ .../cli-utils/src/commands/turbo/index.ts | 23 +- .../cli-utils/src/commands/turbo/logger.ts | 101 +++++++++ .../cli-utils/src/commands/turbo/runner.ts | 191 +++++++++-------- .../cli-utils/src/commands/turbo/thread.ts | 104 +++++++++ .../cli-utils/src/commands/turbo/types.ts | 20 ++ packages/cli-utils/src/lsp.ts | 42 ---- packages/cli-utils/src/resolve.ts | 19 -- packages/cli-utils/src/tada.ts | 51 ----- packages/cli-utils/src/term/write.ts | 3 +- packages/cli-utils/src/ts/index.ts | 2 + packages/cli-utils/src/ts/project.ts | 2 +- packages/cli-utils/src/ts/utils.ts | 27 +++ packages/cli-utils/src/tsconfig.ts | 40 ---- packages/internal/LICENSE.md | 26 --- packages/internal/package.json | 5 +- packages/internal/src/config.ts | 63 ++++++ packages/internal/src/errors.ts | 14 +- packages/internal/src/helpers.ts | 8 + packages/internal/src/index.ts | 5 +- packages/internal/src/resolve.ts | 159 +++++++++++--- pnpm-lock.yaml | 6 - 47 files changed, 1311 insertions(+), 780 deletions(-) create mode 100644 .changeset/mighty-taxis-end.md create mode 100644 .changeset/thin-bikes-help.md create mode 100644 .changeset/twelve-kiwis-fail.md create mode 100644 packages/cli-utils/src/commands/generate-output/logger.ts create mode 100644 packages/cli-utils/src/commands/generate-persisted/logger.ts create mode 100644 packages/cli-utils/src/commands/generate-persisted/thread.ts create mode 100644 packages/cli-utils/src/commands/generate-persisted/types.ts create mode 100644 packages/cli-utils/src/commands/generate-schema/logger.ts create mode 100644 packages/cli-utils/src/commands/shared/index.ts create mode 100644 packages/cli-utils/src/commands/shared/logger.ts create mode 100644 packages/cli-utils/src/commands/shared/utils.ts create mode 100644 packages/cli-utils/src/commands/turbo/logger.ts create mode 100644 packages/cli-utils/src/commands/turbo/thread.ts create mode 100644 packages/cli-utils/src/commands/turbo/types.ts delete mode 100644 packages/cli-utils/src/lsp.ts delete mode 100644 packages/cli-utils/src/resolve.ts delete mode 100644 packages/cli-utils/src/tada.ts create mode 100644 packages/cli-utils/src/ts/index.ts create mode 100644 packages/cli-utils/src/ts/utils.ts delete mode 100644 packages/cli-utils/src/tsconfig.ts create mode 100644 packages/internal/src/config.ts create mode 100644 packages/internal/src/helpers.ts diff --git a/.changeset/mighty-taxis-end.md b/.changeset/mighty-taxis-end.md new file mode 100644 index 00000000..86152ba5 --- /dev/null +++ b/.changeset/mighty-taxis-end.md @@ -0,0 +1,5 @@ +--- +"@gql.tada/cli-utils": major +--- + +Add stylised log output and threading to commands. diff --git a/.changeset/thin-bikes-help.md b/.changeset/thin-bikes-help.md new file mode 100644 index 00000000..7a535a3d --- /dev/null +++ b/.changeset/thin-bikes-help.md @@ -0,0 +1,5 @@ +--- +"@gql.tada/cli-utils": minor +--- + +Add annotations for GitHub actions to command outputs that report diagnostics. diff --git a/.changeset/twelve-kiwis-fail.md b/.changeset/twelve-kiwis-fail.md new file mode 100644 index 00000000..bc6ea914 --- /dev/null +++ b/.changeset/twelve-kiwis-fail.md @@ -0,0 +1,5 @@ +--- +"@gql.tada/internal": minor +--- + +Implement new config resolution helpers diff --git a/packages/cli-utils/LICENSE.md b/packages/cli-utils/LICENSE.md index 5a6bc9c2..c030b6b2 100644 --- a/packages/cli-utils/LICENSE.md +++ b/packages/cli-utils/LICENSE.md @@ -120,32 +120,6 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -## json5 - -MIT License - -Copyright (c) 2012-2018 Aseem Kishore, and [others]. - -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. - -[others]: https://github.com/json5/json5/contributors - ## merge-stream The MIT License (MIT) diff --git a/packages/cli-utils/package.json b/packages/cli-utils/package.json index ded966c4..e5333194 100644 --- a/packages/cli-utils/package.json +++ b/packages/cli-utils/package.json @@ -44,7 +44,6 @@ "@types/node": "^20.11.0", "clipanion": "4.0.0-rc.3", "execa": "^8.0.1", - "json5": "^2.2.3", "rollup": "^4.9.4", "sade": "^1.8.1", "semiver": "^1.1.0", diff --git a/packages/cli-utils/src/commands/check/logger.ts b/packages/cli-utils/src/commands/check/logger.ts index 0a1d4cb4..ce04e2b1 100644 --- a/packages/cli-utils/src/commands/check/logger.ts +++ b/packages/cli-utils/src/commands/check/logger.ts @@ -5,14 +5,11 @@ import * as t from '../../term'; import type { DiagnosticMessage } from './types'; import type { SeveritySummary } from './types'; -const CWD = process.cwd(); +export * from '../shared/logger'; +import { indent } from '../shared/logger'; -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 - )}`; -} +const CWD = process.cwd(); +const INDENT = ' '; export function diagnosticFile(filePath: string) { const relativePath = path.relative(CWD, filePath); @@ -26,8 +23,6 @@ export function diagnosticFile(filePath: string) { } 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; @@ -37,13 +32,8 @@ export function diagnosticMessage(message: DiagnosticMessage) { 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, + INDENT, t.cmd(t.CSI.Style, t.Style.BrightBlack), `${message.line}:${message.col}`, t.Chars.Tab, @@ -51,7 +41,7 @@ export function diagnosticMessage(message: DiagnosticMessage) { message.severity, t.Chars.Tab, t.cmd(t.CSI.Style, t.Style.Foreground), - text, + indent(message.message.trim(), t.text([INDENT, t.Chars.Tab, t.Chars.Tab])), t.Chars.Newline, ]); } @@ -95,6 +85,8 @@ export function diagnosticMessageGithub(message: DiagnosticMessage): void { file: message.file, line: message.line, col: message.col, + endLine: message.endLine, + endColumn: message.endColumn, }); } @@ -115,13 +107,3 @@ export function runningDiagnostics(file: number, ofFiles?: number) { }) ); } - -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 088a2ab3..be35f78f 100644 --- a/packages/cli-utils/src/commands/check/runner.ts +++ b/packages/cli-utils/src/commands/check/runner.ts @@ -1,9 +1,7 @@ -import path from 'node:path'; +import type { GraphQLSPConfig, LoadConfigResult } from '@gql.tada/internal'; +import { loadConfig, parseConfig } from '@gql.tada/internal'; -import { getGraphQLSPConfig } from '../../lsp'; -import { getTsConfig } from '../../tsconfig'; import * as logger from './logger'; - import type { ComposeInput } from '../../term'; import type { Severity, SeveritySummary } from './types'; @@ -33,37 +31,24 @@ export interface Options { } 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); - if (!config) { - throw logger.errorMessage( - `No ${logger.code('"@0no-co/graphqlsp"')} plugin was found in your ${logger.code( - 'tsconfig.json' - )}.\n` - ); + let configResult: LoadConfigResult; + let pluginConfig: GraphQLSPConfig; + try { + configResult = await loadConfig(opts.tsconfig); + pluginConfig = parseConfig(configResult.pluginConfig); + } catch (error) { + throw logger.externalError('Failed to load configuration.', error); } - let tsconfigPath = opts.tsconfig || CWD; - tsconfigPath = - path.extname(tsconfigPath) !== '.json' - ? path.resolve(CWD, tsconfigPath, 'tsconfig.json') - : path.resolve(CWD, tsconfigPath); - const summary: SeveritySummary = { warn: 0, error: 0, info: 0 }; const minSeverity = opts.minSeverity; - const generator = runDiagnostics({ tsconfigPath, config }); + const generator = runDiagnostics({ + rootPath: configResult.rootPath, + configPath: configResult.configPath, + pluginConfig, + }); let totalFileCount = 0; let fileCount = 0; @@ -90,7 +75,7 @@ export async function* run(opts: Options): AsyncIterable { yield logger.runningDiagnostics(++fileCount, totalFileCount); } } catch (error: any) { - throw logger.errorMessage(error.message || `${error}`); + throw logger.externalError('Could not check files', error); } // Reset notice count if it's outside of min severity diff --git a/packages/cli-utils/src/commands/check/thread.ts b/packages/cli-utils/src/commands/check/thread.ts index 6f35204c..7dfdce88 100644 --- a/packages/cli-utils/src/commands/check/thread.ts +++ b/packages/cli-utils/src/commands/check/thread.ts @@ -1,32 +1,15 @@ import * as path from 'node:path'; import { Project, ts } from 'ts-morph'; -import { load, resolveTypeScriptRootDir } from '@gql.tada/internal'; +import type { GraphQLSPConfig } from '@gql.tada/internal'; +import { load } from '@gql.tada/internal'; import { init, getGraphQLDiagnostics } from '@0no-co/graphqlsp/api'; -import type { GraphQLSPConfig } from '../../lsp'; -import { createPluginInfo } from '../../ts/project'; +import { createPluginInfo, getFilePosition } from '../../ts'; 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(); @@ -35,53 +18,61 @@ const loadSchema = async (rootPath: string, config: GraphQLSPConfig) => { }; export interface DiagnosticsParams { - config: GraphQLSPConfig; - tsconfigPath: string; + rootPath: string; + configPath: string; + pluginConfig: GraphQLSPConfig; } 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(); + const projectPath = path.dirname(params.configPath); + const schemaRef = await loadSchema(projectPath, params.pluginConfig); + const project = new Project({ tsConfigFilePath: params.configPath }); + const pluginInfo = createPluginInfo(project, params.pluginConfig, projectPath); + + // Filter source files by whether they're under the relevant root path + const sourceFiles = project.getSourceFiles().filter((sourceFile) => { + const filePath = path.resolve(projectPath, sourceFile.getFilePath()); + const relative = path.relative(params.rootPath, filePath); + return !relative.startsWith('..'); + }); yield { kind: 'FILE_COUNT', fileCount: sourceFiles.length, }; - for (const sourceFile of sourceFiles) { - const filePath = sourceFile.getFilePath(); + for (const { compilerNode: sourceFile } of sourceFiles) { + const filePath = sourceFile.fileName; 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); + const position = getFilePosition(sourceFile, diagnostic.start, diagnostic.length); messages.push({ severity, message: diagnostic.messageText, file: diagnostic.file.fileName, - line, - col, + line: position.line, + col: position.col, + endLine: position.endLine, + endColumn: position.endColumn, }); } } diff --git a/packages/cli-utils/src/commands/check/types.ts b/packages/cli-utils/src/commands/check/types.ts index f18347f7..2c5b1a71 100644 --- a/packages/cli-utils/src/commands/check/types.ts +++ b/packages/cli-utils/src/commands/check/types.ts @@ -8,6 +8,8 @@ export interface DiagnosticMessage { file: string; line: number; col: number; + endLine: number | undefined; + endColumn: number | undefined; } export interface FileDiagnosticsSignal { diff --git a/packages/cli-utils/src/commands/doctor/logger.ts b/packages/cli-utils/src/commands/doctor/logger.ts index 1d5b596f..c872e53f 100644 --- a/packages/cli-utils/src/commands/doctor/logger.ts +++ b/packages/cli-utils/src/commands/doctor/logger.ts @@ -2,27 +2,7 @@ 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 * from '../shared/logger'; export function console(error: any) { return t.text([ diff --git a/packages/cli-utils/src/commands/doctor/runner.ts b/packages/cli-utils/src/commands/doctor/runner.ts index 04bbbacf..0560e059 100644 --- a/packages/cli-utils/src/commands/doctor/runner.ts +++ b/packages/cli-utils/src/commands/doctor/runner.ts @@ -1,10 +1,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { parse } from 'json5'; import semiver from 'semiver'; -import type { TsConfigJson } from 'type-fest'; -import { resolveTypeScriptRootDir } from '@gql.tada/internal'; -import { existsSync } from 'node:fs'; + +import type { GraphQLSPConfig, LoadConfigResult } from '@gql.tada/internal'; +import { load, loadConfig, parseConfig } from '@gql.tada/internal'; import type { ComposeInput } from '../../term'; import * as logger from './logger'; @@ -21,6 +20,11 @@ const delay = (ms = 700) => { } }; +const semiverComply = (version: string, compare: string) => { + const match = version.match(/\d+\.\d+\.\d+/); + return match ? semiver(match[0], compare) >= 0 : false; +}; + const enum Messages { TITLE = 'Doctor', DESCRIPTION = 'Detects problems with your setup', @@ -72,7 +76,7 @@ export async function* run(): AsyncIterable { `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) { + } else if (!semiverComply(typeScriptVersion[1], MINIMUM_VERSIONS.typescript)) { // TypeScript version lower than v4.1 which is when they introduced template lits yield logger.failedTask(Messages.CHECK_TS_VERSION); throw logger.errorMessage( @@ -94,7 +98,7 @@ export async function* run(): AsyncIterable { `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) { + } else if (!semiverComply(gqlspVersion[1], 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` + @@ -111,7 +115,7 @@ export async function* run(): AsyncIterable { `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) { + } else if (!semiverComply(gqlTadaVersion[1], '1.0.0')) { yield logger.failedTask(Messages.CHECK_DEPENDENCIES); throw logger.errorMessage( `The version of ${logger.code('gql.tada')} in your dependencies is out of date.\n` + @@ -127,91 +131,28 @@ export async function* run(): AsyncIterable { yield logger.runningTask(Messages.CHECK_TSCONFIG); await delay(); - const tsconfigpath = path.resolve(cwd, 'tsconfig.json'); - - let tsconfigContents: string; - try { - 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' - )}.` - ) - ); - } - - let tsConfig: TsConfigJson; + let configResult: LoadConfigResult; try { - tsConfig = parse(tsconfigContents) as TsConfigJson; - } catch (error: any) { + configResult = await loadConfig(); + } catch (error) { yield logger.failedTask(Messages.CHECK_TSCONFIG); - throw logger.errorMessage( - `Your ${logger.code('tsconfig.json')} file could not be parsed.\n` + - logger.console(error.message) + throw logger.externalError( + `A ${logger.code('tsconfig.json')} file was not found in the current working directory.`, + error ); } - let root: string; + let pluginConfig: GraphQLSPConfig; 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) + pluginConfig = parseConfig(configResult.pluginConfig); + } catch (error) { + throw logger.externalError( + `The plugin configuration for ${logger.code('"@0no-co/graphqlsp"')} seems to be invalid.`, + error ); } - 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 = - 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) { - 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) { - 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) { + if (!pluginConfig.schema) { yield logger.failedTask(Messages.CHECK_TSCONFIG); throw logger.errorMessage( `No ${logger.code('"schema"')} option was found in your configuration.\n` + @@ -223,29 +164,19 @@ export async function* run(): AsyncIterable { 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 { - 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?`) - ); - } + const loader = load({ + origin: pluginConfig.schema, + rootPath: path.dirname(configResult.configPath), + }); + + let hasSchema = false; + try { + hasSchema = !!(await loader.loadIntrospection()); + } catch (error) { + throw logger.externalError('Failed to load schema.', error); + } + if (!hasSchema) { + throw logger.errorMessage('Failed to load schema.'); } yield logger.completedTask(Messages.CHECK_SCHEMA, true); diff --git a/packages/cli-utils/src/commands/generate-output/index.ts b/packages/cli-utils/src/commands/generate-output/index.ts index af3ceed1..3930e92f 100644 --- a/packages/cli-utils/src/commands/generate-output/index.ts +++ b/packages/cli-utils/src/commands/generate-output/index.ts @@ -21,10 +21,14 @@ export class GenerateOutputCommand extends Command { }); async execute() { - await run(initTTY(), { - disablePreprocessing: this.disablePreprocessing, - output: this.output, - tsconfig: this.tsconfig, - }); + const tty = initTTY(); + const result = await tty.start( + run(tty, { + disablePreprocessing: this.disablePreprocessing, + output: this.output, + tsconfig: this.tsconfig, + }) + ); + return process.exitCode || (typeof result === 'object' ? result.exit : 0); } } diff --git a/packages/cli-utils/src/commands/generate-output/logger.ts b/packages/cli-utils/src/commands/generate-output/logger.ts new file mode 100644 index 00000000..7c6cad80 --- /dev/null +++ b/packages/cli-utils/src/commands/generate-output/logger.ts @@ -0,0 +1,10 @@ +import * as t from '../../term'; + +export * from '../shared/logger'; + +export function summary() { + return t.text([ + t.cmd(t.CSI.Style, t.Style.BrightGreen), + `${t.Icons.Tick} Introspection output was generated successfully\n`, + ]); +} diff --git a/packages/cli-utils/src/commands/generate-output/runner.ts b/packages/cli-utils/src/commands/generate-output/runner.ts index c8a64da6..a1c44a59 100644 --- a/packages/cli-utils/src/commands/generate-output/runner.ts +++ b/packages/cli-utils/src/commands/generate-output/runner.ts @@ -1,17 +1,19 @@ import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; +import type { GraphQLSPConfig, LoadConfigResult } from '@gql.tada/internal'; import type { IntrospectionQuery } from 'graphql'; import { + load, + loadConfig, + parseConfig, minifyIntrospection, outputIntrospectionFile, - resolveTypeScriptRootDir, - load, } from '@gql.tada/internal'; -import type { TTY } from '../../term'; -import { getGraphQLSPConfig } from '../../lsp'; -import { getTsConfig } from '../../tsconfig'; +import type { TTY, ComposeInput } from '../../term'; +import type { WriteTarget } from '../shared'; +import { writeOutput } from '../shared'; +import * as logger from './logger'; interface Options { disablePreprocessing: boolean; @@ -19,63 +21,69 @@ interface Options { tsconfig: string | undefined; } -const CWD = process.cwd(); - -export async function run(tty: TTY, opts: Options) { - const tsConfig = await getTsConfig(opts.tsconfig); - if (!tsConfig) { - return; - } - - const config = getGraphQLSPConfig(tsConfig); - if (!config) { - return; +export async function* run(tty: TTY, opts: Options): AsyncIterable { + let configResult: LoadConfigResult; + let pluginConfig: GraphQLSPConfig; + try { + configResult = await loadConfig(opts.tsconfig); + pluginConfig = parseConfig(configResult.pluginConfig); + } catch (error) { + throw logger.externalError('Failed to load configuration.', error); } - let tsconfigPath = opts.tsconfig || CWD; - tsconfigPath = - path.extname(tsconfigPath) !== '.json' - ? path.resolve(CWD, tsconfigPath, 'tsconfig.json') - : path.resolve(CWD, tsconfigPath); - const rootPath = (await resolveTypeScriptRootDir(tsconfigPath)) || path.dirname(tsconfigPath); - // TODO: allow this to be overwritten using arguments (like in `generate schema`) const loader = load({ - origin: config.schema, - rootPath, + origin: pluginConfig.schema, + rootPath: path.dirname(configResult.configPath), }); let introspection: IntrospectionQuery | null; try { introspection = await loader.loadIntrospection(); } catch (error) { - console.error('Something went wrong while trying to load the schema.', error); - return; + throw logger.externalError('Failed to load introspection.', error); } if (!introspection) { - console.error('Could not retrieve introspection schema.'); - return; + throw logger.errorMessage('Failed to load introspection.'); } + let contents: string; try { - const contents = outputIntrospectionFile(minifyIntrospection(introspection), { - fileType: config.tadaOutputLocation, + contents = outputIntrospectionFile(minifyIntrospection(introspection), { + fileType: pluginConfig.tadaOutputLocation || '.d.ts', shouldPreprocess: !opts.disablePreprocessing, }); + } catch (error) { + throw logger.externalError('Could not generate introspection output', error); + } - let destination: string; - if (!opts.output && tty.pipeTo) { - tty.pipeTo.write(contents); - return; - } else if (!opts.output) { - destination = path.resolve(rootPath, config.tadaOutputLocation); - } else { - destination = path.resolve(CWD, opts.output); - } + let destination: WriteTarget; + if (!opts.output && tty.pipeTo) { + destination = tty.pipeTo; + } else if (opts.output) { + destination = path.resolve(process.cwd(), opts.output); + } else if (pluginConfig.tadaOutputLocation) { + destination = path.resolve( + path.dirname(configResult.configPath), + pluginConfig.tadaOutputLocation + ); + } else { + throw logger.errorMessage( + 'No output path was specified to write the output file to.\n' + + logger.hint( + `You have to either set ${logger.code('"tadaOutputLocation"')} in your configuration,\n` + + `pass an ${logger.code('--output')} argument to this command,\n` + + 'or pipe this command to an output file.' + ) + ); + } - await fs.writeFile(destination, contents); + try { + await writeOutput(destination, contents); } catch (error) { - console.error('Something went wrong while writing the introspection file', error); + throw logger.externalError('Something went wrong while writing the introspection file', error); } + + yield logger.summary(); } diff --git a/packages/cli-utils/src/commands/generate-persisted/index.ts b/packages/cli-utils/src/commands/generate-persisted/index.ts index a1178c5f..11381a55 100644 --- a/packages/cli-utils/src/commands/generate-persisted/index.ts +++ b/packages/cli-utils/src/commands/generate-persisted/index.ts @@ -10,20 +10,25 @@ export class GeneratePersisted extends Command { description: 'Specify the `tsconfig.json` used to read, unless `--output` is passed.', }); + failOnWarn = Option.Boolean('--fail-on-warn', false, { + description: 'Triggers an error and a non-zero exit code if any warnings have been reported', + }); + output = Option.String('--output,-o', { - description: 'Specify where to output the persisted manifest file to.\tDefault: STDOUT', + description: + 'Specifies where to output the file to.\tDefault: The `tadaPersistedLocation` configuration option', }); async execute() { - const tty = initTTY(); - if (!this.output && !tty.pipeTo) { - console.error( - "The --output option wasn't passed, but you also are not piping the current output to a file." - ); - return 1; - } // TODO: Add verbose/log/list/debug/trace option that outputs discovered documents (by name) per file - await run(tty, { output: this.output, tsconfig: this.tsconfig }); - return 0; + const tty = initTTY(); + const result = await tty.start( + run(tty, { + failOnWarn: this.failOnWarn, + output: this.output, + tsconfig: this.tsconfig, + }) + ); + return process.exitCode || (typeof result === 'object' ? result.exit : 0); } } diff --git a/packages/cli-utils/src/commands/generate-persisted/logger.ts b/packages/cli-utils/src/commands/generate-persisted/logger.ts new file mode 100644 index 00000000..52782dfb --- /dev/null +++ b/packages/cli-utils/src/commands/generate-persisted/logger.ts @@ -0,0 +1,97 @@ +import { pipe, interval, map } from 'wonka'; + +import * as path from 'node:path'; +import * as t from '../../term'; + +import type { PersistedWarning } from './types'; +import { indent } from '../shared/logger'; + +export * from '../shared/logger'; + +const CWD = process.cwd(); +const INDENT = ' '; + +export function warningFile(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 warningMessage(message: PersistedWarning) { + return t.text([ + INDENT, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `${message.line}:${message.col}`, + t.Chars.Tab, + t.cmd(t.CSI.Style, t.Style.Foreground), + indent(message.message.trim(), t.text([INDENT, t.Chars.Tab])), + t.Chars.Newline, + ]); +} + +export function warningSummary(warningCount: number, documentCount: number) { + return t.error([ + t.cmd(t.CSI.Style, t.Style.Red), + `${t.Icons.Cross} ${warningCount} warnings `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `(${documentCount} documents extracted)\n`, + ]); +} + +export function infoSummary(warningCount: number, documentCount: number) { + let out = ''; + if (warningCount) { + out += t.text([ + t.cmd(t.CSI.Style, t.Style.BrightYellow), + t.Icons.Warning, + ` ${warningCount} warnings\n`, + ]); + } + if (documentCount) { + out += t.text([ + t.cmd(t.CSI.Style, t.Style.BrightGreen), + `${t.Icons.Tick} Persisted manifest was generated successfully `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `(${documentCount} documents extracted)\n`, + ]); + } else { + out += t.text([ + t.cmd(t.CSI.Style, t.Style.Blue), + `${t.Icons.Info} No persisted documents were found `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `(Persisted manifest was not generated)\n`, + ]); + } + return out; +} + +export function warningGithub(message: PersistedWarning): void { + t.githubAnnotation('warning', message.message, { + file: message.file, + line: message.line, + col: message.col, + }); +} + +export function runningPersisted(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), + `Scanning files${t.Chars.Ellipsis} `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + progress, + ]); + }) + ); +} diff --git a/packages/cli-utils/src/commands/generate-persisted/runner.ts b/packages/cli-utils/src/commands/generate-persisted/runner.ts index 712cfd51..c929f4a8 100644 --- a/packages/cli-utils/src/commands/generate-persisted/runner.ts +++ b/packages/cli-utils/src/commands/generate-persisted/runner.ts @@ -1,144 +1,104 @@ -import { Project, ts } from 'ts-morph'; -import { print } from '@0no-co/graphql.web'; +import * as path from 'node:path'; +import type { GraphQLSPConfig, LoadConfigResult } from '@gql.tada/internal'; -import { - init, - findAllPersistedCallExpressions, - getDocumentReferenceFromTypeQuery, - unrollTadaFragments, -} from '@0no-co/graphqlsp/api'; +import { loadConfig, parseConfig } from '@gql.tada/internal'; -import { load, resolveTypeScriptRootDir } from '@gql.tada/internal'; -import path from 'node:path'; -import fs from 'node:fs/promises'; - -import type { TTY } from '../../term'; -import type { GraphQLSPConfig } from '../../lsp'; -import { getGraphQLSPConfig } from '../../lsp'; -import { getTsConfig } from '../../tsconfig'; -import { createPluginInfo } from '../../ts/project'; +import type { TTY, ComposeInput } from '../../term'; +import type { WriteTarget } from '../shared'; +import { writeOutput } from '../shared'; +import * as logger from './logger'; interface Options { tsconfig: string | undefined; output: string | undefined; + failOnWarn: boolean; } -export async function run(tty: TTY, opts: Options) { - const tsconfig = await getTsConfig(opts.tsconfig); - if (!tsconfig) { - return; - } +export async function* run(tty: TTY, opts: Options): AsyncIterable { + const { runPersisted } = await import('./thread'); - const config = getGraphQLSPConfig(tsconfig); - if (!config) { - return; + let configResult: LoadConfigResult; + let pluginConfig: GraphQLSPConfig; + try { + configResult = await loadConfig(opts.tsconfig); + pluginConfig = parseConfig(configResult.pluginConfig); + } catch (error) { + throw logger.externalError('Failed to load configuration.', error); } - const persistedOperations = await getPersistedOperationsFromFiles(opts, config); - const json = JSON.stringify(persistedOperations, null, 2); - if (opts.output) { - const resolved = path.resolve(process.cwd(), opts.output); - await fs.writeFile(resolved, json); + let destination: WriteTarget; + if (!opts.output && tty.pipeTo) { + destination = tty.pipeTo; + } else if (opts.output) { + destination = path.resolve(process.cwd(), opts.output); + } else if (pluginConfig.tadaPersistedLocation) { + destination = path.resolve( + path.dirname(configResult.configPath), + pluginConfig.tadaPersistedLocation + ); } else { - const stream = tty.pipeTo || process.stdout; - stream.write(json); + throw logger.errorMessage( + 'No output path was specified to write the persisted manifest file to.\n' + + logger.hint( + `You have to either set ${logger.code( + '"tadaPersistedLocation"' + )} in your configuration,\n` + + `pass an ${logger.code('--output')} argument to this command,\n` + + 'or pipe this command to an output file.' + ) + ); } -} - -const CWD = process.cwd(); -async function getPersistedOperationsFromFiles( - opts: Options, - config: GraphQLSPConfig -): Promise> { - let tsconfigPath = opts.tsconfig || CWD; - tsconfigPath = - path.extname(tsconfigPath) !== '.json' - ? path.resolve(CWD, tsconfigPath, 'tsconfig.json') - : path.resolve(CWD, tsconfigPath); - - const projectPath = path.dirname(tsconfigPath); - const rootPath = (await resolveTypeScriptRootDir(tsconfigPath)) || tsconfigPath; - const project = new Project({ - tsConfigFilePath: tsconfigPath, - }); - - init({ - typescript: ts as any, + let documents: Record = {}; + const generator = runPersisted({ + rootPath: configResult.rootPath, + configPath: configResult.configPath, + pluginConfig, }); - const pluginCreateInfo = createPluginInfo(project, config, projectPath); + let warnings = 0; + 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; + } + + documents = Object.assign(documents, signal.documents); + if ((warnings += signal.warnings.length)) { + let buffer = logger.warningFile(signal.filePath); + for (const warning of signal.warnings) { + buffer += logger.warningMessage(warning); + logger.warningGithub(warning); + } + yield buffer + '\n'; + } + + yield logger.runningPersisted(++fileCount, totalFileCount); } } catch (error) { - throw new Error(`Failed to load schema: ${error}`); + throw logger.externalError('Could not generate persisted manifest file', error); } - return sourceFiles.reduce((acc, sourceFile) => { - const persistedCallExpressions = findAllPersistedCallExpressions(sourceFile.compilerNode); - const currentFilename = sourceFile.compilerNode.fileName; - return { - ...acc, - ...persistedCallExpressions.reduce((acc, callExpression) => { - const hash = callExpression.arguments[0].getText(); - if (!callExpression.typeArguments) { - console.warn( - `Persisted call expression in "${currentFilename}" is missing a type argument like "graphql.persisted". Skipping...` - ); - return acc; - } - const [typeQuery] = callExpression.typeArguments; - if (!ts.isTypeQueryNode(typeQuery)) { - console.warn( - `Persisted call expression in "${currentFilename}" is missing a type argument like "graphql.persisted". Skipping...` - ); - return acc; - } - - const { node: foundNode } = getDocumentReferenceFromTypeQuery( - typeQuery, - currentFilename, - pluginCreateInfo + const documentCount = Object.keys(documents).length; + if (warnings && opts.failOnWarn) { + throw logger.warningSummary(warnings, documentCount); + } else { + if (documentCount) { + try { + const contents = JSON.stringify(documents, null, 2); + await writeOutput(destination, contents); + } catch (error) { + throw logger.externalError( + 'Something went wrong while writing the introspection file', + error ); + } + } - if (!foundNode) { - console.warn( - `Could not find reference for "${typeQuery.getText()}" in "${currentFilename}", if this is unexpected file an issue at "https://github.com/0no-co/gql.tada/issues/new/choose" describing your case.` - ); - return acc; - } - - const initializer = foundNode.initializer; - if ( - !initializer || - !ts.isCallExpression(initializer) || - (!ts.isNoSubstitutionTemplateLiteral(initializer.arguments[0]) && - !ts.isStringLiteral(initializer.arguments[0])) - ) { - console.warn( - `Persisted call expression in "${currentFilename}" is missing a string argument containing the hash. Skipping...` - ); - return acc; - } - - const fragments = []; - const operation = initializer.arguments[0].getText().slice(1, -1); - if (initializer.arguments[1] && ts.isArrayLiteralExpression(initializer.arguments[1])) { - unrollTadaFragments(initializer.arguments[1], fragments, pluginCreateInfo); - } - - const document = `${operation}\n\n${fragments.map((frag) => print(frag)).join('\n\n')}`; - acc[hash.slice(1, -1)] = document; - return acc; - }, {}), - }; - }, {}); + yield logger.infoSummary(warnings, documentCount); + } } diff --git a/packages/cli-utils/src/commands/generate-persisted/thread.ts b/packages/cli-utils/src/commands/generate-persisted/thread.ts new file mode 100644 index 00000000..dd12cc2b --- /dev/null +++ b/packages/cli-utils/src/commands/generate-persisted/thread.ts @@ -0,0 +1,130 @@ +import * as path from 'node:path'; +import { print } from '@0no-co/graphql.web'; +import { Project, ts } from 'ts-morph'; + +import type { FragmentDefinitionNode } from '@0no-co/graphql.web'; +import type { GraphQLSPConfig } from '@gql.tada/internal'; + +import { + init, + findAllPersistedCallExpressions, + getDocumentReferenceFromTypeQuery, + unrollTadaFragments, +} from '@0no-co/graphqlsp/api'; + +import { createPluginInfo, getFilePosition } from '../../ts'; +import { expose } from '../../threads'; + +import type { PersistedSignal, PersistedWarning } from './types'; + +export interface PersistedParams { + rootPath: string; + configPath: string; + pluginConfig: GraphQLSPConfig; +} + +async function* _runPersisted(params: PersistedParams): AsyncIterableIterator { + init({ typescript: ts as any }); + + const projectPath = path.dirname(params.configPath); + const project = new Project({ tsConfigFilePath: params.configPath }); + const pluginInfo = createPluginInfo(project, params.pluginConfig, projectPath); + + // Filter source files by whether they're under the relevant root path + const sourceFiles = project.getSourceFiles().filter((sourceFile) => { + const filePath = path.resolve(projectPath, sourceFile.getFilePath()); + const relative = path.relative(params.rootPath, filePath); + return !relative.startsWith('..'); + }); + + yield { + kind: 'FILE_COUNT', + fileCount: sourceFiles.length, + }; + + for (const { compilerNode: sourceFile } of sourceFiles) { + const filePath = sourceFile.fileName; + const documents: Record = {}; + const warnings: PersistedWarning[] = []; + + const calls = findAllPersistedCallExpressions(sourceFile); + for (const call of calls) { + const position = getFilePosition(sourceFile, call.getStart()); + const hash = call.arguments[0]; + if (!hash || !ts.isStringLiteral(hash)) { + warnings.push({ + message: + '"graphql.persisted" must be called with a string literal as the first argument.', + file: filePath, + line: position.line, + col: position.col, + }); + continue; + } else if (!call.typeArguments || !ts.isTypeQueryNode(call.typeArguments[0])) { + warnings.push({ + message: + '"graphql.persisted" is missing a generic such as `graphql.persisted`.', + file: filePath, + line: position.line, + col: position.col, + }); + continue; + } + + const typeQuery = call.typeArguments[0]; + const { node: foundNode } = getDocumentReferenceFromTypeQuery( + typeQuery, + filePath, + pluginInfo + ); + if (!foundNode) { + warnings.push({ + message: + `Could not find reference for "${typeQuery.getText()}".\n` + + 'If this is unexpected, please file an issue describing your case.', + file: filePath, + line: position.line, + col: position.col, + }); + continue; + } + + const { initializer } = foundNode; + if ( + !initializer || + !ts.isCallExpression(initializer) || + (!ts.isNoSubstitutionTemplateLiteral(initializer.arguments[0]) && + !ts.isStringLiteral(initializer.arguments[0])) + ) { + warnings.push({ + message: + `The referenced document of "${typeQuery.getText()}" contains no document string literal.\n` + + 'If this is unexpected, please file an issue describing your case.', + file: filePath, + line: position.line, + col: position.col, + }); + continue; + } + + const fragments: FragmentDefinitionNode[] = []; + const operation = initializer.arguments[0].getText().slice(1, -1); + if (initializer.arguments[1] && ts.isArrayLiteralExpression(initializer.arguments[1])) { + unrollTadaFragments(initializer.arguments[1], fragments, pluginInfo); + } + + let document = operation; + for (const fragment of fragments) document += '\n\n' + print(fragment); + documents[JSON.parse(hash.getFullText())] = document; + } + + yield { + kind: 'FILE_PERSISTED', + filePath, + documents, + warnings, + }; + } +} + +export const runPersisted = expose(_runPersisted); diff --git a/packages/cli-utils/src/commands/generate-persisted/types.ts b/packages/cli-utils/src/commands/generate-persisted/types.ts new file mode 100644 index 00000000..77e2095c --- /dev/null +++ b/packages/cli-utils/src/commands/generate-persisted/types.ts @@ -0,0 +1,20 @@ +export interface PersistedWarning { + message: string; + file: string; + line: number; + col: number; +} + +export interface FilePersistedSignal { + kind: 'FILE_PERSISTED'; + filePath: string; + documents: Record; + warnings: PersistedWarning[]; +} + +export interface FileCountSignal { + kind: 'FILE_COUNT'; + fileCount: number; +} + +export type PersistedSignal = FilePersistedSignal | FileCountSignal; diff --git a/packages/cli-utils/src/commands/generate-schema/index.ts b/packages/cli-utils/src/commands/generate-schema/index.ts index 175f0894..06858fe5 100644 --- a/packages/cli-utils/src/commands/generate-schema/index.ts +++ b/packages/cli-utils/src/commands/generate-schema/index.ts @@ -43,13 +43,15 @@ export class GenerateSchema extends Command { }); async execute() { - await run(initTTY(), { - input: this.input, - headers: parseHeaders(this.headers), - output: this.output, - tsconfig: this.tsconfig, - }); - - return 0; + const tty = initTTY(); + const result = await tty.start( + run(tty, { + input: this.input, + headers: parseHeaders(this.headers), + output: this.output, + tsconfig: this.tsconfig, + }) + ); + return process.exitCode || (typeof result === 'object' ? result.exit : 0); } } diff --git a/packages/cli-utils/src/commands/generate-schema/logger.ts b/packages/cli-utils/src/commands/generate-schema/logger.ts new file mode 100644 index 00000000..7c6cad80 --- /dev/null +++ b/packages/cli-utils/src/commands/generate-schema/logger.ts @@ -0,0 +1,10 @@ +import * as t from '../../term'; + +export * from '../shared/logger'; + +export function summary() { + return t.text([ + t.cmd(t.CSI.Style, t.Style.BrightGreen), + `${t.Icons.Tick} Introspection output was generated successfully\n`, + ]); +} diff --git a/packages/cli-utils/src/commands/generate-schema/runner.ts b/packages/cli-utils/src/commands/generate-schema/runner.ts index 38cb0ab0..30caee23 100644 --- a/packages/cli-utils/src/commands/generate-schema/runner.ts +++ b/packages/cli-utils/src/commands/generate-schema/runner.ts @@ -1,12 +1,13 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import { printSchema } from 'graphql'; import type { GraphQLSchema } from 'graphql'; -import { load } from '@gql.tada/internal'; +import type { GraphQLSPConfig, LoadConfigResult } from '@gql.tada/internal'; +import { load, loadConfig, parseConfig } from '@gql.tada/internal'; -import type { TTY } from '../../term'; -import { getGraphQLSPConfig } from '../../lsp'; -import { getTsConfig } from '../../tsconfig'; +import type { TTY, ComposeInput } from '../../term'; +import type { WriteTarget } from '../shared'; +import { writeOutput } from '../shared'; +import * as logger from './logger'; interface Options { input: string; @@ -15,7 +16,7 @@ interface Options { tsconfig: string | undefined; } -export async function run(tty: TTY, opts: Options) { +export async function* run(tty: TTY, opts: Options): AsyncIterable { const origin = opts.headers ? { url: opts.input, headers: opts.headers } : opts.input; const loader = load({ rootPath: process.cwd(), origin }); @@ -23,38 +24,54 @@ export async function run(tty: TTY, opts: Options) { try { schema = await loader.loadSchema(); } catch (error) { - console.error('Something went wrong while trying to load the schema.', error); - return; + throw logger.externalError('Failed to load schema.', error); } if (!schema) { - console.error('Could not load the schema.'); - return; + throw logger.errorMessage('Failed to load schema.'); } - let destination: string; + let destination: WriteTarget; if (!opts.output && tty.pipeTo) { - tty.pipeTo.write(printSchema(schema)); - return; + destination = tty.pipeTo; } else if (opts.output) { - destination = opts.output; + destination = path.resolve(process.cwd(), opts.output); } else { - const tsconfig = await getTsConfig(opts.tsconfig); - const config = tsconfig && getGraphQLSPConfig(tsconfig); - if (!tsconfig) { - console.error('Could not find a tsconfig.json file'); - return; - } else if (!config) { - console.error('Could not find a "@0no-co/graphqlsp" plugin in your tsconfig.'); - return; - } else if (typeof config.schema !== 'string' || !config.schema.endsWith('.graphql')) { - console.error(`Found "${config.schema}" which is not a path to a .graphql SDL file.`); - return; + let configResult: LoadConfigResult; + let pluginConfig: GraphQLSPConfig; + try { + configResult = await loadConfig(opts.tsconfig); + pluginConfig = parseConfig(configResult.pluginConfig); + } catch (error) { + throw logger.externalError('Failed to load configuration.', error); + } + + if ( + typeof pluginConfig.schema === 'string' && + path.extname(pluginConfig.schema) === '.graphql' + ) { + destination = path.resolve(path.dirname(configResult.configPath), pluginConfig.schema); } else { - destination = config.schema; + throw logger.errorMessage( + `No output path was specified but writing to ${logger.code( + 'schema' + )} is not a file path.\n` + + logger.hint( + `You have to either set ${logger.code('"schema"')} to a ${logger.code( + '.graphql' + )} file in your configuration,\n` + + `pass an ${logger.code('--output')} argument to this command,\n` + + 'or pipe this command to an output file.' + ) + ); } } - const resolved = path.resolve(process.cwd(), destination); - await fs.writeFile(resolved, printSchema(schema)); + try { + await writeOutput(destination, printSchema(schema)); + } catch (error) { + throw logger.externalError('Something went wrong while writing the introspection file', error); + } + + yield logger.summary(); } diff --git a/packages/cli-utils/src/commands/init/runner.ts b/packages/cli-utils/src/commands/init/runner.ts index 51d99732..3632f990 100644 --- a/packages/cli-utils/src/commands/init/runner.ts +++ b/packages/cli-utils/src/commands/init/runner.ts @@ -2,7 +2,8 @@ import { intro, outro, isCancel, cancel, text, confirm, spinner } from '@clack/p import fs from 'node:fs/promises'; import path from 'node:path'; import { execa } from 'execa'; -import { parse } from 'json5'; + +import { readTSConfigFile } from '@gql.tada/internal'; const s = spinner(); @@ -121,8 +122,7 @@ export async function run(target: string) { s.start('Writing to tsconfig.json.'); try { const tsConfigPath = path.resolve(target, 'tsconfig.json'); - const tsConfigContents = await fs.readFile(tsConfigPath, 'utf-8'); - const tsConfig = parse(tsConfigContents); + const tsConfig = await readTSConfigFile(tsConfigPath); // TODO: do we need to ensure that include contains the tadaOutputLocation? const isFile = schemaLocation.endsWith('.json') || schemaLocation.endsWith('.graphql'); tsConfig.compilerOptions = { @@ -132,7 +132,7 @@ export async function run(target: string) { name: '@0no-co/graphqlsp', schema: isFile ? path.relative(target, schemaLocation) : schemaLocation, tadaOutputLocation: path.relative(target, tadaLocation), - }, + } as any, ], }; await fs.writeFile(tsConfigPath, JSON.stringify(tsConfig, null, 2)); diff --git a/packages/cli-utils/src/commands/shared/index.ts b/packages/cli-utils/src/commands/shared/index.ts new file mode 100644 index 00000000..8ff45f49 --- /dev/null +++ b/packages/cli-utils/src/commands/shared/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './utils'; diff --git a/packages/cli-utils/src/commands/shared/logger.ts b/packages/cli-utils/src/commands/shared/logger.ts new file mode 100644 index 00000000..0f67cd9f --- /dev/null +++ b/packages/cli-utils/src/commands/shared/logger.ts @@ -0,0 +1,75 @@ +import * as t from '../../term'; + +export function indent(text: string, indent: string) { + if (text.includes('\n')) { + return text.split('\n').join(t.text([t.Chars.Newline, indent])); + } else { + return text; + } +} + +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), + indent(text, ' '), + ]); +} + +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`, + ]); +} + +export function externalError(message: string, error: unknown) { + let title: string; + let text: string; + if (error && typeof error === 'object') { + if ( + 'name' in error && + (error.name === 'TSError' || error.name === 'TadaError' || 'code' in error) + ) { + title = 'code' in error ? 'System Error' : 'Error'; + text = (error as Error).message.trim(); + } else if ('message' in error && typeof error.message === 'string') { + title = 'Unexpected Error'; + text = `${error.message}`; + } else { + title = 'Unexpected Error'; + text = `${error}`; + } + } else { + title = 'Unexpected Error'; + text = `${error}`; + } + + return t.error([ + '\n', + t.cmd(t.CSI.Style, [t.Style.Red, t.Style.Invert]), + ` ${t.Icons.Warning} ${title} `, + t.cmd(t.CSI.Style, t.Style.NoInvert), + `\n${message.trim()}\n`, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `${t.HeavyBox.BottomLeft} `, + indent(text, ' '), + ]); +} diff --git a/packages/cli-utils/src/commands/shared/utils.ts b/packages/cli-utils/src/commands/shared/utils.ts new file mode 100644 index 00000000..5e847f59 --- /dev/null +++ b/packages/cli-utils/src/commands/shared/utils.ts @@ -0,0 +1,57 @@ +import type { WriteStream } from 'node:tty'; +import type { PathLike } from 'node:fs'; +import * as fs from 'node:fs/promises'; + +/** Checks whether a file exists on disk */ +export const fileExists = (file: PathLike): Promise => + fs + .stat(file) + .then((stat) => stat.isFile()) + .catch(() => false); + +const touchFile = async (file: PathLike): Promise => { + try { + const now = new Date(); + await fs.utimes(file, now, now); + } catch (_error) {} +}; + +export type WriteTarget = PathLike | WriteStream; + +/** Writes a file to a swapfile then moves it into place to prevent excess change events. */ +export const writeOutput = async (target: WriteTarget, contents: string): Promise => { + if (target && typeof target === 'object' && 'writable' in target) { + // If we get a WritableStream (e.g. stdout), we write to that + // but we listen for errors and wait for it to flush fully + return await new Promise((resolve, reject) => { + target.write(contents, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } else if (!(await fileExists(target))) { + // If the file doesn't exist, we can write directly, and not + // try-catch so the error falls through + await fs.writeFile(target, contents); + } else { + // If the file exists, we write to a swap-file, then rename (i.e. move) + // the file into place. No try-catch around `writeFile` for proper + // directory/permission errors + const tempTarget = target + '.tmp'; + await fs.writeFile(tempTarget, contents); + try { + await fs.rename(tempTarget, target); + } catch (error) { + await fs.unlink(tempTarget); + throw error; + } finally { + // When we move the file into place, we also update its access and + // modification time manually, in case the rename doesn't trigger + // a change event + await touchFile(target); + } + } +}; diff --git a/packages/cli-utils/src/commands/turbo/index.ts b/packages/cli-utils/src/commands/turbo/index.ts index a2a634bf..21fec4c9 100644 --- a/packages/cli-utils/src/commands/turbo/index.ts +++ b/packages/cli-utils/src/commands/turbo/index.ts @@ -1,15 +1,34 @@ import { Command, Option } from 'clipanion'; + +import { initTTY } from '../../term'; import { run } from './runner'; export class TurboCommand extends Command { - static paths = [['generate turbo'], ['turbo']]; + static paths = [['generate', 'turbo'], ['turbo']]; tsconfig = Option.String('--tsconfig,-c', { description: 'Specify the `tsconfig.json` read for configuration.', }); + failOnWarn = Option.Boolean('--fail-on-warn,-w', false, { + description: 'Triggers an error and a non-zero exit code if any warnings have been reported', + }); + + output = Option.String('--output,-o', { + description: + 'Specifies where to output the file to.\tDefault: The `tadaTurboLocation` configuration option', + }); + async execute() { // TODO: Add verbose/log/list/debug/trace option that outputs discovered documents (by name) per file - await run({ tsconfig: this.tsconfig }); + const tty = initTTY(); + const result = await tty.start( + run(tty, { + failOnWarn: this.failOnWarn, + output: this.output, + tsconfig: this.tsconfig, + }) + ); + return process.exitCode || (typeof result === 'object' ? result.exit : 0); } } diff --git a/packages/cli-utils/src/commands/turbo/logger.ts b/packages/cli-utils/src/commands/turbo/logger.ts new file mode 100644 index 00000000..a39d3e21 --- /dev/null +++ b/packages/cli-utils/src/commands/turbo/logger.ts @@ -0,0 +1,101 @@ +import { pipe, interval, map } from 'wonka'; + +import * as path from 'node:path'; +import * as t from '../../term'; + +import type { TurboWarning } from './types'; +import { indent } from '../shared/logger'; + +export * from '../shared/logger'; + +const CWD = process.cwd(); +const INDENT = ' '; + +export function warningFile(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 warningMessage(message: TurboWarning) { + return t.text([ + INDENT, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `${message.line}:${message.col}`, + t.Chars.Tab, + t.cmd(t.CSI.Style, t.Style.Foreground), + indent(message.message.trim(), t.text([INDENT, t.Chars.Tab])), + t.Chars.Newline, + ]); +} + +export function warningSummary(warningCount: number, documentCount: number) { + return t.error([ + t.cmd(t.CSI.Style, t.Style.Red), + `${t.Icons.Cross} ${warningCount} warnings `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `(${documentCount} document types cached)\n`, + ]); +} + +export function infoSummary(warningCount: number, documentCount: number) { + let out = ''; + if (warningCount) { + out += t.text([ + t.cmd(t.CSI.Style, t.Style.BrightYellow), + t.Icons.Warning, + ` ${warningCount} warnings\n`, + ]); + } + if (documentCount) { + out += t.text([ + t.cmd(t.CSI.Style, t.Style.BrightGreen), + `${t.Icons.Tick} Type cache was generated successfully `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + `(${documentCount} document types cached)\n`, + ]); + } else { + out += t.text([t.cmd(t.CSI.Style, t.Style.Blue), `${t.Icons.Info} No documents were found\n`]); + } + return out; +} + +export function warningGithub(message: TurboWarning): void { + t.githubAnnotation('warning', message.message, { + file: message.file, + line: message.line, + col: message.col, + }); +} + +export function runningTurbo(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), + `Scanning files${t.Chars.Ellipsis} `, + t.cmd(t.CSI.Style, t.Style.BrightBlack), + progress, + ]); + }) + ); +} + +export function hintMessage(message: string) { + return t.error([ + t.cmd(t.CSI.Style, [t.Style.Yellow, t.Style.Bold]), + `${t.Icons.Warning} Note: `, + t.cmd(t.CSI.Style, t.Style.Reset), + `${message.trim()}\n\n`, + ]); +} diff --git a/packages/cli-utils/src/commands/turbo/runner.ts b/packages/cli-utils/src/commands/turbo/runner.ts index c36cd636..a63d1af4 100644 --- a/packages/cli-utils/src/commands/turbo/runner.ts +++ b/packages/cli-utils/src/commands/turbo/runner.ts @@ -1,49 +1,119 @@ -import { Project, TypeFormatFlags, TypeFlags, ts } from 'ts-morph'; -import path from 'node:path'; -import fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { GraphQLSPConfig, LoadConfigResult } from '@gql.tada/internal'; -import type { GraphQLSPConfig } from '../../lsp'; -import { getGraphQLSPConfig } from '../../lsp'; -import { getTsConfig } from '../../tsconfig'; -import { createPluginInfo } from '../../ts/project'; +import { loadConfig, parseConfig } from '@gql.tada/internal'; -const PREAMBLE_IGNORE = ['/* eslint-disable */', '/* prettier-ignore */'].join('\n') + '\n'; +import type { TTY, ComposeInput } from '../../term'; +import type { WriteTarget } from '../shared'; +import { writeOutput } from '../shared'; +import * as logger from './logger'; -const existsFile = async (file: string): Promise => { - return fs - .stat(file) - .then((stat) => stat.isFile()) - .catch(() => false); -}; +const PREAMBLE_IGNORE = ['/* eslint-disable */', '/* prettier-ignore */'].join('\n') + '\n'; interface Options { + failOnWarn: boolean; tsconfig: string | undefined; + output: string | undefined; } -export async function run(opts: Options) { - const tsConfig = await getTsConfig(opts.tsconfig); - if (!tsConfig) { - return; +export async function* run(tty: TTY, opts: Options): AsyncIterable { + const { runTurbo } = await import('./thread'); + + let configResult: LoadConfigResult; + let pluginConfig: GraphQLSPConfig; + try { + configResult = await loadConfig(opts.tsconfig); + pluginConfig = parseConfig(configResult.pluginConfig); + } catch (error) { + throw logger.externalError('Failed to load configuration.', error); } - const config = getGraphQLSPConfig(tsConfig); - if (!config) { - return; + let destination: WriteTarget; + if (!opts.output && tty.pipeTo) { + destination = tty.pipeTo; + } else if (opts.output) { + destination = path.resolve(process.cwd(), opts.output); + } else if (pluginConfig.tadaTurboLocation) { + destination = path.resolve( + path.dirname(configResult.configPath), + pluginConfig.tadaTurboLocation + ); + } else if (pluginConfig.tadaOutputLocation) { + destination = path.resolve( + path.dirname(configResult.configPath), + pluginConfig.tadaOutputLocation, + '..', + 'graphql-cache.d.ts' + ); + yield logger.hintMessage( + 'No output location was specified.\n' + + `The turbo cache will by default be saved as ${logger.code('"graphql-cache.d.ts"')}.\n` + + logger.hint( + `To change this, add a ${logger.code('"tadaTurboLocation"')} in your configuration,\n` + + `pass an ${logger.code('--output')} argument to this command,\n` + + 'or pipe this command to an output file.' + ) + ); + } else { + throw logger.errorMessage( + 'No output path was specified to write the output file to.\n' + + logger.hint( + `You have to either set ${logger.code('"tadaTurboLocation"')} in your configuration,\n` + + `pass an ${logger.code('--output')} argument to this command,\n` + + 'or pipe this command to an output file.' + ) + ); } - const cacheFileName = path.resolve(config.tadaOutputLocation, '..', 'graphql-cache.d.ts'); - const tmpFileName = cacheFileName + '.temp'; + let cache: Record = {}; + const generator = runTurbo({ + rootPath: configResult.rootPath, + configPath: configResult.configPath, + pluginConfig, + }); + + let warnings = 0; + let totalFileCount = 0; + let fileCount = 0; - if (await existsFile(cacheFileName)) await fs.rename(cacheFileName, tmpFileName); try { - const cache = await getGraphqlInvocationCache(config); - await fs.writeFile(tmpFileName, createCache(cache)); - } finally { - await fs.rename(tmpFileName, cacheFileName); + for await (const signal of generator) { + if (signal.kind === 'FILE_COUNT') { + totalFileCount = signal.fileCount; + continue; + } + + cache = Object.assign(cache, signal.cache); + if ((warnings += signal.warnings.length)) { + let buffer = logger.warningFile(signal.filePath); + for (const warning of signal.warnings) { + buffer += logger.warningMessage(warning); + logger.warningGithub(warning); + } + yield buffer + '\n'; + } + + yield logger.runningTurbo(++fileCount, totalFileCount); + } + } catch (error) { + throw logger.externalError('Could not build cache', error); + } + + const documentCount = Object.keys(cache).length; + if (warnings && opts.failOnWarn) { + throw logger.warningSummary(warnings, documentCount); + } else { + try { + const contents = createCacheContents(cache); + await writeOutput(destination, contents); + } catch (error) { + throw logger.externalError('Something went wrong while writing the cache file', error); + } + yield logger.infoSummary(warnings, documentCount); } } -function createCache(cache: Record): string { +function createCacheContents(cache: Record): string { let output = ''; for (const key in cache) { if (output) output += '\n'; @@ -59,66 +129,3 @@ function createCache(cache: Record): string { '\n}\n' ); } - -async function getGraphqlInvocationCache(config: GraphQLSPConfig): Promise> { - // TODO: leverage ts-morph tsconfig resolver - const projectName = path.resolve(process.cwd(), 'tsconfig.json'); - const project = new Project({ - tsConfigFilePath: projectName, - }); - - const pluginCreateInfo = createPluginInfo(project, config, projectName); - const typeChecker = project.getTypeChecker().compilerObject; - const sourceFiles = project.getSourceFiles(); - - return sourceFiles.reduce((acc, sourceFile) => { - const tadaCallExpressions = findAllCallExpressions(sourceFile.compilerNode, pluginCreateInfo); - return { - ...acc, - ...tadaCallExpressions.reduce((acc, callExpression) => { - const returnType = typeChecker.getTypeAtLocation(callExpression); - const argumentType = typeChecker.getTypeAtLocation(callExpression.arguments[0]); - if (returnType.symbol.getEscapedName() !== 'TadaDocumentNode') { - return acc; // TODO: we could collect this and warn if all extracted types have some kind of error - } - const keyString: string = - 'value' in argumentType && - typeof argumentType.value === 'string' && - (argumentType.flags & TypeFlags.StringLiteral) === 0 - ? JSON.stringify(argumentType.value) - : typeChecker.typeToString(argumentType, callExpression, BUILDER_FLAGS); - const valueString = typeChecker.typeToString(returnType, callExpression, BUILDER_FLAGS); - acc[keyString] = valueString; - return acc; - }, {}), - }; - }, {}); -} - -const BUILDER_FLAGS: TypeFormatFlags = - TypeFormatFlags.NoTruncation | - TypeFormatFlags.NoTypeReduction | - TypeFormatFlags.InTypeAlias | - TypeFormatFlags.UseFullyQualifiedType | - TypeFormatFlags.GenerateNamesForShadowedTypeParams | - TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | - TypeFormatFlags.AllowUniqueESSymbolType | - TypeFormatFlags.WriteTypeArgumentsOfSignature; - -function findAllCallExpressions( - sourceFile: ts.SourceFile, - info: ts.server.PluginCreateInfo -): Array { - const result: Array = []; - const templates = new Set([info.config.template, 'graphql', 'gql'].filter(Boolean)); - function find(node: ts.Node) { - if (ts.isCallExpression(node) && templates.has(node.expression.getText())) { - result.push(node); - return; - } else { - ts.forEachChild(node, find); - } - } - find(sourceFile); - return result; -} diff --git a/packages/cli-utils/src/commands/turbo/thread.ts b/packages/cli-utils/src/commands/turbo/thread.ts new file mode 100644 index 00000000..da412deb --- /dev/null +++ b/packages/cli-utils/src/commands/turbo/thread.ts @@ -0,0 +1,104 @@ +import * as path from 'node:path'; +import { Project, TypeFormatFlags, TypeFlags, ts } from 'ts-morph'; + +import type { GraphQLSPConfig } from '@gql.tada/internal'; +import { init } from '@0no-co/graphqlsp/api'; + +import { getFilePosition } from '../../ts'; +import { expose } from '../../threads'; + +import type { TurboSignal, TurboWarning } from './types'; + +export interface TurboParams { + rootPath: string; + configPath: string; + pluginConfig: GraphQLSPConfig; +} + +async function* _runTurbo(params: TurboParams): AsyncIterableIterator { + init({ typescript: ts as any }); + + const projectPath = path.dirname(params.configPath); + const project = new Project({ tsConfigFilePath: params.configPath }); + const checker = project.getTypeChecker().compilerObject; + + // Filter source files by whether they're under the relevant root path + const sourceFiles = project.getSourceFiles().filter((sourceFile) => { + const filePath = path.resolve(projectPath, sourceFile.getFilePath()); + const relative = path.relative(params.rootPath, filePath); + return !relative.startsWith('..'); + }); + + yield { + kind: 'FILE_COUNT', + fileCount: sourceFiles.length, + }; + + for (const { compilerNode: sourceFile } of sourceFiles) { + const filePath = sourceFile.fileName; + const cache: Record = {}; + const warnings: TurboWarning[] = []; + + const calls = findAllCallExpressions(sourceFile, params.pluginConfig); + for (const call of calls) { + const returnType = checker.getTypeAtLocation(call); + const argumentType = checker.getTypeAtLocation(call.arguments[0]); + if (returnType.symbol.getEscapedName() !== 'TadaDocumentNode') { + const position = getFilePosition(sourceFile, call.getStart()); + warnings.push({ + message: + `The discovered document is not of type "TadaDocumentNode".\n` + + 'If this is unexpected, please file an issue describing your case.', + file: filePath, + line: position.line, + col: position.col, + }); + continue; + } + const key: string = + 'value' in argumentType && + typeof argumentType.value === 'string' && + (argumentType.flags & TypeFlags.StringLiteral) === 0 + ? JSON.stringify(argumentType.value) + : checker.typeToString(argumentType, call, BUILDER_FLAGS); + cache[key] = checker.typeToString(returnType, call, BUILDER_FLAGS); + } + + yield { + kind: 'FILE_TURBO', + filePath, + cache, + warnings, + }; + } +} + +export const runTurbo = expose(_runTurbo); + +const BUILDER_FLAGS: TypeFormatFlags = + TypeFormatFlags.NoTruncation | + TypeFormatFlags.NoTypeReduction | + TypeFormatFlags.InTypeAlias | + TypeFormatFlags.UseFullyQualifiedType | + TypeFormatFlags.GenerateNamesForShadowedTypeParams | + TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + TypeFormatFlags.AllowUniqueESSymbolType | + TypeFormatFlags.WriteTypeArgumentsOfSignature; + +function findAllCallExpressions( + sourceFile: ts.SourceFile, + config: GraphQLSPConfig +): Array { + const result: ts.CallExpression[] = []; + const templates = new Set([config.template, 'graphql', 'gql'].filter(Boolean)); + function find(node: ts.Node) { + if (ts.isCallExpression(node) && templates.has(node.expression.getText())) { + result.push(node); + return; + } else { + ts.forEachChild(node, find); + } + } + find(sourceFile); + return result; +} diff --git a/packages/cli-utils/src/commands/turbo/types.ts b/packages/cli-utils/src/commands/turbo/types.ts new file mode 100644 index 00000000..f293f383 --- /dev/null +++ b/packages/cli-utils/src/commands/turbo/types.ts @@ -0,0 +1,20 @@ +export interface TurboWarning { + message: string; + file: string; + line: number; + col: number; +} + +export interface FileTurboSignal { + kind: 'FILE_TURBO'; + filePath: string; + cache: Record; + warnings: TurboWarning[]; +} + +export interface FileCountSignal { + kind: 'FILE_COUNT'; + fileCount: number; +} + +export type TurboSignal = FileTurboSignal | FileCountSignal; diff --git a/packages/cli-utils/src/lsp.ts b/packages/cli-utils/src/lsp.ts deleted file mode 100644 index f51f6cf6..00000000 --- a/packages/cli-utils/src/lsp.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { TsConfigJson } from 'type-fest'; -import type { SchemaOrigin } from '@gql.tada/internal'; - -export type GraphQLSPConfig = { - name: string; - schema: SchemaOrigin; - tadaOutputLocation: string; -}; - -export function getGraphQLSPConfig(tsconfig: TsConfigJson): GraphQLSPConfig | null { - if (tsconfig.compilerOptions) { - if (tsconfig.compilerOptions.plugins) { - const foundPlugin = tsconfig.compilerOptions.plugins.find( - (plugin) => plugin.name === '@0no-co/graphqlsp' || plugin.name === 'gql.tada/lsp' - ) as GraphQLSPConfig | undefined; - if (!foundPlugin) { - console.error('Missing @0no-co/graphqlsp plugin in tsconfig.json.'); - return null; - } - - if (!foundPlugin.schema) { - console.warn('Missing schema property in @0no-co/graphqlsp plugin in tsconfig.json.'); - return null; - } - - if (!foundPlugin.tadaOutputLocation) { - console.warn( - 'Missing tadaOutputLocation property in @0no-co/graphqlsp plugin in tsconfig.json.' - ); - return null; - } - - return foundPlugin; - } else { - console.warn('Missing plugins array in tsconfig.json.'); - return null; - } - } else { - console.warn('Missing compilerOptions object in tsconfig.json.'); - return null; - } -} diff --git a/packages/cli-utils/src/resolve.ts b/packages/cli-utils/src/resolve.ts deleted file mode 100644 index bc40c6ac..00000000 --- a/packages/cli-utils/src/resolve.ts +++ /dev/null @@ -1,19 +0,0 @@ -import path from 'node:path'; -import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; - -export const dirname = - typeof __dirname !== 'string' ? path.dirname(fileURLToPath(import.meta.url)) : __dirname; - -export const requireResolve = - typeof require === 'function' ? require.resolve : createRequire(import.meta.url).resolve; - -export const loadTypings = async () => { - const tadaModule = requireResolve('gql.tada/package.json', { - paths: ['node_modules', ...(requireResolve.paths('gql.tada') || [])], - }); - - const typingsPath = path.join(path.dirname(tadaModule), 'dist/gql-tada.d.ts'); - return readFile(typingsPath, { encoding: 'utf8' }); -}; diff --git a/packages/cli-utils/src/tada.ts b/packages/cli-utils/src/tada.ts deleted file mode 100644 index e63781ff..00000000 --- a/packages/cli-utils/src/tada.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import type { IntrospectionQuery } from 'graphql'; - -import { - type SchemaOrigin, - minifyIntrospection, - outputIntrospectionFile, - load, -} from '@gql.tada/internal'; - -/** - * This function mimics the behavior of the LSP, this so we can ensure - * that gql.tada will work in any environment. The JetBrains IDE's do not - * implement the tsserver plugin protocol hence in those and editors where - * we are not able to leverage the workspace TS version we will rely on - * this function. - */ -export async function ensureTadaIntrospection( - origin: SchemaOrigin, - outputLocation: string, - base: string = process.cwd(), - shouldPreprocess = true -) { - const loader = load({ origin, rootPath: base }); - - let introspection: IntrospectionQuery | null; - try { - introspection = await loader.loadIntrospection(); - } catch (error) { - console.error('Something went wrong while trying to load the schema.', error); - return; - } - - if (!introspection) { - console.error('Could not retrieve introspection schema.'); - return; - } - - try { - const contents = outputIntrospectionFile(minifyIntrospection(introspection), { - fileType: outputLocation, - shouldPreprocess, - }); - - const resolvedOutputLocation = path.resolve(base, outputLocation); - await fs.writeFile(resolvedOutputLocation, contents); - } catch (error) { - console.error('Something went wrong while writing the introspection file', error); - } -} diff --git a/packages/cli-utils/src/term/write.ts b/packages/cli-utils/src/term/write.ts index f2ac514c..7aef2b45 100644 --- a/packages/cli-utils/src/term/write.ts +++ b/packages/cli-utils/src/term/write.ts @@ -27,11 +27,10 @@ export class CLIError extends Error { constructor(message: string, exitCode?: number) { super(stripAnsi(message)); this.output = message; - this.exit = exitCode == null ? 0 : 1; + this.exit = exitCode != null ? exitCode : 1; } toString() { - if (this.exit) process.exitCode = this.exit; return this.output; } } diff --git a/packages/cli-utils/src/ts/index.ts b/packages/cli-utils/src/ts/index.ts new file mode 100644 index 00000000..1ca54f1a --- /dev/null +++ b/packages/cli-utils/src/ts/index.ts @@ -0,0 +1,2 @@ +export * from './project'; +export * from './utils'; diff --git a/packages/cli-utils/src/ts/project.ts b/packages/cli-utils/src/ts/project.ts index fa79d23f..329e5c9a 100644 --- a/packages/cli-utils/src/ts/project.ts +++ b/packages/cli-utils/src/ts/project.ts @@ -1,5 +1,5 @@ +import type { GraphQLSPConfig } from '@gql.tada/internal'; import type { Project } from 'ts-morph'; -import type { GraphQLSPConfig } from '../lsp'; export const createPluginInfo = ( project: Project, diff --git a/packages/cli-utils/src/ts/utils.ts b/packages/cli-utils/src/ts/utils.ts new file mode 100644 index 00000000..6ca4c38a --- /dev/null +++ b/packages/cli-utils/src/ts/utils.ts @@ -0,0 +1,27 @@ +import type { SourceFile } from 'typescript'; + +export interface Position { + line: number; + col: number; + endLine: number | undefined; + endColumn: number | undefined; +} + +export const getFilePosition = ( + file: SourceFile, + start?: number | undefined, + length?: number | undefined +): Position => { + const output: Position = { line: 1, col: 1, endLine: undefined, endColumn: undefined }; + if (start) { + let lineAndChar = file.getLineAndCharacterOfPosition(start); + output.line = lineAndChar.line + 1; + output.col = lineAndChar.character + 1; + if (length) { + lineAndChar = file.getLineAndCharacterOfPosition(start + length - 1); + output.endLine = lineAndChar.line + 1; + output.endColumn = lineAndChar.character + 1; + } + } + return output; +}; diff --git a/packages/cli-utils/src/tsconfig.ts b/packages/cli-utils/src/tsconfig.ts deleted file mode 100644 index 32d089be..00000000 --- a/packages/cli-utils/src/tsconfig.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { TsConfigJson } from 'type-fest'; - -import { resolveTypeScriptRootDir } from '@gql.tada/internal'; -import path from 'node:path'; -import fs from 'node:fs/promises'; -import { parse } from 'json5'; - -const CWD = process.cwd(); - -export const getTsConfig = async (target?: string): Promise => { - let tsconfigPath = target || CWD; - tsconfigPath = - path.extname(tsconfigPath) !== '.json' - ? path.resolve(CWD, tsconfigPath, 'tsconfig.json') - : path.resolve(CWD, tsconfigPath); - - const root = (await resolveTypeScriptRootDir(tsconfigPath)) || tsconfigPath; - - let tsconfigContents: string; - try { - tsconfigPath = - path.extname(root) !== '.json' - ? path.resolve(CWD, root, 'tsconfig.json') - : path.resolve(CWD, root); - tsconfigContents = await fs.readFile(tsconfigPath, 'utf-8'); - } catch (error) { - console.error('Failed to read tsconfig.json in current working directory.', error); - return; - } - - let tsConfig: TsConfigJson; - try { - tsConfig = parse(tsconfigContents); - } catch (err) { - console.error(err); - return; - } - - return tsConfig; -}; diff --git a/packages/internal/LICENSE.md b/packages/internal/LICENSE.md index 72feedaa..aefb1e35 100644 --- a/packages/internal/LICENSE.md +++ b/packages/internal/LICENSE.md @@ -70,32 +70,6 @@ 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. -## json5 - -MIT License - -Copyright (c) 2012-2018 Aseem Kishore, and [others]. - -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. - -[others]: https://github.com/json5/json5/contributors - ## wonka MIT License diff --git a/packages/internal/package.json b/packages/internal/package.json index 4133db4d..06c7295c 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -44,7 +44,6 @@ "@types/node": "^20.11.0", "@urql/core": "^4.3.0", "@urql/exchange-retry": "^1.2.1", - "json5": "^2.2.3", "graphql": "^16.8.1", "rollup": "^4.9.4", "sade": "^1.8.1", @@ -55,8 +54,8 @@ "@0no-co/graphql.web": "^1.0.5" }, "peerDependencies": { - "typescript": "^5.0.0", - "graphql": "^16.8.1" + "graphql": "^16.8.1", + "typescript": "^5.0.0" }, "publishConfig": { "access": "public", diff --git a/packages/internal/src/config.ts b/packages/internal/src/config.ts new file mode 100644 index 00000000..cc1564b4 --- /dev/null +++ b/packages/internal/src/config.ts @@ -0,0 +1,63 @@ +import { TadaError } from './errors'; +import type { SchemaOrigin } from './loaders/types'; + +export interface GraphQLSPConfig { + schema: SchemaOrigin; + tadaOutputLocation?: string; + tadaTurboLocation?: string; + tadaPersistedLocation?: string; + template?: string; +} + +export const parseConfig = (input: Record) => { + if (input.schema && typeof input.schema === 'object') { + const { schema } = input; + if (!('url' in schema)) { + throw new TadaError('Configuration contains a `schema` object, but no `url` property'); + } + + if ('headers' in schema && schema.headers && typeof schema.headers !== 'object') { + for (const key in schema.headers) { + if (schema.headers[key] && typeof schema.headers[key] !== 'string') { + throw new TadaError( + 'Headers at `schema.headers` contain a non-string value at key: ' + key + ); + } + } + } else if ('headers' in schema) { + throw new TadaError( + "Configuration contains a `schema.headers` property, but it's not an object" + ); + } + } else if (typeof input.schema !== 'string') { + throw new TadaError('Configuration is missing a `schema` property'); + } else if ( + 'tadaOutputLocation' in input && + input.tadaOutputLocation && + typeof input.tadaOutputLocation !== 'string' + ) { + throw new TadaError( + "Configuration contains a `tadaOutputLocation` property, but it's not a file path" + ); + } else if ( + 'tadaTurboLocation' in input && + input.tadaTurboLocation && + typeof input.tadaTurboLocation !== 'string' + ) { + throw new TadaError( + "Configuration contains a `tadaTurboLocation` property, but it's not a file path" + ); + } else if ( + 'tadaPersistedLocation' in input && + input.tadaPersistedLocation && + typeof input.tadaPersistedLocation !== 'string' + ) { + throw new TadaError( + "Configuration contains a `tadaPersistedLocation` property, but it's not a file path" + ); + } else if ('template' in input && input.template && typeof input.template !== 'string') { + throw new TadaError("Configuration contains a `template` property, but it's not a string"); + } + + return input as any as GraphQLSPConfig; +}; diff --git a/packages/internal/src/errors.ts b/packages/internal/src/errors.ts index c7e83218..a56ee147 100644 --- a/packages/internal/src/errors.ts +++ b/packages/internal/src/errors.ts @@ -1,15 +1,23 @@ import type { Diagnostic } from 'typescript'; +import { maybeRelative } from './helpers'; export class TSError extends Error { - diagnostics: readonly Diagnostic[]; - constructor(message: string, diagnostics?: readonly Diagnostic[]) { + readonly name: 'TSError'; + readonly diagnostic: Diagnostic; + constructor(diagnostic: Diagnostic) { + let message = + typeof diagnostic.messageText !== 'string' + ? diagnostic.messageText.messageText + : diagnostic.messageText; + if (diagnostic.file) message += ` (${maybeRelative(diagnostic.file.fileName)})`; super(message); this.name = 'TSError'; - this.diagnostics = diagnostics || []; + this.diagnostic = diagnostic; } } export class TadaError extends Error { + readonly name: 'TadaError'; constructor(message: string) { super(message); this.name = 'TadaError'; diff --git a/packages/internal/src/helpers.ts b/packages/internal/src/helpers.ts new file mode 100644 index 00000000..f48805c7 --- /dev/null +++ b/packages/internal/src/helpers.ts @@ -0,0 +1,8 @@ +import * as path from 'node:path'; + +export const cwd = process.cwd(); + +export const maybeRelative = (filePath: string): string => { + const relative = path.relative(cwd, filePath); + return !relative.startsWith('..') ? relative : filePath; +}; diff --git a/packages/internal/src/index.ts b/packages/internal/src/index.ts index 4a5dbc44..c837ee99 100644 --- a/packages/internal/src/index.ts +++ b/packages/internal/src/index.ts @@ -1,10 +1,11 @@ export * from './vfs'; export * from './loaders'; +export * from './errors'; +export * from './config'; +export * from './resolve'; export { minifyIntrospection, preprocessIntrospection, outputIntrospectionFile, } from './introspection'; - -export { resolveTypeScriptRootDir } from './resolve'; diff --git a/packages/internal/src/resolve.ts b/packages/internal/src/resolve.ts index 75b606ac..9f8431b2 100644 --- a/packages/internal/src/resolve.ts +++ b/packages/internal/src/resolve.ts @@ -1,36 +1,137 @@ -import path from 'path'; -import JSON5 from 'json5'; -import fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import type { Stats } from 'node:fs'; + import type { TsConfigJson } from 'type-fest'; +import { parseConfigFileTextToJson } from 'typescript'; + +import { cwd, maybeRelative } from './helpers'; +import { TSError, TadaError } from './errors'; + +const TSCONFIG = 'tsconfig.json'; + +const isFile = (stat: Stats): boolean => stat.isFile(); +const isDir = (stat: Stats): boolean => stat.isDirectory(); +const stat = (file: string, predicate = isFile): Promise => + fs + .stat(file) + .then(predicate) + .catch(() => false); + +const _resolve = + typeof require !== 'undefined' + ? require.resolve.bind(require) + : createRequire(import.meta.url).resolve; +const resolveExtend = async (extend: string, from: string) => { + try { + return toTSConfigPath(_resolve(extend, { paths: [from] })); + } catch (_error) { + return null; + } +}; + +const toTSConfigPath = (tsconfigPath: string): string => + path.extname(tsconfigPath) !== '.json' + ? path.resolve(cwd, tsconfigPath, TSCONFIG) + : path.resolve(cwd, tsconfigPath); + +export const readTSConfigFile = async (filePath: string): Promise => { + const tsconfigPath = toTSConfigPath(filePath); + const contents = await fs.readFile(tsconfigPath, 'utf8'); + const result = parseConfigFileTextToJson(tsconfigPath, contents); + if (result.error) throw new TSError(result.error); + return result.config || {}; +}; + +export const findTSConfigFile = async (targetPath?: string): Promise => { + let tsconfigPath = toTSConfigPath(targetPath || cwd); + const rootPath = toTSConfigPath(path.resolve(tsconfigPath, '/')); + while (tsconfigPath !== rootPath) { + if (await stat(tsconfigPath)) return tsconfigPath; + const gitPath = path.resolve(tsconfigPath, '..', '.git'); + if (await stat(gitPath, isDir)) return null; + const parentPath = toTSConfigPath(path.resolve(tsconfigPath, '..', '..')); + if (parentPath === tsconfigPath) break; + tsconfigPath = parentPath; + } + return null; +}; -// TODO: Replace config loading with typescript package's native config loading +const getPluginConfig = (tsconfig: TsConfigJson | null): Record | null => + (tsconfig && + tsconfig.compilerOptions && + tsconfig.compilerOptions.plugins && + tsconfig.compilerOptions.plugins.find( + (x) => x.name === '@0no-co/graphqlsp' || x.name === 'gql.tada/lsp' + )) || + null; + +export interface LoadConfigResult { + pluginConfig: Record; + configPath: string; + rootPath: string; +} + +export const loadConfig = async (targetPath?: string): Promise => { + const rootTsconfigPath = await findTSConfigFile(targetPath); + if (!rootTsconfigPath) { + throw new TadaError( + targetPath + ? `No tsconfig.json found at or above: ${maybeRelative(targetPath)}` + : 'No tsconfig.json found at or above current working directory' + ); + } + const tsconfig = await readTSConfigFile(rootTsconfigPath); + const pluginConfig = getPluginConfig(tsconfig); + if (pluginConfig) { + return { + pluginConfig, + configPath: rootTsconfigPath, + rootPath: path.dirname(rootTsconfigPath), + }; + } + + if (Array.isArray(tsconfig.extends)) { + for (let extend of tsconfig.extends) { + if (path.extname(extend) !== '.json') extend += '.json'; + try { + const tsconfigPath = await resolveExtend(extend, path.dirname(rootTsconfigPath)); + if (tsconfigPath) { + const config = loadConfig(targetPath); + return { + ...config, + rootPath: path.dirname(rootTsconfigPath), + }; + } + } catch (_error) {} + } + } else if (tsconfig.extends) { + try { + const tsconfigPath = await resolveExtend(tsconfig.extends, path.dirname(rootTsconfigPath)); + if (tsconfigPath) { + const config = loadConfig(targetPath); + return { + ...config, + rootPath: path.dirname(rootTsconfigPath), + }; + } + } catch (_error) {} + } + + throw new TadaError( + `Could not find a valid GraphQLSP plugin entry in: ${maybeRelative(rootTsconfigPath)}` + ); +}; + +/** @deprecated Use {@link loadConfig} instead */ export const resolveTypeScriptRootDir = async ( tsconfigPath: string ): Promise => { - const tsconfigContents = await fs.readFile(tsconfigPath, { encoding: 'utf8' }); - const parsed = JSON5.parse(tsconfigContents); - - if ( - parsed.compilerOptions && - parsed.compilerOptions.plugins && - parsed.compilerOptions.plugins.find( - (x) => x.name === '@0no-co/graphqlsp' || x.name === 'gql.tada/lsp' - ) - ) { - return path.dirname(tsconfigPath); - } else if (Array.isArray(parsed.extends)) { - return parsed.extends.find((p) => { - // TODO: This doesn't account for *.json being omitted - // See: https://www.typescriptlang.org/tsconfig#extends - const resolved = require.resolve(p, { - paths: [path.dirname(tsconfigPath)], - }); - return resolveTypeScriptRootDir(resolved); - }); - } else if (parsed.extends) { - const resolved = require.resolve(parsed.extends, { - paths: [path.dirname(tsconfigPath)], - }); - return resolveTypeScriptRootDir(resolved); + try { + const result = await loadConfig(tsconfigPath); + return path.dirname(result.configPath); + } catch (_error) { + return undefined; } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c8c2ecc..efb85d3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,9 +175,6 @@ importers: execa: specifier: ^8.0.1 version: 8.0.1 - json5: - specifier: ^2.2.3 - version: 2.2.3 rollup: specifier: ^4.9.4 version: 4.9.4 @@ -218,9 +215,6 @@ importers: graphql: specifier: ^16.8.1 version: 16.8.1 - json5: - specifier: ^2.2.3 - version: 2.2.3 rollup: specifier: ^4.9.4 version: 4.9.4