@embrace-ai/infra-api-schema-sync 1.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/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2025-06-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **commands:** remove unnecessary cli commands, update workflows, move bash to dispath-update ([14aa3a8](https://github.com/Embrace-AI/infra-api-schema-sync/commit/14aa3a82e142eb9c135bf6d409ad36d9cad5e20f))
9
+ * **lockfile:** update lockfile ([706bf37](https://github.com/Embrace-AI/infra-api-schema-sync/commit/706bf372d80d49692db9159da6ff25593869064a))
10
+ * **logging:** add globals for console logging in workflows ([fe00324](https://github.com/Embrace-AI/infra-api-schema-sync/commit/fe003249a5859b7769fce977556b71e610cb42d9))
11
+ * **workflow:** move dispatch logic to js, and update consumer workflow ([b0ff4c1](https://github.com/Embrace-AI/infra-api-schema-sync/commit/b0ff4c14096d090a50a93795dc45f69e5f2a6d8e))
12
+ * **workflows:** add issue_count and remove token in env ([662e8ad](https://github.com/Embrace-AI/infra-api-schema-sync/commit/662e8adae80cfc6a4f1ddfa389a28a94a7c04ac7))
13
+ * **workflows:** only emit update on **/*schema.ts change, only show summary when issues are found ([2c2a589](https://github.com/Embrace-AI/infra-api-schema-sync/commit/2c2a589eff7192c833d008ac64e6bad6f5186acb))
14
+ * **workflows:** restore workflow files ([63eb5ad](https://github.com/Embrace-AI/infra-api-schema-sync/commit/63eb5ad7e1a4f2e004f6fad3524d3d30bb0278aa))
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # CHANGEME
2
+
3
+ ## Setup
4
+
5
+ ### Node
6
+
7
+ This project uses `nvm` ([Node Version Manager](https://github.com/nvm-sh/nvm))
8
+ and `pnpm` ([Package Node Modules](https://pnpm.js.org/)).
9
+
10
+ 1. Install `nvm`
11
+ 2. `cd` to the project directory and execute the following:
12
+ ```
13
+ nvm install
14
+ nvm use
15
+ ```
16
+ 3. Install `pnpm`
17
+ ```
18
+ npm install -g pnpm
19
+ ```
20
+ 4. Install dependencies
21
+ ```
22
+ pnpm install
23
+ ```
24
+
25
+ ### IDE Setup
26
+
27
+ This project uses [EditorConfig](https://editorconfig.org/) for IDE configuration.
28
+
29
+ See `.editorconfig` for settings.
30
+
31
+ Many popular IDEs and editors support this out of the box or with a plugin.
32
+
33
+ ## Development
34
+
35
+ ### Prettier
36
+
37
+ This project uses [Prettier](https://prettier.io/), so please run it before checking in:
38
+
39
+ ```
40
+ pnpm prettier-format
41
+ ```
42
+
43
+ See `.prettierrc` for settings.
44
+
45
+ Some IDEs and editors have plugins for running Prettier.
46
+
47
+ ### Linting
48
+
49
+ This project uses [ESLint](https://eslint.org/). Check linting before checking in:
50
+
51
+ ```
52
+ pnpm lint
53
+ ```
54
+
55
+ See `eslint.config.mjs` for settings.
56
+
57
+ Many IDEs and editors support ESLint.
58
+
59
+ ### Typechecking
60
+
61
+ In addition, running typecheck is recommended before checking in:
62
+
63
+ ```
64
+ pnpm -R typecheck
65
+ ```
66
+
67
+ ## Testing
68
+
69
+ This project uses [Vitest](https://vitest.dev/) for testing. Run tests before checking in.
70
+
71
+ ```
72
+ pnpm test
73
+ ```
74
+
75
+ ## Building
76
+
77
+ ```
78
+ pnpm build
79
+ ```
80
+
81
+ ## Releasing
82
+
83
+ This project uses Github Actions to release to npmjs.
@@ -0,0 +1,3 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
3
+ //# sourceMappingURL=vitest.config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vitest.config.d.ts","sourceRoot":"","sources":["../vitest.config.ts"],"names":[],"mappings":";AAEA,wBAyBG"}
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const config_1 = require("vitest/config");
4
+ exports.default = (0, config_1.defineConfig)({
5
+ test: {
6
+ exclude: [
7
+ "**/node_modules/**",
8
+ "**/dist/**",
9
+ "**/.{idea,git,cache,output,temp}/**",
10
+ "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*",
11
+ ],
12
+ chaiConfig: { truncateThreshold: 200 },
13
+ maxConcurrency: 20,
14
+ testTimeout: 20 * 1000, // 20 seconds
15
+ coverage: {
16
+ enabled: false,
17
+ exclude: [
18
+ "**/.idea/**",
19
+ "**/dist/**",
20
+ "**/node_modules/**",
21
+ "**/test-context.ts",
22
+ ],
23
+ reporter: ["text", "html"],
24
+ },
25
+ },
26
+ resolve: {
27
+ preserveSymlinks: true,
28
+ },
29
+ });
30
+ //# sourceMappingURL=vitest.config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vitest.config.js","sourceRoot":"","sources":["../vitest.config.ts"],"names":[],"mappings":";;AAAA,0CAA6C;AAE7C,kBAAe,IAAA,qBAAY,EAAC;IAC1B,IAAI,EAAE;QACJ,OAAO,EAAE;YACP,oBAAoB;YACpB,YAAY;YACZ,qCAAqC;YACrC,sFAAsF;SACvF;QACD,UAAU,EAAE,EAAE,iBAAiB,EAAE,GAAG,EAAE;QACtC,cAAc,EAAE,EAAE;QAClB,WAAW,EAAE,EAAE,GAAG,IAAI,EAAE,aAAa;QACrC,QAAQ,EAAE;YACR,OAAO,EAAE,KAAK;YACd,OAAO,EAAE;gBACP,aAAa;gBACb,YAAY;gBACZ,oBAAoB;gBACpB,oBAAoB;aACrB;YACD,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC;SAC3B;KACF;IACD,OAAO,EAAE;QACP,gBAAgB,EAAE,IAAI;KACvB;CACF,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@embrace-ai/infra-api-schema-sync",
3
+ "version": "1.0.0",
4
+ "bin": {
5
+ "api-tool": "./dist/index.js"
6
+ },
7
+ "files": [
8
+ "src/",
9
+ "templates/",
10
+ "dist",
11
+ "CHANGELOG.md",
12
+ "README.md"
13
+ ],
14
+ "description": "API schema synchronization tool for automated client validation and code generation across distributed repositories",
15
+ "author": "Embrace.ai",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/Embrace-AI/infra-api-schema-sync"
19
+ },
20
+ "types": "dist/**/*.d.ts",
21
+ "keywords": [],
22
+ "type": "module",
23
+ "dependencies": {
24
+ "@graphql-codegen/cli": "^5.0.7",
25
+ "@graphql-codegen/typescript": "^4.1.6",
26
+ "@graphql-inspector/cli": "^5.0.8",
27
+ "commander": "^14.0.0",
28
+ "fs-extra": "^11.3.0",
29
+ "globals": "^16.2.0",
30
+ "graphql": "^16.11.0",
31
+ "yaml": "^2.8.0"
32
+ },
33
+ "devDependencies": {
34
+ "@eslint/compat": "1.3.0",
35
+ "@graphql-inspector/core": "6.2.1",
36
+ "@trivago/prettier-plugin-sort-imports": "5.2.2",
37
+ "@tsconfig/node20": "20.1.6",
38
+ "@types/node": "22.15.32",
39
+ "@typescript-eslint/eslint-plugin": "8.34.1",
40
+ "@typescript-eslint/parser": "8.34.1",
41
+ "@vitest/coverage-v8": "3.2.4",
42
+ "eslint": "9.29.0",
43
+ "eslint-config-prettier": "10.1.5",
44
+ "eslint-plugin-prefer-arrow": "1.2.3",
45
+ "eslint-plugin-prettier": "5.5.0",
46
+ "eslint-plugin-unused-imports": "4.1.4",
47
+ "prettier": "3.5.3",
48
+ "ts-pattern": "5.7.1",
49
+ "typescript": "5.8.3",
50
+ "vitest": "3.2.4"
51
+ },
52
+ "scripts": {
53
+ "clean": "rm -rf dist",
54
+ "build": "tsc",
55
+ "build:watch": "tsc --watch",
56
+ "rebuild": "pnpm clean && pnpm build",
57
+ "types": "echo no types",
58
+ "typecheck": "tsc --noEmit",
59
+ "lint": "eslint .",
60
+ "test": "vitest run"
61
+ }
62
+ }
@@ -0,0 +1,322 @@
1
+ import { execSync } from "child_process";
2
+
3
+ /**
4
+ * Dispatch schema update events to repositories
5
+ * @param {Object} options - Configuration options
6
+ * @param {string} options.schemaUrl - The schema URL to dispatch
7
+ * @param {string} options.sourceRepo - Source repository name
8
+ * @param {string} options.commit - Git commit SHA
9
+ * @param {string} options.branch - Git branch name
10
+ * @param {string} options.timestamp - ISO timestamp
11
+ * @param {string} options.token - GitHub token for API access
12
+ * @param {string} options.orgName - GitHub organization name
13
+ * @param {string} options.repoFilter - Regex pattern to filter repositories
14
+ * @param {boolean} options.dryRun - If true, only show what would be dispatched
15
+ */
16
+ export const dispatchUpdate = async (options) => {
17
+ try {
18
+ const {
19
+ schemaUrl,
20
+ sourceRepo,
21
+ commit,
22
+ branch,
23
+ timestamp,
24
+ token,
25
+ orgName,
26
+ repoFilter = ".*",
27
+ dryRun = false,
28
+ } = options;
29
+
30
+ console.log("📡 Starting schema update dispatch...");
31
+ console.log(`🌐 Schema URL: ${schemaUrl}`);
32
+ console.log(`📁 Source Repository: ${sourceRepo}`);
33
+ console.log(`🔍 Organization: ${orgName}`);
34
+ console.log(`🎯 Repository Filter: ${repoFilter}`);
35
+
36
+ if (dryRun) {
37
+ console.log("DRY RUN MODE - No actual dispatches will be sent");
38
+ }
39
+
40
+ // Function to fetch repositories with pagination
41
+ const fetchAllRepos = async () => {
42
+ let page = 1;
43
+ const perPage = 100;
44
+ let allRepos = "";
45
+
46
+ while (true) {
47
+ console.log(`Fetching repos page ${page}...`);
48
+
49
+ // Fetch repositories for the organization
50
+ const response = await fetch(
51
+ `https://api.github.com/orgs/${orgName}/repos?page=${page}&per_page=${perPage}&type=all`,
52
+ {
53
+ headers: {
54
+ Authorization: `Bearer ${token}`,
55
+ Accept: "application/vnd.github.v3+json",
56
+ },
57
+ },
58
+ );
59
+
60
+ if (!response.ok) {
61
+ const errorData = await response.json();
62
+ throw new Error(
63
+ `GitHub API error: ${errorData.message || response.statusText}`,
64
+ );
65
+ }
66
+
67
+ const repos = await response.json();
68
+
69
+ // If no repos on this page, we're done
70
+ if (!repos || repos.length === 0) {
71
+ break;
72
+ }
73
+
74
+ // Extract repository names
75
+ const repoNames = repos.map((repo) => repo.full_name).join("\n");
76
+ allRepos += `${repoNames}\n`;
77
+
78
+ // Check if we got fewer than per_page results (last page)
79
+ if (repos.length < perPage) {
80
+ break;
81
+ }
82
+
83
+ page++;
84
+ }
85
+
86
+ return allRepos;
87
+ };
88
+
89
+ // Get all repositories
90
+ console.log("🔍 Fetching all organization repositories...");
91
+
92
+ // In dry-run mode with fake token, simulate some repositories
93
+ let allRepos;
94
+ if (dryRun && (token === "fake-token" || token.startsWith("fake"))) {
95
+ console.log("DRY RUN: Using simulated repositories");
96
+ allRepos = `${orgName}/repo1\n${orgName}/repo2\n${orgName}/frontend\n${sourceRepo}\n`;
97
+ } else {
98
+ allRepos = await fetchAllRepos();
99
+ }
100
+
101
+ // Filter repositories if pattern is provided
102
+ let filteredRepos;
103
+ if (repoFilter !== ".*") {
104
+ console.log(`🔍 Filtering repositories with pattern: ${repoFilter}`);
105
+ const repoList = allRepos.split("\n").filter((repo) => repo.trim());
106
+ const regex = new RegExp(repoFilter);
107
+ filteredRepos = repoList.filter((repo) => regex.test(repo)).join("\n");
108
+ } else {
109
+ filteredRepos = allRepos;
110
+ }
111
+
112
+ // Remove empty lines and current repository from the list
113
+ const targetRepos = filteredRepos
114
+ .split("\n")
115
+ .filter((repo) => repo.trim() && repo !== sourceRepo)
116
+ .filter((repo) => repo.length > 0);
117
+
118
+ if (targetRepos.length === 0) {
119
+ console.log("⚠️ No target repositories found to notify");
120
+ return { success: 0, errors: 0, total: 0 };
121
+ }
122
+
123
+ console.log(`Found ${targetRepos.length} repositories to notify:`);
124
+ targetRepos.forEach((repo) => console.log(` - ${repo}`));
125
+ console.log("");
126
+
127
+ if (dryRun) {
128
+ console.log("DRY RUN: Would dispatch to the above repositories");
129
+ return {
130
+ success: targetRepos.length,
131
+ errors: 0,
132
+ total: targetRepos.length,
133
+ dryRun: true,
134
+ };
135
+ }
136
+
137
+ // Dispatch to each repository
138
+ let successCount = 0;
139
+ let errorCount = 0;
140
+
141
+ for (const repo of targetRepos) {
142
+ if (repo.trim()) {
143
+ console.log(`📡 Dispatching to repository: ${repo}`);
144
+
145
+ try {
146
+ const dispatchResponse = await fetch(
147
+ `https://api.github.com/repos/${repo}/dispatches`,
148
+ {
149
+ method: "POST",
150
+ headers: {
151
+ Authorization: `Bearer ${token}`,
152
+ Accept: "application/vnd.github.v3+json",
153
+ "Content-Type": "application/json",
154
+ },
155
+ body: JSON.stringify({
156
+ event_type: "graphql-schema-updated",
157
+ client_payload: {
158
+ schemaUrl,
159
+ sourceRepo,
160
+ commit,
161
+ branch,
162
+ timestamp,
163
+ },
164
+ }),
165
+ },
166
+ );
167
+
168
+ if (dispatchResponse.status === 204) {
169
+ console.log(" ✅ Success");
170
+ successCount++;
171
+ } else {
172
+ console.log(` ❌ Failed (HTTP ${dispatchResponse.status})`);
173
+ errorCount++;
174
+ }
175
+ } catch (error) {
176
+ console.log(` ❌ Failed: ${error.message}`);
177
+ errorCount++;
178
+ }
179
+ }
180
+ }
181
+
182
+ console.log("");
183
+ console.log("📊 Dispatch Summary:");
184
+ console.log(` ✅ Successful: ${successCount}`);
185
+ console.log(` ❌ Failed: ${errorCount}`);
186
+ console.log(` 📋 Total: ${successCount + errorCount}`);
187
+
188
+ if (errorCount > 0) {
189
+ console.log(
190
+ "⚠️ Some dispatches failed. Check repository permissions and workflow file presence.",
191
+ );
192
+ }
193
+
194
+ return {
195
+ success: successCount,
196
+ errors: errorCount,
197
+ total: successCount + errorCount,
198
+ };
199
+ } catch (error) {
200
+ console.error(`❌ Failed to dispatch schema updates: ${error.message}`);
201
+ process.exit(1);
202
+ }
203
+ };
204
+
205
+ /**
206
+ * Auto-detect values from environment or git
207
+ */
208
+ const detectValues = () => {
209
+ const sourceRepo = process.env.GITHUB_REPOSITORY || detectRepositoryFromGit();
210
+ const commit = process.env.GITHUB_SHA || detectCommitFromGit();
211
+ const branch = process.env.GITHUB_REF_NAME || detectBranchFromGit();
212
+ const orgName =
213
+ process.env.GITHUB_REPOSITORY_OWNER || sourceRepo?.split("/")[0];
214
+ const timestamp = new Date().toISOString();
215
+
216
+ return { sourceRepo, commit, branch, orgName, timestamp };
217
+ };
218
+
219
+ const detectRepositoryFromGit = () => {
220
+ try {
221
+ const remoteUrl = execSync("git config --get remote.origin.url", {
222
+ encoding: "utf8",
223
+ stdio: ["pipe", "pipe", "ignore"],
224
+ }).trim();
225
+
226
+ // Extract org/repo from various URL formats
227
+ const match = remoteUrl.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
228
+ return match ? match[1] : null;
229
+ } catch (error) {
230
+ console.warn("Failed to detect repository from git:", error.message);
231
+ return null;
232
+ }
233
+ };
234
+
235
+ const detectCommitFromGit = () => {
236
+ try {
237
+ return execSync("git rev-parse HEAD", {
238
+ encoding: "utf8",
239
+ stdio: ["pipe", "pipe", "ignore"],
240
+ }).trim();
241
+ } catch (error) {
242
+ console.warn("Failed to detect commit from git:", error.message);
243
+ return null;
244
+ }
245
+ };
246
+
247
+ const detectBranchFromGit = () => {
248
+ try {
249
+ return execSync("git rev-parse --abbrev-ref HEAD", {
250
+ encoding: "utf8",
251
+ stdio: ["pipe", "pipe", "ignore"],
252
+ }).trim();
253
+ } catch (error) {
254
+ console.warn("Failed to detect branch from git:", error.message);
255
+ return null;
256
+ }
257
+ };
258
+
259
+ /**
260
+ * CLI command handler
261
+ */
262
+ export const dispatchUpdateCommand = async (opts) => {
263
+ const {
264
+ schemaUrl,
265
+ sourceRepo: providedSourceRepo,
266
+ commit: providedCommit,
267
+ branch: providedBranch,
268
+ token,
269
+ orgName: providedOrgName,
270
+ repoFilter,
271
+ dryRun,
272
+ } = opts;
273
+
274
+ // Auto-detect missing values
275
+ const detected = detectValues();
276
+
277
+ const sourceRepo = providedSourceRepo || detected.sourceRepo;
278
+ const commit = providedCommit || detected.commit;
279
+ const branch = providedBranch || detected.branch;
280
+ const orgName = providedOrgName || detected.orgName;
281
+ const timestamp = detected.timestamp;
282
+
283
+ // Validate required parameters
284
+ if (!schemaUrl) {
285
+ throw new Error("Schema URL is required (--schema-url)");
286
+ }
287
+ if (!token) {
288
+ throw new Error("GitHub token is required (--token)");
289
+ }
290
+ if (!sourceRepo) {
291
+ throw new Error(
292
+ "Source repository could not be detected. Please provide --source-repo",
293
+ );
294
+ }
295
+ if (!orgName) {
296
+ throw new Error(
297
+ "Organization name could not be detected. Please provide --org-name",
298
+ );
299
+ }
300
+
301
+ console.log("🔧 Preparing schema update dispatch...");
302
+ console.log(`📁 Source Repository: ${sourceRepo}`);
303
+ console.log(`🏷️ Commit: ${commit || "not detected"}`);
304
+ console.log(`🌿 Branch: ${branch || "not detected"}`);
305
+ console.log(`🏢 Organization: ${orgName}`);
306
+ console.log(`⏰ Timestamp: ${timestamp}`);
307
+
308
+ return dispatchUpdate({
309
+ schemaUrl,
310
+ sourceRepo,
311
+ commit,
312
+ branch,
313
+ timestamp,
314
+ token,
315
+ orgName,
316
+ repoFilter,
317
+ dryRun,
318
+ });
319
+ };
320
+
321
+ // Default export for CLI
322
+ export default dispatchUpdateCommand;
@@ -0,0 +1,65 @@
1
+ import fs from "fs";
2
+ import fse from "fs-extra";
3
+ import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql";
4
+ import path from "path";
5
+
6
+ export const fetchSchema = async ({ url, output }) => {
7
+ try {
8
+ console.log(`🔄 Fetching GraphQL schema from ${url}...`);
9
+
10
+ // Prepare introspection query
11
+ const introspectionQuery = getIntrospectionQuery();
12
+
13
+ // Fetch schema using introspection
14
+ const response = await fetch(url, {
15
+ method: "POST",
16
+ headers: {
17
+ "Content-Type": "application/json",
18
+ Accept: "application/json",
19
+ },
20
+ body: JSON.stringify({
21
+ query: introspectionQuery,
22
+ }),
23
+ });
24
+
25
+ if (!response.ok) {
26
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
27
+ }
28
+
29
+ const result = await response.json();
30
+
31
+ if (result.errors) {
32
+ throw new Error(
33
+ `GraphQL errors: ${result.errors.map((e) => e.message).join(", ")}`,
34
+ );
35
+ }
36
+
37
+ if (!result.data) {
38
+ throw new Error("No data returned from introspection query");
39
+ }
40
+
41
+ // Build schema from introspection result
42
+ const schema = buildClientSchema(result.data);
43
+
44
+ // Convert schema to SDL (Schema Definition Language)
45
+ const schemaSDL = printSchema(schema);
46
+
47
+ // Ensure output directory exists
48
+ const outputDir = path.dirname(output);
49
+ fse.ensureDirSync(outputDir);
50
+
51
+ // Write schema to file
52
+ fs.writeFileSync(output, schemaSDL, "utf8");
53
+
54
+ console.log(`✅ Schema successfully saved to ${output}`);
55
+ console.log(
56
+ `📊 Schema contains ${Object.keys(schema.getTypeMap()).length} types`,
57
+ );
58
+ } catch (error) {
59
+ console.error(`❌ Failed to fetch schema: ${error.message}`);
60
+ process.exit(1);
61
+ }
62
+ };
63
+
64
+ // Default export for CLI
65
+ export default fetchSchema;
@@ -0,0 +1,165 @@
1
+ import { execSync } from "child_process";
2
+ import path from "path";
3
+
4
+ /**
5
+ * Generate a dynamic GraphQL schema URL based on repository and deployment stage
6
+ * @param {Object} options - Configuration options
7
+ * @param {string} options.stage - Deployment stage (dev, prod, pr-*)
8
+ * @param {string} options.service - Service name (optional, auto-detected if not provided)
9
+ * @param {string} options.repo - Repository name (optional, auto-detected if not provided)
10
+ * @returns {string} The generated schema URL
11
+ */
12
+ export const buildSchemaUrl = ({ stage = "dev", service, repo }) => {
13
+ // Auto-detect repository name if not provided
14
+ if (!repo) {
15
+ repo = detectRepositoryName();
16
+ }
17
+
18
+ // Auto-detect service name if not provided
19
+ if (!service) {
20
+ service = extractServiceName(repo);
21
+ }
22
+
23
+ // Special case for embrace repository - always use the main API URL
24
+ if (
25
+ service === "embrace" ||
26
+ repo === "embrace" ||
27
+ repo.endsWith("/embrace")
28
+ ) {
29
+ return buildEmbraceUrl(stage);
30
+ }
31
+
32
+ // Build URL for other services
33
+ return buildServiceUrl(service, stage);
34
+ };
35
+
36
+ /**
37
+ * Build URL for the main embrace service
38
+ * @param {string} stage - Deployment stage
39
+ * @returns {string} The embrace service URL
40
+ */
41
+ const buildEmbraceUrl = (stage) => {
42
+ switch (stage) {
43
+ case "dev":
44
+ return "https://api.dev.embrace.ai/v2/graphql/schema";
45
+ default:
46
+ if (stage.startsWith("pr-")) {
47
+ return `https://api.${stage}.dev.embrace.ai/v2/graphql/schema`;
48
+ }
49
+ // Default to dev pattern for unknown stages
50
+ return "https://api.dev.embrace.ai/v2/graphql/schema";
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Build URL for microservices
56
+ * @param {string} service - Service name
57
+ * @param {string} stage - Deployment stage
58
+ * @returns {string} The service URL
59
+ */
60
+ const buildServiceUrl = (service, stage) => {
61
+ switch (stage) {
62
+ case "prod":
63
+ return `https://api.${service}.services.embrace.ai/graphql/schema`;
64
+ case "dev":
65
+ return `https://api.${service}.services.dev.embrace.ai/graphql/schema`;
66
+ default:
67
+ if (stage.startsWith("pr-")) {
68
+ return `https://api.${service}.${stage}.services.dev.embrace.ai/graphql/schema`;
69
+ }
70
+ // Default to dev pattern for unknown stages
71
+ return `https://api.${service}.services.dev.embrace.ai/graphql/schema`;
72
+ }
73
+ };
74
+
75
+ /**
76
+ * Detect the current repository name from git
77
+ * @returns {string} Repository name
78
+ */
79
+ const detectRepositoryName = () => {
80
+ try {
81
+ // Try to get repository name from git remote
82
+ const remoteUrl = execSync("git config --get remote.origin.url", {
83
+ encoding: "utf8",
84
+ stdio: ["pipe", "pipe", "ignore"],
85
+ }).trim();
86
+
87
+ // Extract repository name from various URL formats
88
+ // https://github.com/org/repo.git -> repo
89
+ // git@github.com:org/repo.git -> repo
90
+ // https://github.com/org/repo -> repo
91
+ const match = remoteUrl.match(/[/:]([^/]+?)(?:\.git)?$/);
92
+ if (match) {
93
+ return match[1];
94
+ }
95
+ } catch (error) {
96
+ // Fallback: try to get from current directory name
97
+ console.warn(
98
+ "⚠️ Could not detect repository from git, using directory name: ",
99
+ error.message,
100
+ );
101
+ }
102
+
103
+ // Fallback to current directory name
104
+ return path.basename(process.cwd());
105
+ };
106
+
107
+ /**
108
+ * Extract service name from repository name
109
+ * @param {string} repo - Repository name
110
+ * @returns {string} Service name
111
+ */
112
+ const extractServiceName = (repo) => {
113
+ // Handle common repository naming patterns
114
+ // Remove common prefixes/suffixes but preserve the core service name
115
+ let serviceName = repo
116
+ .replace(/^(api-|service-|microservice-|ms-)/, "") // Remove API prefixes
117
+ .replace(/(-api|-service|-microservice|-ms)$/, "") // Remove API suffixes
118
+ .replace(/^embrace-/, ""); // Remove embrace prefix
119
+
120
+ // Handle special cases
121
+ if (serviceName === "embrace" || serviceName === "") {
122
+ return "embrace";
123
+ }
124
+
125
+ // Keep the full service name (e.g., "orgs-core", "integrations-plugins")
126
+ return serviceName;
127
+ };
128
+
129
+ /**
130
+ * CLI command handler
131
+ */
132
+ export const generateSchemaUrl = (opts) => {
133
+ try {
134
+ const { stage, service, repo } = opts;
135
+
136
+ console.log("🔧 Generating schema URL...");
137
+
138
+ // Detect values if not provided
139
+ const detectedRepo = repo || detectRepositoryName();
140
+ const detectedService = service || extractServiceName(detectedRepo);
141
+
142
+ console.log(`📁 Repository: ${detectedRepo}`);
143
+ console.log(`🏷️ Service: ${detectedService}`);
144
+ console.log(`🚀 Stage: ${stage}`);
145
+
146
+ const url = buildSchemaUrl({
147
+ stage,
148
+ service: detectedService,
149
+ repo: detectedRepo,
150
+ });
151
+
152
+ console.log(`🌐 Generated URL: ${url}`);
153
+
154
+ // Output just the URL for easy consumption by scripts
155
+ process.stdout.write(url);
156
+
157
+ return url;
158
+ } catch (error) {
159
+ console.error(`❌ Failed to generate schema URL: ${error.message}`);
160
+ process.exit(1);
161
+ }
162
+ };
163
+
164
+ // Default export for CLI
165
+ export default generateSchemaUrl;
@@ -0,0 +1,61 @@
1
+ import fs from "fs";
2
+
3
+ export const generateSummaryFromReport = (report) => {
4
+ if (!report || report.length === 0) {
5
+ return "No GraphQL validation errors found.";
6
+ }
7
+
8
+ let output = "## 🚨 GraphQL Schema Validation Issues\n\n";
9
+
10
+ // Group issues by file
11
+ const grouped = report.reduce((acc, issue) => {
12
+ const file = issue.location?.file || "Unknown file";
13
+ if (!acc[file]) {
14
+ acc[file] = [];
15
+ }
16
+ acc[file].push(issue);
17
+ return acc;
18
+ }, {});
19
+
20
+ for (const [file, issues] of Object.entries(grouped)) {
21
+ output += `### \`${file}\`\n`;
22
+ issues.forEach(({ message, location, operation }) => {
23
+ const line = location?.line || "?";
24
+ output += `- ❌ \`${operation || "Unknown Operation"}\`: ${message} (line ${line})\n`;
25
+ });
26
+ output += "\n";
27
+ }
28
+
29
+ return output;
30
+ };
31
+
32
+ // CLI function that handles file I/O
33
+ export const generateSummary = (opts) => {
34
+ const reportPath = opts.report;
35
+ const outputPath = opts.output;
36
+
37
+ const raw = fs.readFileSync(reportPath, "utf-8");
38
+ const report = JSON.parse(raw);
39
+
40
+ const summary = generateSummaryFromReport(report);
41
+
42
+ if (outputPath) {
43
+ fs.writeFileSync(outputPath, summary, "utf-8");
44
+ console.log(`✅ Summary written to ${outputPath}`);
45
+ } else {
46
+ console.log(summary);
47
+ }
48
+ };
49
+
50
+ // Support direct script usage (for backwards compatibility)
51
+ if (process.argv[1] && process.argv[1].endsWith("generate-summary.js")) {
52
+ const reportPath = process.argv[2] || "report.json";
53
+ const raw = fs.readFileSync(reportPath, "utf-8");
54
+ const report = JSON.parse(raw);
55
+
56
+ const summary = generateSummaryFromReport(report);
57
+ console.log(summary);
58
+ }
59
+
60
+ // Default export for CLI
61
+ export default generateSummary;
package/src/index.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+
4
+ const program = new Command();
5
+
6
+ program
7
+ .name("api-tool")
8
+ .description("CLI for API schema management")
9
+ .version("0.1.0");
10
+
11
+ program
12
+ .command("fetch")
13
+ .description("Fetch GraphQL schema from a URL")
14
+ .requiredOption("--url <url>", "Schema URL")
15
+ .requiredOption("--output <file>", "Output file path")
16
+ .action(async (opts) => {
17
+ const { default: fetchSchema } = await import("./fetch-schema.js");
18
+ return fetchSchema(opts);
19
+ });
20
+
21
+ program
22
+ .command("validate")
23
+ .description("Validate queries against schema")
24
+ .requiredOption("--schema <file>", "Schema file path")
25
+ .requiredOption("--queries <glob>", "Glob pattern to GraphQL query files")
26
+ .requiredOption("--output <file>", "JSON report output")
27
+ .action(async (opts) => {
28
+ const { default: validateSchema } = await import("./validate-schema.js");
29
+ return validateSchema(opts);
30
+ });
31
+
32
+ program
33
+ .command("summarize")
34
+ .description("Generate markdown from a validation report")
35
+ .requiredOption("--report <file>", "Validation JSON report")
36
+ .requiredOption("--output <file>", "Markdown file path")
37
+ .action(async (opts) => {
38
+ const { default: generateSummary } = await import("./generate-summary.js");
39
+ return generateSummary(opts);
40
+ });
41
+
42
+ program
43
+ .command("generate-schema-url")
44
+ .description("Generate dynamic schema URL based on repository and stage")
45
+ .option("--stage <stage>", "Deployment stage (dev, prod, pr-*)", "dev")
46
+ .option(
47
+ "--service <service>",
48
+ "Service name (auto-detected from repo if not provided)",
49
+ )
50
+ .option(
51
+ "--repo <repo>",
52
+ "Repository name (auto-detected from git if not provided)",
53
+ )
54
+ .action(async (opts) => {
55
+ const { default: generateSchemaUrl } = await import(
56
+ "./generate-schema-url.js"
57
+ );
58
+ return generateSchemaUrl(opts);
59
+ });
60
+
61
+ program
62
+ .command("dispatch-update")
63
+ .description("Dispatch schema update events to organization repositories")
64
+ .requiredOption("--schema-url <url>", "Schema URL to dispatch")
65
+ .requiredOption("--token <token>", "GitHub token for API access")
66
+ .option(
67
+ "--source-repo <repo>",
68
+ "Source repository (auto-detected if not provided)",
69
+ )
70
+ .option("--commit <sha>", "Git commit SHA (auto-detected if not provided)")
71
+ .option(
72
+ "--branch <branch>",
73
+ "Git branch name (auto-detected if not provided)",
74
+ )
75
+ .option(
76
+ "--org-name <org>",
77
+ "GitHub organization name (auto-detected if not provided)",
78
+ )
79
+ .option(
80
+ "--repo-filter <pattern>",
81
+ "Regex pattern to filter repositories",
82
+ ".*",
83
+ )
84
+ .option("--dry-run", "Show what would be dispatched without sending", false)
85
+ .action(async (opts) => {
86
+ const { default: dispatchUpdate } = await import("./dispatch-update.js");
87
+ return dispatchUpdate(opts);
88
+ });
89
+
90
+ program.parse();
@@ -0,0 +1,157 @@
1
+ import { validate } from "@graphql-inspector/core";
2
+ import fs from "fs";
3
+ import fse from "fs-extra";
4
+ import { buildSchema } from "graphql";
5
+ import path from "path";
6
+
7
+ export const validateSchema = async ({ schema, queries, output }) => {
8
+ try {
9
+ console.log(`🔄 Validating GraphQL queries against schema...`);
10
+ console.log(`📄 Schema: ${schema}`);
11
+ console.log(`🔍 Queries: ${queries}`);
12
+
13
+ // Validate input files exist
14
+ if (!fs.existsSync(schema)) {
15
+ throw new Error(`Schema file not found: ${schema}`);
16
+ }
17
+
18
+ // Ensure output directory exists
19
+ const outputDir = path.dirname(output);
20
+ fse.ensureDirSync(outputDir);
21
+
22
+ // Load and parse the schema
23
+ const schemaContent = fs.readFileSync(schema, "utf8");
24
+ const graphqlSchema = buildSchema(schemaContent);
25
+
26
+ // Find and load query files based on the glob pattern
27
+ const queryFiles = findFiles(queries);
28
+
29
+ if (queryFiles.length === 0) {
30
+ console.log(`⚠️ No query files found matching pattern: ${queries}`);
31
+ }
32
+
33
+ // Create source objects for GraphQL Inspector
34
+ const sources = queryFiles.map((filePath) => ({
35
+ name: filePath,
36
+ body: fs.readFileSync(filePath, "utf8"),
37
+ location: filePath,
38
+ }));
39
+
40
+ console.log(
41
+ `🚀 Validating ${sources.length} query file(s) against schema...`,
42
+ );
43
+
44
+ // Run validation using GraphQL Inspector core
45
+ const invalidDocuments = validate(graphqlSchema, sources, {
46
+ strictDeprecated: false, // Don't fail on deprecated usage by default
47
+ strictFragments: true,
48
+ apollo: false,
49
+ });
50
+
51
+ // Transform the results to match expected output format
52
+ const validationReport = [];
53
+
54
+ for (const doc of invalidDocuments) {
55
+ // Add validation errors
56
+ for (const error of doc.errors) {
57
+ validationReport.push({
58
+ message: error.message,
59
+ location: {
60
+ file: doc.source.name,
61
+ line: error.locations?.[0]?.line || 1,
62
+ column: error.locations?.[0]?.column || 1,
63
+ },
64
+ operation: "validation",
65
+ });
66
+ }
67
+
68
+ // Add deprecated usage warnings
69
+ for (const deprecated of doc.deprecated) {
70
+ validationReport.push({
71
+ message: `Deprecated: ${deprecated.message}`,
72
+ location: {
73
+ file: doc.source.name,
74
+ line: deprecated.locations?.[0]?.line || 1,
75
+ column: deprecated.locations?.[0]?.column || 1,
76
+ },
77
+ operation: "deprecated",
78
+ });
79
+ }
80
+ }
81
+
82
+ // Write validation report to output file
83
+ fs.writeFileSync(output, JSON.stringify(validationReport, null, 2), "utf8");
84
+
85
+ // Log results
86
+ const errorCount = validationReport.length;
87
+ if (errorCount === 0) {
88
+ console.log(`✅ Validation completed successfully - no issues found`);
89
+ console.log(`📊 Report saved to ${output}`);
90
+ } else {
91
+ console.log(`⚠️ Validation completed with ${errorCount} issue(s) found`);
92
+ console.log(`📊 Report saved to ${output}`);
93
+
94
+ // Show summary of issues
95
+ const fileGroups = validationReport.reduce((acc, issue) => {
96
+ const file = issue.location?.file || "Unknown file";
97
+ acc[file] = (acc[file] || 0) + 1;
98
+ return acc;
99
+ }, {});
100
+
101
+ console.log(`📋 Issues by file:`);
102
+ Object.entries(fileGroups).forEach(([file, count]) => {
103
+ console.log(` ${file}: ${count} issue(s)`);
104
+ });
105
+ }
106
+ } catch (error) {
107
+ console.error(`❌ Validation failed: ${error.message}`);
108
+ process.exit(1);
109
+ }
110
+ };
111
+
112
+ const findFiles = (pattern) => {
113
+ // Simple glob-like pattern matching for GraphQL files
114
+ // This handles basic patterns like "*.graphql", "**/*.graphql", etc.
115
+
116
+ if (fs.existsSync(pattern) && fs.statSync(pattern).isFile()) {
117
+ // If it's a direct file path, return it
118
+ return [pattern];
119
+ }
120
+
121
+ // Handle glob patterns
122
+ const files = [];
123
+
124
+ if (pattern.includes("*")) {
125
+ // Basic glob support - find all .graphql files in current directory
126
+ const dir = pattern.includes("/") ? path.dirname(pattern) : ".";
127
+ const searchDir = fs.existsSync(dir) ? dir : ".";
128
+
129
+ const searchDirectory = (dirPath, recursive = false) => {
130
+ const entries = fs.readdirSync(dirPath);
131
+
132
+ for (const entry of entries) {
133
+ const fullPath = path.join(dirPath, entry);
134
+ const stat = fs.statSync(fullPath);
135
+
136
+ if (stat.isDirectory() && recursive) {
137
+ searchDirectory(fullPath, true);
138
+ } else if (stat.isFile() && entry.endsWith(".graphql")) {
139
+ files.push(fullPath);
140
+ }
141
+ }
142
+ };
143
+
144
+ const recursive = pattern.includes("**");
145
+ searchDirectory(searchDir, recursive);
146
+ } else {
147
+ // Direct file or simple pattern
148
+ if (fs.existsSync(pattern)) {
149
+ files.push(pattern);
150
+ }
151
+ }
152
+
153
+ return files;
154
+ };
155
+
156
+ // Default export for CLI
157
+ export default validateSchema;
@@ -0,0 +1,83 @@
1
+ # .github/workflows/emit-schema-update.yml
2
+ name: Emit GraphQL Schema Update Event
3
+
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+ paths:
9
+ - '**/*schema.ts'
10
+
11
+ env:
12
+ # Configure these environment variables for your setup
13
+ # SCHEMA_URL will be dynamically generated based on repository and stage
14
+ # You can override this by setting a static URL if needed
15
+ # SCHEMA_URL: "https://api.dev.embrace.ai/v2/graphql/schema"
16
+
17
+ # Deployment stage - determines the environment URL pattern
18
+ DEPLOYMENT_STAGE: "dev" # Options: dev, prod, pr-* (e.g., pr-123)
19
+ # Optional: Override service name (auto-detected from repository name if not set)
20
+ # SERVICE_NAME: "your-service-name"
21
+
22
+ # Optional: Filter repositories by name pattern (regex)
23
+ REPO_FILTER: ".*" # Default: all repos. Example: "^(frontend|mobile|web)-.*" for specific patterns
24
+ permissions:
25
+ id-token: write
26
+ contents: read
27
+
28
+ jobs:
29
+ emit-schema-update:
30
+ runs-on: ubuntu-latest
31
+ container:
32
+ image: node:22.16.0@sha256:71bcbb3b215b3fa84b5b167585675072f4c270855e37a599803f1a58141a0716
33
+ options: --privileged --user root
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+
37
+ - name: Setup pnpm
38
+ uses: pnpm/action-setup@v4
39
+ - name: Setup node cache
40
+ uses: actions/setup-node@v4
41
+ with:
42
+ node-version: 22.16.0
43
+ cache: 'pnpm'
44
+ - name: Setup NPM
45
+ env:
46
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
47
+ run: pnpm config set //registry.npmjs.org/:_authToken "${NPM_TOKEN}"
48
+ - name: Install dependencies
49
+ run: pnpm i --frozen-lockfile
50
+
51
+ - name: Install GraphQL schema sync tool
52
+ run: npm install @embrace-ai/infra-api-schema-sync
53
+ # Generate dynamic schema URL based on repository and stage
54
+ - name: Generate schema URL
55
+ id: schema-url
56
+ run: |
57
+ # Generate URL using the CLI tool
58
+ if [ -n "$SERVICE_NAME" ]; then
59
+ SCHEMA_URL=$(npx api-tool generate-schema-url --stage "$DEPLOYMENT_STAGE" --service "$SERVICE_NAME")
60
+ else
61
+ SCHEMA_URL=$(npx api-tool generate-schema-url --stage "$DEPLOYMENT_STAGE")
62
+ fi
63
+
64
+ # Set as output for use in later steps
65
+ echo "url=$SCHEMA_URL" >> $GITHUB_OUTPUT
66
+ echo "🌐 Generated schema URL: $SCHEMA_URL"
67
+
68
+
69
+ # Fetch all organization repositories and dispatch to each
70
+ - name: Dispatch schema update events to all org repositories
71
+ env:
72
+ # GitHub token with repo scope - defaults to GITHUB_TOKEN but can be overridden
73
+ # Use the dynamically generated schema URL
74
+ SCHEMA_URL: ${{ steps.schema-url.outputs.url }}
75
+ run: |
76
+ npx api-tool dispatch-update \
77
+ --schema-url "$SCHEMA_URL" \
78
+ --token "${{ secrets.GITHUB_TOKEN }}" \
79
+ --source-repo "${{ github.repository }}" \
80
+ --commit "${{ github.sha }}" \
81
+ --branch "${{ github.ref_name }}" \
82
+ --org-name "${{ github.repository_owner }}" \
83
+ --repo-filter "$REPO_FILTER"
@@ -0,0 +1,106 @@
1
+ # .github/workflows/graphql-schema-validate.yml
2
+ name: Validate GraphQL Schema Updates
3
+
4
+ on:
5
+ repository_dispatch:
6
+ types: [graphql-schema-updated]
7
+
8
+
9
+ jobs:
10
+ validate-schema:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Setup pnpm
17
+ uses: pnpm/action-setup@v4
18
+
19
+ - name: Setup node cache
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: 22.16.0
23
+ cache: 'pnpm'
24
+
25
+ - name: Setup NPM
26
+ env:
27
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
28
+ run: pnpm config set //registry.npmjs.org/:_authToken "${NPM_TOKEN}"
29
+
30
+ - name: Install dependencies
31
+ run: pnpm i --frozen-lockfile
32
+
33
+ - name: Install GraphQL schema sync tool
34
+ run: npm install @embrace-ai/infra-api-schema-sync
35
+
36
+ - name: Configure aws credentials
37
+ uses: aws-actions/configure-aws-credentials@v4.2.1
38
+ with:
39
+ role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_DEV }}
40
+ aws-region: ${{ vars.AWS_REGION }}
41
+
42
+ - name: Display schema update context
43
+ run: |
44
+ echo "Schema update received from: ${{ github.event.client_payload.sourceRepo }}"
45
+ echo "Schema URL: ${{ github.event.client_payload.schemaUrl }}"
46
+ echo "Source commit: ${{ github.event.client_payload.commit }}"
47
+ echo "Source branch: ${{ github.event.client_payload.branch }}"
48
+ echo "Timestamp: ${{ github.event.client_payload.timestamp }}"
49
+
50
+ - name: Fetch updated schema
51
+ run: npx api-tool fetch --url ${{ github.event.client_payload.schemaUrl }} --output new-schema.graphql
52
+
53
+ - name: Validate queries against new schema
54
+ id: validate
55
+ run: npx api-tool validate --schema new-schema.graphql --queries 'src/**/*.graphql' --output report.json
56
+
57
+ - name: Check for issues
58
+ id: check-issues
59
+ run: |
60
+ if [ -s report.json ] && [ "$(jq '.issues | length' report.json)" -gt 0 ]; then
61
+ echo "has_issues=true" >> $GITHUB_OUTPUT
62
+ echo "issue_count=$(jq '.issues | length' report.json)" >> $GITHUB_OUTPUT
63
+ else
64
+ echo "has_issues=false" >> $GITHUB_OUTPUT
65
+ echo "issue_count=0" >> $GITHUB_OUTPUT
66
+ fi
67
+
68
+ - name: Generate summary
69
+ id: gen-summary
70
+ if: steps.check-issues.outputs.has_issues == 'true'
71
+ run: npx api-tool summarize --report report.json --output GRAPHQL_ISSUES.md && echo "summary=$(cat GRAPHQL_ISSUES.md)" >> $GITHUB_OUTPUT
72
+
73
+ - name: Commit and create PR with issues
74
+ uses: peter-evans/create-pull-request@v6
75
+ with:
76
+ commit-message: "chore(api): GraphQL schema update from ${{ github.event.client_payload.sourceRepo }}"
77
+ branch: "graphql/schema-update-${{ github.run_id }}"
78
+ title: "GraphQL Schema Update (${{ steps.check-issues.outputs.issue_count }} issues)"
79
+ body: |
80
+ # GraphQL Schema Update Impact Report
81
+
82
+ **Schema Source:** ${{ github.event.client_payload.sourceRepo }}
83
+ **Schema URL:** ${{ github.event.client_payload.schemaUrl }}
84
+ **Source Commit:** ${{ github.event.client_payload.commit }}
85
+ **Source Branch:** ${{ github.event.client_payload.branch }}
86
+ **Update Time:** ${{ github.event.client_payload.timestamp }}
87
+
88
+ ---
89
+ ## ${{ steps.check-issues.outputs.has_issues == 'true' && 'Detected Issues' || '🎉 No Issues Found' }}
90
+ ${{
91
+ steps.check-issues.outputs.has_issues == 'true'
92
+ ? steps.gen-summary.outputs.summary
93
+ : 'The updated schema is compatible with your existing GraphQL queries. No changes are required.'
94
+ }}
95
+
96
+ ## Next Steps
97
+ 1. Review the compatibility issues above
98
+ 2. Update your GraphQL queries to match the new schema
99
+ 3. Test your changes thoroughly
100
+ 4. Merge this PR once all issues are resolved
101
+
102
+ - name: Create success comment
103
+ if: steps.check-issues.outputs.has_issues == 'false'
104
+ run: |
105
+ echo "✅ No GraphQL compatibility issues found!"
106
+ echo "Schema from ${{ github.event.client_payload.sourceRepo }} is compatible with existing queries."