diff --git a/.github/workflows/tests-integration.yaml b/.github/workflows/tests-integration.yaml index f8e42e6dc2..b63d71fb16 100644 --- a/.github/workflows/tests-integration.yaml +++ b/.github/workflows/tests-integration.yaml @@ -71,8 +71,6 @@ jobs: - name: run integration tests timeout-minutes: 10 - env: - HIVE_DEBUG: 1 run: | VITEST_MAX_THREADS=${{ steps.cpu-cores.outputs.count }} pnpm --filter integration-tests test:integration --shard=${{ matrix.shardIndex }}/3 diff --git a/.vscode/settings.json b/.vscode/settings.json index 4a91f9652e..aa28ac8397 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,9 @@ { + "search.exclude": { + "**/dist": true, + "**/pnpm-lock.yaml": true, + "**/*.tsbuildinfo": true + }, "commands.commands": [ { "command": "terminals.runTerminals", @@ -28,5 +33,8 @@ ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ] + ], + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/codegen.mts b/codegen.mts index ae23bb9ff7..8172c611de 100644 --- a/codegen.mts +++ b/codegen.mts @@ -85,7 +85,7 @@ const config: CodegenConfig = { }, // CLI './packages/libraries/cli/src/gql/': { - documents: ['./packages/libraries/cli/src/(commands|helpers)/**/*.ts'], + documents: ['./packages/libraries/cli/src/(commands|fragments)/**/*.ts'], preset: 'client', plugins: [], config: { diff --git a/integration-tests/package.json b/integration-tests/package.json index b59b79dbc8..13cfb3a8fd 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -15,12 +15,14 @@ "@aws-sdk/client-s3": "3.693.0", "@esm2cjs/execa": "6.1.1-cjs.1", "@graphql-hive/apollo": "workspace:*", + "@graphql-hive/cli": "workspace:*", "@graphql-hive/core": "workspace:*", "@graphql-typed-document-node/core": "3.2.0", "@hive/rate-limit": "workspace:*", "@hive/schema": "workspace:*", "@hive/server": "workspace:*", "@hive/storage": "workspace:*", + "@sinclair/typebox": "^0.34.12", "@trpc/client": "10.45.2", "@trpc/server": "10.45.2", "@types/async-retry": "1.4.8", @@ -34,9 +36,9 @@ "human-id": "4.1.1", "ioredis": "5.4.1", "slonik": "30.4.4", - "strip-ansi": "7.1.0", + "strip-ansi": "6.0.1", "tslib": "2.8.1", - "vitest": "2.0.5", + "vitest": "2.1.8", "zod": "3.23.8" } } diff --git a/integration-tests/testkit/cli.ts b/integration-tests/testkit/cli.ts index fdbe3e0f26..39d017fdaf 100644 --- a/integration-tests/testkit/cli.ts +++ b/integration-tests/testkit/cli.ts @@ -1,26 +1,15 @@ import { randomUUID } from 'node:crypto'; -import { writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { execaCommand } from '@esm2cjs/execa'; import { fetchLatestSchema, fetchLatestValidSchema } from './flow'; +import { generateTmpFile } from './fs'; import { getServiceHost } from './utils'; const binPath = resolve(__dirname, '../../packages/libraries/cli/bin/run'); const cliDir = resolve(__dirname, '../../packages/libraries/cli'); -async function generateTmpFile(content: string, extension: string) { - const dir = tmpdir(); - const fileName = randomUUID(); - const filepath = join(dir, `${fileName}.${extension}`); - - await writeFile(filepath, content, 'utf-8'); - - return filepath; -} - -async function exec(cmd: string) { - const outout = await execaCommand(`${binPath} ${cmd}`, { +export async function exec(cmd: string) { + const result = await execaCommand(`${binPath} ${cmd}`, { shell: true, env: { OCLIF_CLI_CUSTOM_PATH: cliDir, @@ -28,11 +17,11 @@ async function exec(cmd: string) { }, }); - if (outout.failed) { - throw new Error(outout.stderr); + if (result.failed) { + throw new Error('CLI execution marked as "failed".', { cause: result.stderr }); } - return outout.stdout; + return result.stdout; } export async function schemaPublish(args: string[]) { @@ -70,6 +59,8 @@ async function dev(args: string[]) { ); } +export type CLI = ReturnType; + export function createCLI(tokens: { readwrite: string; readonly: string }) { let publishCount = 0; @@ -81,6 +72,7 @@ export function createCLI(tokens: { readwrite: string; readonly: string }) { expect: expectedStatus, legacy_force, legacy_acceptBreakingChanges, + json, }: { sdl: string; commit?: string; @@ -90,6 +82,7 @@ export function createCLI(tokens: { readwrite: string; readonly: string }) { legacy_force?: boolean; legacy_acceptBreakingChanges?: boolean; expect: 'latest' | 'latest-composable' | 'ignored' | 'rejected'; + json?: boolean; }): Promise { const publishName = ` #${++publishCount}`; const commit = randomUUID(); @@ -106,6 +99,7 @@ export function createCLI(tokens: { readwrite: string; readonly: string }) { ...(metadata ? ['--metadata', await generateTmpFile(JSON.stringify(metadata), 'json')] : []), ...(legacy_force ? ['--force'] : []), ...(legacy_acceptBreakingChanges ? ['--experimental_acceptBreakingChanges'] : []), + ...(json ? ['--json'] : []), await generateTmpFile(sdl, 'graphql'), ]); @@ -177,15 +171,18 @@ export function createCLI(tokens: { readwrite: string; readonly: string }) { sdl, serviceName, expect: expectedStatus, + json, }: { sdl: string; serviceName?: string; expect: 'approved' | 'rejected'; + json?: boolean; }): Promise { const cmd = schemaCheck([ '--registry.accessToken', tokens.readonly, ...(serviceName ? ['--service', serviceName] : []), + ...(json ? ['--json'] : []), await generateTmpFile(sdl, 'graphql'), ]); @@ -199,11 +196,19 @@ export function createCLI(tokens: { readwrite: string; readonly: string }) { async function deleteCommand({ serviceName, expect: expectedStatus, + json, }: { serviceName?: string; + json?: boolean; expect: 'latest' | 'latest-composable' | 'rejected'; }): Promise { - const cmd = schemaDelete(['--token', tokens.readwrite, '--confirm', serviceName ?? '']); + const cmd = schemaDelete([ + '--token', + tokens.readwrite, + '--confirm', + serviceName ?? '', + ...(json ? ['--json'] : []), + ]); const before = { latest: await fetchLatestSchema(tokens.readonly).then(r => r.expectNoGraphQLErrors()), @@ -259,11 +264,13 @@ export function createCLI(tokens: { readwrite: string; readonly: string }) { url: string; sdl: string; }>; + json?: boolean; remote: boolean; write?: string; useLatestVersion?: boolean; }) { return dev([ + ...(input.json ? ['--json'] : []), ...(input.remote ? [ '--remote', diff --git a/integration-tests/testkit/fs.ts b/integration-tests/testkit/fs.ts new file mode 100644 index 0000000000..b430adf1b1 --- /dev/null +++ b/integration-tests/testkit/fs.ts @@ -0,0 +1,27 @@ +import { randomUUID } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export function tmpFile(extension: string) { + const dir = tmpdir(); + const fileName = randomUUID(); + const filepath = join(dir, `${fileName}.${extension}`); + + return { + filepath, + read() { + return readFile(filepath, 'utf-8'); + }, + }; +} + +export async function generateTmpFile(content: string, extension: string) { + const dir = tmpdir(); + const fileName = randomUUID(); + const filepath = join(dir, `${fileName}.${extension}`); + + await writeFile(filepath, content, 'utf-8'); + + return filepath; +} diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 5ee9b61503..483f3b013f 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -62,6 +62,12 @@ import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from import { collect, CollectedOperation, legacyCollect } from './usage'; import { generateUnique } from './utils'; +export type Seed = ReturnType; +export type OwnerSeed = Awaited>; +export type OrgSeed = Awaited>; +export type ProjectSeed = Awaited>; +export type TargetAccessTokenSeed = Awaited>; + export function initSeed() { const pg = { user: ensureEnv('POSTGRES_USER'), diff --git a/integration-tests/testkit/test.ts b/integration-tests/testkit/test.ts new file mode 100644 index 0000000000..38b1f552ca --- /dev/null +++ b/integration-tests/testkit/test.ts @@ -0,0 +1,102 @@ +/** + * This module uses Vitest's fixture system to make common usage patterns + * of our testkit easily consumable in test cases. @see https://vitest.dev/guide/test-context.html#test-extend + */ + +import { test as testBase } from 'vitest'; +import { CLI, createCLI } from './cli'; +import { ProjectType } from './gql/graphql'; +import { initSeed, OrgSeed, OwnerSeed, ProjectSeed, Seed, TargetAccessTokenSeed } from './seed'; + +interface Context { + seed: Seed; + owner: OwnerSeed; + org: OrgSeed; + // + // "single" branch + // + projectSingle: ProjectSeed; + targetAccessTokenSingle: TargetAccessTokenSeed; + cliSingle: CLI; + // + // "federation" branch + // + projectFederation: ProjectSeed; + targetAccessTokenFederation: TargetAccessTokenSeed; + cliFederation: CLI; + // + // "stitching" branch + // + projectStitching: ProjectSeed; + targetAccessTokenStitching: TargetAccessTokenSeed; + cliStitching: CLI; +} + +export const test = testBase.extend({ + seed: async ({}, use) => { + const seed = await initSeed(); + await use(seed); + }, + owner: async ({ seed }, use) => { + const owner = await seed.createOwner(); + await use(owner); + }, + org: async ({ owner }, use) => { + const org = await owner.createOrg(); + await use(org); + }, + // + // "single" branch + // + projectSingle: async ({ org }, use) => { + const project = await org.createProject(ProjectType.Single); + await use(project); + }, + targetAccessTokenSingle: async ({ projectSingle }, use) => { + const targetAccessToken = await projectSingle.createTargetAccessToken({}); + await use(targetAccessToken); + }, + cliSingle: async ({ targetAccessTokenSingle }, use) => { + const cli = createCLI({ + readwrite: targetAccessTokenSingle.secret, + readonly: targetAccessTokenSingle.secret, + }); + await use(cli); + }, + // + // "federation" branch + // + projectFederation: async ({ org }, use) => { + const project = await org.createProject(ProjectType.Federation); + await use(project); + }, + targetAccessTokenFederation: async ({ projectFederation }, use) => { + const targetAccessToken = await projectFederation.createTargetAccessToken({}); + await use(targetAccessToken); + }, + cliFederation: async ({ targetAccessTokenFederation }, use) => { + const cli = createCLI({ + readwrite: targetAccessTokenFederation.secret, + readonly: targetAccessTokenFederation.secret, + }); + await use(cli); + }, + // + // "stitching" branch + // + projectStitching: async ({ org }, use) => { + const project = await org.createProject(ProjectType.Stitching); + await use(project); + }, + targetAccessTokenStitching: async ({ projectStitching }, use) => { + const targetAccessToken = await projectStitching.createTargetAccessToken({}); + await use(targetAccessToken); + }, + cliStitching: async ({ targetAccessTokenStitching }, use) => { + const cli = createCLI({ + readwrite: targetAccessTokenStitching.secret, + readonly: targetAccessTokenStitching.secret, + }); + await use(cli); + }, +}); diff --git a/integration-tests/tests/cli/__snapshot_serializers__/_.ts b/integration-tests/tests/cli/__snapshot_serializers__/_.ts new file mode 100644 index 0000000000..e1cf0ac7b9 --- /dev/null +++ b/integration-tests/tests/cli/__snapshot_serializers__/_.ts @@ -0,0 +1 @@ +export * from './cli-output'; diff --git a/integration-tests/tests/cli/__snapshot_serializers__/__.ts b/integration-tests/tests/cli/__snapshot_serializers__/__.ts new file mode 100644 index 0000000000..9015937e9c --- /dev/null +++ b/integration-tests/tests/cli/__snapshot_serializers__/__.ts @@ -0,0 +1 @@ +export * as SnapshotSerializers from './_'; diff --git a/integration-tests/tests/cli/__snapshot_serializers__/cli-output.ts b/integration-tests/tests/cli/__snapshot_serializers__/cli-output.ts new file mode 100644 index 0000000000..b3b7200d64 --- /dev/null +++ b/integration-tests/tests/cli/__snapshot_serializers__/cli-output.ts @@ -0,0 +1,73 @@ +import stripAnsi from 'strip-ansi'; +import type { SnapshotSerializer } from 'vitest'; +import { ExecaError } from '@esm2cjs/execa'; + +export const cliOutput: SnapshotSerializer = { + test: (value: unknown) => { + if (typeof value === 'string') { + return variableReplacements.some(replacement => replacement.pattern.test(value)); + } + return isExecaError(value); + }, + serialize: (value: unknown) => { + if (typeof value === 'string') { + let valueSerialized = ''; + valueSerialized += ':::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::\n'; + valueSerialized += '\nstdout--------------------------------------------:\n'; + valueSerialized += clean(value); + return valueSerialized; + } + if (isExecaError(value)) { + let valueSerialized = ''; + valueSerialized += ':::::::::::::::: CLI FAILURE OUTPUT :::::::::::::::\n'; + valueSerialized += 'exitCode------------------------------------------:\n'; + valueSerialized += value.exitCode; + valueSerialized += '\nstderr--------------------------------------------:\n'; + valueSerialized += clean(value.stderr || '__NONE__'); + valueSerialized += '\nstdout--------------------------------------------:\n'; + valueSerialized += clean(value.stdout || '__NONE__'); + return valueSerialized; + } + return String(value); + }, +}; + +const variableReplacements = [ + { + pattern: /("reference": "|"requestId": "|"https?:\/\/)[^"]+/gi, + mask: '$1__ID__', + }, + { + pattern: /"https?:\/\/[^"]+/gi, + mask: '"__URL__', + }, + { + pattern: /(Reference: )[^ ]+/gi, + mask: '$1__ID__', + }, + { + pattern: /(https?:\/\/)[^ ]+/gi, + mask: '$1__PATH__', + }, +]; + +/** + * Strip ANSI codes and mask variables. + */ +const clean = (value: string) => { + // We strip ANSI codes because their output can vary by platform (e.g. between macOS and GH CI linux-based runner) + // and we don't care enough about CLI output styling to fork our snapshots for it. + value = stripAnsi(value); + for (const replacement of variableReplacements) { + value = value.replaceAll(replacement.pattern, replacement.mask); + } + return value; +}; + +/** + * The esm2cjs execa package we are using is not exporting the error class, so use this. + */ +const isExecaError = (value: unknown): value is ExecaError => { + // @ts-expect-error + return typeof value.exitCode === 'number'; +}; diff --git a/integration-tests/tests/cli/__snapshots__/cli-error-user-input.spec.ts.snap b/integration-tests/tests/cli/__snapshots__/cli-error-user-input.spec.ts.snap new file mode 100644 index 0000000000..679d1f9a8c --- /dev/null +++ b/integration-tests/tests/cli/__snapshots__/cli-error-user-input.spec.ts.snap @@ -0,0 +1,365 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FailureUserInput - { command: 'app:create' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nfile Path to the persisted operations mapping.\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'app:create' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › file Path to the persisted operations mapping. + › See more help with --help + +USAGE + $ hive app:create FILE --name --version [--json] + [--debug] [--registry.endpoint ] [--registry.accessToken ] + +ARGUMENTS + FILE Path to the persisted operations mapping. + +FLAGS + --debug Whether debug output for HTTP calls and + similar should be enabled. + --name= (required) app name + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --version= (required) app version + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'app:publish' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "message": "The following errors occurred:\\n Missing required flag name\\n Missing required flag version\\nSee more help with --help", + "problem": "namedArgumentInvalid" + } +} +`; + +exports[`FailureUserInput - { command: 'app:publish' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: The following errors occurred: + › Missing required flag name + › Missing required flag version + › See more help with --help +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:check' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nfile Path to the schema file(s)\\n\\nNote: --require allows multiple values. Because of this you need to provide all arguments before providing that flag.\\nAlternatively, you can use \\"--\\" to signify the end of the flags and the beginning of arguments.\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:check' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › file Path to the schema file(s) + › + › Note: --require allows multiple values. Because of this you need to + › provide all arguments before providing that flag. + › Alternatively, you can use "--" to signify the end of the flags and the + › beginning of arguments. + › See more help with --help + +USAGE + $ hive schema:check FILE [--json] [--debug] [--service ] + [--registry.endpoint ] [--registry ] [--registry.accessToken + ] [--token ] [--forceSafe] [--github] [--require ] + [--author ] [--commit ] [--contextId ] + +ARGUMENTS + FILE Path to the schema file(s) + +FLAGS + --author= Author of the change + --commit= Associated commit sha + --contextId= Context ID for grouping the schema check. + --debug Whether debug output for HTTP calls and + similar should be enabled. + --forceSafe mark the check as safe, breaking changes are + expected + --github Connect with GitHub Application + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --require=... [default: ] Loads specific require.extensions + before running the codegen and reading the + configuration + --service= service name (only for distributed schemas) + --token= api token + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:delete' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nservice name of the service\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:delete' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › service name of the service + › See more help with --help + +USAGE + $ hive schema:delete SERVICE [--json] [--debug] [--registry.endpoint + ] [--registry ] [--registry.accessToken ] [--token + ] [--dryRun] [--confirm] + +ARGUMENTS + SERVICE name of the service + +FLAGS + --confirm Confirm deletion of the service + --debug Whether debug output for HTTP calls and + similar should be enabled. + --dryRun Does not delete the service, only reports what + it would have done. + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --token= api token + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:fetch' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nactionId action id (e.g. commit sha)\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:fetch' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › actionId action id (e.g. commit sha) + › See more help with --help + +USAGE + $ hive schema:fetch ACTIONID [--json] [--debug] [--registry ] + [--token ] [--registry.endpoint ] [--registry.accessToken + ] [--type ] [--write ] [--outputFile ] + +ARGUMENTS + ACTIONID action id (e.g. commit sha) + +FLAGS + --debug Whether debug output for HTTP calls and + similar should be enabled. + --outputFile= whether to write to a file instead of stdout + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --token= api token + --type= Type to fetch (possible types: sdl, + supergraph) + --write= Write to a file (possible extensions: + .graphql, .gql, .gqls, .graphqls) + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:publish' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nfile Path to the schema file(s)\\n\\nNote: --require allows multiple values. Because of this you need to provide all arguments before providing that flag.\\nAlternatively, you can use \\"--\\" to signify the end of the flags and the beginning of arguments.\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:publish' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › file Path to the schema file(s) + › + › Note: --require allows multiple values. Because of this you need to + › provide all arguments before providing that flag. + › Alternatively, you can use "--" to signify the end of the flags and the + › beginning of arguments. + › See more help with --help + +USAGE + $ hive schema:publish FILE [--json] [--debug] [--service ] [--url + ] [--metadata ] [--registry.endpoint ] [--registry + ] [--registry.accessToken ] [--token ] [--author + ] [--commit ] [--github] [--force] + [--experimental_acceptBreakingChanges] [--require ] + +ARGUMENTS + FILE Path to the schema file(s) + +FLAGS + --author= author of the change + --commit= associated commit sha + --debug Whether debug output for HTTP calls and + similar should be enabled. + --experimental_acceptBreakingChanges (experimental) accept breaking changes + and mark schema as valid (only if + composable) + --force force publish even on breaking changes + --github Connect with GitHub Application + --metadata= additional metadata to attach to the + GraphQL schema. This can be a string + with a valid JSON, or a path to a file + containing a valid JSON + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --require=... [default: ] Loads specific + require.extensions before running the + codegen and reading the configuration + --service= service name (only for distributed + schemas) + --token= api token + --url= service url (only for distributed + schemas) + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'whoami' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "problem": "namedArgumentMissing", + "parameter": "registry.accessToken" + } +} +`; + +exports[`FailureUserInput - { command: 'whoami' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing "registry.accessToken" +stdout--------------------------------------------: +__NONE__ +`; diff --git a/integration-tests/tests/cli/__snapshots__/failure-user-input.spec.ts.snap b/integration-tests/tests/cli/__snapshots__/failure-user-input.spec.ts.snap new file mode 100644 index 0000000000..3ac3d1466e --- /dev/null +++ b/integration-tests/tests/cli/__snapshots__/failure-user-input.spec.ts.snap @@ -0,0 +1,359 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FailureUserInput - { command: 'app:create' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nfile Path to the persisted operations mapping.\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'app:create' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › file Path to the persisted operations mapping. + › See more help with --help + +USAGE + $ hive app:create FILE --name --version [--json] + [--debug] [--registry.endpoint ] [--registry.accessToken ] + +ARGUMENTS + FILE Path to the persisted operations mapping. + +FLAGS + --debug Whether debug output for HTTP calls and + similar should be enabled. + --name= (required) app name + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --version= (required) app version + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'app:publish' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "data": { + "type": "FailureUserInput", + "message": "The following errors occurred:\\n Missing required flag name\\n Missing required flag version\\nSee more help with --help", + "problem": "namedArgumentInvalid" + } +} +`; + +exports[`FailureUserInput - { command: 'app:publish' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: The following errors occurred: + › Missing required flag name + › Missing required flag version + › See more help with --help +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:check' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nfile Path to the schema file(s)\\n\\nNote: --require allows multiple values. Because of this you need to provide all arguments before providing that flag.\\nAlternatively, you can use \\"--\\" to signify the end of the flags and the beginning of arguments.\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:check' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › file Path to the schema file(s) + › + › Note: --require allows multiple values. Because of this you need to + › provide all arguments before providing that flag. + › Alternatively, you can use "--" to signify the end of the flags and the + › beginning of arguments. + › See more help with --help + +USAGE + $ hive schema:check FILE [--json] [--debug] [--service ] + [--registry.endpoint ] [--registry ] [--registry.accessToken + ] [--token ] [--forceSafe] [--github] [--require ] + [--author ] [--commit ] [--contextId ] + +ARGUMENTS + FILE Path to the schema file(s) + +FLAGS + --author= Author of the change + --commit= Associated commit sha + --contextId= Context ID for grouping the schema check. + --debug Whether debug output for HTTP calls and + similar should be enabled. + --forceSafe mark the check as safe, breaking changes are + expected + --github Connect with GitHub Application + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --require=... [default: ] Loads specific require.extensions + before running the codegen and reading the + configuration + --service= service name (only for distributed schemas) + --token= api token + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:delete' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nservice name of the service\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:delete' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › service name of the service + › See more help with --help + +USAGE + $ hive schema:delete SERVICE [--json] [--debug] [--registry.endpoint + ] [--registry ] [--registry.accessToken ] [--token + ] [--dryRun] [--confirm] + +ARGUMENTS + SERVICE name of the service + +FLAGS + --confirm Confirm deletion of the service + --debug Whether debug output for HTTP calls and + similar should be enabled. + --dryRun Does not delete the service, only reports what + it would have done. + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --token= api token + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:fetch' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nactionId action id (e.g. commit sha)\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:fetch' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › actionId action id (e.g. commit sha) + › See more help with --help + +USAGE + $ hive schema:fetch ACTIONID [--json] [--debug] [--registry ] + [--token ] [--registry.endpoint ] [--registry.accessToken + ] [--type ] [--write ] [--outputFile ] + +ARGUMENTS + ACTIONID action id (e.g. commit sha) + +FLAGS + --debug Whether debug output for HTTP calls and + similar should be enabled. + --outputFile= whether to write to a file instead of stdout + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --token= api token + --type= Type to fetch (possible types: sdl, + supergraph) + --write= Write to a file (possible extensions: + .graphql, .gql, .gqls, .graphqls) + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'schema:publish' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "data": { + "type": "FailureUserInput", + "message": "Missing 1 required arg:\\nfile Path to the schema file(s)\\n\\nNote: --require allows multiple values. Because of this you need to provide all arguments before providing that flag.\\nAlternatively, you can use \\"--\\" to signify the end of the flags and the beginning of arguments.\\nSee more help with --help", + "problem": "positionalArgumentMissing" + } +} +`; + +exports[`FailureUserInput - { command: 'schema:publish' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing 1 required arg: + › file Path to the schema file(s) + › + › Note: --require allows multiple values. Because of this you need to + › provide all arguments before providing that flag. + › Alternatively, you can use "--" to signify the end of the flags and the + › beginning of arguments. + › See more help with --help + +USAGE + $ hive schema:publish FILE [--json] [--debug] [--service ] [--url + ] [--metadata ] [--registry.endpoint ] [--registry + ] [--registry.accessToken ] [--token ] [--author + ] [--commit ] [--github] [--force] + [--experimental_acceptBreakingChanges] [--require ] + +ARGUMENTS + FILE Path to the schema file(s) + +FLAGS + --author= author of the change + --commit= associated commit sha + --debug Whether debug output for HTTP calls and + similar should be enabled. + --experimental_acceptBreakingChanges (experimental) accept breaking changes + and mark schema as valid (only if + composable) + --force force publish even on breaking changes + --github Connect with GitHub Application + --metadata= additional metadata to attach to the + GraphQL schema. This can be a string + with a valid JSON, or a path to a file + containing a valid JSON + --registry= registry address + --registry.accessToken= registry access token + --registry.endpoint= registry endpoint + --require=... [default: ] Loads specific + require.extensions before running the + codegen and reading the configuration + --service= service name (only for distributed + schemas) + --token= api token + --url= service url (only for distributed + schemas) + +GLOBAL FLAGS + --json Format output as json. + +stdout--------------------------------------------: +__NONE__ +`; + +exports[`FailureUserInput - { command: 'whoami' } > OUTPUT FORMAT: JSON 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureUserInput", + "problem": "namedArgumentMissing", + "parameter": "registry.accessToken" + } +} +`; + +exports[`FailureUserInput - { command: 'whoami' } > OUTPUT FORMAT: TEXT 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Missing "registry.accessToken" +stdout--------------------------------------------: +__NONE__ +`; diff --git a/integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap b/integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap new file mode 100644 index 0000000000..ea7524b88a --- /dev/null +++ b/integration-tests/tests/cli/__snapshots__/schema.spec.ts.snap @@ -0,0 +1,2099 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 4 changes + + Breaking changes: + - Field email was removed from object type User + Safe changes: + - Enum value VIEWER was added to enum UserRole + - Field address was added to object type User + - Field User.role changed type from UserRole to UserRole! + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 1 change + + Breaking changes: + - Field email was removed from object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +ℹ Detected 1 change + + Safe changes: + - Field nickname was added to object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ New service url: http://__PATH__ (previously: http://__PATH__ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Syntax Error: Unexpected Name "iliketurtles". +stdout--------------------------------------------: +✖ The SDL is not valid at line 1, column 1: + Syntax Error: Unexpected Name "iliketurtles". +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Schema registry is empty, nothing to compare your schema with. +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 3 errors + + - Unknown type: User. + - Unknown type User. + - Type Query must define one or more fields. + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: false > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Request to Hive API failed. Caused by error(s): + › Invalid token provided +stdout--------------------------------------------: +__NONE__ +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Enum value 'VIEWER' was added to enum 'UserRole'", + "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'address' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'User.role' changed type from 'UserRole' to 'UserRole!'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'nickname' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [ + { + "message": "[test] New service url: 'http://__PATH__ (previously: 'http://__PATH__ "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureSchemaPublishInvalidGraphQLSchema", + "message": "Syntax Error: Unexpected Name \\"iliketurtles\\".", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'legacy' | json: true > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureHiveApiRequest", + "message": "Request to Hive API failed. Caused by error(s):\\nInvalid token provided", + "requestId": "__ID__", + "errors": [ + { + "message": "Invalid token provided" + } + ] + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 4 changes + + Breaking changes: + - Field email was removed from object type User + Safe changes: + - Enum value VIEWER was added to enum UserRole + - Field address was added to object type User + - Field User.role changed type from UserRole to UserRole! + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 1 change + + Breaking changes: + - Field email was removed from object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +ℹ Detected 1 change + + Safe changes: + - Field nickname was added to object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ New service url: http://__PATH__ (previously: http://__PATH__ Available at http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Syntax Error: Unexpected Name "iliketurtles". +stdout--------------------------------------------: +✖ The SDL is not valid at line 1, column 1: + Syntax Error: Unexpected Name "iliketurtles". +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Schema registry is empty, nothing to compare your schema with. +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - [test] Unknown type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: false > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Request to Hive API failed. Caused by error(s): + › Invalid token provided +stdout--------------------------------------------: +__NONE__ +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Enum value 'VIEWER' was added to enum 'UserRole'", + "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'address' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'User.role' changed type from 'UserRole' to 'UserRole!'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'nickname' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureSchemaPublishInvalidGraphQLSchema", + "message": "Syntax Error: Unexpected Name \\"iliketurtles\\".", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'FEDERATION' | model: 'modern' | json: true > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureHiveApiRequest", + "message": "Request to Hive API failed. Caused by error(s):\\nInvalid token provided", + "requestId": "__ID__", + "errors": [ + { + "message": "Invalid token provided" + } + ] + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 4 changes + + Breaking changes: + - Field email was removed from object type User + Safe changes: + - Enum value VIEWER was added to enum UserRole + - Field address was added to object type User + - Field User.role changed type from UserRole to UserRole! + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 1 change + + Breaking changes: + - Field email was removed from object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +ℹ Detected 1 change + + Safe changes: + - Field nickname was added to object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Syntax Error: Unexpected Name "iliketurtles". +stdout--------------------------------------------: +✖ The SDL is not valid at line 1, column 1: + Syntax Error: Unexpected Name "iliketurtles". +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Schema registry is empty, nothing to compare your schema with. +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Unknown type User. + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: false > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Request to Hive API failed. Caused by error(s): + › Invalid token provided +stdout--------------------------------------------: +__NONE__ +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Enum value 'VIEWER' was added to enum 'UserRole'", + "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'address' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'User.role' changed type from 'UserRole' to 'UserRole!'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'nickname' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureSchemaPublishInvalidGraphQLSchema", + "message": "Syntax Error: Unexpected Name \\"iliketurtles\\".", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'legacy' | json: true > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureHiveApiRequest", + "message": "Request to Hive API failed. Caused by error(s):\\nInvalid token provided", + "requestId": "__ID__", + "errors": [ + { + "message": "Invalid token provided" + } + ] + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 4 changes + + Breaking changes: + - Field email was removed from object type User + Safe changes: + - Enum value VIEWER was added to enum UserRole + - Field address was added to object type User + - Field User.role changed type from UserRole to UserRole! + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 1 change + + Breaking changes: + - Field email was removed from object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +ℹ Detected 1 change + + Safe changes: + - Field nickname was added to object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Syntax Error: Unexpected Name "iliketurtles". +stdout--------------------------------------------: +✖ The SDL is not valid at line 1, column 1: + Syntax Error: Unexpected Name "iliketurtles". +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Schema registry is empty, nothing to compare your schema with. +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Unknown type User. + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: false > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Request to Hive API failed. Caused by error(s): + › Invalid token provided +stdout--------------------------------------------: +__NONE__ +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Enum value 'VIEWER' was added to enum 'UserRole'", + "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'address' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'User.role' changed type from 'UserRole' to 'UserRole!'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'nickname' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureSchemaPublishInvalidGraphQLSchema", + "message": "Syntax Error: Unexpected Name \\"iliketurtles\\".", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'SINGLE' | model: 'modern' | json: true > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureHiveApiRequest", + "message": "Request to Hive API failed. Caused by error(s):\\nInvalid token provided", + "requestId": "__ID__", + "errors": [ + { + "message": "Invalid token provided" + } + ] + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 4 changes + + Breaking changes: + - Field email was removed from object type User + Safe changes: + - Enum value VIEWER was added to enum UserRole + - Field address was added to object type User + - Field User.role changed type from UserRole to UserRole! + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 1 change + + Breaking changes: + - Field email was removed from object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +ℹ Detected 1 change + + Safe changes: + - Field nickname was added to object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ New service url: http://__PATH__ (previously: http://__PATH__ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Syntax Error: Unexpected Name "iliketurtles". +stdout--------------------------------------------: +✖ The SDL is not valid at line 1, column 1: + Syntax Error: Unexpected Name "iliketurtles". +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Schema registry is empty, nothing to compare your schema with. +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 3 errors + + - Unknown type User. + - Unknown type: User. + - Unknown type: User. + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: false > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Request to Hive API failed. Caused by error(s): + › Invalid token provided +stdout--------------------------------------------: +__NONE__ +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Enum value 'VIEWER' was added to enum 'UserRole'", + "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'address' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'User.role' changed type from 'UserRole' to 'UserRole!'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'nickname' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [ + { + "message": "[test] New service url: 'http://__PATH__ (previously: 'http://__PATH__ "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureSchemaPublishInvalidGraphQLSchema", + "message": "Syntax Error: Unexpected Name \\"iliketurtles\\".", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'legacy' | json: true > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureHiveApiRequest", + "message": "Request to Hive API failed. Caused by error(s):\\nInvalid token provided", + "requestId": "__ID__", + "errors": [ + { + "message": "Invalid token provided" + } + ] + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 4 changes + + Breaking changes: + - Field email was removed from object type User + Safe changes: + - Enum value VIEWER was added to enum UserRole + - Field address was added to object type User + - Field User.role changed type from UserRole to UserRole! + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 1 error + + - Field email was removed from object type User + +ℹ Detected 1 change + + Breaking changes: + - Field email was removed from object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +ℹ Detected 1 change + + Safe changes: + - Field nickname was added to object type User + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Published initial schema. +ℹ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ New service url: http://__PATH__ (previously: http://__PATH__ Available at http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Syntax Error: Unexpected Name "iliketurtles". +stdout--------------------------------------------: +✖ The SDL is not valid at line 1, column 1: + Syntax Error: Unexpected Name "iliketurtles". +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +✔ Schema registry is empty, nothing to compare your schema with. +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +✖ Detected 3 errors + + - Unknown type User. + - Unknown type: User. + - Unknown type: User. + +View full report: +http://__PATH__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: false > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +2 +stderr--------------------------------------------: + › Error: Request to Hive API failed. Caused by error(s): + › Invalid token provided +stdout--------------------------------------------: +__NONE__ +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > can publish a schema with breaking, warning and safe changes > SchemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > can publish a schema with breaking, warning and safe changes > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Enum value 'VIEWER' was added to enum 'UserRole'", + "criticality": "Dangerous", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'address' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + }, + { + "message": "Field 'User.role' changed type from 'UserRole' to 'UserRole!'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (breaking) 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'email' was removed from object type 'User'", + "criticality": "Breaking", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaCheck (non-breaking) 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [ + { + "message": "Field 'nickname' was added to object type 'User'", + "criticality": "Safe", + "isSafeBasedOnUsage": false, + "approval": null + } + ], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > can publish and check a schema with target:registry:read access > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > can update the service url and show it in comparison query > schemaPublish 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > can update the service url and show it in comparison query > schemaPublish 2`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaPublish", + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > publishing invalid schema SDL provides meaningful feedback for the user. > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureSchemaPublishInvalidGraphQLSchema", + "message": "Syntax Error: Unexpected Name \\"iliketurtles\\".", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > schema:check should notify user when registry is empty > schemaCheck 1`] = ` +:::::::::::::::: CLI SUCCESS OUTPUT ::::::::::::::::: + +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "SuccessSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > schema:check should throw on corrupted schema > schemaCheck 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "success", + "data": { + "type": "FailureSchemaCheck", + "warnings": [], + "changes": [], + "url": "__URL__" + } +} +`; + +exports[`projectType: 'STITCHING' | model: 'modern' | json: true > schema:publish should see Invalid Token error when token is invalid > schemaPublish 1`] = ` +:::::::::::::::: CLI FAILURE OUTPUT ::::::::::::::: +exitCode------------------------------------------: +1 +stderr--------------------------------------------: +__NONE__ +stdout--------------------------------------------: +{ + "type": "failure", + "reference": null, + "suggestions": [], + "data": { + "type": "FailureHiveApiRequest", + "message": "Request to Hive API failed. Caused by error(s):\\nInvalid token provided", + "requestId": "__ID__", + "errors": [ + { + "message": "Invalid token provided" + } + ] + } +} +`; diff --git a/integration-tests/tests/cli/dev.spec.ts b/integration-tests/tests/cli/dev.spec.ts index 711c2ad32c..8c09201c6e 100644 --- a/integration-tests/tests/cli/dev.spec.ts +++ b/integration-tests/tests/cli/dev.spec.ts @@ -1,33 +1,9 @@ /* eslint-disable no-process-env */ -import { randomUUID } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { ProjectType } from 'testkit/gql/graphql'; -import { createCLI } from '../../testkit/cli'; -import { initSeed } from '../../testkit/seed'; - -function tmpFile(extension: string) { - const dir = tmpdir(); - const fileName = randomUUID(); - const filepath = join(dir, `${fileName}.${extension}`); - - return { - filepath, - read() { - return readFile(filepath, 'utf-8'); - }, - }; -} +import { tmpFile } from '../../testkit/fs'; +import { test } from '../../testkit/test'; describe('dev', () => { - test('composes only the locally provided service', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createTargetAccessToken } = await createProject(ProjectType.Federation); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ readwrite: secret, readonly: secret }); - + test('composes only the locally provided service', async ({ cliFederation: cli }) => { await cli.publish({ sdl: 'type Query { foo: String }', serviceName: 'foo', @@ -55,13 +31,7 @@ describe('dev', () => { }); describe('dev --remote', () => { - test('not available for SINGLE project', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createTargetAccessToken } = await createProject(ProjectType.Single); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ readwrite: secret, readonly: secret }); - + test('not available for SINGLE project', async ({ cliSingle: cli }) => { const cmd = cli.dev({ remote: true, services: [ @@ -76,13 +46,7 @@ describe('dev --remote', () => { await expect(cmd).rejects.toThrowError(/Only Federation projects are supported/); }); - test('not available for STITCHING project', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createTargetAccessToken } = await createProject(ProjectType.Stitching); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ readwrite: secret, readonly: secret }); - + test('not available for STITCHING project', async ({ cliStitching: cli }) => { const cmd = cli.dev({ remote: true, services: [ @@ -97,13 +61,7 @@ describe('dev --remote', () => { await expect(cmd).rejects.toThrowError(/Only Federation projects are supported/); }); - test('adds a service', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createTargetAccessToken } = await createProject(ProjectType.Federation); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ readwrite: secret, readonly: secret }); - + test('adds a service', async ({ cliFederation: cli }) => { await cli.publish({ sdl: 'type Query { foo: String }', serviceName: 'foo', @@ -128,13 +86,7 @@ describe('dev --remote', () => { await expect(supergraph.read()).resolves.toMatch('http://localhost/bar'); }); - test('replaces a service', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createTargetAccessToken } = await createProject(ProjectType.Federation); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ readwrite: secret, readonly: secret }); - + test('replaces a service', async ({ cliFederation: cli }) => { await cli.publish({ sdl: 'type Query { foo: String }', serviceName: 'foo', @@ -166,18 +118,14 @@ describe('dev --remote', () => { await expect(supergraph.read()).resolves.toMatch('http://localhost/bar'); }); - test('uses latest composable version by default', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject, setFeatureFlag } = await createOrg(); - const { createTargetAccessToken, setNativeFederation } = await createProject( - ProjectType.Federation, - ); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ readwrite: secret, readonly: secret }); - + test('uses latest composable version by default', async ({ + org, + projectFederation: project, + cliFederation: cli, + }) => { // Once we ship native federation v2 composition by default, we can remove these two lines - await setFeatureFlag('compareToPreviousComposableVersion', true); - await setNativeFederation(true); + await org.setFeatureFlag('compareToPreviousComposableVersion', true); + await project.setNativeFederation(true); await cli.publish({ sdl: /* GraphQL */ ` @@ -246,18 +194,14 @@ describe('dev --remote', () => { expect(content).toMatch('http://localhost/baz'); }); - test('uses latest version when requested', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject, setFeatureFlag } = await createOrg(); - const { createTargetAccessToken, setNativeFederation } = await createProject( - ProjectType.Federation, - ); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ readwrite: secret, readonly: secret }); - + test('uses latest version when requested', async ({ + org, + projectFederation: project, + cliFederation: cli, + }) => { // Once we ship native federation v2 composition by default, we can remove these two lines - await setFeatureFlag('compareToPreviousComposableVersion', true); - await setNativeFederation(true); + await org.setFeatureFlag('compareToPreviousComposableVersion', true); + await project.setNativeFederation(true); await cli.publish({ sdl: /* GraphQL */ ` diff --git a/integration-tests/tests/cli/failure-user-input.spec.ts b/integration-tests/tests/cli/failure-user-input.spec.ts new file mode 100644 index 0000000000..2610957651 --- /dev/null +++ b/integration-tests/tests/cli/failure-user-input.spec.ts @@ -0,0 +1,40 @@ +import { exec } from '../../testkit/cli'; +import { test } from '../../testkit/test'; +import { SnapshotSerializers } from './__snapshot_serializers__/__'; + +expect.addSnapshotSerializer(SnapshotSerializers.cliOutput); + +interface TestCase { + command: + | 'whoami' + | 'schema:publish' + | 'schema:check' + | 'schema:delete' + | 'schema:fetch' + | 'app:create' + | 'app:publish'; + args?: Record; +} + +// prettier-ignore +const testCases: TestCase[] = [ + { command: 'whoami' }, + { command: 'schema:publish' }, + { command: 'schema:check' }, + { command: 'schema:delete' }, + { command: 'schema:fetch' }, + { command: 'app:create' }, + { command: 'app:publish' }, +]; + +test.each(testCases)('FailureUserInput - %s', async ({ command, args }) => { + const preparedArgs = args + ? Object.entries(args) + .map(([key, value]) => `--${key}=${value}`) + .join(' ') + : ''; + const preparedCommand = `${command} ${preparedArgs}`; + await expect(exec(preparedCommand)).rejects.toMatchSnapshot('OUTPUT FORMAT: TEXT'); + const preparedCommandJson = `${preparedCommand} --json`; + await expect(exec(preparedCommandJson)).rejects.toMatchSnapshot('OUTPUT FORMAT: JSON'); +}); diff --git a/integration-tests/tests/cli/schema.spec.ts b/integration-tests/tests/cli/schema.spec.ts index e9739ee42b..ce3c195eca 100644 --- a/integration-tests/tests/cli/schema.spec.ts +++ b/integration-tests/tests/cli/schema.spec.ts @@ -2,106 +2,127 @@ import { createHash } from 'node:crypto'; import { ProjectType } from 'testkit/gql/graphql'; import { createCLI, schemaCheck, schemaPublish } from '../../testkit/cli'; -import { initSeed } from '../../testkit/seed'; +import { test } from '../../testkit/test'; +import { SnapshotSerializers } from './__snapshot_serializers__/__'; + +expect.addSnapshotSerializer(SnapshotSerializers.cliOutput); describe.each` - projectType | model - ${ProjectType.Single} | ${'modern'} - ${ProjectType.Stitching} | ${'modern'} - ${ProjectType.Federation} | ${'modern'} - ${ProjectType.Single} | ${'legacy'} - ${ProjectType.Stitching} | ${'legacy'} - ${ProjectType.Federation} | ${'legacy'} -`('$projectType ($model)', ({ projectType, model }) => { + projectType | model | json + ${ProjectType.Single} | ${'modern'} | ${false} + ${ProjectType.Stitching} | ${'modern'} | ${false} + ${ProjectType.Federation} | ${'modern'} | ${false} + ${ProjectType.Single} | ${'legacy'} | ${false} + ${ProjectType.Stitching} | ${'legacy'} | ${false} + ${ProjectType.Federation} | ${'legacy'} | ${false} + ${ProjectType.Single} | ${'modern'} | ${true} + ${ProjectType.Stitching} | ${'modern'} | ${true} + ${ProjectType.Federation} | ${'modern'} | ${true} + ${ProjectType.Single} | ${'legacy'} | ${true} + ${ProjectType.Stitching} | ${'legacy'} | ${true} + ${ProjectType.Federation} | ${'legacy'} | ${true} +`('projectType: $projectType | model: $model | json: $json', ({ projectType, model, json }) => { const serviceNameArgs = projectType === ProjectType.Single ? [] : ['--service', 'test']; const serviceUrlArgs = projectType === ProjectType.Single ? [] : ['--url', 'http://localhost:4000']; const serviceName = projectType === ProjectType.Single ? undefined : 'test'; const serviceUrl = projectType === ProjectType.Single ? undefined : 'http://localhost:4000'; - test.concurrent('can publish a schema with breaking, warning and safe changes', async () => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, createProject } = await createOrg(); - await inviteAndJoinMember(); - const { createTargetAccessToken } = await createProject(projectType, { - useLegacyRegistryModels: model === 'legacy', - }); - const { secret } = await createTargetAccessToken({}); + test.concurrent( + 'can publish a schema with breaking, warning and safe changes', + async ({ expect, org }) => { + await org.inviteAndJoinMember(); + const { createTargetAccessToken } = await org.createProject(projectType, { + useLegacyRegistryModels: model === 'legacy', + }); + const { secret } = await createTargetAccessToken({}); - await schemaPublish([ - '--registry.accessToken', - secret, - '--author', - 'Kamil', - '--commit', - 'abc123', - ...serviceNameArgs, - ...serviceUrlArgs, - 'fixtures/init-schema-detailed.graphql', - ]); - await expect( - schemaCheck([ - ...serviceNameArgs, - '--registry.accessToken', - secret, - 'fixtures/breaking-schema-detailed.graphql', - ]), - ).rejects.toThrowError(/breaking changes:|dangerous changes:|safe changes/i); - }); + await expect( + schemaPublish([ + ...(json ? ['--json'] : []), + '--registry.accessToken', + secret, + '--author', + 'Kamil', + '--commit', + 'abc123', + ...serviceNameArgs, + ...serviceUrlArgs, + 'fixtures/init-schema-detailed.graphql', + ]), + ).resolves.toMatchSnapshot('SchemaPublish'); - test.concurrent('can publish and check a schema with target:registry:read access', async () => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, createProject } = await createOrg(); - await inviteAndJoinMember(); - const { createTargetAccessToken } = await createProject(projectType, { - useLegacyRegistryModels: model === 'legacy', - }); - const { secret } = await createTargetAccessToken({}); + await expect( + schemaCheck([ + ...(json ? ['--json'] : []), + ...serviceNameArgs, + '--registry.accessToken', + secret, + 'fixtures/breaking-schema-detailed.graphql', + ]), + ).rejects.toMatchSnapshot('schemaCheck'); + }, + ); - await schemaPublish([ - '--registry.accessToken', - secret, - '--author', - 'Kamil', - '--commit', - 'abc123', - ...serviceNameArgs, - ...serviceUrlArgs, - 'fixtures/init-schema.graphql', - ]); + test.concurrent( + 'can publish and check a schema with target:registry:read access', + async ({ expect, org }) => { + await org.inviteAndJoinMember(); + const { createTargetAccessToken } = await org.createProject(projectType, { + useLegacyRegistryModels: model === 'legacy', + }); + const { secret } = await createTargetAccessToken({}); - await schemaCheck([ - '--service', - 'test', - '--registry.accessToken', - secret, - 'fixtures/nonbreaking-schema.graphql', - ]); + await expect( + schemaPublish([ + ...(json ? ['--json'] : []), + '--registry.accessToken', + secret, + '--author', + 'Kamil', + '--commit', + 'abc123', + ...serviceNameArgs, + ...serviceUrlArgs, + 'fixtures/init-schema.graphql', + ]), + ).resolves.toMatchSnapshot('schemaPublish'); - await expect( - schemaCheck([ - ...serviceNameArgs, - '--registry.accessToken', - secret, - 'fixtures/breaking-schema.graphql', - ]), - ).rejects.toThrowError(/breaking/i); - }); + await expect( + schemaCheck([ + ...(json ? ['--json'] : []), + '--service', + 'test', + '--registry.accessToken', + secret, + 'fixtures/nonbreaking-schema.graphql', + ]), + ).resolves.toMatchSnapshot('schemaCheck (non-breaking)'); + + await expect( + schemaCheck([ + ...(json ? ['--json'] : []), + ...serviceNameArgs, + '--registry.accessToken', + secret, + 'fixtures/breaking-schema.graphql', + ]), + ).rejects.toMatchSnapshot('schemaCheck (breaking)'); + }, + ); test.concurrent( 'publishing invalid schema SDL provides meaningful feedback for the user.', - async () => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, createProject } = await createOrg(); - await inviteAndJoinMember(); - const { createTargetAccessToken } = await createProject(projectType, { + async ({ expect, org }) => { + await org.inviteAndJoinMember(); + const { createTargetAccessToken } = await org.createProject(projectType, { useLegacyRegistryModels: model === 'legacy', }); const { secret } = await createTargetAccessToken({}); - const allocatedError = new Error('Should have thrown.'); - try { - await schemaPublish([ + await expect( + schemaPublish([ + ...(json ? ['--json'] : []), '--registry.accessToken', secret, '--author', @@ -111,29 +132,21 @@ describe.each` ...serviceNameArgs, ...serviceUrlArgs, 'fixtures/init-invalid-schema.graphql', - ]); - throw allocatedError; - } catch (err) { - if (err === allocatedError) { - throw err; - } - expect(String(err)).toMatch(`The SDL is not valid at line 1, column 1:`); - expect(String(err)).toMatch(`Syntax Error: Unexpected Name "iliketurtles"`); - } + ]), + ).rejects.toMatchSnapshot('schemaPublish'); }, ); - test.concurrent('schema:publish should print a link to the website', async () => { - const { createOrg } = await initSeed().createOwner(); - const { organization, inviteAndJoinMember, createProject } = await createOrg(); - await inviteAndJoinMember(); - const { project, target, createTargetAccessToken } = await createProject(projectType, { + test.concurrent('schema:publish should print a link to the website', async ({ expect, org }) => { + await org.inviteAndJoinMember(); + const { createTargetAccessToken, project, target } = await org.createProject(projectType, { useLegacyRegistryModels: model === 'legacy', }); const { secret } = await createTargetAccessToken({}); await expect( schemaPublish([ + ...(json ? ['--json'] : []), ...serviceNameArgs, ...serviceUrlArgs, '--registry.accessToken', @@ -141,11 +154,12 @@ describe.each` 'fixtures/init-schema.graphql', ]), ).resolves.toMatch( - `Available at ${process.env.HIVE_APP_BASE_URL}/${organization.slug}/${project.slug}/${target.slug}`, + `${process.env.HIVE_APP_BASE_URL}/${org.organization.slug}/${project.slug}/${target.slug}`, ); await expect( schemaPublish([ + ...(json ? ['--json'] : []), ...serviceNameArgs, ...serviceUrlArgs, '--registry.accessToken', @@ -153,132 +167,137 @@ describe.each` 'fixtures/nonbreaking-schema.graphql', ]), ).resolves.toMatch( - `Available at ${process.env.HIVE_APP_BASE_URL}/${organization.slug}/${project.slug}/${target.slug}/history/`, + `${process.env.HIVE_APP_BASE_URL}/${org.organization.slug}/${project.slug}/${target.slug}/history/`, ); }); - test.concurrent('schema:check should notify user when registry is empty', async () => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, createProject } = await createOrg(); - await inviteAndJoinMember(); - const { createTargetAccessToken } = await createProject(projectType, { + test.concurrent( + 'schema:check should notify user when registry is empty', + async ({ expect, org }) => { + await org.inviteAndJoinMember(); + const { createTargetAccessToken } = await org.createProject(projectType, { + useLegacyRegistryModels: model === 'legacy', + }); + const { secret } = await createTargetAccessToken({}); + + await expect( + schemaCheck([ + ...(json ? ['--json'] : []), + '--registry.accessToken', + secret, + ...serviceNameArgs, + 'fixtures/init-schema.graphql', + ]), + ).resolves.toMatchSnapshot('schemaCheck'); + }, + ); + + test.concurrent('schema:check should throw on corrupted schema', async ({ expect, org }) => { + await org.inviteAndJoinMember(); + const { createTargetAccessToken } = await org.createProject(projectType, { useLegacyRegistryModels: model === 'legacy', }); const { secret } = await createTargetAccessToken({}); await expect( schemaCheck([ + ...(json ? ['--json'] : []), + ...serviceNameArgs, '--registry.accessToken', secret, - ...serviceNameArgs, - 'fixtures/init-schema.graphql', + 'fixtures/missing-type.graphql', ]), - ).resolves.toMatch('empty'); - }); - - test.concurrent('schema:check should throw on corrupted schema', async () => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, createProject } = await createOrg(); - await inviteAndJoinMember(); - const { createTargetAccessToken } = await createProject(projectType, { - useLegacyRegistryModels: model === 'legacy', - }); - const { secret } = await createTargetAccessToken({}); - - const output = schemaCheck([ - ...serviceNameArgs, - '--registry.accessToken', - secret, - 'fixtures/missing-type.graphql', - ]); - await expect(output).rejects.toThrowError('Unknown type'); + ).rejects.toMatchSnapshot('schemaCheck'); }); test.concurrent( 'schema:publish should see Invalid Token error when token is invalid', - async () => { + async ({ expect }) => { const invalidToken = createHash('md5').update('nope').digest('hex').substring(0, 31); const output = schemaPublish([ + ...(json ? ['--json'] : []), ...serviceNameArgs, ...serviceUrlArgs, '--registry.accessToken', invalidToken, 'fixtures/init-schema.graphql', ]); - - await expect(output).rejects.toThrowError('Invalid token provided'); + await expect(output).rejects.toMatchSnapshot('schemaPublish'); }, ); test .skipIf(projectType === ProjectType.Single) - .concurrent('can update the service url and show it in comparison query', async () => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, createProject } = await createOrg(); - await inviteAndJoinMember(); - const { createTargetAccessToken, compareToPreviousVersion, fetchVersions } = - await createProject(projectType, { - useLegacyRegistryModels: model === 'legacy', + .concurrent( + 'can update the service url and show it in comparison query', + async ({ expect, org }) => { + await org.inviteAndJoinMember(); + const { createTargetAccessToken, compareToPreviousVersion, fetchVersions } = + await org.createProject(projectType, { + useLegacyRegistryModels: model === 'legacy', + }); + const { secret } = await createTargetAccessToken({}); + const cli = createCLI({ + readonly: secret, + readwrite: secret, }); - const { secret } = await createTargetAccessToken({}); - const cli = createCLI({ - readonly: secret, - readwrite: secret, - }); - const sdl = /* GraphQL */ ` - type Query { - users: [User!] - } + const sdl = /* GraphQL */ ` + type Query { + users: [User!] + } - type User { - id: ID! - name: String! - email: String! - } - `; + type User { + id: ID! + name: String! + email: String! + } + `; - await expect( - cli.publish({ - sdl, - commit: 'push1', - serviceName, - serviceUrl, - expect: 'latest-composable', - }), - ).resolves.toMatch(/published/i); + await expect( + cli.publish({ + json, + sdl, + commit: 'push1', + serviceName, + serviceUrl, + expect: 'latest-composable', + }), + ).resolves.toMatchSnapshot('schemaPublish'); - const newServiceUrl = serviceUrl + '/new'; - await expect( - cli.publish({ - sdl, - commit: 'push2', - serviceName, - serviceUrl: newServiceUrl, - expect: 'latest-composable', - }), - ).resolves.toMatch(/New service url/i); + const newServiceUrl = serviceUrl + '/new'; + await expect( + cli.publish({ + json, + sdl, + commit: 'push2', + serviceName, + serviceUrl: newServiceUrl, + expect: 'latest-composable', + }), + ).resolves.toMatchSnapshot('schemaPublish'); - const versions = await fetchVersions(3); - expect(versions).toHaveLength(2); + const versions = await fetchVersions(3); + expect(versions).toHaveLength(2); - const versionWithNewServiceUrl = versions[0]; + const versionWithNewServiceUrl = versions[0]; - expect(await compareToPreviousVersion(versionWithNewServiceUrl.id)).toEqual( - expect.objectContaining({ - target: expect.objectContaining({ - schemaVersion: expect.objectContaining({ - safeSchemaChanges: expect.objectContaining({ - nodes: expect.arrayContaining([ - expect.objectContaining({ - criticality: 'Dangerous', - message: `[${serviceName}] New service url: '${newServiceUrl}' (previously: '${serviceUrl}')`, - }), - ]), + expect(await compareToPreviousVersion(versionWithNewServiceUrl.id)).toEqual( + expect.objectContaining({ + target: expect.objectContaining({ + schemaVersion: expect.objectContaining({ + safeSchemaChanges: expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ + criticality: 'Dangerous', + message: `[${serviceName}] New service url: '${newServiceUrl}' (previously: '${serviceUrl}')`, + }), + ]), + }), }), }), }), - }), - ); - }); + ); + }, + ); }); diff --git a/packages/libraries/apollo/src/version.ts b/packages/libraries/apollo/src/version.ts index 2c5a354654..55e04c82cb 100644 --- a/packages/libraries/apollo/src/version.ts +++ b/packages/libraries/apollo/src/version.ts @@ -1 +1 @@ -export const version = '0.36.2'; +export const version = '0.36.3'; diff --git a/packages/libraries/cli/package.json b/packages/libraries/cli/package.json index 2bebdb0e85..fae6bc96e8 100644 --- a/packages/libraries/cli/package.json +++ b/packages/libraries/cli/package.json @@ -31,6 +31,7 @@ ], "scripts": { "build": "tsc", + "check:types": "tsc --noEmit", "oclif:pack": "npm pack && pnpm oclif pack tarballs --no-xz", "oclif:upload": "pnpm oclif upload tarballs --no-xz", "postpack": "rm -f oclif.manifest.json", @@ -42,6 +43,7 @@ "schema:check:stitching": "pnpm start schema:check --service posts examples/stitching.posts.graphql", "schema:publish:federation": "pnpm start schema:publish --service reviews --url reviews.com/graphql examples/federation.reviews.graphql", "start": "./bin/dev", + "typecheck:make-me-work-in-ci": "pnpm check:types", "version": "oclif readme && git add README.md" }, "dependencies": { @@ -57,6 +59,7 @@ "@oclif/core": "^3.26.6", "@oclif/plugin-help": "6.0.22", "@oclif/plugin-update": "4.2.13", + "@sinclair/typebox": "0.34.12", "@theguild/federation-composition": "0.14.2", "colors": "1.4.0", "env-ci": "7.3.0", diff --git a/packages/libraries/cli/src/base-command.ts b/packages/libraries/cli/src/base-command.ts index ec9a6cc8b2..a473a60faf 100644 --- a/packages/libraries/cli/src/base-command.ts +++ b/packages/libraries/cli/src/base-command.ts @@ -1,19 +1,27 @@ -import colors from 'colors'; -import { print, type GraphQLError } from 'graphql'; +import { print } from 'graphql'; import type { ExecutionResult } from 'graphql'; import { http } from '@graphql-hive/core'; import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; -import { Command, Errors, Flags, Interfaces } from '@oclif/core'; +import { Command, Flags, Interfaces } from '@oclif/core'; +import { ParserOutput } from '@oclif/core/lib/interfaces/parser'; import { Config, GetConfigurationValueType, ValidConfigurationKeys } from './helpers/config'; +import { Errors } from './helpers/errors/__'; +import { CLIErrorWithData } from './helpers/errors/cli-error-with-data'; +import { OmitNever } from './helpers/general'; +import { Tex } from './helpers/tex/__'; +import { tb } from './helpers/typebox/__'; +import { Output } from './output/__'; -export type Flags = Interfaces.InferredFlags< - (typeof BaseCommand)['baseFlags'] & T['flags'] ->; -export type Args = Interfaces.InferredArgs; +export default abstract class BaseCommand<$Command extends typeof Command> extends Command { + public static enableJsonFlag = true; -type OmitNever = { [K in keyof T as T[K] extends never ? never : K]: T[K] }; + /** + * The data type returned by this command when executed. + * + * Used by methods: {@link BaseCommand.success}, {@link BaseCommand.failure}, {@link BaseCommand.runResult}. + */ + public static output: Output.DataType[] = []; -export default abstract class BaseCommand extends Command { protected _userConfig: Config | undefined; static baseFlags = { @@ -23,8 +31,146 @@ export default abstract class BaseCommand extends Comm }), }; - protected flags!: Flags; - protected args!: Args; + protected flags!: InferFlags<$Command>; + + protected args!: InferArgs<$Command>; + + /** + * Prefer implementing {@link BaseCommand.runResult} instead of this method. Refer to it for its benefits. + * + * By default this command runs {@link BaseCommand.runResult}, having logic to handle its return value. + */ + async run(): Promise>> { + // todo: Make it easier for the Hive team to be alerted. + // - Alert the Hive team automatically with some opt-in telemetry? + // - A single-click-link with all relevant variables serialized into search parameters? + const schemaViolationMessage = `Whoops. This Hive CLI command tried to output a value that violates its own schema. This should never happen. Please report this error to the Hive team at https://github.com/graphql-hive/console/issues/new.`; + + const thisClass = this.constructor as typeof BaseCommand; + const resultUnparsed = await this.runResult(); + // @ts-expect-error fixme + const resultDataTypeName = resultUnparsed.data.type; + const dataType = thisClass.output.find( + dataType => dataType.schema.properties.data.properties.type.const === resultDataTypeName, + ); + if (!dataType) { + throw new CLIErrorWithData({ + message: schemaViolationMessage, + data: { + type: 'ErrorDataTypeNotFound', + message: schemaViolationMessage, + value: resultUnparsed, + }, + }); + } + + const errorsIterator = tb.Value.Value.Errors(dataType.schema, resultUnparsed); + const materializedErrors = tb.Value.MaterializeValueErrorIterator(errorsIterator); + if (materializedErrors.length > 0) { + // todo: Display data in non-json output. + // The default textual output of an OClif error will not display any of the data below. We will want that information in a bug report. + throw new Errors.CLIErrorWithData({ + message: schemaViolationMessage, + data: { + type: 'ErrorOutputSchemaViolation', + message: schemaViolationMessage, + schema: dataType, + value: resultUnparsed, + errors: materializedErrors, + }, + }); + } + + // Should never throw because we checked for errors above. + const result = tb.Value.Parse(dataType.schema, resultUnparsed); + + // Data types can optionally bundle a textual representation of their data. + if (dataType.text) { + this.log(dataType.text({ flags: this.flags, args: this.args }, result.data)); + } + + /** + * OClif outputs returned values as JSON. + */ + if (Output.isSuccess(result as any)) { + return result as any; + } + + /** + * OClif supports converting thrown errors into JSON. + * + * OClif will run {@link BaseCommand.toErrorJson} which + * allows us to convert thrown values into JSON. + * We throw a CLIFailure which will be specially handled it. + */ + throw new Errors.CLIErrorWithData({ + // @ts-expect-error fixme + data: result.data, + // @ts-expect-error fixme + message: result.data.message ?? 'Unknown error.', + }); + } + + /** + * A safer alternative to {@link BaseCommand.run}. Benefits: + * + * 1. Clearer control-flow: Treats errors as data (meaning you return them). + * 2. More type-safe 1: Throwing is not tracked by TypeScript, return is. + * 3. More type-safe 2: You are prevented from forgetting to return JSON data (void return not allowed). + * + * Note: You must specify your command's output type in {@link BaseCommand.output} to take advantage of this method. + */ + async runResult(): Promise< + Output.InferSuccess> | Output.InferFailure> + > { + throw new Error('Not implemented'); + } + + /** + * Variant of {@link BaseCommand.successEnvelope} that only requires passing the data. + * See that method for more details. + */ + success(data: InferOutputSuccessData<$Command>): InferOutputSuccess<$Command> { + return this.successEnvelope({ data } as any) as any; + } + + /** + * Helper function for easy creation of success envelope (with defaults) that + * adheres to the type specified by your command's {@link BaseCommand.output}. + */ + successEnvelope( + envelopeInit: InferOutputSuccessEnvelopeInit<$Command>, + ): InferOutputSuccess<$Command> { + return { + ...Output.successDefaults, + ...(envelopeInit as object), + } as any; + } + + /** + * Variant of {@link BaseCommand.failure} that only requires passing the data. + * See that method for more details. + */ + failure(data: InferOutputFailureData<$Command>): InferOutputFailure<$Command> { + return this.failureEnvelope({ data } as any) as any; + } + + /** + * Helper function for easy creation of failure data (with defaults) that + * adheres to the type specified by your command's {@link BaseCommand.output}. + * + * This is only useful within {@link BaseCommand.runResult} which allows returning instead of throwing failures. + * + * When you return this, + */ + failureEnvelope( + envelopeInit: InferOutputFailureEnvelopeInit<$Command>, + ): InferOutputFailure<$Command> { + return { + ...Output.failureDefaults, + ...(envelopeInit as object), + } as any; + } protected get userConfig(): Config { if (!this._userConfig) { @@ -33,7 +179,7 @@ export default abstract class BaseCommand extends Comm return this._userConfig!; } - public async init(): Promise { + async init(): Promise { await super.init(); this._userConfig = new Config({ @@ -45,36 +191,40 @@ export default abstract class BaseCommand extends Comm const { args, flags } = await this.parse({ flags: this.ctor.flags, baseFlags: (super.ctor as typeof BaseCommand).baseFlags, + enableJsonFlag: this.ctor.enableJsonFlag, args: this.ctor.args, strict: this.ctor.strict, }); - this.flags = flags as Flags; - this.args = args as Args; - } - - success(...args: any[]) { - this.log(colors.green('✔'), ...args); + this.flags = flags as InferFlags<$Command>; + this.args = args as InferArgs<$Command>; } - fail(...args: any[]) { - this.log(colors.red('✖'), ...args); + /** + * {@link Command.log} with success styling. + */ + logSuccess(...args: any[]) { + this.log(Tex.success(...args)); } - info(...args: any[]) { - this.log(colors.yellow('ℹ'), ...args); + /** + * {@link Command.log} with failure styling. + */ + logFailure(...args: any[]) { + this.log(Tex.failure(...args)); } - infoWarning(...args: any[]) { - this.log(colors.yellow('⚠'), ...args); + /** + * {@link Command.log} with info styling. + */ + logInfo(...args: any[]) { + this.log(Tex.info(...args)); } - bolderize(msg: string) { - const findSingleQuotes = /'([^']+)'/gim; - const findDoubleQuotes = /"([^"]+)"/gim; - - return msg - .replace(findSingleQuotes, (_: string, value: string) => colors.bold(value)) - .replace(findDoubleQuotes, (_: string, value: string) => colors.bold(value)); + /** + * {@link Command.log} with warning styling. + */ + logWarning(...args: any[]) { + this.log(Tex.warning(...args)); } maybe, TKey extends keyof TArgs>({ @@ -111,9 +261,9 @@ export default abstract class BaseCommand extends Comm * @param env an env var name */ ensure< - TKey extends ValidConfigurationKeys, - TArgs extends { - [key in TKey]: GetConfigurationValueType; + $Key extends ValidConfigurationKeys, + $Args extends { + [key in $Key]: GetConfigurationValueType<$Key>; }, >({ key, @@ -123,34 +273,34 @@ export default abstract class BaseCommand extends Comm message, env, }: { - args: TArgs; - key: TKey; + args: $Args; + key: $Key; /** By default we try to match config names with flag names, but for legacy compatibility we need to provide the old flag name. */ legacyFlagName?: keyof OmitNever<{ // Symbol.asyncIterator to discriminate against any lol - [TArgKey in keyof TArgs]: typeof Symbol.asyncIterator extends TArgs[TArgKey] + [TArgKey in keyof $Args]: typeof Symbol.asyncIterator extends $Args[TArgKey] ? never - : string extends TArgs[TArgKey] + : string extends $Args[TArgKey] ? TArgKey : never; }>; - defaultValue?: TArgs[keyof TArgs] | null; + defaultValue?: $Args[keyof $Args] | null; message?: string; env?: string; - }): NonNullable> | never { + }): NonNullable> | never { if (args[key] != null) { - return args[key] as NonNullable>; + return args[key] as NonNullable>; } if (legacyFlagName && (args as any)[legacyFlagName] != null) { - return args[legacyFlagName] as any as NonNullable>; + return args[legacyFlagName] as any as NonNullable>; } // eslint-disable-next-line no-process-env if (env && process.env[env]) { // eslint-disable-next-line no-process-env - return process.env[env] as TArgs[keyof TArgs] as NonNullable>; + return process.env[env] as $Args[keyof $Args] as NonNullable>; } const userConfigValue = this._userConfig!.get(key); @@ -164,14 +314,23 @@ export default abstract class BaseCommand extends Comm } if (message) { - throw new Errors.CLIError(message); + throw new Errors.CLIErrorWithData({ + message, + data: { + type: 'FailureUserInput', + parameter: key, + }, + }); } - throw new Errors.CLIError(`Missing "${String(key)}"`); - } - - cleanRequestId(requestId?: string | null) { - return requestId ? requestId.split(',')[0].trim() : undefined; + throw new Errors.CLIErrorWithData({ + message: `Missing "${String(key)}"`, + data: { + type: 'FailureUserInput', + problem: 'namedArgumentMissing', + parameter: key, + }, + }); } registryApi(registry: string, token: string) { @@ -236,7 +395,7 @@ export default abstract class BaseCommand extends Comm const jsonData = (await response.json()) as ExecutionResult; if (jsonData.errors && jsonData.errors.length > 0) { - throw new ClientError( + throw new Errors.ClientError( `Failed to execute GraphQL operation: ${jsonData.errors .map(e => e.message) .join('\n')}`, @@ -252,30 +411,86 @@ export default abstract class BaseCommand extends Comm }; } - handleFetchError(error: unknown): never { - if (typeof error === 'string') { - return this.error(error); + /** + * @see https://oclif.io/docs/error_handling/#error-handling-in-the-catch-method + */ + async catch(error: Errors.CommandError): Promise { + if (error instanceof Errors.ClientError) { + await super.catch(clientErrorToCLIFailure(error)); + } else { + await super.catch(error); } + } - if (error instanceof Error) { - if (isClientError(error)) { - const errors = error.response?.errors; + /** + * Custom logic for how thrown values are converted into JSON. + * + * @remarks + * + * 1. OClif input validation error classes have + * no structured information available about the error + * which limits our ability here to forward structure to + * the user. :( + */ + toErrorJson(value: unknown) { + if (value instanceof Errors.CLIErrorWithData) { + return value.envelope; + } - if (Array.isArray(errors) && errors.length > 0) { - return this.error(errors[0].message, { - ref: this.cleanRequestId(error.response?.headers?.get('x-request-id')), - }); - } + if (value instanceof Errors.FailedFlagValidationError) { + return this.failureEnvelope({ + suggestions: value.suggestions, + data: { + type: 'FailureUserInput', + message: value.message, + problem: 'namedArgumentInvalid', + }, + } as any); + } + + if (value instanceof Errors.RequiredArgsError) { + return this.failureEnvelope({ + suggestions: value.suggestions, + data: { + type: 'FailureUserInput', + message: value.message, + problem: 'positionalArgumentMissing', + }, + } as any); + } - return this.error(error.message, { - ref: this.cleanRequestId(error.response?.headers?.get('x-request-id')), - }); - } + if (value instanceof Errors.CLIError) { + return this.failureEnvelope({ + suggestions: value.suggestions, + data: { + type: 'Failure', + message: value.message, + }, + } as any); + } + if (value instanceof Error) { + return this.failure({ + type: 'Failure', + message: value.message, + } as any); + } + return super.toErrorJson(value); + } - return this.error(error); + handleFetchError(error: unknown): never { + if (typeof error === 'string') { + this.error(error); } - return this.error(JSON.stringify(error)); + if (error instanceof Errors.ClientError) { + this.error(clientErrorToCLIFailure(error)); + } + + if (error instanceof Error) { + this.error(error); + } + + this.error(JSON.stringify(error)); } async require< @@ -292,18 +507,78 @@ export default abstract class BaseCommand extends Comm } } -class ClientError extends Error { - constructor( - message: string, - public response: { - errors?: readonly GraphQLError[]; - headers: Headers; +const clientErrorToCLIFailure = (error: Errors.ClientError): Errors.CLIErrorWithData => { + const requestId = cleanRequestId(error.response?.headers?.get('x-request-id')); + const errors = + error.response?.errors?.map(e => { + return { + message: e.message, + }; + }) ?? []; + // todo: Use error chains & aggregate errors. + const causedByMessage = + errors.length > 0 + ? `Caused by error(s):\n${errors.map(e => e.message).join('\n')}` + : `Caused by:\n${error.message}`; + const message = `Request to Hive API failed. ${causedByMessage}`; + + return new Errors.CLIErrorWithData({ + message, + ref: requestId, + data: { + type: 'FailureHiveApiRequest', + message, + requestId, + errors, }, - ) { - super(message); - } -} - -function isClientError(error: Error): error is ClientError { - return error instanceof ClientError; -} + }); +}; + +// prettier-ignore +type InferFlags<$CommandClass extends typeof Command> = + Interfaces.InferredFlags<(typeof BaseCommand)['baseFlags'] & $CommandClass['flags']>; + +// prettier-ignore +type InferArgs<$CommandClass extends typeof Command> = + Interfaces.InferredArgs<$CommandClass['args']>; + +// prettier-ignore +type InferOutputSuccess<$CommandClass extends typeof Command> = + Output.InferSuccess>; + +// prettier-ignore +type InferOutputFailure<$CommandClass extends typeof Command> = + Output.InferFailure>; + +// prettier-ignore +type InferOutputFailureEnvelopeInit<$CommandClass extends typeof Command> = + Output.InferFailureEnvelopeInit>; + +// prettier-ignore +type InferOutputSuccessEnvelopeInit<$CommandClass extends typeof Command> = + Output.InferSuccessEnvelopeInit>; + +// prettier-ignore +type InferOutputFailureData<$CommandClass extends typeof Command> = + Output.InferFailureData>; + +// prettier-ignore +type InferOutputSuccessData<$CommandClass extends typeof Command> = + Output.InferSuccessData>; + +// prettier-ignore +type GetOutput<$CommandClass extends typeof Command> = + 'output' extends keyof $CommandClass + ? $CommandClass['output'] extends Output.DataType[] + ? $CommandClass['output'][number] + : never + : never; + +const cleanRequestId = (requestId?: string | null) => { + return requestId ? requestId.split(',')[0].trim() : undefined; +}; + +export type InferInput<$Command extends typeof Command> = Pick< + ParserOutput<$Command['flags'], $Command['baseFlags'], $Command['args']>, + 'args' | 'flags' +>; diff --git a/packages/libraries/cli/src/command-index.ts b/packages/libraries/cli/src/command-index.ts new file mode 100644 index 0000000000..0695ae0e81 --- /dev/null +++ b/packages/libraries/cli/src/command-index.ts @@ -0,0 +1,42 @@ +/** + * This module gathers all the command classes for use in the inferred library + * which is useful for testing. + * + * See {@link Infer} for more information. + */ + +import AppCreate from './commands/app/create'; +import AppPublish from './commands/app/publish'; +import ArtifactsFetch from './commands/artifact/fetch'; +import Dev from './commands/dev'; +import Introspect from './commands/introspect'; +import OperationsCheck from './commands/operations/check'; +import SchemaCheck from './commands/schema/check'; +import SchemaDelete from './commands/schema/delete'; +import SchemaFetch from './commands/schema/fetch'; +import SchemaPublish from './commands/schema/publish'; +import Whoami from './commands/whoami'; +// todo raise issue with respective ESLint lib author about type imports used in JSDoc being marked as "unused" +// eslint-disable-next-line +import type { Infer } from './library/infer'; +import { CommandIndexGeneric } from './library/infer'; + +export const commandIndex = { + Dev, + Whoami, + Introspect, + // app: + AppCreate, + AppPublish, + // schema: + SchemaPublish, + SchemaCheck, + SchemaDelete, + SchemaFetch, + // artifact: + ArtifactsFetch, + // operations: + OperationsCheck, +} satisfies CommandIndexGeneric; + +export type CommandIndex = typeof commandIndex; diff --git a/packages/libraries/cli/src/commands/app/create.ts b/packages/libraries/cli/src/commands/app/create.ts index a53457a447..984e06ebd6 100644 --- a/packages/libraries/cli/src/commands/app/create.ts +++ b/packages/libraries/cli/src/commands/app/create.ts @@ -1,9 +1,10 @@ -import { z } from 'zod'; import { Args, Flags } from '@oclif/core'; -import Command from '../../base-command'; +import Command, { InferInput } from '../../base-command'; import { graphql } from '../../gql'; -import { AppDeploymentStatus } from '../../gql/graphql'; import { graphqlEndpoint } from '../../helpers/config'; +import { SchemaHive } from '../../helpers/schema'; +import { tb } from '../../helpers/typebox/__'; +import { Output } from '../../output/__'; export default class AppCreate extends Command { static description = 'create an app deployment'; @@ -23,7 +24,6 @@ export default class AppCreate extends Command { required: true, }), }; - static args = { file: Args.string({ name: 'file', @@ -32,8 +32,33 @@ export default class AppCreate extends Command { hidden: false, }), }; + static output = [ + Output.success('SuccessSkipAppCreate', { + data: { + status: Output.AppDeploymentStatus, + }, + text(input: InferInput, output) { + return `App deployment "${input.flags.name}@${input.flags.version}" is "${output.status}". Skip uploading documents...`; + }, + }), + Output.success('SuccessAppCreate', { + data: { + id: tb.StringNonEmpty, + }, + }), + Output.failure('FailureAppCreate', { + data: { + message: tb.String(), + }, + }), + Output.failure('FailureInvalidManifestModel', { + data: { + errors: tb.Array(tb.Value.MaterializedValueErrorT), + }, + }), + ]; - async run() { + async runResult() { const { flags, args } = await this.parse(AppCreate); const endpoint = this.ensure({ @@ -51,60 +76,68 @@ export default class AppCreate extends Command { const file: string = args.file; const fs = await import('fs/promises'); const contents = await fs.readFile(file, 'utf-8'); - const operations: unknown = JSON.parse(contents); - const validationResult = ManifestModel.safeParse(operations); - - if (validationResult.success === false) { - // TODO: better error message :) - throw new Error('Invalid manifest'); + const operations = tb.Value.ParseJsonSafe(ManifestModel, contents); + if (operations instanceof tb.Value.AssertError) { + return this.failure({ + type: 'FailureInvalidManifestModel', + errors: tb.Value.MaterializeValueErrorIterator(operations.Errors()), + }); } - const result = await this.registryApi(endpoint, accessToken).request({ - operation: CreateAppDeploymentMutation, - variables: { - input: { - appName: flags['name'], - appVersion: flags['version'], + const result = await this.registryApi(endpoint, accessToken) + .request({ + operation: CreateAppDeploymentMutation, + variables: { + input: { + appName: flags['name'], + appVersion: flags['version'], + }, }, - }, - }); - - if (result.createAppDeployment.error) { - // TODO: better error message formatting :) - throw new Error(result.createAppDeployment.error.message); + }) + .then(_ => _.createAppDeployment); + + if (result.error) { + return this.failure({ + type: 'FailureAppCreate', + message: result.error.message, + }); } - if (!result.createAppDeployment.ok) { + // TODO: Improve Hive API by returning a union type. + if (!result.ok) { throw new Error('Unknown error'); } - if (result.createAppDeployment.ok.createdAppDeployment.status !== AppDeploymentStatus.Pending) { - this.log( - `App deployment "${flags['name']}@${flags['version']}" is "${result.createAppDeployment.ok.createdAppDeployment.status}". Skip uploading documents...`, - ); - return; + if (result.ok.createdAppDeployment.status !== SchemaHive.AppDeploymentStatus.Pending) { + // this.log( + // `App deployment "${flags['name']}@${flags['version']}" is "${result.ok.createdAppDeployment.status}". Skip uploading documents...`, + // ); + return this.success({ + type: 'SuccessSkipAppCreate', + status: result.ok.createdAppDeployment.status, + }); } let buffer: Array<{ hash: string; body: string }> = []; const flush = async (force = false) => { if (buffer.length >= 100 || force) { - const result = await this.registryApi(endpoint, accessToken).request({ - operation: AddDocumentsToAppDeploymentMutation, - variables: { - input: { - appName: flags['name'], - appVersion: flags['version'], - documents: buffer, + const result = await this.registryApi(endpoint, accessToken) + .request({ + operation: AddDocumentsToAppDeploymentMutation, + variables: { + input: { + appName: flags['name'], + appVersion: flags['version'], + documents: buffer, + }, }, - }, - }); + }) + .then(_ => _.addDocumentsToAppDeployment); - if (result.addDocumentsToAppDeployment.error) { - if (result.addDocumentsToAppDeployment.error.details) { - const affectedOperation = buffer.at( - result.addDocumentsToAppDeployment.error.details.index, - ); + if (result.error) { + if (result.error.details) { + const affectedOperation = buffer.at(result.error.details.index); const maxCharacters = 40; @@ -114,14 +147,14 @@ export default class AppCreate extends Command { ? affectedOperation.body.substring(0, maxCharacters) + '...' : affectedOperation.body ).replace(/\n/g, '\\n'); - this.infoWarning( - `Failed uploading document: ${result.addDocumentsToAppDeployment.error.details.message}` + + this.logWarning( + `Failed uploading document: ${result.error.details.message}` + `\nOperation hash: ${affectedOperation?.hash}` + `\nOperation body: ${truncatedBody}`, ); } } - this.error(result.addDocumentsToAppDeployment.error.message); + this.error(result.error.message); } buffer = []; } @@ -129,7 +162,7 @@ export default class AppCreate extends Command { let counter = 0; - for (const [hash, body] of Object.entries(validationResult.data)) { + for (const [hash, body] of Object.entries(operations)) { buffer.push({ hash, body }); await flush(); counter++; @@ -138,12 +171,16 @@ export default class AppCreate extends Command { await flush(true); this.log( - `\nApp deployment "${flags['name']}@${flags['version']}" (${counter} operations) created.\nActive it with the "hive app:publish" command.`, + `App deployment "${flags['name']}@${flags['version']}" (${counter} operations) created.\nActivate it with the "hive app:publish" command.`, ); + return this.success({ + type: 'SuccessAppCreate', + id: result.ok.createdAppDeployment.id, + }); } } -const ManifestModel = z.record(z.string()); +const ManifestModel = tb.Record(tb.String(), tb.String()); const CreateAppDeploymentMutation = graphql(/* GraphQL */ ` mutation CreateAppDeployment($input: CreateAppDeploymentInput!) { diff --git a/packages/libraries/cli/src/commands/app/publish.ts b/packages/libraries/cli/src/commands/app/publish.ts index fbc31e3b68..7b873076eb 100644 --- a/packages/libraries/cli/src/commands/app/publish.ts +++ b/packages/libraries/cli/src/commands/app/publish.ts @@ -2,6 +2,8 @@ import { Flags } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; +import { tb } from '../../helpers/typebox/__'; +import { Output } from '../../output/__'; export default class AppPublish extends Command { static description = 'publish an app deployment'; @@ -21,8 +23,27 @@ export default class AppPublish extends Command { required: true, }), }; + static output = [ + Output.success('SuccessSkipAppPublish', { + data: { + name: tb.StringNonEmpty, + version: tb.StringNonEmpty, + }, + }), + Output.success('SuccessAppPublish', { + data: { + name: tb.StringNonEmpty, + version: tb.StringNonEmpty, + }, + }), + Output.failure('FailureAppPublish', { + data: { + message: tb.String(), + }, + }), + ]; - async run() { + async runResult() { const { flags } = await this.parse(AppPublish); const endpoint = this.ensure({ @@ -37,29 +58,51 @@ export default class AppPublish extends Command { env: 'HIVE_TOKEN', }); - const result = await this.registryApi(endpoint, accessToken).request({ - operation: ActivateAppDeploymentMutation, - variables: { - input: { - appName: flags['name'], - appVersion: flags['version'], + const result = await this.registryApi(endpoint, accessToken) + .request({ + operation: ActivateAppDeploymentMutation, + variables: { + input: { + appName: flags['name'], + appVersion: flags['version'], + }, }, - }, - }); + }) + .then(_ => _.activateAppDeployment); - if (result.activateAppDeployment.error) { - throw new Error(result.activateAppDeployment.error.message); + if (result.error) { + return this.failure({ + type: 'FailureAppPublish', + message: result.error.message, + }); } - if (result.activateAppDeployment.ok) { - const name = `${result.activateAppDeployment.ok.activatedAppDeployment.name}@${result.activateAppDeployment.ok.activatedAppDeployment.version}`; + // TODO: Improve Hive API by returning a union type. + if (!result.ok) { + throw new Error('Unknown error'); + } - if (result.activateAppDeployment.ok.isSkipped) { - this.warn(`App deployment "${name}" is already published. Skipping...`); - return; - } - this.log(`App deployment "${name}" published successfully.`); + const name = `${result.ok.activatedAppDeployment.name}@${result.ok.activatedAppDeployment.version}`; + + if (result.ok.isSkipped) { + this.warn(`App deployment "${name}" is already published. Skipping...`); + return this.successEnvelope({ + data: { + type: 'SuccessSkipAppPublish', + name: result.ok.activatedAppDeployment.name, + version: result.ok.activatedAppDeployment.version, + }, + }); } + + this.log(`App deployment "${name}" published successfully.`); + return this.successEnvelope({ + data: { + type: 'SuccessAppPublish', + name: result.ok.activatedAppDeployment.name, + version: result.ok.activatedAppDeployment.version, + }, + }); } } diff --git a/packages/libraries/cli/src/commands/artifact/fetch.ts b/packages/libraries/cli/src/commands/artifact/fetch.ts index 3677b35710..4abee681b7 100644 --- a/packages/libraries/cli/src/commands/artifact/fetch.ts +++ b/packages/libraries/cli/src/commands/artifact/fetch.ts @@ -1,6 +1,7 @@ import { http, URL } from '@graphql-hive/core'; import { Flags } from '@oclif/core'; import Command from '../../base-command'; +import { Output } from '../../output/__'; export default class ArtifactsFetch extends Command { static description = 'fetch artifacts from the CDN'; @@ -20,8 +21,9 @@ export default class ArtifactsFetch extends Command { description: 'whether to write to a file instead of stdout', }), }; + static output = [Output.SuccessOutputFile, Output.SuccessOutputStdout]; - async run() { + async runResult() { const { flags } = await this.parse(ArtifactsFetch); const cdnEndpoint = this.ensure({ @@ -69,10 +71,18 @@ export default class ArtifactsFetch extends Command { const fs = await import('fs/promises'); const contents = Buffer.from(await response.arrayBuffer()); await fs.writeFile(flags.outputFile, contents); - this.log(`Wrote ${contents.length} bytes to ${flags.outputFile}`); - return; + const message = `Wrote ${contents.length} bytes to ${flags.outputFile}`; + this.log(message); + return this.success({ + type: 'SuccessOutputFile', + path: flags.outputFile, + bytes: contents.length, + }); } - this.log(await response.text()); + return this.success({ + type: 'SuccessOutputStdout', + content: await response.text(), + }); } } diff --git a/packages/libraries/cli/src/commands/config/delete.ts b/packages/libraries/cli/src/commands/config/delete.ts deleted file mode 100644 index a3922da099..0000000000 --- a/packages/libraries/cli/src/commands/config/delete.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Args } from '@oclif/core'; -import Command from '../../base-command'; - -export default class DeleteConfig extends Command { - static description = 'deletes specific cli configuration'; - static args = { - key: Args.string({ - name: 'key', - required: true, - description: 'config key', - }), - }; - - async run() { - const { args } = await this.parse(DeleteConfig); - this._userConfig!.delete(args.key); - this.success(this.bolderize(`Config flag "${args.key}" was deleted`)); - } -} diff --git a/packages/libraries/cli/src/commands/config/get.ts b/packages/libraries/cli/src/commands/config/get.ts deleted file mode 100644 index 893a4991e1..0000000000 --- a/packages/libraries/cli/src/commands/config/get.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Args } from '@oclif/core'; -import Command from '../../base-command'; -import { allowedKeys, ValidConfigurationKeys } from '../../helpers/config'; - -export default class GetConfig extends Command { - static description = 'prints specific cli configuration'; - static args = { - key: Args.string({ - name: 'key', - required: true, - description: 'config key', - options: allowedKeys, - }), - }; - - async run() { - const { args } = await this.parse(GetConfig); - console.dir(this.userConfig.get(args.key as ValidConfigurationKeys)); - } -} diff --git a/packages/libraries/cli/src/commands/config/reset.ts b/packages/libraries/cli/src/commands/config/reset.ts deleted file mode 100644 index 7800fea672..0000000000 --- a/packages/libraries/cli/src/commands/config/reset.ts +++ /dev/null @@ -1,10 +0,0 @@ -import Command from '../../base-command'; - -export default class ResetConfig extends Command { - static description = 'resets local cli configuration'; - - async run() { - this.userConfig.clear(); - this.success('Config cleared.'); - } -} diff --git a/packages/libraries/cli/src/commands/config/set.ts b/packages/libraries/cli/src/commands/config/set.ts deleted file mode 100644 index bcbc5d9ac4..0000000000 --- a/packages/libraries/cli/src/commands/config/set.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Args } from '@oclif/core'; -import Command from '../../base-command'; -import { allowedKeys, ValidConfigurationKeys } from '../../helpers/config'; - -export default class SetConfig extends Command { - static description = 'updates specific cli configuration'; - static args = { - key: Args.string({ - name: 'key', - required: true, - description: 'config key', - options: allowedKeys, - }), - value: Args.string({ - name: 'value', - required: true, - description: 'config value', - }), - }; - - async run() { - const { args } = await this.parse(SetConfig); - this.userConfig.set(args.key as ValidConfigurationKeys, args.value); - this.success(this.bolderize(`Config flag "${args.key}" was set to "${args.value}"`)); - } -} diff --git a/packages/libraries/cli/src/commands/dev.ts b/packages/libraries/cli/src/commands/dev.ts index e7a56bbfd0..48b989d0f1 100644 --- a/packages/libraries/cli/src/commands/dev.ts +++ b/packages/libraries/cli/src/commands/dev.ts @@ -9,9 +9,10 @@ import { CompositionResult, } from '@theguild/federation-composition'; import Command from '../base-command'; +import { Fragments } from '../fragments/__'; import { graphql } from '../gql'; import { graphqlEndpoint } from '../helpers/config'; -import { loadSchema, renderErrors } from '../helpers/schema'; +import { loadSchema } from '../helpers/schema'; import { invariant } from '../helpers/validation'; const CLI_SchemaComposeMutation = graphql(/* GraphQL */ ` @@ -83,6 +84,17 @@ type ServiceWithSource = { }; }; +// TODO add JSON output support. +// Unlike other commands that return, this common kicks off a long running process in the terminal +// that outputs messages to the terminal over time. +// Therefore the OClif framework pattern of returning an object is incompatible. +// We'll need to inspect the json flag ourselves and return the appropriate output. +// +// Presumably users would typically NOT use JSON output for this command. This task appears to be motivated by +// the principal of simplicity via consistency. +// +// NDJSON (new line delimited JSON) would be a suitable output. + export default class Dev extends Command { static description = [ 'Develop and compose Supergraph with your local services.', @@ -165,6 +177,8 @@ export default class Dev extends Command { dependsOn: ['remote'], }), }; + // todo + // static output = SchemaOutput.output(); async run() { const { flags } = await this.parse(Dev); @@ -214,7 +228,7 @@ export default class Dev extends Command { write: flags.write, unstable__forceLatest, onError: message => { - this.fail(message); + this.logFailure(message); }, }), ); @@ -227,7 +241,7 @@ export default class Dev extends Command { services, write: flags.write, onError: message => { - this.fail(message); + this.logFailure(message); }, }), ); @@ -299,13 +313,11 @@ export default class Dev extends Command { } catch (error) { reject(error); } - }).catch(error => { - this.handleFetchError(error); }); if (compositionHasErrors(compositionResult)) { if (compositionResult.errors) { - renderErrors.call(this, { + Fragments.SchemaErrorConnection.log.call(this, { total: compositionResult.errors.length, nodes: compositionResult.errors.map(error => ({ message: error.message, @@ -324,7 +336,7 @@ export default class Dev extends Command { return; } - this.success('Composition successful'); + this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); await writeFile(resolve(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); } @@ -368,7 +380,7 @@ export default class Dev extends Command { if (!valid) { if (compositionResult.errors) { - renderErrors.call(this, compositionResult.errors); + Fragments.SchemaErrorConnection.log.call(this, compositionResult.errors); } input.onError('Composition failed'); @@ -382,7 +394,7 @@ export default class Dev extends Command { return; } - this.success('Composition successful'); + this.logSuccess('Composition successful'); this.log(`Saving supergraph schema to ${input.write}`); await writeFile(resolve(process.cwd(), input.write), compositionResult.supergraphSdl, 'utf-8'); } @@ -392,12 +404,12 @@ export default class Dev extends Command { serviceInputs: ServiceInput[], compose: (services: Service[]) => Promise, ) { - this.info('Watch mode enabled'); + this.logInfo('Watch mode enabled'); let services = await this.resolveServices(serviceInputs); await compose(services); - this.info('Watching for changes'); + this.logInfo('Watching for changes'); let resolveWatchMode: () => void; @@ -414,25 +426,25 @@ export default class Dev extends Command { service => services.find(s => s.name === service.name)!.sdl !== service.sdl, ) ) { - this.info('Detected changes, recomposing'); + this.logInfo('Detected changes, recomposing'); await compose(newServices); services = newServices; } } catch (error) { - this.fail(String(error)); + this.logFailure(String(error)); } timeoutId = setTimeout(watch, watchInterval); }; process.once('SIGINT', () => { - this.info('Exiting watch mode'); + this.logInfo('Exiting watch mode'); clearTimeout(timeoutId); resolveWatchMode(); }); process.once('SIGTERM', () => { - this.info('Exiting watch mode'); + this.logInfo('Exiting watch mode'); clearTimeout(timeoutId); resolveWatchMode(); }); diff --git a/packages/libraries/cli/src/commands/introspect.ts b/packages/libraries/cli/src/commands/introspect.ts index a29b731b9b..05c8f66a0a 100644 --- a/packages/libraries/cli/src/commands/introspect.ts +++ b/packages/libraries/cli/src/commands/introspect.ts @@ -4,6 +4,7 @@ import { buildSchema, GraphQLError, introspectionFromSchema } from 'graphql'; import { Args, Flags } from '@oclif/core'; import Command from '../base-command'; import { loadSchema } from '../helpers/schema'; +import { Output } from '../output/__'; export default class Introspect extends Command { static description = 'introspects a GraphQL Schema'; @@ -18,7 +19,6 @@ export default class Introspect extends Command { multiple: true, }), }; - static args = { location: Args.string({ name: 'location', @@ -27,8 +27,9 @@ export default class Introspect extends Command { hidden: false, }), }; + static output = [Output.SuccessOutputFile, Output.SuccessOutputStdout]; - async run() { + async runResult() { const { flags, args } = await this.parse(Introspect); const headers = flags.header?.reduce( (acc, header) => { @@ -47,7 +48,7 @@ export default class Introspect extends Command { method: 'POST', }).catch(err => { if (err instanceof GraphQLError) { - this.fail(err.message); + this.logFailure(err.message); this.exit(1); } @@ -57,43 +58,47 @@ export default class Introspect extends Command { }); if (!schema) { - this.fail('Unable to load schema'); + this.logFailure('Unable to load schema'); this.exit(1); } if (!flags.write) { - this.log(schema); - return; + return this.success({ + type: 'SuccessOutputStdout', + content: schema, + }); } - if (flags.write) { - const filepath = resolve(process.cwd(), flags.write); - - switch (extname(flags.write.toLowerCase())) { - case '.graphql': - case '.gql': - case '.gqls': - case '.graphqls': - writeFileSync(filepath, schema, 'utf8'); - break; - case '.json': { - const schemaObject = buildSchema(schema, { - assumeValidSDL: true, - assumeValid: true, - }); - writeFileSync( - filepath, - JSON.stringify(introspectionFromSchema(schemaObject), null, 2), - 'utf8', - ); - break; - } - default: - this.fail(`Unsupported file extension ${extname(flags.write)}`); - this.exit(1); + const filepath = resolve(process.cwd(), flags.write); + switch (extname(flags.write.toLowerCase())) { + case '.graphql': + case '.gql': + case '.gqls': + case '.graphqls': + writeFileSync(filepath, schema, 'utf8'); + break; + case '.json': { + const schemaObject = buildSchema(schema, { + assumeValidSDL: true, + assumeValid: true, + }); + writeFileSync( + filepath, + JSON.stringify(introspectionFromSchema(schemaObject), null, 2), + 'utf8', + ); + break; } - - this.success(`Saved to ${filepath}`); + default: + this.logFailure(`Unsupported file extension ${extname(flags.write)}`); + this.exit(1); } + + this.logSuccess(`Saved to ${filepath}`); + return this.success({ + type: 'SuccessOutputFile', + path: filepath, + bytes: schema.length, + }); } } diff --git a/packages/libraries/cli/src/commands/operations/check.ts b/packages/libraries/cli/src/commands/operations/check.ts index 466e923f35..d44e2bd6bf 100644 --- a/packages/libraries/cli/src/commands/operations/check.ts +++ b/packages/libraries/cli/src/commands/operations/check.ts @@ -1,10 +1,13 @@ import { buildSchema, GraphQLError, Source } from 'graphql'; import { InvalidDocument, validate } from '@graphql-inspector/core'; -import { Args, Errors, Flags, ux } from '@oclif/core'; +import { Args, Flags, ux } from '@oclif/core'; import Command from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; import { loadOperations } from '../../helpers/operations'; +import { Tex } from '../../helpers/tex/__'; +import { tb } from '../../helpers/typebox/__'; +import { Output } from '../../output/__'; const fetchLatestVersionQuery = graphql(/* GraphQL */ ` query fetchLatestVersion { @@ -67,7 +70,6 @@ export default class OperationsCheck extends Command { default: false, }), }; - static args = { file: Args.string({ name: 'file', @@ -76,92 +78,128 @@ export default class OperationsCheck extends Command { hidden: false, }), }; + static output = [ + Output.failure('FailureOperationsCheckNoSchemaFound', { data: {} }), + Output.success('SuccessOperationsCheckNoOperationsFound', { data: {} }), + Output.success('SuccessOperationsCheck', { + data: { + countTotal: tb.Integer({ minimum: 0 }), + countInvalid: tb.Integer({ minimum: 0 }), + countValid: tb.Integer({ minimum: 0 }), + invalidOperations: tb.Array( + tb.Object({ + source: tb.Object({ + name: tb.String(), + }), + errors: tb.Array( + tb.Object({ + message: tb.String(), + locations: tb.Array( + tb.Object({ + line: tb.Integer({ minimum: 0 }), + column: tb.Integer({ minimum: 0 }), + }), + ), + }), + ), + }), + ), + }, + }), + ]; - async run() { - try { - const { flags, args } = await this.parse(OperationsCheck); + async runResult() { + const { flags, args } = await this.parse(OperationsCheck); - await this.require(flags); + await this.require(flags); - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - }); - const graphqlTag = flags.graphqlTag; - const globalGraphqlTag = flags.globalGraphqlTag; - - const file: string = args.file; - - const operations = await loadOperations(file, { - normalize: false, - pluckModules: graphqlTag?.map(tag => { - const [name, identifier] = tag.split(':'); - return { - name, - identifier, - }; - }), - pluckGlobalGqlIdentifierName: globalGraphqlTag, - }); + const endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + }); + const accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + }); + const graphqlTag = flags.graphqlTag; + const globalGraphqlTag = flags.globalGraphqlTag; - if (operations.length === 0) { - this.info('No operations found'); - this.exit(0); - return; - } + const file: string = args.file; - const result = await this.registryApi(endpoint, accessToken).request({ - operation: fetchLatestVersionQuery, + const operations = await loadOperations(file, { + normalize: false, + pluckModules: graphqlTag?.map(tag => { + const [name, identifier] = tag.split(':'); + return { + name, + identifier, + }; + }), + pluckGlobalGqlIdentifierName: globalGraphqlTag, + }); + + if (operations.length === 0) { + const message = 'No operations found'; + this.logInfo(message); + return this.success({ + type: 'SuccessOperationsCheckNoOperationsFound', }); + } - const sdl = result.latestValidVersion?.sdl; + const result = await this.registryApi(endpoint, accessToken) + .request({ + operation: fetchLatestVersionQuery, + }) + .then(_ => _.latestValidVersion); - if (!sdl) { - this.error('Could not find a published schema. Please publish a valid schema first.'); - } + const sdl = result?.sdl; - const schema = buildSchema(sdl, { - assumeValidSDL: true, - assumeValid: true, + if (!sdl) { + this.logFailure('Could not find a published schema.'); + return this.failureEnvelope({ + suggestions: ['Publish a valid schema first.'], + data: { + type: 'FailureOperationsCheckNoSchemaFound', + }, }); + } - if (!flags.apolloClient) { - const detectedApolloDirectives = operations.some( - s => s.content.includes('@client') || s.content.includes('@connection'), - ); - - if (detectedApolloDirectives) { - this.warn( - 'Apollo Client specific directives detected (@client, @connection). Please use the --apolloClient flag to enable support.', - ); - } - } + const schema = buildSchema(sdl, { + assumeValidSDL: true, + assumeValid: true, + }); - const invalidOperations = validate( - schema, - operations.map(s => new Source(s.content, s.location)), - { - apollo: flags.apolloClient === true, - }, + if (!flags.apolloClient) { + const detectedApolloDirectives = operations.some( + s => s.content.includes('@client') || s.content.includes('@connection'), ); - const operationsWithErrors = invalidOperations.filter(o => o.errors.length > 0); - - if (operationsWithErrors.length === 0) { - this.success(`All operations are valid (${operations.length})`); - this.exit(0); - return; + if (detectedApolloDirectives) { + // TODO: Gather warnings into a "warnings" array property in our envelope. + this.warn( + 'Apollo Client specific directives detected (@client, @connection). Please use the --apolloClient flag to enable support.', + ); } + } + + const invalidOperations = validate( + schema, + operations.map(s => new Source(s.content, s.location)), + { + apollo: flags.apolloClient === true, + }, + ); + + const operationsWithErrors = invalidOperations.filter(o => o.errors.length > 0); + if (operationsWithErrors.length === 0) { + this.logSuccess(`All operations are valid (${operations.length})`); + } else { ux.styledHeader('Summary'); this.log( [ @@ -176,15 +214,34 @@ export default class OperationsCheck extends Command { ux.styledHeader('Details'); this.printInvalidDocuments(operationsWithErrors); - this.exit(1); - } catch (error) { - if (error instanceof Errors.ExitError) { - throw error; - } else { - this.fail('Failed to validate operations'); - this.handleFetchError(error); - } + process.exitCode = 1; } + + return this.success({ + type: 'SuccessOperationsCheck', + countTotal: operations.length, + countInvalid: operationsWithErrors.length, + countValid: operations.length - operationsWithErrors.length, + invalidOperations: operationsWithErrors.map(o => { + return { + source: { + name: o.source.name, + }, + errors: o.errors.map(e => { + return { + message: e.message, + locations: + e.locations?.map(l => { + return { + line: l.line, + column: l.column, + }; + }) ?? [], + }; + }), + }; + }), + }); } private printInvalidDocuments(invalidDocuments: InvalidDocument[]): void { @@ -194,9 +251,9 @@ export default class OperationsCheck extends Command { } private renderErrors(sourceName: string, errors: GraphQLError[]) { - this.fail(sourceName); + this.logFailure(sourceName); errors.forEach(e => { - this.log(` - ${this.bolderize(e.message)}`); + this.log(` - ${Tex.bolderize(e.message)}`); }); this.log(''); } diff --git a/packages/libraries/cli/src/commands/schema/check.ts b/packages/libraries/cli/src/commands/schema/check.ts index dcb1af36c2..04681a4341 100644 --- a/packages/libraries/cli/src/commands/schema/check.ts +++ b/packages/libraries/cli/src/commands/schema/check.ts @@ -1,15 +1,14 @@ import { Args, Errors, Flags } from '@oclif/core'; import Command from '../../base-command'; +import { Fragments } from '../../fragments/__'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; +import { casesExhausted } from '../../helpers/general'; import { gitInfo } from '../../helpers/git'; -import { - loadSchema, - minifySchema, - renderChanges, - renderErrors, - renderWarnings, -} from '../../helpers/schema'; +import { loadSchema, minifySchema } from '../../helpers/schema'; +import { Tex } from '../../helpers/tex/__'; +import { tb } from '../../helpers/typebox/__'; +import { Output } from '../../output/__'; const schemaCheckMutation = graphql(/* GraphQL */ ` mutation schemaCheck($input: SchemaCheckInput!, $usesGitHubApp: Boolean!) { @@ -137,7 +136,6 @@ export default class SchemaCheck extends Command { description: 'Context ID for grouping the schema check.', }), }; - static args = { file: Args.string({ name: 'file', @@ -146,72 +144,105 @@ export default class SchemaCheck extends Command { hidden: false, }), }; + static output = [ + Output.success('SuccessSchemaCheck', { + data: { + changes: tb.Array(Output.SchemaChange), + warnings: tb.Array(Output.SchemaWarning), + url: tb.Nullable(tb.String({ format: 'uri' })), + }, + }), + Output.success('SuccessSchemaCheckGitHub', { + data: { + message: tb.String(), + }, + text: (_, output) => { + return Tex.success(output.message); + }, + }), + Output.success('FailureSchemaCheck', { + data: { + changes: tb.Array(Output.SchemaChange), + warnings: tb.Array(Output.SchemaWarning), + url: tb.Nullable(tb.String({ format: 'uri' })), + }, + }), - async run() { - try { - const { flags, args } = await this.parse(SchemaCheck); + Output.failure('FailureSchemaCheckGitHub', { + data: { + message: tb.String(), + }, + text: (_, data) => { + return Tex.failure(data.message); + }, + }), + ]; - await this.require(flags); + async runResult() { + const { flags, args } = await this.parse(SchemaCheck); - const service = flags.service; - const forceSafe = flags.forceSafe; - const usesGitHubApp = flags.github === true; - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const file = args.file; - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - }); - const sdl = await loadSchema(file); - const git = await gitInfo(() => { - // noop - }); + await this.require(flags); - const commit = flags.commit || git?.commit; - const author = flags.author || git?.author; + const service = flags.service; + const forceSafe = flags.forceSafe; + const usesGitHubApp = flags.github === true; + const endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + }); + const file = args.file; + const accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + }); + const sdl = await loadSchema(file); + const git = await gitInfo(() => { + // noop + }); - if (typeof sdl !== 'string' || sdl.length === 0) { - throw new Errors.CLIError('Schema seems empty'); - } + const commit = flags.commit || git?.commit; + const author = flags.author || git?.author; - let github: null | { - commit: string; - repository: string | null; - pullRequestNumber: string | null; - } = null; + if (typeof sdl !== 'string' || sdl.length === 0) { + throw new Errors.CLIError('Schema seems empty'); + } - if (usesGitHubApp) { - if (!commit) { - throw new Errors.CLIError(`Couldn't resolve commit sha required for GitHub Application`); - } - if (!git.repository) { - throw new Errors.CLIError( - `Couldn't resolve git repository required for GitHub Application`, - ); - } - if (!git.pullRequestNumber) { - this.warn( - "Could not resolve pull request number. Are you running this command on a 'pull_request' event?\n" + - 'See https://the-guild.dev/graphql/hive/docs/other-integrations/ci-cd#github-workflow-for-ci', - ); - } + let github: null | { + commit: string; + repository: string | null; + pullRequestNumber: string | null; + } = null; - github = { - commit: commit, - repository: git.repository, - pullRequestNumber: git.pullRequestNumber, - }; + if (usesGitHubApp) { + if (!commit) { + throw new Errors.CLIError(`Couldn't resolve commit sha required for GitHub Application`); + } + if (!git.repository) { + throw new Errors.CLIError( + `Couldn't resolve git repository required for GitHub Application`, + ); + } + if (!git.pullRequestNumber) { + this.warn( + "Could not resolve pull request number. Are you running this command on a 'pull_request' event?\n" + + 'See https://the-guild.dev/graphql/hive/docs/other-integrations/ci-cd#github-workflow-for-ci', + ); } - const result = await this.registryApi(endpoint, accessToken).request({ + github = { + commit: commit, + repository: git.repository, + pullRequestNumber: git.pullRequestNumber, + }; + } + + const result = await this.registryApi(endpoint, accessToken) + .request({ operation: schemaCheckMutation, variables: { input: { @@ -229,68 +260,87 @@ export default class SchemaCheck extends Command { }, usesGitHubApp, }, - }); + }) + .then(_ => _.schemaCheck); - if (result.schemaCheck.__typename === 'SchemaCheckSuccess') { - const changes = result.schemaCheck.changes; - if (result.schemaCheck.initial) { - this.success('Schema registry is empty, nothing to compare your schema with.'); - } else if (!changes?.total) { - this.success('No changes'); - } else { - renderChanges.call(this, changes); - this.log(''); - } + if (result.__typename === 'SchemaCheckSuccess') { + const changes = result.changes; + if (result.initial) { + this.logSuccess('Schema registry is empty, nothing to compare your schema with.'); + } else if (!changes?.total) { + this.logSuccess('No changes'); + } else { + Fragments.SchemaChangeConnection.log.call(this, changes); + this.log(''); + } - const warnings = result.schemaCheck.warnings; - if (warnings?.total) { - renderWarnings.call(this, warnings); - this.log(''); - } + const warnings = result.warnings; + if (warnings?.total) { + Fragments.SchemaWarningConnection.log.call(this, warnings); + this.log(''); + } - if (result.schemaCheck.schemaCheck?.webUrl) { - this.log(`View full report:\n${result.schemaCheck.schemaCheck.webUrl}`); - } - } else if (result.schemaCheck.__typename === 'SchemaCheckError') { - const changes = result.schemaCheck.changes; - const errors = result.schemaCheck.errors; - const warnings = result.schemaCheck.warnings; - renderErrors.call(this, errors); + if (result.schemaCheck?.webUrl) { + this.log(`View full report:\n${result.schemaCheck.webUrl}`); + } - if (warnings?.total) { - renderWarnings.call(this, warnings); - this.log(''); - } + return this.success({ + type: 'SuccessSchemaCheck', + // breakingChanges: false, + warnings: Fragments.SchemaWarningConnection.toSchemaOutput(result.warnings), + changes: Fragments.SchemaChangeConnection.toSchemaOutput(result.changes), + url: result.schemaCheck?.webUrl ?? null, + }); + } - if (changes && changes.total) { - this.log(''); - renderChanges.call(this, changes); - } + if (result.__typename === 'SchemaCheckError') { + Fragments.SchemaErrorConnection.log.call(this, result.errors); - if (result.schemaCheck.schemaCheck?.webUrl) { - this.log(''); - this.log(`View full report:\n${result.schemaCheck.schemaCheck.webUrl}`); - } + if (result?.warnings?.total) { + Fragments.SchemaWarningConnection.log.call(this, result.warnings); + this.log(''); + } + if (result?.changes?.total) { this.log(''); + Fragments.SchemaChangeConnection.log.call(this, result.changes); + } - if (forceSafe) { - this.success('Breaking changes were expected (forced)'); - } else { - this.exit(1); - } - } else if (result.schemaCheck.__typename === 'GitHubSchemaCheckSuccess') { - this.success(result.schemaCheck.message); - } else { - this.error(result.schemaCheck.message); + if (result.schemaCheck?.webUrl) { + this.log(''); + this.log(`View full report:\n${result.schemaCheck.webUrl}`); } - } catch (error) { - if (error instanceof Errors.ExitError) { - throw error; + + this.log(''); + + if (forceSafe) { + this.logSuccess('Breaking changes were expected (forced)'); } else { - this.fail('Failed to check schema'); - this.handleFetchError(error); + process.exitCode = 1; } + + return this.success({ + type: 'FailureSchemaCheck', + warnings: Fragments.SchemaWarningConnection.toSchemaOutput(result.warnings), + changes: Fragments.SchemaChangeConnection.toSchemaOutput(result.changes), + url: result.schemaCheck?.webUrl ?? null, + }); } + + if (result.__typename === 'GitHubSchemaCheckSuccess') { + return this.success({ + type: 'SuccessSchemaCheckGitHub', + message: result.message, + }); + } + + if (result.__typename === 'GitHubSchemaCheckError') { + return this.failure({ + type: 'FailureSchemaCheckGitHub', + message: result.message, + }); + } + + throw casesExhausted(result); } } diff --git a/packages/libraries/cli/src/commands/schema/delete.ts b/packages/libraries/cli/src/commands/schema/delete.ts index 61e6c5e5ba..7ad9809ddc 100644 --- a/packages/libraries/cli/src/commands/schema/delete.ts +++ b/packages/libraries/cli/src/commands/schema/delete.ts @@ -1,8 +1,11 @@ -import { Args, Errors, Flags, ux } from '@oclif/core'; -import Command from '../../base-command'; +import { Args, Flags, ux } from '@oclif/core'; +import Command, { InferInput } from '../../base-command'; +import { Fragments } from '../../fragments/__'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; -import { renderErrors } from '../../helpers/schema'; +import { casesExhausted } from '../../helpers/general'; +import { Tex } from '../../helpers/tex/__'; +import { Output } from '../../output/__'; const schemaDeleteMutation = graphql(/* GraphQL */ ` mutation schemaDelete($input: SchemaDeleteInput!) { @@ -71,7 +74,6 @@ export default class SchemaDelete extends Command { default: false, }), }; - static args = { service: Args.string({ name: 'service' as const, @@ -80,68 +82,79 @@ export default class SchemaDelete extends Command { hidden: false, }), }; + static output = [ + Output.success('SuccessSchemaDelete', { + data: {}, + text: ({ args }: InferInput) => { + return Tex.success(`${args.service} deleted`); + }, + }), + Output.failure('FailureSchemaDelete', { + data: { + errors: Output.SchemaErrors, + }, + text: ({ args }: InferInput, data) => { + let o = ''; + o += Tex.failure(`Failed to delete ${args.service}\n`); + o += Output.SchemaErrorsText(data.errors); + return o; + }, + }), + ]; - async run() { - try { - const { flags, args } = await this.parse(SchemaDelete); - - const service: string = args.service; + async runResult() { + const { flags, args } = await this.parse(SchemaDelete); - if (!flags.confirm) { - const confirmed = await ux.confirm( - `Are you sure you want to delete "${service}" from the registry? (y/n)`, - ); + if (!flags.confirm) { + const confirmed = await ux.confirm( + `Are you sure you want to delete "${args.service}" from the registry? (y/n)`, + ); - if (!confirmed) { - this.info('Aborting'); - this.exit(0); - } + if (!confirmed) { + this.logInfo('Aborting'); + this.exit(0); } + } - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - }); + const endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + }); + const accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + }); - const result = await this.registryApi(endpoint, accessToken).request({ + const result = await this.registryApi(endpoint, accessToken) + .request({ operation: schemaDeleteMutation, variables: { input: { - serviceName: service, + serviceName: args.service, dryRun: flags.dryRun, }, }, - }); - - if (result.schemaDelete.__typename === 'SchemaDeleteSuccess') { - this.success(`${service} deleted`); - this.exit(0); - return; - } + }) + .then(_ => _.schemaDelete); - this.fail(`Failed to delete ${service}`); - const errors = result.schemaDelete.errors; + if (result.__typename === 'SchemaDeleteSuccess') { + return this.success({ + type: 'SuccessSchemaDelete', + }); + } - if (errors) { - renderErrors.call(this, errors); - this.exit(1); - } - } catch (error) { - if (error instanceof Errors.ExitError) { - throw error; - } else { - this.fail(`Failed to complete`); - this.handleFetchError(error); - } + if (result.__typename === 'SchemaDeleteError') { + return this.failure({ + type: 'FailureSchemaDelete', + errors: Fragments.SchemaErrorConnection.toSchemaOutput(result.errors), + }); } + + throw casesExhausted(result); } } diff --git a/packages/libraries/cli/src/commands/schema/fetch.ts b/packages/libraries/cli/src/commands/schema/fetch.ts index a2e729d9ac..401976cee5 100644 --- a/packages/libraries/cli/src/commands/schema/fetch.ts +++ b/packages/libraries/cli/src/commands/schema/fetch.ts @@ -1,9 +1,11 @@ import { writeFile } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; import { Args, Flags } from '@oclif/core'; -import Command from '../../base-command'; +import Command, { InferInput } from '../../base-command'; import { graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; +import { Tex } from '../../helpers/tex/__'; +import { Output } from '../../output/__'; const SchemaVersionForActionIdQuery = graphql(/* GraphQL */ ` query SchemaVersionForActionId( @@ -57,7 +59,6 @@ export default class SchemaFetch extends Command { description: 'whether to write to a file instead of stdout', }), }; - static args = { actionId: Args.string({ name: 'actionId' as const, @@ -66,8 +67,30 @@ export default class SchemaFetch extends Command { hidden: false, }), }; + static output = [ + Output.failure('FailureSchemaFetchMissingSchema', { + data: {}, + text: ({ args }: InferInput) => { + return Tex.failure(`No schema found for action id ${args.actionId}`); + }, + }), + Output.failure('FailureSchemaFetchInvalidSchema', { + data: {}, + text: ({ args }: InferInput) => { + return Tex.failure(`Schema is invalid for action id ${args.actionId}`); + }, + }), + Output.failure('FailureSchemaFetchMissingSDLType', { + data: {}, + text: ({ args, flags }: InferInput) => { + return Tex.failure(`No ${flags.type} found for action id ${args.actionId}`); + }, + }), + Output.SuccessOutputFile, + Output.SuccessOutputStdout, + ]; - async run() { + async runResult() { const { flags, args } = await this.parse(SchemaFetch); const endpoint = this.ensure({ @@ -85,8 +108,6 @@ export default class SchemaFetch extends Command { env: 'HIVE_TOKEN', }); - const actionId: string = args.actionId; - const sdlType = this.ensure({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -97,28 +118,35 @@ export default class SchemaFetch extends Command { defaultValue: 'sdl', }); - const result = await this.registryApi(endpoint, accessToken).request({ - operation: SchemaVersionForActionIdQuery, - variables: { - actionId, - includeSDL: sdlType === 'sdl', - includeSupergraph: sdlType === 'supergraph', - }, - }); + const result = await this.registryApi(endpoint, accessToken) + .request({ + operation: SchemaVersionForActionIdQuery, + variables: { + actionId: args.actionId, + includeSDL: sdlType === 'sdl', + includeSupergraph: sdlType === 'supergraph', + }, + }) + .then(_ => _.schemaVersionForActionId); - if (result.schemaVersionForActionId == null) { - return this.error(`No schema found for action id ${actionId}`); + if (result == null) { + return this.failure({ + type: 'FailureSchemaFetchMissingSchema', + }); } - if (result.schemaVersionForActionId.valid === false) { - return this.error(`Schema is invalid for action id ${actionId}`); + if (result.valid === false) { + return this.failure({ + type: 'FailureSchemaFetchInvalidSchema', + }); } - const schema = - result.schemaVersionForActionId.sdl ?? result.schemaVersionForActionId.supergraph; + const schema = result.sdl ?? result.supergraph; if (schema == null) { - return this.error(`No ${sdlType} found for action id ${actionId}`); + return this.failure({ + type: 'FailureSchemaFetchMissingSDLType', + }); } if (flags.write) { @@ -131,11 +159,19 @@ export default class SchemaFetch extends Command { await writeFile(filepath, schema, 'utf8'); break; default: - this.fail(`Unsupported file extension ${extname(flags.write)}`); + this.logFailure(`Unsupported file extension ${extname(flags.write)}`); this.exit(1); } - return; + return this.success({ + type: 'SuccessOutputFile', + path: filepath, + bytes: schema.length, + }); } - this.log(schema); + + return this.success({ + type: 'SuccessOutputStdout', + content: schema, + }); } } diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index 108f6260a0..507b532316 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -2,12 +2,17 @@ import { existsSync, readFileSync } from 'fs'; import { GraphQLError, print } from 'graphql'; import { transformCommentsToDescriptions } from '@graphql-tools/utils'; import { Args, Errors, Flags } from '@oclif/core'; -import Command from '../../base-command'; +import Command, { InferInput } from '../../base-command'; +import { Fragments } from '../../fragments/__'; import { DocumentType, graphql } from '../../gql'; import { graphqlEndpoint } from '../../helpers/config'; +import { casesExhausted } from '../../helpers/general'; import { gitInfo } from '../../helpers/git'; -import { loadSchema, minifySchema, renderChanges, renderErrors } from '../../helpers/schema'; +import { loadSchema, minifySchema } from '../../helpers/schema'; +import { Tex } from '../../helpers/tex/__'; +import { tb } from '../../helpers/typebox/__'; import { invariant } from '../../helpers/validation'; +import { Output } from '../../output/__'; const schemaPublishMutation = graphql(/* GraphQL */ ` mutation schemaPublish($input: SchemaPublishInput!, $usesGitHubApp: Boolean!) { @@ -132,7 +137,6 @@ export default class SchemaPublish extends Command { multiple: true, }), }; - static args = { file: Args.string({ name: 'file', @@ -141,6 +145,87 @@ export default class SchemaPublish extends Command { hidden: false, }), }; + static output = [ + Output.success('SuccessSchemaPublish', { + data: { + isInitial: tb.Boolean(), + message: tb.Nullable(tb.String()), + changes: tb.Array(Output.SchemaChange), + url: tb.Nullable(tb.String({ format: 'uri' })), + }, + text: (_, data) => { + let o = ''; + if (data.isInitial) { + o += Tex.success('Published initial schema.\n'); + } else if (data.message) { + o += Tex.success(data.message + '\n'); + } else if (data.changes && data.changes.length === 0) { + o += Tex.success('No changes. Skipping.\n'); + } else { + if (data.changes.length) { + o += Output.SchemaChangesText(data.changes); + } + o += Tex.success('Schema published\n'); + } + if (data.url) { + o += Tex.info(`Available at ${data.url}\n`); + } + return o; + }, + }), + Output.success('FailureSchemaPublish', { + data: { + changes: Output.SchemaChanges, + errors: Output.SchemaErrors, + url: tb.Nullable(tb.String({ format: 'uri' })), + }, + text: ({ flags }: InferInput, data) => { + let o = ''; + o += Output.SchemaErrorsText(data.errors); + o += '\n'; + if (data.changes.length) { + o += Output.SchemaChangesText(data.changes); + o += '\n'; + } + if (!flags.force) { + o += Tex.failure('Failed to publish schema\n'); + } else { + o += Tex.success('Schema published (forced)\n'); + } + if (data.url) { + o += Tex.info(`Available at ${data.url}\n`); + } + return o; + }, + }), + Output.success('SuccessSchemaPublishGitHub', { + data: { + message: tb.String(), + }, + text: (_, data) => { + return Tex.success(data.message); + }, + }), + Output.failure('FailureSchemaPublishGitHub', { + data: { + message: tb.String(), + }, + text: (_, data) => { + return Tex.failure(data.message); + }, + }), + Output.failure('FailureSchemaPublishInvalidGraphQLSchema', { + data: { + message: tb.String(), + locations: tb.Array( + tb.Object({ + line: tb.Readonly(tb.Number()), + column: tb.Readonly(tb.Number()), + }), + ), + }, + }), + ]; resolveMetadata(metadata: string | undefined): string | undefined { if (!metadata) { @@ -157,7 +242,7 @@ export default class SchemaPublish extends Command { const exists = existsSync(metadata); if (!exists) { - throw new Error( + throw new Errors.CLIError( `Failed to load metadata from "${metadata}": Please specify a path to an existing file, or a string with valid JSON.`, ); } @@ -168,111 +253,116 @@ export default class SchemaPublish extends Command { return fileContent; } catch (e) { - throw new Error( + throw new Errors.CLIError( `Failed to load metadata from file "${metadata}": Please make sure the file is readable and contains a valid JSON`, ); } } } - async run() { - try { - const { flags, args } = await this.parse(SchemaPublish); + async runResult() { + const { flags, args } = await this.parse(SchemaPublish); - await this.require(flags); + await this.require(flags); - const endpoint = this.ensure({ - key: 'registry.endpoint', - args: flags, - legacyFlagName: 'registry', - defaultValue: graphqlEndpoint, - env: 'HIVE_REGISTRY', - }); - const accessToken = this.ensure({ - key: 'registry.accessToken', - args: flags, - legacyFlagName: 'token', - env: 'HIVE_TOKEN', - }); - const service = flags.service; - const url = flags.url; - const file = args.file; - const force = flags.force; - const experimental_acceptBreakingChanges = flags.experimental_acceptBreakingChanges; - const metadata = this.resolveMetadata(flags.metadata); - const usesGitHubApp = flags.github; - - let commit: string | undefined | null = this.maybe({ - key: 'commit', - args: flags, - env: 'HIVE_COMMIT', - }); - let author: string | undefined | null = this.maybe({ - key: 'author', - args: flags, - env: 'HIVE_AUTHOR', - }); + const endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + }); + const accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + }); + const service = flags.service; + const url = flags.url; + const file = args.file; + const experimental_acceptBreakingChanges = flags.experimental_acceptBreakingChanges; + const metadata = this.resolveMetadata(flags.metadata); + const usesGitHubApp = flags.github; - let gitHub: null | { - repository: string; - commit: string; - } = null; + let commit: string | undefined | null = this.maybe({ + key: 'commit', + args: flags, + env: 'HIVE_COMMIT', + }); + let author: string | undefined | null = this.maybe({ + key: 'author', + args: flags, + env: 'HIVE_AUTHOR', + }); - if (!commit || !author) { - const git = await gitInfo(() => { - this.warn(`No git information found. Couldn't resolve author and commit.`); - }); + let gitHub: null | { + repository: string; + commit: string; + } = null; - if (!commit) { - commit = git.commit; - } + if (!commit || !author) { + const git = await gitInfo(() => { + this.warn(`No git information found. Couldn't resolve author and commit.`); + }); - if (!author) { - author = git.author; - } + if (!commit) { + commit = git.commit; } if (!author) { - throw new Errors.CLIError(`Missing "author"`); + author = git.author; } + } - if (!commit) { - throw new Errors.CLIError(`Missing "commit"`); - } + if (!author) { + throw new Errors.CLIError(`Missing "author"`); + } - if (usesGitHubApp) { - // eslint-disable-next-line no-process-env - const repository = process.env['GITHUB_REPOSITORY'] ?? null; - if (!repository) { - throw new Errors.CLIError(`Missing "GITHUB_REPOSITORY" environment variable.`); - } - gitHub = { - repository, - commit, - }; + if (!commit) { + throw new Errors.CLIError(`Missing "commit"`); + } + + if (usesGitHubApp) { + // eslint-disable-next-line no-process-env + const repository = process.env['GITHUB_REPOSITORY'] ?? null; + if (!repository) { + throw new Errors.CLIError(`Missing "GITHUB_REPOSITORY" environment variable.`); } + gitHub = { + repository, + commit, + }; + } - let sdl: string; - try { - const rawSdl = await loadSchema(file); - invariant(typeof rawSdl === 'string' && rawSdl.length > 0, 'Schema seems empty'); - const transformedSDL = print(transformCommentsToDescriptions(rawSdl)); - sdl = minifySchema(transformedSDL); - } catch (err) { - if (err instanceof GraphQLError) { - const location = err.locations?.[0]; - const locationString = location - ? ` at line ${location.line}, column ${location.column}` + let sdl: string; + try { + const rawSdl = await loadSchema(file); + invariant(typeof rawSdl === 'string' && rawSdl.length > 0, 'Schema seems empty'); + const transformedSDL = print(transformCommentsToDescriptions(rawSdl)); + sdl = minifySchema(transformedSDL); + } catch (err) { + if (err instanceof GraphQLError) { + const locations = err.locations?.map(location => ({ ...location })) ?? []; + const locationString = + locations.length > 0 + ? ` at line ${locations[0].line}, column ${locations[0].column}` : ''; - throw new Error(`The SDL is not valid${locationString}:\n ${err.message}`); - } - throw err; + this.logFailure(`The SDL is not valid${locationString}:\n ${err.message}`); + return this.failure({ + type: 'FailureSchemaPublishInvalidGraphQLSchema', + message: err.message, + locations, + }); } + throw err; + } - let result: DocumentType | null = null; + let result: DocumentType['schemaPublish'] | null = null; - do { - result = await this.registryApi(endpoint, accessToken).request({ + do { + const loopResult = await this.registryApi(endpoint, accessToken) + .request({ operation: schemaPublishMutation, variables: { input: { @@ -281,7 +371,7 @@ export default class SchemaPublish extends Command { author, commit, sdl, - force, + force: flags.force, experimental_acceptBreakingChanges: experimental_acceptBreakingChanges === true, metadata, gitHub, @@ -291,77 +381,68 @@ export default class SchemaPublish extends Command { }, /** Gateway timeout is 60 seconds. */ timeout: 55_000, - }); - - if (result.schemaPublish.__typename === 'SchemaPublishSuccess') { - const changes = result.schemaPublish.changes; - - if (result.schemaPublish.initial) { - this.success('Published initial schema.'); - } else if (result.schemaPublish.successMessage) { - this.success(result.schemaPublish.successMessage); - } else if (changes && changes.total === 0) { - this.success('No changes. Skipping.'); - } else { - if (changes) { - renderChanges.call(this, changes); - } - this.success('Schema published'); - } + }) + .then(data => data.schemaPublish); - if (result.schemaPublish.linkToWebsite) { - this.info(`Available at ${result.schemaPublish.linkToWebsite}`); - } - } else if (result.schemaPublish.__typename === 'SchemaPublishRetry') { - this.log(result.schemaPublish.reason); + if (loopResult) { + if (loopResult.__typename === 'SchemaPublishRetry') { + this.log(loopResult.reason); this.log('Waiting for other schema publishes to complete...'); result = null; - } else if (result.schemaPublish.__typename === 'SchemaPublishMissingServiceError') { - this.fail( - `${result.schemaPublish.missingServiceError} Please use the '--service ' parameter.`, - ); - this.exit(1); - } else if (result.schemaPublish.__typename === 'SchemaPublishMissingUrlError') { - this.fail( - `${result.schemaPublish.missingUrlError} Please use the '--url ' parameter.`, - ); - this.exit(1); - } else if (result.schemaPublish.__typename === 'SchemaPublishError') { - const changes = result.schemaPublish.changes; - const errors = result.schemaPublish.errors; - renderErrors.call(this, errors); + continue; + } - if (changes && changes.total) { - this.log(''); - renderChanges.call(this, changes); - } - this.log(''); + result = loopResult; + } + } while (result === null); - if (!force) { - this.fail('Failed to publish schema'); - this.exit(1); - } else { - this.success('Schema published (forced)'); - } + if (result.__typename === 'SchemaPublishSuccess') { + return this.success({ + type: 'SuccessSchemaPublish', + isInitial: result.initial, + message: result.successMessage ?? null, + changes: Fragments.SchemaChangeConnection.toSchemaOutput(result.changes), + url: result.linkToWebsite ?? null, + }); + } - if (result.schemaPublish.linkToWebsite) { - this.info(`Available at ${result.schemaPublish.linkToWebsite}`); - } - } else if (result.schemaPublish.__typename === 'GitHubSchemaPublishSuccess') { - this.success(result.schemaPublish.message); - } else { - this.error( - 'message' in result.schemaPublish ? result.schemaPublish.message : 'Unknown error', - ); - } - } while (result === null); - } catch (error) { - if (error instanceof Errors.ExitError) { - throw error; - } else { - this.fail('Failed to publish schema'); - this.handleFetchError(error); + if (result.__typename === 'SchemaPublishMissingServiceError') { + this.logFailure(`${result.missingServiceError} Please use the '--service ' parameter.`); + this.exit(1); + } + + if (result.__typename === 'SchemaPublishMissingUrlError') { + this.logFailure(`${result.missingUrlError} Please use the '--url ' parameter.`); + this.exit(1); + } + + if (result.__typename === 'SchemaPublishError') { + if (!flags.force) { + process.exitCode = 1; } + return this.success({ + type: 'FailureSchemaPublish', + changes: Fragments.SchemaChangeConnection.toSchemaOutput(result.changes), + errors: Fragments.SchemaErrorConnection.toSchemaOutput(result.errors), + url: result.linkToWebsite ?? null, + }); + } + + if (result.__typename === 'GitHubSchemaPublishSuccess') { + return this.success({ + type: 'SuccessSchemaPublishGitHub', + message: result.message, + }); } + + if (result.__typename === 'GitHubSchemaPublishError') { + return this.failure({ + type: 'FailureSchemaPublishGitHub', + // todo: Why property check? Types suggest it is always there. + message: 'message' in result ? result.message : 'Unknown error', + }); + } + + throw casesExhausted(result); } } diff --git a/packages/libraries/cli/src/commands/whoami.ts b/packages/libraries/cli/src/commands/whoami.ts index 3ada6a5ed0..65bb24e95a 100644 --- a/packages/libraries/cli/src/commands/whoami.ts +++ b/packages/libraries/cli/src/commands/whoami.ts @@ -3,6 +3,9 @@ import { Flags } from '@oclif/core'; import Command from '../base-command'; import { graphql } from '../gql'; import { graphqlEndpoint } from '../helpers/config'; +import { casesExhausted } from '../helpers/general'; +import { tb } from '../helpers/typebox/__'; +import { Output } from '../output/__'; const myTokenInfoQuery = graphql(/* GraphQL */ ` query myTokenInfo { @@ -32,7 +35,7 @@ const myTokenInfoQuery = graphql(/* GraphQL */ ` } `); -export default class WhoAmI extends Command { +export default class Whoami extends Command { static description = 'shows information about the current token'; static flags = { 'registry.endpoint': Flags.string({ @@ -58,10 +61,39 @@ export default class WhoAmI extends Command { }, }), }; + static output = [ + Output.success('SuccessWhoami', { + data: { + token: tb.Object({ + name: tb.String(), + }), + organization: tb.Object({ + slug: tb.String(), + }), + project: tb.Object({ + type: tb.String(), + slug: tb.String(), + }), + target: tb.Object({ + slug: tb.String(), + }), + authorization: tb.Object({ + schema: tb.Object({ + publish: tb.Boolean(), + check: tb.Boolean(), + }), + }), + }, + }), + Output.failure('FailureWhoamiTokenNotFound', { + data: { + message: tb.String(), + }, + }), + ]; - async run() { - const { flags } = await this.parse(WhoAmI); - + async runResult() { + const { flags } = await this.parse(Whoami); const registry = this.ensure({ key: 'registry.endpoint', legacyFlagName: 'registry', @@ -80,13 +112,10 @@ export default class WhoAmI extends Command { .request({ operation: myTokenInfoQuery, }) - .catch(error => { - this.handleFetchError(error); - }); + .then(_ => _.tokenInfo); - if (result.tokenInfo.__typename === 'TokenInfo') { - const { tokenInfo } = result; - const { organization, project, target } = tokenInfo; + if (result.__typename === 'TokenInfo') { + const { organization, project, target } = result; const organizationUrl = `https://app.graphql-hive.com/${organization.slug}`; const projectUrl = `${organizationUrl}/${project.slug}`; @@ -98,23 +127,56 @@ export default class WhoAmI extends Command { }; const print = createPrinter({ - 'Token name:': [colors.bold(tokenInfo.token.name)], + 'Token name:': [colors.bold(result.token.name)], ' ': [''], 'Organization:': [colors.bold(organization.slug), colors.dim(organizationUrl)], 'Project:': [colors.bold(project.slug), colors.dim(projectUrl)], 'Target:': [colors.bold(target.slug), colors.dim(targetUrl)], ' ': [''], - 'Access to schema:publish': [tokenInfo.canPublishSchema ? access.yes : access.not], - 'Access to schema:check': [tokenInfo.canCheckSchema ? access.yes : access.not], + 'Access to schema:publish': [result.canPublishSchema ? access.yes : access.not], + 'Access to schema:check': [result.canCheckSchema ? access.yes : access.not], }); this.log(print()); - } else if (result.tokenInfo.__typename === 'TokenNotFoundError') { - this.error(`Token not found. Reason: ${result.tokenInfo.message}`, { - exit: 0, - suggestions: [`How to create a token? https://docs.graphql-hive.com/features/tokens`], + + return this.success({ + type: 'SuccessWhoami', + token: { + name: result.token.name, + }, + organization: { + slug: organization.slug, + }, + project: { + type: project.type, + slug: project.slug, + }, + target: { + slug: target.slug, + }, + authorization: { + schema: { + publish: result.canPublishSchema, + check: result.canCheckSchema, + }, + }, }); } + + if (result.__typename === 'TokenNotFoundError') { + process.exitCode = 0; + return this.failureEnvelope({ + suggestions: [ + `Not sure how to create a token? Learn more at https://docs.graphql-hive.com/features/tokens.`, + ], + data: { + type: 'FailureWhoamiTokenNotFound', + message: result.message, + }, + }); + } + + throw casesExhausted(result); } } diff --git a/packages/libraries/cli/src/fragments/_.ts b/packages/libraries/cli/src/fragments/_.ts new file mode 100644 index 0000000000..9d19f70ccc --- /dev/null +++ b/packages/libraries/cli/src/fragments/_.ts @@ -0,0 +1,3 @@ +export * from './schema-change-connection'; +export * from './schema-error-connection'; +export * from './schema-warning-connection'; diff --git a/packages/libraries/cli/src/fragments/__.ts b/packages/libraries/cli/src/fragments/__.ts new file mode 100644 index 0000000000..66fb84fef5 --- /dev/null +++ b/packages/libraries/cli/src/fragments/__.ts @@ -0,0 +1 @@ +export * as Fragments from './_'; diff --git a/packages/libraries/cli/src/fragments/schema-change-connection.ts b/packages/libraries/cli/src/fragments/schema-change-connection.ts new file mode 100644 index 0000000000..79dd7e6998 --- /dev/null +++ b/packages/libraries/cli/src/fragments/schema-change-connection.ts @@ -0,0 +1,105 @@ +import colors from 'colors'; +import type { ResultOf } from '@graphql-typed-document-node/core'; +import BaseCommand from '../base-command'; +import { FragmentType, graphql, useFragment } from '../gql'; +import { CriticalityLevel } from '../gql/graphql'; +import { Tex } from '../helpers/tex/__'; +import { indent } from '../helpers/tex/tex'; +import * as SchemaOutput from '../output/data'; + +const fragment = graphql(` + fragment RenderChanges_schemaChanges on SchemaChangeConnection { + total + nodes { + criticality + isSafeBasedOnUsage + message(withSafeBasedOnUsageNote: false) + approval { + approvedBy { + displayName + } + } + } + } +`); + +type Mask = FragmentType; + +type SchemaChangeConnection = ResultOf; + +type SchemaChange = SchemaChangeConnection['nodes'][number]; + +export namespace SchemaChangeConnection { + export function log(this: BaseCommand, mask: Mask) { + const schemaChanges = useFragment(fragment, mask); + + const writeChanges = (schemaChanges: SchemaChange[]) => { + schemaChanges.forEach(change => { + const messageParts = [ + String(indent), + criticalityMap[change.isSafeBasedOnUsage ? CriticalityLevel.Safe : change.criticality], + Tex.bolderize(change.message), + ]; + + if (change.isSafeBasedOnUsage) { + messageParts.push(colors.green('(Safe based on usage ✓)')); + } + if (change.approval) { + messageParts.push( + colors.green( + `(Approved by ${change.approval.approvedBy?.displayName ?? ''} ✓)`, + ), + ); + } + + this.log(...messageParts); + }); + }; + + this.logInfo(`Detected ${schemaChanges.total} change${schemaChanges.total > 1 ? 's' : ''}`); + this.log(''); + + const breakingChanges = schemaChanges.nodes.filter( + change => change.criticality === CriticalityLevel.Breaking, + ); + const safeChanges = schemaChanges.nodes.filter( + change => change.criticality !== CriticalityLevel.Breaking, + ); + + if (breakingChanges.length) { + this.log(String(indent), `Breaking changes:`); + writeChanges(breakingChanges); + } + + if (safeChanges.length) { + this.log(String(indent), `Safe changes:`); + writeChanges(safeChanges); + } + } + + export const toSchemaOutput = (mask: undefined | null | Mask): SchemaOutput.SchemaChange[] => { + const changes = useFragment(fragment, mask); + return ( + changes?.nodes.map(_ => ({ + message: _.message, + criticality: _.criticality, + isSafeBasedOnUsage: _.isSafeBasedOnUsage, + approval: _.approval + ? { + by: _.approval.approvedBy + ? { + displayName: _.approval.approvedBy.displayName, + } + : null, + } + : null, + })) ?? [] + ); + }; +} + +const criticalityMap: Record = { + [CriticalityLevel.Breaking]: colors.red('-'), + [CriticalityLevel.Safe]: colors.green('-'), + [CriticalityLevel.Dangerous]: colors.green('-'), +}; diff --git a/packages/libraries/cli/src/fragments/schema-error-connection.ts b/packages/libraries/cli/src/fragments/schema-error-connection.ts new file mode 100644 index 0000000000..de98137e5a --- /dev/null +++ b/packages/libraries/cli/src/fragments/schema-error-connection.ts @@ -0,0 +1,21 @@ +import BaseCommand from '../base-command'; +import { SchemaHive } from '../helpers/schema'; +import { Tex } from '../helpers/tex/__'; + +export namespace SchemaErrorConnection { + export function log(this: BaseCommand, errors: SchemaHive.SchemaErrorConnection) { + this.logFailure(`Detected ${errors.total} error${errors.total > 1 ? 's' : ''}`); + this.log(''); + + errors.nodes.forEach(error => { + this.log(Tex.indent, Tex.colors.red('-'), Tex.bolderize(error.message)); + }); + } + export const toSchemaOutput = (errors: SchemaHive.SchemaErrorConnection) => { + return errors.nodes.map(error => { + return { + message: error.message, + }; + }); + }; +} diff --git a/packages/libraries/cli/src/fragments/schema-warning-connection.ts b/packages/libraries/cli/src/fragments/schema-warning-connection.ts new file mode 100644 index 0000000000..0a6f3c1558 --- /dev/null +++ b/packages/libraries/cli/src/fragments/schema-warning-connection.ts @@ -0,0 +1,33 @@ +import BaseCommand from '../base-command'; +import { SchemaHive } from '../helpers/schema'; +import { Tex } from '../helpers/tex/__'; +import { Output } from '../output/__'; + +export namespace SchemaWarningConnection { + export function log(this: BaseCommand, warnings: SchemaHive.SchemaWarningConnection) { + this.log(''); + this.logWarning(`Detected ${warnings.total} warning${warnings.total > 1 ? 's' : ''}`); + this.log(''); + + warnings.nodes.forEach(warning => { + const details = [warning.source ? `source: ${Tex.bolderize(warning.source)}` : undefined] + .filter(Boolean) + .join(', '); + + this.log(Tex.indent, `- ${Tex.bolderize(warning.message)}${details ? ` (${details})` : ''}`); + }); + } + + export const toSchemaOutput = ( + warnings: undefined | null | SchemaHive.SchemaWarningConnection, + ): Output.SchemaWarning[] => { + return ( + warnings?.nodes.map(_ => ({ + message: _.message, + source: _.source ?? null, + line: _.line ?? null, + column: _.column ?? null, + })) ?? [] + ); + }; +} diff --git a/packages/libraries/cli/src/helpers/config.ts b/packages/libraries/cli/src/helpers/config.ts index 2f53dabc1b..7c1ac79a26 100644 --- a/packages/libraries/cli/src/helpers/config.ts +++ b/packages/libraries/cli/src/helpers/config.ts @@ -1,35 +1,35 @@ import fs from 'fs'; import path from 'path'; import { sync as mkdirp } from 'mkdirp'; -import * as zod from 'zod'; +import { z } from 'zod'; -const LegacyConfigModel = zod.object({ - registry: zod.string().optional(), - token: zod.string().optional(), +const LegacyConfigModel = z.object({ + registry: z.string().optional(), + token: z.string().optional(), }); -const ConfigModel = zod.object({ - registry: zod +const ConfigModel = z.object({ + registry: z .object({ - endpoint: zod.string().url().optional(), - accessToken: zod.string().optional(), + endpoint: z.string().url().optional(), + accessToken: z.string().optional(), }) .optional(), - cdn: zod + cdn: z .object({ - endpoint: zod.string().url().optional(), - accessToken: zod.string().optional(), + endpoint: z.string().url().optional(), + accessToken: z.string().optional(), }) .optional(), }); -const getAllowedConfigKeys = >( +const getAllowedConfigKeys = >( config: TConfig, ): Set> => { const keys = new Set>(); - const traverse = (obj: zod.ZodObject>, path: Array = []): string => { - if (obj instanceof zod.ZodObject) { + const traverse = (obj: z.ZodObject>, path: Array = []): string => { + if (obj instanceof z.ZodObject) { const shape = obj.shape; for (const [key, value] of Object.entries(shape)) { traverse(value, [...path, key]); @@ -46,22 +46,22 @@ const getAllowedConfigKeys = >( return keys; }; -export type ConfigModelType = zod.TypeOf; +export type ConfigModelType = z.TypeOf; -type BuildPropertyPath> = `.${GetConfigurationKeys}`; +type BuildPropertyPath> = `.${GetConfigurationKeys}`; type GetConfigurationKeys< - T extends zod.ZodObject<{ - [key: string]: zod.ZodType; + T extends z.ZodObject<{ + [key: string]: z.ZodType; }>, > = - T extends zod.ZodObject + T extends z.ZodObject ? TObjectShape extends Record ? TKey extends string - ? `${TKey}${TObjectPropertyType extends zod.ZodObject + ? `${TKey}${TObjectPropertyType extends z.ZodObject ? BuildPropertyPath - : TObjectPropertyType extends zod.ZodOptional - ? TOptionalInnerObjectPropertyType extends zod.ZodObject + : TObjectPropertyType extends z.ZodOptional + ? TOptionalInnerObjectPropertyType extends z.ZodObject ? BuildPropertyPath : '' : ''}` @@ -71,19 +71,19 @@ type GetConfigurationKeys< type GetZodValueType< TString extends string, - ConfigurationModelType extends zod.ZodObject, + ConfigurationModelType extends z.ZodObject, > = TString extends `${infer TKey}.${infer TNextKey}` - ? ConfigurationModelType extends zod.ZodObject - ? InnerType[TKey] extends zod.ZodObject + ? ConfigurationModelType extends z.ZodObject + ? InnerType[TKey] extends z.ZodObject ? GetZodValueType - : InnerType[TKey] extends zod.ZodOptional - ? OptionalInner extends zod.ZodObject + : InnerType[TKey] extends z.ZodOptional + ? OptionalInner extends z.ZodObject ? GetZodValueType : never : never : never - : ConfigurationModelType extends zod.ZodObject - ? zod.TypeOf + : ConfigurationModelType extends z.ZodObject + ? z.TypeOf : never; export type GetConfigurationValueType = GetZodValueType< diff --git a/packages/libraries/cli/src/helpers/errors/_.ts b/packages/libraries/cli/src/helpers/errors/_.ts new file mode 100644 index 0000000000..ff6741cd1e --- /dev/null +++ b/packages/libraries/cli/src/helpers/errors/_.ts @@ -0,0 +1,12 @@ +export { CommandError } from '@oclif/core/lib/interfaces'; + +export { + CLIError, + RequiredArgsError, + FailedFlagValidationError, + ArgInvalidOptionError, +} from '@oclif/core/lib/parser/errors'; + +export * from './cli-error-with-data'; + +export * from './client-error'; diff --git a/packages/libraries/cli/src/helpers/errors/__.ts b/packages/libraries/cli/src/helpers/errors/__.ts new file mode 100644 index 0000000000..4c5a4cfd86 --- /dev/null +++ b/packages/libraries/cli/src/helpers/errors/__.ts @@ -0,0 +1 @@ +export * as Errors from './_'; diff --git a/packages/libraries/cli/src/helpers/errors/cli-error-with-data.ts b/packages/libraries/cli/src/helpers/errors/cli-error-with-data.ts new file mode 100644 index 0000000000..ad6c377904 --- /dev/null +++ b/packages/libraries/cli/src/helpers/errors/cli-error-with-data.ts @@ -0,0 +1,27 @@ +import { Errors } from '@oclif/core'; +import { Output } from '../../output/__'; + +export class CLIErrorWithData extends Errors.CLIError { + public envelope: Output.FailureGeneric; + constructor(args: { + message: string; + exitCode?: number; + code?: string; + ref?: string | undefined; + suggestions?: string[]; + data?: Partial['data']; + }) { + const envelope = { + ...Output.failureDefaults, + data: args.data ?? {}, + }; + super(args.message, { + exit: args.exitCode, + message: args.message, + code: args.code, + ref: args.ref, + suggestions: args.suggestions, + }); + this.envelope = envelope; + } +} diff --git a/packages/libraries/cli/src/helpers/errors/client-error.ts b/packages/libraries/cli/src/helpers/errors/client-error.ts new file mode 100644 index 0000000000..abd5f6fbe1 --- /dev/null +++ b/packages/libraries/cli/src/helpers/errors/client-error.ts @@ -0,0 +1,13 @@ +import { GraphQLError } from 'graphql'; + +export class ClientError extends Error { + constructor( + message: string, + public response: { + errors?: readonly GraphQLError[]; + headers: Headers; + }, + ) { + super(message); + } +} diff --git a/packages/libraries/cli/src/helpers/general.ts b/packages/libraries/cli/src/helpers/general.ts new file mode 100644 index 0000000000..27231b3a3a --- /dev/null +++ b/packages/libraries/cli/src/helpers/general.ts @@ -0,0 +1,42 @@ +/** + * This module is for assorted "standard library" like functions and types each of + * which are to simple or incomplete to justify factoring out to a dedicated module. + */ + +/** + * This code should never be reached. + */ +export const casesExhausted = (value: never): never => { + throw new Error(`Unhandled case: ${String(value)}`); +}; + +export type OmitNever = { [K in keyof T as T[K] extends never ? never : K]: T[K] }; + +export type OptionalizePropertyUnsafe<$Object extends object, $Key extends PropertyKey> = Omit< + $Object, + $Key +> & { + [_ in keyof $Object as _ extends $Key ? _ : never]?: $Object[_]; +}; + +export type Simplify = { + [K in keyof T]: T[K]; +}; + +export const toSnakeCase = (str: string): string => { + return ( + str + // Handle camelCase to snake_case + .replace(/([a-z])([A-Z])/g, '$1_$2') + // Handle PascalCase to snake_case + .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') + // Replace spaces and hyphens with underscores + .replace(/[\s-]+/g, '_') + // Convert to lowercase + .toLowerCase() + ); +}; + +export const uncapitalize = <$String extends string>(str: $String): Uncapitalize<$String> => { + return (str.charAt(0).toLowerCase() + str.slice(1)) as Uncapitalize<$String>; +}; diff --git a/packages/libraries/cli/src/helpers/schema.ts b/packages/libraries/cli/src/helpers/schema.ts index 41f4d9fc09..967ae6bf77 100644 --- a/packages/libraries/cli/src/helpers/schema.ts +++ b/packages/libraries/cli/src/helpers/schema.ts @@ -1,109 +1,11 @@ -import colors from 'colors'; import { concatAST, print } from 'graphql'; import { CodeFileLoader } from '@graphql-tools/code-file-loader'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { JsonFileLoader } from '@graphql-tools/json-file-loader'; import { loadTypedefs } from '@graphql-tools/load'; import { UrlLoader } from '@graphql-tools/url-loader'; -import BaseCommand from '../base-command'; -import { FragmentType, graphql, useFragment as unmaskFragment } from '../gql'; -import { CriticalityLevel, SchemaErrorConnection, SchemaWarningConnection } from '../gql/graphql'; -const indent = ' '; - -const criticalityMap: Record = { - [CriticalityLevel.Breaking]: colors.red('-'), - [CriticalityLevel.Safe]: colors.green('-'), - [CriticalityLevel.Dangerous]: colors.green('-'), -}; - -export function renderErrors(this: BaseCommand, errors: SchemaErrorConnection) { - this.fail(`Detected ${errors.total} error${errors.total > 1 ? 's' : ''}`); - this.log(''); - - errors.nodes.forEach(error => { - this.log(String(indent), colors.red('-'), this.bolderize(error.message)); - }); -} - -const RenderChanges_SchemaChanges = graphql(` - fragment RenderChanges_schemaChanges on SchemaChangeConnection { - total - nodes { - criticality - isSafeBasedOnUsage - message(withSafeBasedOnUsageNote: false) - approval { - approvedBy { - displayName - } - } - } - } -`); - -export function renderChanges( - this: BaseCommand, - maskedChanges: FragmentType, -) { - const changes = unmaskFragment(RenderChanges_SchemaChanges, maskedChanges); - type ChangeType = (typeof changes)['nodes'][number]; - - const writeChanges = (changes: ChangeType[]) => { - changes.forEach(change => { - const messageParts = [ - String(indent), - criticalityMap[change.isSafeBasedOnUsage ? CriticalityLevel.Safe : change.criticality], - this.bolderize(change.message), - ]; - - if (change.isSafeBasedOnUsage) { - messageParts.push(colors.green('(Safe based on usage ✓)')); - } - if (change.approval) { - messageParts.push( - colors.green(`(Approved by ${change.approval.approvedBy?.displayName ?? ''} ✓)`), - ); - } - - this.log(...messageParts); - }); - }; - - this.info(`Detected ${changes.total} change${changes.total > 1 ? 's' : ''}`); - this.log(''); - - const breakingChanges = changes.nodes.filter( - change => change.criticality === CriticalityLevel.Breaking, - ); - const safeChanges = changes.nodes.filter( - change => change.criticality !== CriticalityLevel.Breaking, - ); - - if (breakingChanges.length) { - this.log(String(indent), `Breaking changes:`); - writeChanges(breakingChanges); - } - - if (safeChanges.length) { - this.log(String(indent), `Safe changes:`); - writeChanges(safeChanges); - } -} - -export function renderWarnings(this: BaseCommand, warnings: SchemaWarningConnection) { - this.log(''); - this.infoWarning(`Detected ${warnings.total} warning${warnings.total > 1 ? 's' : ''}`); - this.log(''); - - warnings.nodes.forEach(warning => { - const details = [warning.source ? `source: ${this.bolderize(warning.source)}` : undefined] - .filter(Boolean) - .join(', '); - - this.log(indent, `- ${this.bolderize(warning.message)}${details ? ` (${details})` : ''}`); - }); -} +export * as SchemaHive from '../gql/graphql'; export async function loadSchema( file: string, diff --git a/packages/libraries/cli/src/helpers/tex/__.ts b/packages/libraries/cli/src/helpers/tex/__.ts new file mode 100644 index 0000000000..869c268a6b --- /dev/null +++ b/packages/libraries/cli/src/helpers/tex/__.ts @@ -0,0 +1 @@ +export * as Tex from './tex'; diff --git a/packages/libraries/cli/src/helpers/tex/tex.ts b/packages/libraries/cli/src/helpers/tex/tex.ts new file mode 100644 index 0000000000..058bac6190 --- /dev/null +++ b/packages/libraries/cli/src/helpers/tex/tex.ts @@ -0,0 +1,37 @@ +import { inspect as nodeInspect } from 'node:util'; +import colors from 'colors'; + +export { colors }; + +export const indent = ' '; + +export const bolderize = (msg: string) => { + const findSingleQuotes = /'([^']+)'/gim; + const findDoubleQuotes = /"([^"]+)"/gim; + + return msg + .replace(findSingleQuotes, (_: string, value: string) => colors.bold(value)) + .replace(findDoubleQuotes, (_: string, value: string) => colors.bold(value)); +}; + +export const prefixedInspect = + (prefix: string) => + (...values: unknown[]) => { + const body = values.map(inspect).join(' '); + return [prefix, body].join(' '); + }; + +export const inspect = (value: unknown) => { + if (typeof value === 'string') { + return value; + } + return nodeInspect(value); +}; + +export const success = prefixedInspect(colors.green('✔')); + +export const failure = prefixedInspect(colors.red('✖')); + +export const info = prefixedInspect(colors.yellow('ℹ')); + +export const warning = prefixedInspect(colors.yellow('⚠')); diff --git a/packages/libraries/cli/src/helpers/typebox/_.ts b/packages/libraries/cli/src/helpers/typebox/_.ts new file mode 100644 index 0000000000..987e697088 --- /dev/null +++ b/packages/libraries/cli/src/helpers/typebox/_.ts @@ -0,0 +1,19 @@ +import { FormatRegistry, TSchema, Type } from '@sinclair/typebox'; + +const uriRegex = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/)?[^\s]*$/i; + +FormatRegistry.Set('uri', (value: unknown) => { + if (typeof value !== 'string') { + return false; + } + + return uriRegex.test(value); +}); + +export * from '@sinclair/typebox'; + +export * from './value/__'; + +export const Nullable = (schema: T) => Type.Union([schema, Type.Null()]); + +export const StringNonEmpty = Type.String({ minLength: 1 }); diff --git a/packages/libraries/cli/src/helpers/typebox/__.ts b/packages/libraries/cli/src/helpers/typebox/__.ts new file mode 100644 index 0000000000..6d58a28521 --- /dev/null +++ b/packages/libraries/cli/src/helpers/typebox/__.ts @@ -0,0 +1 @@ +export * as tb from './_'; diff --git a/packages/libraries/cli/src/helpers/typebox/value/_.ts b/packages/libraries/cli/src/helpers/typebox/value/_.ts new file mode 100644 index 0000000000..686005bb46 --- /dev/null +++ b/packages/libraries/cli/src/helpers/typebox/value/_.ts @@ -0,0 +1,60 @@ +import { Static, TAnySchema } from '@sinclair/typebox'; +import { AssertError, Value, ValueError, ValueErrorType } from '@sinclair/typebox/value'; + +export * from '@sinclair/typebox/value'; +export * from './materialized-value-error'; + +/** + * Non-throwing version of {@link ParseJson}. + * + * If JSON parsing fails then {@link TypeBoxError} is returned. + * Otherwise its just the regular {@link Value.Parse} error of type {@link AssertError}. + */ +export const ParseJsonSafe = <$Type extends TAnySchema>( + type: $Type, + jsonString: string, +): Static<$Type> | AssertError => { + try { + return ParseJson(type, jsonString); + } catch (e) { + return e; + } +}; + +/** + * Parses a JSON string and validates it against a TypeBox schema + * + * @returns The parsed and typed value + * + * @throwsError {@link TypeBoxError} If JSON parsing fails or if validation fails + */ +export const ParseJson = <$Type extends TAnySchema>( + /** + * The TypeBox schema to validate against + */ + type: $Type, + /** + * The JSON string to parse + */ + jsonString: string, +): Static<$Type> => { + let rawData: unknown; + try { + rawData = JSON.parse(jsonString); + } catch (e) { + const error = e as Error; + const message = `Failed to parse contents of given JSON because the JSON itself was invalid: ${error.message}`; + const valueError: ValueError = { + value: jsonString, + type: ValueErrorType.StringFormat, + message, + path: '', + errors: [], + schema: type, + }; + + throw new AssertError(new Value.ValueErrorIterator([valueError][Symbol.iterator]())); + } + + return Value.Parse(type, rawData); +}; diff --git a/packages/libraries/cli/src/helpers/typebox/value/__.ts b/packages/libraries/cli/src/helpers/typebox/value/__.ts new file mode 100644 index 0000000000..de05b26395 --- /dev/null +++ b/packages/libraries/cli/src/helpers/typebox/value/__.ts @@ -0,0 +1 @@ +export * as Value from './_'; diff --git a/packages/libraries/cli/src/helpers/typebox/value/materialized-value-error.ts b/packages/libraries/cli/src/helpers/typebox/value/materialized-value-error.ts new file mode 100644 index 0000000000..779525c606 --- /dev/null +++ b/packages/libraries/cli/src/helpers/typebox/value/materialized-value-error.ts @@ -0,0 +1,39 @@ +/** + * @see https://github.com/sinclairzx81/typebox/issues/1044#issuecomment-2451582765 + */ + +import { Array, Object, Recursive, Static, String, Unknown } from '@sinclair/typebox'; +import { ValueError, ValueErrorIterator } from '@sinclair/typebox/value'; + +export const MaterializedValueErrorT = Recursive( + Self => + Object({ + message: String(), + path: String(), + value: Unknown(), + errors: Array(Self), + }), + { $id: 'MaterializedValueError' }, +); + +/** + * @see https://github.com/sinclairzx81/typebox/issues/1044#issuecomment-2451582765 + */ +type MaterializedValueError = Static; + +/** + * @see https://github.com/sinclairzx81/typebox/issues/1044#issuecomment-2451582765 + */ +export const MaterializeValueError = (error: ValueError) => ({ + message: error.message, + value: error.value, + path: error.path, + errors: error.errors.map(iterator => MaterializeValueErrorIterator(iterator)), +}); + +/** + * @see https://github.com/sinclairzx81/typebox/issues/1044#issuecomment-2451582765 + */ +export const MaterializeValueErrorIterator = ( + iterator: ValueErrorIterator, +): MaterializedValueError[] => [...iterator].map(error => MaterializeValueError(error)) as never; diff --git a/packages/libraries/cli/src/index.ts b/packages/libraries/cli/src/index.ts index d620e709ab..cd052aa07d 100644 --- a/packages/libraries/cli/src/index.ts +++ b/packages/libraries/cli/src/index.ts @@ -1 +1,2 @@ export { run } from '@oclif/core'; +export * as HiveCLI from './library/_'; diff --git a/packages/libraries/cli/src/library/_.ts b/packages/libraries/cli/src/library/_.ts new file mode 100644 index 0000000000..af2f61f756 --- /dev/null +++ b/packages/libraries/cli/src/library/_.ts @@ -0,0 +1,49 @@ +import { Command } from '@oclif/core'; +import { commandIndex } from '../command-index'; +import { toSnakeCase, uncapitalize } from '../helpers/general'; +import { Args, Infer, renderSubcommandExecution } from './infer'; + +export { commandIndex as Commands }; + +export const create = (config: { + /** + * Optional middleware to manipulate various aspects of the CLI. + */ + middleware?: { + /** + * Manipulate the arguments before execution runs. + */ + args?: (args: Args) => Promise; + }; + /** + * Function to execute the sub-command. Generally you will spawn a child process + * that invokes the CLI like a real user would. + */ + execute: (command: string) => Promise; +}): Infer => { + return Object.fromEntries( + Object.entries(commandIndex).map(([commandClassName, commandClass]) => { + return [ + // The property name on the CLI object. + uncapitalize(commandClassName), + // The method that runs the command. + async (args: Args) => { + const args_ = (await config.middleware?.args?.(args)) ?? args; + const subCommandPath = inferCommandPath(commandClass); + const subCommandRendered = renderSubcommandExecution(subCommandPath, args_); + const result = await config.execute(subCommandRendered); + + if (args_.json) { + return JSON.parse(result); + } + + return result; + }, + ]; + }), + ) as any; +}; + +const inferCommandPath = (commandClass: typeof Command) => { + return toSnakeCase(commandClass.name).replace(/_/g, ':'); +}; diff --git a/packages/libraries/cli/src/library/infer.ts b/packages/libraries/cli/src/library/infer.ts new file mode 100644 index 0000000000..86e4375eab --- /dev/null +++ b/packages/libraries/cli/src/library/infer.ts @@ -0,0 +1,65 @@ +import BaseCommand from '../base-command'; +import { tb } from '../helpers/typebox/__'; + +export type Infer<$Commands extends CommandIndexGeneric> = { + [K in keyof $Commands as K extends string ? Uncapitalize : K]: InferFunction<$Commands[K]>; +}; + +export type InferFunction<$Command extends typeof BaseCommand> = < + $Args extends InferFunctionParameters<$Command>, +>( + args: $Args, +) => Promise>; + +// prettier-ignore +type InferFunctionParameters<$Command extends typeof BaseCommand> = + & ( + // @ts-expect-error fixme + $Command['parameters']['named'] extends tb.Object + // @ts-expect-error fixme + ? tb.Static<$Command['parameters']['named']> + : {} + ) + // todo *optional* positional inference + & ( + // @ts-expect-error fixme + 'positional' extends keyof $Command['parameters'] + ? { + // @ts-expect-error fixme + $positional: tb.Static<$Command['parameters']['positional']> + } + : {} + ) + +// prettier-ignore +type InferReturn<$Command extends typeof BaseCommand, $Args extends Args> = + $Args extends { json: true } + ? tb.Static<$Command['output'][number]['schema']> + : string + +export const renderSubcommandExecution = ( + subCommandName: string, + args: Record, +): string => { + const { $positional, ...named } = args; + const execArgsPositional = Array.isArray($positional) ? $positional : []; + const execArgsNamed = Object.entries(named) + .filter(([_, value]) => value !== undefined) // treat undefined value for optional flags as if not there. + .filter(([_, value]) => value !== false) // treat false boolean flags as if not there. + .flatMap(([name, value]) => { + const values = Array.isArray(value) ? value : [value]; // expand arrays for support of flags that are allowed to be repeated. + return values.flatMap(_ => { + const flagName = `--${name}`; + if (_ === true) return flagName; // true boolean flags are just the flag name. + return [flagName, String(value)]; // other values are flag name and value. + }); + }); + const execArgs = [subCommandName, ...execArgsPositional, ...execArgsNamed].join(' '); + return execArgs; +}; + +export type CommandIndexGeneric = Record>; + +export interface Args { + [name: string]: unknown; +} diff --git a/packages/libraries/cli/src/output/_.ts b/packages/libraries/cli/src/output/_.ts new file mode 100644 index 0000000000..2422c44bf6 --- /dev/null +++ b/packages/libraries/cli/src/output/_.ts @@ -0,0 +1,5 @@ +export * from './data'; +export * from './failure'; +export * from './success'; +export * from './success-output'; +export * from './output-data-type'; diff --git a/packages/libraries/cli/src/output/__.ts b/packages/libraries/cli/src/output/__.ts new file mode 100644 index 0000000000..c30f26e6c5 --- /dev/null +++ b/packages/libraries/cli/src/output/__.ts @@ -0,0 +1 @@ +export * as Output from './_'; diff --git a/packages/libraries/cli/src/output/data.ts b/packages/libraries/cli/src/output/data.ts new file mode 100644 index 0000000000..8161ee361f --- /dev/null +++ b/packages/libraries/cli/src/output/data.ts @@ -0,0 +1,115 @@ +import { SchemaHive } from '../helpers/schema'; +import { Tex } from '../helpers/tex/__'; +import { tb } from '../helpers/typebox/__'; + +export const schemaChangeCriticalityLevel = { + Breaking: 'Breaking', + Dangerous: 'Dangerous', + Safe: 'Safe', +} as const; +export type SchemaChangeCriticalityLevel = keyof typeof schemaChangeCriticalityLevel; + +export const SchemaChange = tb.Object({ + message: tb.String(), + criticality: tb.Enum(schemaChangeCriticalityLevel), + isSafeBasedOnUsage: tb.Boolean(), + approval: tb.Nullable( + tb.Object({ + by: tb.Nullable( + tb.Object({ + displayName: tb.Nullable(tb.String()), + }), + ), + }), + ), +}); +export type SchemaChange = tb.Static; + +export const SchemaChanges = tb.Array(SchemaChange); +export type SchemaChanges = tb.Static; +export const SchemaChangesText = (data: SchemaChanges) => { + let o = ''; + + const writeChanges = (schemaChanges: SchemaChange[]) => { + return schemaChanges + .map(change => { + const messageParts = [ + Tex.indent, + criticalityMap[ + change.isSafeBasedOnUsage ? schemaChangeCriticalityLevel.Safe : change.criticality + ], + Tex.bolderize(change.message), + ]; + + if (change.isSafeBasedOnUsage) { + messageParts.push(Tex.colors.green('(Safe based on usage ✓)')); + } + if (change.approval) { + messageParts.push( + Tex.colors.green(`(Approved by ${change.approval.by?.displayName ?? ''} ✓)`), + ); + } + + return messageParts.join(' '); + }) + .join('\n'); + }; + + o += Tex.info(`Detected ${data.length} change${data.length > 1 ? 's' : ''}\n`); + + const breakingChanges = data.filter( + change => change.criticality === schemaChangeCriticalityLevel.Breaking, + ); + const safeChanges = data.filter( + change => change.criticality !== schemaChangeCriticalityLevel.Breaking, + ); + + if (breakingChanges.length) { + o += Tex.indent + `Breaking changes:\n`; + o += writeChanges(breakingChanges); + } + + if (safeChanges.length) { + o += Tex.indent + `Safe changes:\n`; + o += writeChanges(safeChanges); + } + + return o + '\n'; +}; + +const criticalityMap = { + [schemaChangeCriticalityLevel.Breaking]: Tex.colors.red('-'), + [schemaChangeCriticalityLevel.Safe]: Tex.colors.green('-'), + [schemaChangeCriticalityLevel.Dangerous]: Tex.colors.green('-'), +} satisfies Record; + +export const SchemaWarning = tb.Object({ + message: tb.String(), + source: tb.Nullable(tb.String()), + line: tb.Nullable(tb.Number()), + column: tb.Nullable(tb.Number()), +}); +export type SchemaWarning = tb.Static; + +export const SchemaError = tb.Object({ + message: tb.String(), +}); + +export type SchemaError = tb.Static; + +export const SchemaErrors = tb.Array(SchemaError); +export const SchemaErrorsText = (data: tb.Static) => { + let o = ''; + o += Tex.failure(`Detected ${data.length} error${data.length > 1 ? 's' : ''}\n`); + data.forEach(error => { + o += Tex.indent + Tex.colors.red('-') + Tex.bolderize(error.message) + '\n'; + }); + return o + '\n'; +}; + +export const AppDeploymentStatus = tb.Enum({ + active: SchemaHive.AppDeploymentStatus.Active, + pending: SchemaHive.AppDeploymentStatus.Pending, + retired: SchemaHive.AppDeploymentStatus.Retired, +}); +export type AppDeploymentStatus = tb.Static; diff --git a/packages/libraries/cli/src/output/failure.ts b/packages/libraries/cli/src/output/failure.ts new file mode 100644 index 0000000000..2b547481de --- /dev/null +++ b/packages/libraries/cli/src/output/failure.ts @@ -0,0 +1,44 @@ +import { OptionalizePropertyUnsafe, Simplify } from '../helpers/general'; +import { tb } from '../helpers/typebox/__'; +import { DataType } from './output-data-type'; +import type { SuccessBase } from './success'; + +export const FailureBase = tb.Object({ + type: tb.Literal('failure'), + reference: tb.Nullable(tb.String()), + suggestions: tb.Array(tb.String()), +}); +export type FailureBase = tb.Static; + +export const FailureGeneric = tb.Composite([ + FailureBase, + tb.Object({ + data: tb.Record(tb.String(), tb.Any()), + }), +]); +export type FailureGeneric = tb.Static; + +export const isFailure = <$Output extends SuccessBase | FailureBase>( + schema: $Output, +): schema is Extract<$Output, { type: 'failure' }> => + schema.type === FailureBase.properties.type.const; + +export const failureDefaults: tb.Static = { + type: 'failure', + reference: null, + suggestions: [], + data: {}, +}; + +export type InferFailureData<$DataType extends DataType> = Simplify< + InferFailure<$DataType>['data'] +>; + +export type InferFailureEnvelopeInit<$DataType extends DataType> = Simplify< + OptionalizePropertyUnsafe, 'type'>, 'suggestions' | 'reference'> +>; + +export type InferFailure<$DataType extends DataType> = Extract< + tb.Static<$DataType['schema']>, + { type: 'failure' } +>; diff --git a/packages/libraries/cli/src/output/output-data-type.ts b/packages/libraries/cli/src/output/output-data-type.ts new file mode 100644 index 0000000000..1129ab9533 --- /dev/null +++ b/packages/libraries/cli/src/output/output-data-type.ts @@ -0,0 +1,84 @@ +import { tb } from '../helpers/typebox/__'; +import { FailureBase } from './failure'; +import { SuccessBase } from './success'; + +export interface DataType<$Schema extends tb.TObject = tb.TObject> { + /** + * The schema for this data type. + */ + schema: $Schema; + /** + * An optional function that returns a string to be displayed to the user + * whenever this data type is output by a command. + * + * If user invoked the CLI with --json, then the output from this function is ignored. + */ + text?: (input: { flags: any; args: any }, output: any) => string; +} + +export const success: Factory = (typeName, config) => { + return { + text: config.text, + schema: tb.Composite([ + SuccessBase, + tb.Object({ + data: tb.Composite([ + tb.Object({ type: tb.Literal(typeName, { default: typeName }) }), + tb.Object(config.data), + ]), + }), + ]), + } as any; +}; + +export const failure: Factory = (typeName, config) => { + return { + text: config.text, + schema: tb.Composite([ + FailureBase, + tb.Object({ + data: tb.Composite([ + tb.Object({ type: tb.Literal(typeName, { default: typeName }) }), + tb.Object(config.data), + ]), + }), + ]), + } as any; +}; + +export type Factory<$Base extends tb.TObject> = < + $DataSchema extends tb.TProperties, + $TypeName extends string, +>( + typeName: $TypeName, + config: { + data: $DataSchema; + text?: ( + input: { flags: any; args: any }, + // @ts-expect-error fixme + output: tb.Static< + tb.TComposite< + [ + $Base, + tb.TObject<{ + data: tb.TComposite< + [tb.TObject<{ type: tb.TLiteral<$TypeName> }>, tb.TObject>] + >; + }>, + ] + > + >['data'], + ) => string; + }, +) => DataType< + tb.TComposite< + [ + $Base, + tb.TObject<{ + data: tb.TComposite< + [tb.TObject<{ type: tb.TLiteral<$TypeName> }>, tb.TObject<$DataSchema>] + >; + }>, + ] + > +>; diff --git a/packages/libraries/cli/src/output/success-output.ts b/packages/libraries/cli/src/output/success-output.ts new file mode 100644 index 0000000000..d92c23259b --- /dev/null +++ b/packages/libraries/cli/src/output/success-output.ts @@ -0,0 +1,18 @@ +import { tb } from '../helpers/typebox/__'; +import { success } from './output-data-type'; + +export const SuccessOutputFile = success('SuccessOutputFile', { + data: { + path: tb.String(), + bytes: tb.Number(), + }, +}); + +export const SuccessOutputStdout = success('SuccessOutputStdout', { + data: { + content: tb.String(), + }, + text: (_, data) => { + return data.content; + }, +}); diff --git a/packages/libraries/cli/src/output/success.ts b/packages/libraries/cli/src/output/success.ts new file mode 100644 index 0000000000..43d98f3259 --- /dev/null +++ b/packages/libraries/cli/src/output/success.ts @@ -0,0 +1,40 @@ +import { OptionalizePropertyUnsafe, Simplify } from '../helpers/general'; +import { tb } from '../helpers/typebox/__'; +import type { FailureBase } from './failure'; +import { DataType } from './output-data-type'; + +export const SuccessBase = tb.Object({ + type: tb.Literal('success', { default: 'success' }), +}); +export type SuccessBase = tb.Static; + +export const SuccessGeneric = tb.Composite([ + SuccessBase, + tb.Object({ + data: tb.Record(tb.String(), tb.Any()), + }), +]); +export type SuccessGeneric = tb.Static; + +export const successDefaults: tb.Static = { + type: 'success', + data: {}, +}; + +export const isSuccess = <$Output extends FailureBase | SuccessBase>( + schema: $Output, +): schema is Extract<$Output, { type: 'success' }> => + schema.type === SuccessBase.properties.type.const; + +export type InferSuccessData<$DataType extends DataType> = Simplify< + InferSuccess<$DataType>['data'] +>; + +export type InferSuccessEnvelopeInit<$DataType extends DataType> = Simplify< + OptionalizePropertyUnsafe, 'type'>, 'data'> +>; + +export type InferSuccess<$DataType extends DataType> = Extract< + tb.Static<$DataType['schema']>, + { type: 'success' } +>; diff --git a/packages/libraries/envelop/src/version.ts b/packages/libraries/envelop/src/version.ts index a8388e14a8..6a03babc29 100644 --- a/packages/libraries/envelop/src/version.ts +++ b/packages/libraries/envelop/src/version.ts @@ -1 +1 @@ -export const version = '0.33.10'; +export const version = '0.33.11'; diff --git a/packages/libraries/yoga/src/version.ts b/packages/libraries/yoga/src/version.ts index cfad1a37b2..13dc8be485 100644 --- a/packages/libraries/yoga/src/version.ts +++ b/packages/libraries/yoga/src/version.ts @@ -1 +1 @@ -export const version = '0.39.0'; +export const version = '0.39.1'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb3b672640..d835d399c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,9 @@ importers: '@graphql-hive/apollo': specifier: workspace:* version: link:../packages/libraries/apollo/dist + '@graphql-hive/cli': + specifier: workspace:* + version: link:../packages/libraries/cli '@graphql-hive/core': specifier: workspace:* version: link:../packages/libraries/core/dist @@ -292,6 +295,9 @@ importers: '@hive/storage': specifier: workspace:* version: link:../packages/services/storage + '@sinclair/typebox': + specifier: ^0.34.12 + version: 0.34.12 '@trpc/client': specifier: 10.45.2 version: 10.45.2(@trpc/server@10.45.2) @@ -332,14 +338,14 @@ importers: specifier: 30.4.4 version: 30.4.4(patch_hash=jxrvl4xmdvyktjijg7yfdkb34i) strip-ansi: - specifier: 7.1.0 - version: 7.1.0 + specifier: 6.0.1 + version: 6.0.1 tslib: specifier: 2.8.1 version: 2.8.1 vitest: - specifier: 2.0.5 - version: 2.0.5(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0) + specifier: 2.1.8 + version: 2.1.8(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0) zod: specifier: 3.23.8 version: 3.23.8 @@ -417,6 +423,9 @@ importers: '@oclif/plugin-update': specifier: 4.2.13 version: 4.2.13 + '@sinclair/typebox': + specifier: 0.34.12 + version: 0.34.12 '@theguild/federation-composition': specifier: 0.14.2 version: 0.14.2(graphql@16.9.0) @@ -4680,6 +4689,9 @@ packages: '@jridgewell/sourcemap-codec@1.4.15': resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -6776,6 +6788,9 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sinclair/typebox@0.34.12': + resolution: {integrity: sha512-tNc3Z7OUqcneO14tYyUH2QMdvC2c5sQOY0+SDw70aZix7Qi4GkaOQc22LfaVFPn64enKU5ggV2ryLWykeMGEog==} + '@sinclair/typebox@0.34.2': resolution: {integrity: sha512-mqdNXxKuepwsYxJTg4UlE3C8Lbu/oRkbo3oRv2FNrWCqMuBVJo4oRmfXGzbJGnwALi/zlfqgWQPIWes8idMziA==} @@ -7993,6 +8008,20 @@ packages: '@vitest/expect@2.0.5': resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} + '@vitest/expect@2.1.8': + resolution: {integrity: sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==} + + '@vitest/mocker@2.1.8': + resolution: {integrity: sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.0.5': resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} @@ -8002,12 +8031,21 @@ packages: '@vitest/runner@2.0.5': resolution: {integrity: sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==} + '@vitest/runner@2.1.8': + resolution: {integrity: sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==} + '@vitest/snapshot@2.0.5': resolution: {integrity: sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==} + '@vitest/snapshot@2.1.8': + resolution: {integrity: sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==} + '@vitest/spy@2.0.5': resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} + '@vitest/spy@2.1.8': + resolution: {integrity: sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==} + '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} @@ -8670,6 +8708,10 @@ packages: resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} engines: {node: '>=12'} + chai@5.1.2: + resolution: {integrity: sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==} + engines: {node: '>=12'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -10022,6 +10064,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.1.0: + resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} + engines: {node: '>=12.0.0'} + exponential-backoff@3.1.1: resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} @@ -11966,6 +12012,9 @@ packages: magic-string@0.30.10: resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + mailcomposer@3.12.0: resolution: {integrity: sha512-zBeDoKUTNI8IAsazoMQFt3eVSVRtDtgrvBjBVdBjxDEX+5KLlKtEFCrBXnxPhs8aTYufUS1SmbFnGpjHS53deg==} deprecated: This project is unmaintained @@ -14523,6 +14572,9 @@ packages: std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + std-env@3.8.0: + resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} @@ -14843,6 +14895,9 @@ packages: tinybench@2.8.0: resolution: {integrity: sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.1: resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} @@ -14854,6 +14909,10 @@ packages: resolution: {integrity: sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==} engines: {node: ^18.0.0 || >=20.0.0} + tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} @@ -14862,6 +14921,10 @@ packages: resolution: {integrity: sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + title-case@3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} @@ -15462,6 +15525,11 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + vite-node@2.1.8: + resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-tsconfig-paths@5.1.2: resolution: {integrity: sha512-gEIbKfJzSEv0yR3XS2QEocKetONoWkbROj6hGx0FHM18qKUojhvcokQsxQx5nMkelZq2n37zbSGCJn+FSODSjA==} peerDependencies: @@ -15557,6 +15625,31 @@ packages: jsdom: optional: true + vitest@2.1.8: + resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.8 + '@vitest/ui': 2.1.8 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -19750,6 +19843,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.4.15': {} + '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.1 @@ -22355,6 +22450,8 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@sinclair/typebox@0.34.12': {} + '@sinclair/typebox@0.34.2': {} '@sindresorhus/is@4.6.0': {} @@ -23991,6 +24088,21 @@ snapshots: chai: 5.1.1 tinyrainbow: 1.2.0 + '@vitest/expect@2.1.8': + dependencies: + '@vitest/spy': 2.1.8 + '@vitest/utils': 2.1.8 + chai: 5.1.2 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0))': + dependencies: + '@vitest/spy': 2.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 5.4.11(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0) + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -24004,16 +24116,31 @@ snapshots: '@vitest/utils': 2.0.5 pathe: 1.1.2 + '@vitest/runner@2.1.8': + dependencies: + '@vitest/utils': 2.1.8 + pathe: 1.1.2 + '@vitest/snapshot@2.0.5': dependencies: '@vitest/pretty-format': 2.0.5 magic-string: 0.30.10 pathe: 1.1.2 + '@vitest/snapshot@2.1.8': + dependencies: + '@vitest/pretty-format': 2.1.8 + magic-string: 0.30.17 + pathe: 1.1.2 + '@vitest/spy@2.0.5': dependencies: tinyspy: 3.0.0 + '@vitest/spy@2.1.8': + dependencies: + tinyspy: 3.0.2 + '@vitest/utils@2.0.5': dependencies: '@vitest/pretty-format': 2.0.5 @@ -24806,6 +24933,14 @@ snapshots: loupe: 3.1.1 pathval: 2.0.0 + chai@5.1.2: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.2 + pathval: 2.0.0 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -26507,6 +26642,8 @@ snapshots: exit-hook@2.2.1: {} + expect-type@1.1.0: {} + exponential-backoff@3.1.1: {} express@4.21.2: @@ -28682,6 +28819,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + mailcomposer@3.12.0: dependencies: buildmail: 3.10.0 @@ -32041,6 +32182,8 @@ snapshots: std-env@3.7.0: {} + std-env@3.8.0: {} + stoppable@1.1.0: {} storybook@8.4.7(prettier@3.3.3): @@ -32416,6 +32559,8 @@ snapshots: tinybench@2.8.0: {} + tinybench@2.9.0: {} + tinyexec@0.3.1: {} tinyglobby@0.2.10: @@ -32425,10 +32570,14 @@ snapshots: tinypool@1.0.0: {} + tinypool@1.0.2: {} + tinyrainbow@1.2.0: {} tinyspy@3.0.0: {} + tinyspy@3.0.2: {} + title-case@3.0.3: dependencies: tslib: 2.8.1 @@ -33106,6 +33255,24 @@ snapshots: - supports-color - terser + vite-node@2.1.8(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0): + dependencies: + cac: 6.7.14 + debug: 4.3.7(supports-color@8.1.1) + es-module-lexer: 1.5.4 + pathe: 1.1.2 + vite: 5.4.11(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-tsconfig-paths@5.1.2(typescript@5.6.3)(vite@5.4.11(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0)): dependencies: debug: 4.3.7(supports-color@8.1.1) @@ -33174,6 +33341,41 @@ snapshots: - supports-color - terser + vitest@2.1.8(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0): + dependencies: + '@vitest/expect': 2.1.8 + '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0)) + '@vitest/pretty-format': 2.1.8 + '@vitest/runner': 2.1.8 + '@vitest/snapshot': 2.1.8 + '@vitest/spy': 2.1.8 + '@vitest/utils': 2.1.8 + chai: 5.1.2 + debug: 4.3.7(supports-color@8.1.1) + expect-type: 1.1.0 + magic-string: 0.30.17 + pathe: 1.1.2 + std-env: 3.8.0 + tinybench: 2.9.0 + tinyexec: 0.3.1 + tinypool: 1.0.2 + tinyrainbow: 1.2.0 + vite: 5.4.11(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0) + vite-node: 2.1.8(@types/node@22.9.3)(less@4.2.0)(lightningcss@1.28.1)(terser@5.36.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.9.3 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: diff --git a/tsconfig.json b/tsconfig.json index 5b8a1f8313..75e1434e95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,7 @@ "moduleResolution": "node", "strict": true, "paths": { + "@graphql-hive/cli": ["./packages/libraries/cli/src/index.ts"], "@hive/api": ["./packages/services/api/src/index.ts"], "@hive/api/src/modules/schema/providers/artifact-storage-reader": [ "./packages/services/api/src/modules/schema/providers/artifact-storage-reader.ts"