graphql-buddy 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +32 -0
- package/.nvmrc +1 -0
- package/.prettierignore +1 -0
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/api.d.ts +46 -0
- package/dist/api.js +13 -0
- package/dist/api.js.map +1 -0
- package/dist/chunk-Z7B4AQ2U.js +124 -0
- package/dist/chunk-Z7B4AQ2U.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +122 -0
- package/dist/cli.js.map +1 -0
- package/eslint.config.ts +168 -0
- package/package.json +69 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/api/commands/bundle.test.ts +138 -0
- package/src/api/commands/bundle.ts +29 -0
- package/src/api/commands/index.ts +2 -0
- package/src/api/commands/validate.test.ts +35 -0
- package/src/api/commands/validate.ts +49 -0
- package/src/api/index.ts +2 -0
- package/src/api/shared/index.ts +1 -0
- package/src/api/shared/loadSchema.ts +88 -0
- package/src/cli/commands/bundle.ts +62 -0
- package/src/cli/commands/index.ts +2 -0
- package/src/cli/commands/validate.ts +59 -0
- package/src/cli/index.ts +52 -0
- package/src/cli/shared/index.ts +1 -0
- package/src/cli/shared/parsePathAliases.test.ts +43 -0
- package/src/cli/shared/parsePathAliases.ts +25 -0
- package/test_fixtures/missing_scalar/schema.graphql +16 -0
- package/test_fixtures/multiple_files/Bar.graphql +5 -0
- package/test_fixtures/multiple_files/Baz.graphql +5 -0
- package/test_fixtures/multiple_files/Foo.graphql +9 -0
- package/test_fixtures/multiple_files/Mutation.graphql +9 -0
- package/test_fixtures/multiple_files/Query.graphql +5 -0
- package/test_fixtures/single_file/schema.graphql +16 -0
- package/test_fixtures/single_file_with_extra_deps/schema.graphql +46 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +14 -0
- package/vite.config.ts +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "graphql-buddy",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Hunter John Larco",
|
|
7
|
+
"description": "CLI tools for manipulating graphql // designed for build system tooling",
|
|
8
|
+
"bin": {
|
|
9
|
+
"graphql-buddy": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"graphql",
|
|
13
|
+
"parse",
|
|
14
|
+
"build",
|
|
15
|
+
"build-tool",
|
|
16
|
+
"bundler"
|
|
17
|
+
],
|
|
18
|
+
"main": "./dist/api.js",
|
|
19
|
+
"types": "./dist/api.d.ts",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/hunterlarco/graphql-buddy.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/hunterlarco/graphql-buddy/issues"
|
|
26
|
+
},
|
|
27
|
+
"funding": "https://github.com/sponsors/hunterlarco",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "pnpm run typecheck && pnpm run bundle",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"bundle": "tsup",
|
|
32
|
+
"start": "pnpm run build && node dist/cli.js",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:dev": "vitest dev",
|
|
35
|
+
"format": "pnpm run format:eslint && pnpm run format:prettier",
|
|
36
|
+
"format:prettier": "prettier . --write",
|
|
37
|
+
"format:eslint": "eslint . --fix",
|
|
38
|
+
"lint": "concurrently npm:lint:*",
|
|
39
|
+
"lint:prettier": "prettier . --check",
|
|
40
|
+
"lint:eslint": "eslint . --max-warnings=0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@commander-js/extra-typings": "14.0.0",
|
|
44
|
+
"@graphql-tools/graphql-file-loader": "8.1.2",
|
|
45
|
+
"@graphql-tools/load": "8.1.2",
|
|
46
|
+
"@graphql-tools/utils": "10.9.1",
|
|
47
|
+
"commander": "14.0.1",
|
|
48
|
+
"glob": "11.0.3",
|
|
49
|
+
"graphql": "16.11.0",
|
|
50
|
+
"stringify-object": "6.0.0",
|
|
51
|
+
"tslib": "2.8.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@eslint/compat": "1.4.0",
|
|
55
|
+
"@eslint/js": "9.36.0",
|
|
56
|
+
"@types/node": "24.5.2",
|
|
57
|
+
"@types/stringify-object": "4.0.5",
|
|
58
|
+
"concurrently": "9.2.1",
|
|
59
|
+
"eslint": "9.36.0",
|
|
60
|
+
"eslint-import-resolver-typescript": "4.4.4",
|
|
61
|
+
"eslint-plugin-import": "2.32.0",
|
|
62
|
+
"jiti": "2.6.0",
|
|
63
|
+
"prettier": "3.6.2",
|
|
64
|
+
"tsup": "8.5.0",
|
|
65
|
+
"typescript": "5.9.2",
|
|
66
|
+
"typescript-eslint": "8.44.1",
|
|
67
|
+
"vitest": "3.2.4"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import * as bundle from './bundle';
|
|
6
|
+
|
|
7
|
+
describe(`bundle`, () => {
|
|
8
|
+
it(`handles a single file with no extra deps or aliases.`, async () => {
|
|
9
|
+
expect(
|
|
10
|
+
await bundle.bundle({
|
|
11
|
+
schema: {
|
|
12
|
+
files: [`*.graphql`],
|
|
13
|
+
base: nodePath.resolve(
|
|
14
|
+
__dirname,
|
|
15
|
+
`../../../test_fixtures/single_file`,
|
|
16
|
+
),
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
).toStrictEqual(
|
|
20
|
+
`
|
|
21
|
+
type Query {
|
|
22
|
+
foo: Foo
|
|
23
|
+
bar: Bar
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Foo {
|
|
27
|
+
id: ID!
|
|
28
|
+
displayName: String
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Bar {
|
|
32
|
+
id: ID!
|
|
33
|
+
foo: Foo
|
|
34
|
+
}
|
|
35
|
+
`.trim(),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it(`handles multiple files with no extra deps or aliases.`, async () => {
|
|
40
|
+
expect(
|
|
41
|
+
await bundle.bundle({
|
|
42
|
+
schema: {
|
|
43
|
+
files: [`*.graphql`],
|
|
44
|
+
base: nodePath.resolve(
|
|
45
|
+
__dirname,
|
|
46
|
+
`../../../test_fixtures/multiple_files`,
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
).toStrictEqual(
|
|
51
|
+
`
|
|
52
|
+
type Bar {
|
|
53
|
+
baz: Baz
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Baz {
|
|
57
|
+
dateCreated: DateTime!
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
scalar DateTime
|
|
61
|
+
|
|
62
|
+
type Foo {
|
|
63
|
+
id: ID!
|
|
64
|
+
name: String
|
|
65
|
+
bar: Bar
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Mutation {
|
|
69
|
+
addFoo(foo: FooInput!): Foo!
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
input FooInput {
|
|
73
|
+
name: String
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Query {
|
|
77
|
+
allFoo: [Foo!]!
|
|
78
|
+
}
|
|
79
|
+
`.trim(),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it(`shakes extra deps from a single file.`, async () => {
|
|
84
|
+
expect(
|
|
85
|
+
await bundle.bundle({
|
|
86
|
+
schema: {
|
|
87
|
+
files: [`*.graphql`],
|
|
88
|
+
base: nodePath.resolve(
|
|
89
|
+
__dirname,
|
|
90
|
+
`../../../test_fixtures/single_file_with_extra_deps`,
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
).toStrictEqual(
|
|
95
|
+
`
|
|
96
|
+
type Query {
|
|
97
|
+
foo: Foo
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type Mutation {
|
|
101
|
+
addFooToBar(displayName: String!): Bar
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type Subscription {
|
|
105
|
+
foo: FooEvent
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type Foo {
|
|
109
|
+
id: ID!
|
|
110
|
+
displayName: String
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Bar {
|
|
114
|
+
id: ID!
|
|
115
|
+
foo: Foo
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type FooEvent {
|
|
119
|
+
foo: Foo!
|
|
120
|
+
}
|
|
121
|
+
`.trim(),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it(`throws for invalid schema.`, async () => {
|
|
126
|
+
await expect(
|
|
127
|
+
bundle.bundle({
|
|
128
|
+
schema: {
|
|
129
|
+
files: [`*.graphql`],
|
|
130
|
+
base: nodePath.resolve(
|
|
131
|
+
__dirname,
|
|
132
|
+
`../../../test_fixtures/missing_scalar`,
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
).rejects.toThrowError(`Unknown type "DateTime"`);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as graphqlToolsUtils from '@graphql-tools/utils';
|
|
2
|
+
import * as graphql from 'graphql';
|
|
3
|
+
|
|
4
|
+
import * as shared from '../shared';
|
|
5
|
+
|
|
6
|
+
export type BundleOptions = {
|
|
7
|
+
schema: shared.LoadSchemaOptions;
|
|
8
|
+
|
|
9
|
+
// Defaults to true.
|
|
10
|
+
shake?: boolean | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bundles multiple schema together into a single file.
|
|
15
|
+
*
|
|
16
|
+
* @param options - Bundle options.
|
|
17
|
+
*
|
|
18
|
+
* @returns The merged schema as text.
|
|
19
|
+
*/
|
|
20
|
+
export const bundle = async (options: BundleOptions): Promise<string> => {
|
|
21
|
+
const shake = options.shake ?? true;
|
|
22
|
+
|
|
23
|
+
let schema = await shared.loadSchema(options.schema);
|
|
24
|
+
if (shake) {
|
|
25
|
+
schema = graphqlToolsUtils.pruneSchema(schema);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return graphql.printSchema(schema);
|
|
29
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import * as validate from './validate';
|
|
6
|
+
|
|
7
|
+
describe(`validate`, () => {
|
|
8
|
+
it(`creates a validation stamp when validation passes.`, async () => {
|
|
9
|
+
await expect(
|
|
10
|
+
validate.validate({
|
|
11
|
+
schema: {
|
|
12
|
+
files: [`*.graphql`],
|
|
13
|
+
base: nodePath.resolve(
|
|
14
|
+
__dirname,
|
|
15
|
+
`../../../test_fixtures/multiple_files`,
|
|
16
|
+
),
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
).resolves.toStrictEqual(expect.any(String));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it(`throws for invalid schema.`, async () => {
|
|
23
|
+
await expect(
|
|
24
|
+
validate.validate({
|
|
25
|
+
schema: {
|
|
26
|
+
files: [`*.graphql`],
|
|
27
|
+
base: nodePath.resolve(
|
|
28
|
+
__dirname,
|
|
29
|
+
`../../../test_fixtures/missing_scalar`,
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
).rejects.toThrowError(`Unknown type "DateTime"`);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as stringifyObject from 'stringify-object';
|
|
2
|
+
|
|
3
|
+
import * as shared from '../shared';
|
|
4
|
+
|
|
5
|
+
export type ValidateOptions = {
|
|
6
|
+
schema: shared.LoadSchemaOptions;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validates that the provided GraphQL schema contain valid syntax and have no
|
|
11
|
+
* unknown symbols.
|
|
12
|
+
*
|
|
13
|
+
* @param options - Validate options.
|
|
14
|
+
*
|
|
15
|
+
* @returns If validation is successful, returns a validation stamp: a text blob
|
|
16
|
+
* describing the successful outcome and options used during validation. This
|
|
17
|
+
* is often useful in build systems where a build step *must* emit a file. If
|
|
18
|
+
* validation fails, a runtime error will be thrown.
|
|
19
|
+
*/
|
|
20
|
+
export const validate = async (options: ValidateOptions): Promise<string> => {
|
|
21
|
+
// We manually normalize the `LoadSchemaOptions` so that we can write the most
|
|
22
|
+
// accurate data in our validation stamp. For example, normalization expands
|
|
23
|
+
// globs and resolves absolute paths. These are critical steps to ensure the
|
|
24
|
+
// validation stamp describes without ambiguity which schema were loaded.
|
|
25
|
+
const normalizedSchemaOptions = await shared.normalizeLoadSchemaOptions(
|
|
26
|
+
options.schema,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await shared.loadSchema(normalizedSchemaOptions);
|
|
30
|
+
|
|
31
|
+
return createValidationStamp({
|
|
32
|
+
...options,
|
|
33
|
+
schema: normalizedSchemaOptions,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const createValidationStamp = (
|
|
38
|
+
options: Omit<ValidateOptions, `schema`> & {
|
|
39
|
+
schema: shared.NormalizedLoadSchemaOptions;
|
|
40
|
+
},
|
|
41
|
+
): string =>
|
|
42
|
+
stringifyObject.default(
|
|
43
|
+
{ options, outcome: { valid: true } },
|
|
44
|
+
{
|
|
45
|
+
indent: ` `,
|
|
46
|
+
filter: (container, property) =>
|
|
47
|
+
!(container === options.schema && property === `normalized`),
|
|
48
|
+
},
|
|
49
|
+
);
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './loadSchema';
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as nodeFs from 'node:fs/promises';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
3
|
+
|
|
4
|
+
import * as graphqlFileLoader from '@graphql-tools/graphql-file-loader';
|
|
5
|
+
import * as graphqlLoad from '@graphql-tools/load';
|
|
6
|
+
import * as glob from 'glob';
|
|
7
|
+
|
|
8
|
+
import type * as graphql from 'graphql';
|
|
9
|
+
|
|
10
|
+
export type LoadSchemaOptions = {
|
|
11
|
+
normalized?: false | null;
|
|
12
|
+
|
|
13
|
+
// Relative paths will be resolved using `base`.
|
|
14
|
+
//
|
|
15
|
+
// Also accepts glob patterns.
|
|
16
|
+
files: Array<string>;
|
|
17
|
+
|
|
18
|
+
// Directory from which `files` are resolved.
|
|
19
|
+
//
|
|
20
|
+
// Defaults to `process.cwd()`.
|
|
21
|
+
base?: string | null;
|
|
22
|
+
|
|
23
|
+
// See @graphql-tools/import#PathAliases
|
|
24
|
+
//
|
|
25
|
+
// Note that relative paths will be resolved relative to `base` to maintain
|
|
26
|
+
// parity with `files`.
|
|
27
|
+
pathAliases?: Map<string, string> | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const loadSchema = async (
|
|
31
|
+
options: LoadSchemaOptions | NormalizedLoadSchemaOptions,
|
|
32
|
+
): Promise<graphql.GraphQLSchema> => {
|
|
33
|
+
const { files, pathAliases } = options.normalized
|
|
34
|
+
? options
|
|
35
|
+
: await normalizeLoadSchemaOptions(options);
|
|
36
|
+
|
|
37
|
+
// @graphql-tools/graphql-file-loader silently skips input files which are not
|
|
38
|
+
// found. I suspect this is so that it can delegate missing inputs to other
|
|
39
|
+
// loaders. For our purposes, this is undesirable because we want to inform
|
|
40
|
+
// clients if expected inputs are missing. For that reason, we manually
|
|
41
|
+
// validate that the files exist first.
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
try {
|
|
44
|
+
await nodeFs.access(file, nodeFs.constants.F_OK);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`File does not exist: ${file}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return graphqlLoad.loadSchema(files, {
|
|
51
|
+
loaders: [new graphqlFileLoader.GraphQLFileLoader()],
|
|
52
|
+
pathAliases: {
|
|
53
|
+
mappings: Object.fromEntries(pathAliases.entries()),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type NormalizedLoadSchemaOptions = {
|
|
59
|
+
normalized: true;
|
|
60
|
+
|
|
61
|
+
// Absolute paths.
|
|
62
|
+
files: Array<string>;
|
|
63
|
+
|
|
64
|
+
// See @graphql-tools/import#PathAliases
|
|
65
|
+
pathAliases: Map<string, string>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const normalizeLoadSchemaOptions = async (
|
|
69
|
+
options: LoadSchemaOptions,
|
|
70
|
+
): Promise<NormalizedLoadSchemaOptions> => {
|
|
71
|
+
const base = options.base ?? process.cwd();
|
|
72
|
+
const files = await glob.glob(
|
|
73
|
+
options.files.map((file) => nodePath.resolve(base, file)),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const pathAliases = new Map<string, string>();
|
|
77
|
+
if (options.pathAliases != null) {
|
|
78
|
+
for (const [alias, source] of options.pathAliases.entries()) {
|
|
79
|
+
pathAliases.set(alias, nodePath.resolve(base, source));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
normalized: true,
|
|
85
|
+
files,
|
|
86
|
+
pathAliases,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as nodeFs from 'node:fs/promises';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
3
|
+
|
|
4
|
+
import * as commander from '@commander-js/extra-typings';
|
|
5
|
+
|
|
6
|
+
import * as api from '../../api';
|
|
7
|
+
import * as shared from '../shared';
|
|
8
|
+
|
|
9
|
+
export const createBundleCommand = () =>
|
|
10
|
+
new commander.Command()
|
|
11
|
+
.name(`bundle`)
|
|
12
|
+
.description(`Bundles multiple GraphQL inputs into a single file.`)
|
|
13
|
+
.argument(`<schema...>`, `Input GraphQL schema files to bundle.`)
|
|
14
|
+
.option(
|
|
15
|
+
`-b, --base <directory>`,
|
|
16
|
+
`Base directory from which paths are resolved.`,
|
|
17
|
+
)
|
|
18
|
+
.option(
|
|
19
|
+
`-a, --alias <pattern...>`,
|
|
20
|
+
`Path alias options for @graphql-tools/load.`,
|
|
21
|
+
)
|
|
22
|
+
.option(
|
|
23
|
+
`--out <file>`,
|
|
24
|
+
`Where to write the bundled schema.`,
|
|
25
|
+
`bundle.graphql`,
|
|
26
|
+
)
|
|
27
|
+
.option(`--no-shake`, `Prevents pruning of unused types.`)
|
|
28
|
+
.option(`-s, --silent`, `Only log critical information.`, false)
|
|
29
|
+
.action((schema, options) =>
|
|
30
|
+
bundle({
|
|
31
|
+
schema,
|
|
32
|
+
...options,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const bundle = async (options: {
|
|
37
|
+
schema: Array<string>;
|
|
38
|
+
base?: string;
|
|
39
|
+
alias?: Array<string>;
|
|
40
|
+
out: string;
|
|
41
|
+
shake: boolean;
|
|
42
|
+
silent: boolean;
|
|
43
|
+
}): Promise<void> => {
|
|
44
|
+
const { schema, base, alias, shake, silent } = options;
|
|
45
|
+
const out = nodePath.resolve(base ?? process.cwd(), options.out);
|
|
46
|
+
|
|
47
|
+
await nodeFs.writeFile(
|
|
48
|
+
out,
|
|
49
|
+
await api.bundle({
|
|
50
|
+
schema: {
|
|
51
|
+
files: schema,
|
|
52
|
+
base,
|
|
53
|
+
pathAliases: shared.parsePathAliases(alias ?? []),
|
|
54
|
+
},
|
|
55
|
+
shake,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!silent) {
|
|
60
|
+
console.log(`✅ bundler encountered no errors.`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as nodeFs from 'node:fs/promises';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
3
|
+
|
|
4
|
+
import * as commander from '@commander-js/extra-typings';
|
|
5
|
+
|
|
6
|
+
import * as api from '../../api';
|
|
7
|
+
import * as shared from '../shared';
|
|
8
|
+
|
|
9
|
+
export const createValidateCommand = () =>
|
|
10
|
+
new commander.Command()
|
|
11
|
+
.name(`validate`)
|
|
12
|
+
.description(`Checks that all provided GraphQL files form a valid schema.`)
|
|
13
|
+
.argument(`<schema...>`, `Input GraphQL schema files to validate.`)
|
|
14
|
+
.option(
|
|
15
|
+
`-b, --base <directory>`,
|
|
16
|
+
`Base directory from which paths are resolved.`,
|
|
17
|
+
)
|
|
18
|
+
.option(
|
|
19
|
+
`-a, --alias <pattern...>`,
|
|
20
|
+
`Path alias options for @graphql-tools/load.`,
|
|
21
|
+
)
|
|
22
|
+
.option(
|
|
23
|
+
`--stamp <file>`,
|
|
24
|
+
`Creates a validation stamp to confirm valid GraphQL schema.`,
|
|
25
|
+
)
|
|
26
|
+
.option(`-s, --silent`, `Only log critical information.`, false)
|
|
27
|
+
.action((schema, options) =>
|
|
28
|
+
validate({
|
|
29
|
+
schema,
|
|
30
|
+
...options,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const validate = async (options: {
|
|
35
|
+
schema: Array<string>;
|
|
36
|
+
base?: string;
|
|
37
|
+
alias?: Array<string>;
|
|
38
|
+
stamp?: string;
|
|
39
|
+
silent: boolean;
|
|
40
|
+
}): Promise<void> => {
|
|
41
|
+
const { schema, base, alias, stamp, silent } = options;
|
|
42
|
+
|
|
43
|
+
const validationStamp = await api.validate({
|
|
44
|
+
schema: {
|
|
45
|
+
files: schema,
|
|
46
|
+
base,
|
|
47
|
+
pathAliases: shared.parsePathAliases(alias ?? []),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (stamp != null) {
|
|
52
|
+
const destination = nodePath.resolve(base ?? process.cwd(), stamp);
|
|
53
|
+
await nodeFs.writeFile(destination, validationStamp);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!silent) {
|
|
57
|
+
console.log(`✅ validation encountered no errors.`);
|
|
58
|
+
}
|
|
59
|
+
};
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as commander from '@commander-js/extra-typings';
|
|
4
|
+
import * as graphql from 'graphql';
|
|
5
|
+
|
|
6
|
+
import * as commands from './commands';
|
|
7
|
+
|
|
8
|
+
const main = () => {
|
|
9
|
+
process.setUncaughtExceptionCaptureCallback((error) => {
|
|
10
|
+
// When thrown natively, GraphQLError prints like any other error with
|
|
11
|
+
// minimal context, however, when logged with `.toString()` it prints
|
|
12
|
+
// helpful debugging information critical for debugging syntax errors (such
|
|
13
|
+
// as source file, location, symbol, etc...).
|
|
14
|
+
//
|
|
15
|
+
// For example:
|
|
16
|
+
//
|
|
17
|
+
// ```
|
|
18
|
+
// Syntax Error: Expected ":", found Name "ID".
|
|
19
|
+
//
|
|
20
|
+
// src/services/api/schema/CurrentUser.graphql:2:6
|
|
21
|
+
// 1 | type CurrentUser {
|
|
22
|
+
// 2 | id ID!
|
|
23
|
+
// | ^
|
|
24
|
+
// 3 | dateCreated: DateTime!
|
|
25
|
+
// ```
|
|
26
|
+
//
|
|
27
|
+
// Versus the naive output:
|
|
28
|
+
//
|
|
29
|
+
// ```
|
|
30
|
+
// return new _GraphQLError.GraphQLError(`Syntax Error: ${description}`, {
|
|
31
|
+
// GraphQLError: Syntax Error: Expected ":", found Name "ID".
|
|
32
|
+
// at syntaxError (node_modules/graphql/error/syntaxError.js:15:10)
|
|
33
|
+
// ```
|
|
34
|
+
if (error instanceof graphql.GraphQLError) {
|
|
35
|
+
console.error(error.toString());
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.error(error);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
commander.program
|
|
43
|
+
.name(`graphql-buddy`)
|
|
44
|
+
.description(
|
|
45
|
+
`CLI tools for manipulating graphql // designed for build system tooling.`,
|
|
46
|
+
)
|
|
47
|
+
.addCommand(commands.createBundleCommand())
|
|
48
|
+
.addCommand(commands.createValidateCommand())
|
|
49
|
+
.parse();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './parsePathAliases';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import * as parsePathAliases from './parsePathAliases';
|
|
4
|
+
|
|
5
|
+
describe(`parsePathAliases`, () => {
|
|
6
|
+
it(`returns empty when no encoded aliases are passed.`, () => {
|
|
7
|
+
expect(parsePathAliases.parsePathAliases([])).toStrictEqual(new Map());
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it(`splits aliases using "=" delimiter.`, () => {
|
|
11
|
+
expect(
|
|
12
|
+
parsePathAliases.parsePathAliases([
|
|
13
|
+
`@/*=src/*`,
|
|
14
|
+
`custom-foo=path/to/foo`,
|
|
15
|
+
]),
|
|
16
|
+
).toStrictEqual(
|
|
17
|
+
new Map([
|
|
18
|
+
[`@/*`, `src/*`],
|
|
19
|
+
[`custom-foo`, `path/to/foo`],
|
|
20
|
+
]),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it(`throws when aliases conflict.`, () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
parsePathAliases.parsePathAliases([
|
|
27
|
+
`@/*=src/*`,
|
|
28
|
+
`custom-foo=path/to/foo`,
|
|
29
|
+
`@/*=gen/*`,
|
|
30
|
+
]),
|
|
31
|
+
).toThrowError(`Alias "@/*" is set multiple times.`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it(`throws when aliases are malformed.`, () => {
|
|
35
|
+
expect(() => parsePathAliases.parsePathAliases([`@/*`])).toThrowError(
|
|
36
|
+
`Expected an alias in the form "alias=source"`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(() =>
|
|
40
|
+
parsePathAliases.parsePathAliases([`foo=bar=baz`]),
|
|
41
|
+
).toThrowError(`Expected an alias in the form "alias=source"`);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type PathAliases = Map<string, string>;
|
|
2
|
+
|
|
3
|
+
export const parsePathAliases = (
|
|
4
|
+
encodedAliases: Array<string>,
|
|
5
|
+
): PathAliases => {
|
|
6
|
+
const decodedAliases = new Map<string, string>();
|
|
7
|
+
|
|
8
|
+
for (const encodedAlias of encodedAliases) {
|
|
9
|
+
const parts = encodedAlias.split(`=`);
|
|
10
|
+
if (parts.length !== 2) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Expected an alias in the form "alias=source" but found "${encodedAlias}"`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [alias, source] = parts;
|
|
17
|
+
if (decodedAliases.has(alias)) {
|
|
18
|
+
throw new Error(`Alias "${alias}" is set multiple times.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
decodedAliases.set(alias, source);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return decodedAliases;
|
|
25
|
+
};
|