diff --git a/.changeset/honest-walls-jog.md b/.changeset/honest-walls-jog.md new file mode 100644 index 0000000000..fa24cdf22a --- /dev/null +++ b/.changeset/honest-walls-jog.md @@ -0,0 +1,6 @@ +--- +"graphile": patch +--- + +Don't throw error for `graphile --help` when certain libraries are not +installed. diff --git a/eslint.config.mjs b/eslint.config.mjs index 88eefc9957..88508a3b94 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,6 @@ // @ts-check +import { builtinModules } from "node:module"; + import babelParser from "@babel/eslint-parser"; import js from "@eslint/js"; import graphql from "@graphql-eslint/eslint-plugin"; @@ -18,6 +20,78 @@ import path from "path"; import tseslint from "typescript-eslint"; const __dirname = import.meta.dirname; +const graphileCliRoot = "/utils/graphile/src"; +const stripAnsiPath = `${graphileCliRoot}/stripAnsi.ts`; +const builtinModuleNames = new Set( + builtinModules.flatMap((name) => + name.startsWith("node:") ? [name, name.slice("node:".length)] : [name], + ), +); + +function normalizePath(filePath) { + return filePath.replaceAll(path.sep, "/"); +} + +function isGraphileCliFile(filename) { + const normalized = normalizePath(filename); + return normalized.includes(graphileCliRoot) && normalized.endsWith("/cli.ts"); +} + +function resolvesToAllowedRelativeImport(filename, source) { + if (!source.startsWith(".")) { + return false; + } + const resolved = normalizePath(path.resolve(path.dirname(filename), source)); + return resolved.endsWith("/cli.ts") || resolved.endsWith(stripAnsiPath); +} + +const graphileCliImportsRule = { + meta: { + type: "problem", + schema: [], + }, + create(context) { + const filename = context.filename ?? context.getFilename(); + if (!isGraphileCliFile(filename)) { + return {}; + } + + return { + ImportDeclaration(node) { + if (node.importKind === "type") { + return; + } + const source = node.source.value; + if (typeof source !== "string") { + context.report({ + node, + message: + "Static imports in `utils/graphile/src/**/cli.ts` must use a string module specifier.", + }); + return; + } + const allowed = + source === "graphile-config" || + source.startsWith("graphile-config/") || + source === "chalk" || + source === "yargs" || + source === "yargs/helpers" || + builtinModuleNames.has(source) || + resolvesToAllowedRelativeImport(filename, source); + if (!allowed) { + context.report({ + node, + message: + "Static import '{{ source }}' is not allowed in `utils/graphile/src/**/cli.ts` because it might require software to be installed even when this command isn't invoked; use `await import(...)` inside `run()` instead.", + data: { + source, + }, + }); + } + }, + }; + }, +}; const globalIgnoresFromFile = fs .readFileSync(path.resolve(__dirname, ".lintignore"), "utf8") @@ -64,6 +138,11 @@ const config = { }, plugins: { + crystal: { + rules: { + "graphile-cli-imports": graphileCliImportsRule, + }, + }, jest, "@graphql-eslint": graphql, tsdoc, @@ -331,6 +410,13 @@ const oldConfig = { }, }, + { + files: ["utils/graphile/src/**/cli.ts"], + rules: { + "crystal/graphile-cli-imports": "error", + }, + }, + // Don't use Node.js builtins { files: ["grafast/grafast/src/**", "utils/graphile-config/src/**"], diff --git a/utils/graphile/src/commands/behavior/debug/cli.ts b/utils/graphile/src/commands/behavior/debug/cli.ts index f3232f6ecf..e54f770c4c 100644 --- a/utils/graphile/src/commands/behavior/debug/cli.ts +++ b/utils/graphile/src/commands/behavior/debug/cli.ts @@ -1,7 +1,5 @@ import type { ArgsFromOptions, Argv } from "graphile-config/cli"; -import { main } from "./main.ts"; - export function options(yargs: Argv) { return yargs .positional("entityType", { type: "string" }) @@ -19,6 +17,7 @@ export function options(yargs: Argv) { }); } export async function run(args: ArgsFromOptions) { + const { main } = await import("./main.ts"); const text = await main({ config: args.config, entityType: args.entityType, diff --git a/utils/graphile/src/commands/config/options/cli.ts b/utils/graphile/src/commands/config/options/cli.ts index 50261089b2..d341eaddc6 100644 --- a/utils/graphile/src/commands/config/options/cli.ts +++ b/utils/graphile/src/commands/config/options/cli.ts @@ -1,7 +1,5 @@ import type { ArgsFromOptions, Argv } from "graphile-config/cli"; -import { main } from "./main.ts"; - export const command = "options [scope]"; export const description = "Output the options your config may contain"; @@ -29,7 +27,8 @@ export function options(yargs: Argv) { type: "string", }); } -export function run(args: ArgsFromOptions) { +export async function run(args: ArgsFromOptions) { + const { main } = await import("./main.ts"); const text = main({ filename: args.config, scope: args.scope }); console.log(text); } diff --git a/utils/graphile/src/commands/inflection/list/cli.ts b/utils/graphile/src/commands/inflection/list/cli.ts index ea6adb506c..638fa8093d 100644 --- a/utils/graphile/src/commands/inflection/list/cli.ts +++ b/utils/graphile/src/commands/inflection/list/cli.ts @@ -1,7 +1,5 @@ import type { ArgsFromOptions, Argv } from "graphile-config/cli"; -import { main } from "./main.ts"; - export function options(yargs: Argv) { return yargs .example( @@ -21,6 +19,7 @@ export function options(yargs: Argv) { }); } export async function run(args: ArgsFromOptions) { + const { main } = await import("./main.ts"); const text = await main({ filename: args.config, quiet: args.quiet }); console.log(text); } diff --git a/utils/graphile/src/commands/inflection/list/main.ts b/utils/graphile/src/commands/inflection/list/main.ts index cebae2fef0..c76230d89c 100644 --- a/utils/graphile/src/commands/inflection/list/main.ts +++ b/utils/graphile/src/commands/inflection/list/main.ts @@ -3,22 +3,22 @@ import path from "node:path"; import chalk from "chalk"; import type { InflectorSource } from "graphile-build"; -import { buildInflection } from "graphile-build"; import { resolvePreset } from "graphile-config"; import { loadConfig } from "graphile-config/load"; import type { CompletionEntry } from "typescript"; import type { ResolvedDefinition } from "../../../utils/typescriptVfs.ts"; -import { - accessKey, - configVfs, - prettyDocumentation, - prettyQuickInfoDisplayParts, - tightDisplayParts, - tightDocumentation, -} from "../../../utils/typescriptVfs.ts"; export async function main(options: { filename?: string; quiet?: boolean }) { + const { + accessKey, + configVfs, + prettyDocumentation, + prettyQuickInfoDisplayParts, + tightDisplayParts, + tightDocumentation, + } = await import("../../../utils/typescriptVfs.ts"); + const { buildInflection } = await import("graphile-build"); const { filename, quiet } = options; // Create inflection so we can determine where the inflectors came from