diff --git a/.changeset/cli-hive-json-schema.md b/.changeset/cli-hive-json-schema.md new file mode 100644 index 00000000000..6123073b054 --- /dev/null +++ b/.changeset/cli-hive-json-schema.md @@ -0,0 +1,20 @@ +--- +'@graphql-hive/cli': minor +--- + +Add a JSON Schema for the `hive.json` configuration file. + +The CLI now ships a `hive-config.schema.json` describing the supported `hive.json` shapes (the modern +`registry`/`cdn` format, the deprecated flat format, and namespaced/space configurations). Reference +it from your config to get autocompletion, inline documentation, and validation in editors that +support JSON Schema: + +```json +{ + "$schema": "https://raw.githubusercontent.com/graphql-hive/console/main/packages/libraries/cli/hive-config.schema.json", + "registry": { + "endpoint": "", + "accessToken": "" + } +} +``` diff --git a/.prettierignore b/.prettierignore index 4391a16a007..054752f71ca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,7 @@ __snapshots__/ /integration-tests/testkit/gql/ /packages/libraries/cli/src/sdk.ts /packages/libraries/cli/src/gql/ +/packages/libraries/cli/hive-config.schema.json /packages/web/app/src/gql/** /packages/web/app/src/graphql/index.ts /packages/web/app/next.config.mjs diff --git a/packages/libraries/cli/README.md b/packages/libraries/cli/README.md index 83b1eed7afa..c93e45832a9 100644 --- a/packages/libraries/cli/README.md +++ b/packages/libraries/cli/README.md @@ -584,6 +584,7 @@ This is how the structure of the config file should look like: ```json { + "$schema": "https://raw.githubusercontent.com/graphql-hive/console/main/packages/libraries/cli/hive-config.schema.json", "registry": { "endpoint": "", "accessToken": "" @@ -594,3 +595,6 @@ This is how the structure of the config file should look like: } } ``` + +The optional `$schema` property enables autocompletion, inline documentation, and validation for +`hive.json` in editors that support JSON Schema (such as VS Code). diff --git a/packages/libraries/cli/hive-config.schema.json b/packages/libraries/cli/hive-config.schema.json new file mode 100644 index 00000000000..a1c40032529 --- /dev/null +++ b/packages/libraries/cli/hive-config.schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/graphql-hive/console/main/packages/libraries/cli/hive-config.schema.json", + "title": "GraphQL Hive CLI configuration (hive.json)", + "description": "Configuration file for the GraphQL Hive CLI (`hive`). Configuration is resolved with the priority: CLI arguments > environment variables > hive.json. See https://the-guild.dev/graphql/hive/docs/api-reference/cli for details.", + "$comment": "Generated from ConfigModel and LegacyConfigModel in src/helpers/config.ts by scripts/generate-config-schema.ts. Do not edit by hand; run `pnpm --filter @graphql-hive/cli generate:schema`.", + "anyOf": [ + { + "$ref": "#/definitions/modernConfig" + }, + { + "$ref": "#/definitions/legacyConfig" + }, + { + "$ref": "#/definitions/namespacedConfig" + } + ], + "definitions": { + "modernConfig": { + "type": "object", + "additionalProperties": false, + "description": "Hive CLI configuration.", + "properties": { + "$schema": { + "type": "string", + "description": "URL of the JSON Schema used to validate this file." + }, + "registry": { + "type": "object", + "additionalProperties": false, + "description": "Schema registry connection settings.", + "properties": { + "endpoint": { + "type": "string", + "format": "uri", + "description": "GraphQL endpoint of the Hive schema registry. Defaults to https://app.graphql-hive.com/graphql. Set this when self-hosting Hive. Can also be provided via the HIVE_REGISTRY environment variable or the --registry.endpoint argument." + }, + "accessToken": { + "type": "string", + "description": "Registry access token used to authenticate registry commands such as schema:publish and schema:check. Can also be provided via the HIVE_TOKEN environment variable or the --registry.accessToken argument." + } + } + }, + "cdn": { + "type": "object", + "additionalProperties": false, + "description": "High-availability CDN settings used by artifact:fetch.", + "properties": { + "endpoint": { + "type": "string", + "format": "uri", + "description": "High-availability CDN endpoint for fetching artifacts. Can also be provided via the HIVE_CDN_ENDPOINT environment variable or the --cdn.endpoint argument." + }, + "accessToken": { + "type": "string", + "description": "CDN access token used by artifact:fetch. Can also be provided via the --cdn.accessToken argument." + } + } + } + } + }, + "legacyConfig": { + "type": "object", + "additionalProperties": false, + "description": "Deprecated flat configuration format. Use the `registry` object (modernConfig) instead.", + "properties": { + "$schema": { + "type": "string", + "description": "URL of the JSON Schema used to validate this file." + }, + "registry": { + "type": "string", + "description": "Deprecated. Registry endpoint URL. Use `registry.endpoint` instead." + }, + "token": { + "type": "string", + "description": "Deprecated. Registry access token. Use `registry.accessToken` instead." + } + } + }, + "namespacedConfig": { + "type": "object", + "description": "Multiple named configurations ('spaces'). The active space is selected with the HIVE_SPACE environment variable, falling back to the `default` space. Each value is a Hive CLI configuration.", + "properties": { + "$schema": { + "type": "string", + "description": "URL of the JSON Schema used to validate this file." + } + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/modernConfig" + }, + { + "$ref": "#/definitions/legacyConfig" + } + ] + } + } + } +} diff --git a/packages/libraries/cli/hive.json b/packages/libraries/cli/hive.json index 6630d30fac4..61d6d4a9020 100644 --- a/packages/libraries/cli/hive.json +++ b/packages/libraries/cli/hive.json @@ -1,4 +1,5 @@ { + "$schema": "https://raw.githubusercontent.com/graphql-hive/console/main/packages/libraries/cli/hive-config.schema.json", "registry": { "endpoint": "http://localhost:3001/graphql", "accessToken": "905818792799ac1194c1d69d64c94a1a" diff --git a/packages/libraries/cli/package.json b/packages/libraries/cli/package.json index c2ca87c5829..e9904bd4409 100644 --- a/packages/libraries/cli/package.json +++ b/packages/libraries/cli/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/cli", - "version": "0.60.1", + "version": "0.60.0", "description": "A CLI util to manage and control your GraphQL Hive", "repository": { "type": "git", @@ -24,6 +24,7 @@ "files": [ "/bin", "/dist", + "/hive-config.schema.json", "/oclif.manifest.json" ], "keywords": [ @@ -32,6 +33,7 @@ "scripts": { "build": "tsc --build", "build:watch": "tsc --build --watch", + "generate:schema": "tsx scripts/generate-config-schema.ts", "oclif:pack": "npm pack && pnpm oclif pack tarballs --no-xz", "oclif:readme": "pnpm oclif readme && npx prettier --write README.md", "oclif:upload": "pnpm oclif upload tarballs --no-xz", @@ -71,6 +73,7 @@ }, "devDependencies": { "@types/env-ci": "3.1.4", + "ajv": "8.18.0", "graphql": "16.9.0", "oclif": "4.22.65", "rimraf": "6.1.3", @@ -103,4 +106,4 @@ "command_not_found": "./dist/hooks/command-not-found" } } -} +} \ No newline at end of file diff --git a/packages/libraries/cli/scripts/generate-config-schema.ts b/packages/libraries/cli/scripts/generate-config-schema.ts new file mode 100644 index 00000000000..6ddb36cb8de --- /dev/null +++ b/packages/libraries/cli/scripts/generate-config-schema.ts @@ -0,0 +1,118 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { ConfigModel, LegacyConfigModel } from '../src/helpers/config'; + +// The `$id`/`$schema` URL users reference from their `hive.json`. Must match the repository slug +// (graphql-hive/console) so the schema resolves over raw.githubusercontent.com. +const SCHEMA_ID = + 'https://raw.githubusercontent.com/graphql-hive/console/main/packages/libraries/cli/hive-config.schema.json'; + +const SCHEMA_PATH = join(__dirname, '..', 'hive-config.schema.json'); + +type JsonSchema = Record; + +const schemaProperty: JsonSchema = { + type: 'string', + description: 'URL of the JSON Schema used to validate this file.', +}; + +// Minimal Zod -> JSON Schema converter covering the constructs the config models use +// (objects, strings, `.url()`, `.optional()`, `.describe()`). It throws on anything else so that +// unsupported additions fail loudly during `generate:schema` instead of silently producing an +// incorrect schema. +function toJsonSchema(schema: z.ZodTypeAny): JsonSchema { + if (schema instanceof z.ZodOptional) { + return toJsonSchema(schema.unwrap() as z.ZodTypeAny); + } + + if (schema instanceof z.ZodObject) { + const properties: Record = {}; + const required: string[] = []; + const shape = schema.shape as Record; + + for (const [key, value] of Object.entries(shape)) { + properties[key] = toJsonSchema(value); + if (!(value instanceof z.ZodOptional)) { + required.push(key); + } + } + + const output: JsonSchema = { type: 'object', additionalProperties: false }; + if (schema.description) { + output.description = schema.description; + } + output.properties = properties; + if (required.length > 0) { + output.required = required; + } + return output; + } + + if (schema instanceof z.ZodString) { + const output: JsonSchema = { type: 'string' }; + if (schema._def.checks.some(check => check.kind === 'url')) { + output.format = 'uri'; + } + if (schema.description) { + output.description = schema.description; + } + return output; + } + + throw new Error( + `Unsupported Zod type in the hive-config schema generator: ${schema.constructor.name}. Extend toJsonSchema() in scripts/generate-config-schema.ts to handle it.`, + ); +} + +function configDefinition(model: z.ZodTypeAny, description: string): JsonSchema { + const base = toJsonSchema(model); + const properties = (base.properties ?? {}) as Record; + return { + type: 'object', + additionalProperties: false, + description, + properties: { $schema: schemaProperty, ...properties }, + }; +} + +export function buildConfigSchema(): JsonSchema { + const modernConfig = configDefinition(ConfigModel, 'Hive CLI configuration.'); + const legacyConfig = configDefinition( + LegacyConfigModel, + 'Deprecated flat configuration format. Use the `registry` object (modernConfig) instead.', + ); + const namespacedConfig: JsonSchema = { + type: 'object', + description: + "Multiple named configurations ('spaces'). The active space is selected with the HIVE_SPACE environment variable, falling back to the `default` space. Each value is a Hive CLI configuration.", + properties: { $schema: schemaProperty }, + additionalProperties: { + anyOf: [{ $ref: '#/definitions/modernConfig' }, { $ref: '#/definitions/legacyConfig' }], + }, + }; + + return { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: SCHEMA_ID, + title: 'GraphQL Hive CLI configuration (hive.json)', + description: + 'Configuration file for the GraphQL Hive CLI (`hive`). Configuration is resolved with the priority: CLI arguments > environment variables > hive.json. See https://the-guild.dev/graphql/hive/docs/api-reference/cli for details.', + $comment: + 'Generated from ConfigModel and LegacyConfigModel in src/helpers/config.ts by scripts/generate-config-schema.ts. Do not edit by hand; run `pnpm --filter @graphql-hive/cli generate:schema`.', + anyOf: [ + { $ref: '#/definitions/modernConfig' }, + { $ref: '#/definitions/legacyConfig' }, + { $ref: '#/definitions/namespacedConfig' }, + ], + definitions: { modernConfig, legacyConfig, namespacedConfig }, + }; +} + +export function serializeConfigSchema(): string { + return `${JSON.stringify(buildConfigSchema(), null, 2)}\n`; +} + +if (process.argv[1]?.endsWith('generate-config-schema.ts')) { + writeFileSync(SCHEMA_PATH, serializeConfigSchema()); +} diff --git a/packages/libraries/cli/src/helpers/config.ts b/packages/libraries/cli/src/helpers/config.ts index 12d55be5632..ef183d504bb 100644 --- a/packages/libraries/cli/src/helpers/config.ts +++ b/packages/libraries/cli/src/helpers/config.ts @@ -2,23 +2,56 @@ import fs from 'fs'; import path from 'path'; import { z } from 'zod'; -const LegacyConfigModel = z.object({ - registry: z.string().optional(), - token: z.string().optional(), +// These models are the single source of truth for the public JSON Schema at +// packages/libraries/cli/hive-config.schema.json. After changing them, regenerate the schema with +// `pnpm --filter @graphql-hive/cli generate:schema` (CI fails if the committed schema is stale). +export const LegacyConfigModel = z.object({ + registry: z + .string() + .describe('Deprecated. Registry endpoint URL. Use `registry.endpoint` instead.') + .optional(), + token: z + .string() + .describe('Deprecated. Registry access token. Use `registry.accessToken` instead.') + .optional(), }); -const ConfigModel = z.object({ +export const ConfigModel = z.object({ registry: z .object({ - endpoint: z.string().url().optional(), - accessToken: z.string().optional(), + endpoint: z + .string() + .url() + .describe( + 'GraphQL endpoint of the Hive schema registry. Defaults to https://app.graphql-hive.com/graphql. Set this when self-hosting Hive. Can also be provided via the HIVE_REGISTRY environment variable or the --registry.endpoint argument.', + ) + .optional(), + accessToken: z + .string() + .describe( + 'Registry access token used to authenticate registry commands such as schema:publish and schema:check. Can also be provided via the HIVE_TOKEN environment variable or the --registry.accessToken argument.', + ) + .optional(), }) + .describe('Schema registry connection settings.') .optional(), cdn: z .object({ - endpoint: z.string().url().optional(), - accessToken: z.string().optional(), + endpoint: z + .string() + .url() + .describe( + 'High-availability CDN endpoint for fetching artifacts. Can also be provided via the HIVE_CDN_ENDPOINT environment variable or the --cdn.endpoint argument.', + ) + .optional(), + accessToken: z + .string() + .describe( + 'CDN access token used by artifact:fetch. Can also be provided via the --cdn.accessToken argument.', + ) + .optional(), }) + .describe('High-availability CDN settings used by artifact:fetch.') .optional(), }); diff --git a/packages/libraries/cli/tests/hive-config.schema.spec.ts b/packages/libraries/cli/tests/hive-config.schema.spec.ts new file mode 100644 index 00000000000..e71e4281b4d --- /dev/null +++ b/packages/libraries/cli/tests/hive-config.schema.spec.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import Ajv from 'ajv'; +import { serializeConfigSchema } from '../scripts/generate-config-schema'; + +// Compiles the shipped JSON Schema and asserts it accepts every `hive.json` shape +// the `Config` class accepts and rejects common typos. The fixtures mirror +// ConfigModel / LegacyConfigModel in src/helpers/config.ts. +const schemaFileContents = readFileSync(join(__dirname, '..', 'hive-config.schema.json'), 'utf8'); +const schema = JSON.parse(schemaFileContents) as Record; + +// `strict: false`: the schema uses `format: "uri"`, which bare ajv does not +// register. This suite tests structure (shapes + typos), not URI formatting. +const ajv = new Ajv({ strict: false, allErrors: true }); +const validate = ajv.compile(schema); + +const SCHEMA_URL = + 'https://raw.githubusercontent.com/graphql-hive/console/main/packages/libraries/cli/hive-config.schema.json'; + +const registry = { + endpoint: 'https://app.graphql-hive.com/graphql', + accessToken: 'a-registry-token', +}; +const cdn = { + endpoint: 'https://cdn.graphql-hive.com/artifacts/v1/target', + accessToken: 'a-cdn-token', +}; + +const validConfigs: Array<[string, unknown]> = [ + ['a modern config with registry only', { registry }], + ['a modern config with registry and cdn', { registry, cdn }], + ['an empty config', {}], + ['a modern config with a $schema reference', { $schema: SCHEMA_URL, registry }], + ['a legacy flat config', { registry: 'https://app.graphql-hive.com/graphql', token: 'a-token' }], + [ + 'a legacy flat config with a $schema reference', + { $schema: SCHEMA_URL, registry: 'https://app.graphql-hive.com/graphql', token: 'a-token' }, + ], + [ + 'a namespaced config with default and named spaces', + { default: { registry }, production: { registry } }, + ], + ['a namespaced config with a $schema reference', { $schema: SCHEMA_URL, default: { registry } }], +]; + +const invalidConfigs: Array<[string, unknown]> = [ + [ + 'a misspelled top-level "registery" key', + { registery: { endpoint: 'https://app.graphql-hive.com/graphql' } }, + ], + [ + 'a misspelled nested "accesToken" key', + { registry: { endpoint: 'https://app.graphql-hive.com/graphql', accesToken: 'a-token' } }, + ], + ['a non-string registry endpoint', { registry: { endpoint: 123 } }], + ['a registry that is neither an object nor a string', { registry: true }], +]; + +describe('hive-config.schema.json', () => { + test('is up to date with the config models', () => { + // The schema is generated from ConfigModel / LegacyConfigModel. If this fails, run + // `pnpm --filter @graphql-hive/cli generate:schema` and commit the result. + expect(schemaFileContents).toBe(serializeConfigSchema()); + }); + + test('compiles as a valid JSON Schema', () => { + expect(typeof validate).toBe('function'); + }); + + test.each(validConfigs)('accepts %s', (_name, config) => { + const isValid = validate(config); + expect(isValid, ajv.errorsText(validate.errors)).toBe(true); + }); + + test.each(invalidConfigs)('rejects %s', (_name, config) => { + const isValid = validate(config); + expect(isValid).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 742cbecfbd2..16f2e8281c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -545,6 +545,9 @@ importers: '@types/env-ci': specifier: 3.1.4 version: 3.1.4 + ajv: + specifier: ^8.18.0 + version: 8.18.0 oclif: specifier: 4.22.65 version: 4.22.65(patch_hash=14bfe62909a48d0f0115f257cc517dbaa3caac858fc059ddeaa614af424b626b)(@types/node@25.5.0)