@gxp-dev/tools 2.0.70 → 2.0.72
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/README.md +108 -81
- package/bin/lib/cli.js +18 -0
- package/bin/lib/commands/index.js +2 -0
- package/bin/lib/commands/init.js +23 -0
- package/bin/lib/commands/lint.js +77 -0
- package/bin/lib/constants.js +12 -0
- package/bin/lib/lint/formatter.js +91 -0
- package/bin/lib/lint/index.js +284 -0
- package/bin/lib/lint/schemas/app-manifest.schema.json +124 -0
- package/bin/lib/lint/schemas/card.schema.json +165 -0
- package/bin/lib/lint/schemas/common.schema.json +62 -0
- package/bin/lib/lint/schemas/configuration.schema.json +19 -0
- package/bin/lib/lint/schemas/field.schema.json +230 -0
- package/mcp/gxp-api-server.js +56 -127
- package/mcp/lib/api-tools.js +456 -0
- package/mcp/lib/config-ops.js +234 -0
- package/mcp/lib/config-tools.js +549 -0
- package/mcp/lib/docs-tools.js +142 -0
- package/mcp/lib/docs.js +263 -0
- package/mcp/lib/specs.js +135 -0
- package/mcp/lib/test-tools.js +358 -0
- package/package.json +3 -1
- package/runtime/stores/gxpPortalConfigStore.js +0 -4
- package/runtime/vite.config.js +5 -3
- package/template/.prettierrc +10 -0
- package/template/README.md +205 -240
- package/template/app-instructions.md +91 -0
- package/template/eslint.config.js +32 -0
- package/template/githooks/pre-commit +37 -0
package/mcp/lib/docs.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GxP documentation fetcher + searcher.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: https://docs.gxp.dev (Docusaurus, statically pre-rendered).
|
|
5
|
+
* We discover pages from `/sitemap.xml`, extract title + heading list +
|
|
6
|
+
* article body text from each page's HTML with a handful of regexes (no
|
|
7
|
+
* HTML parser dep), and cache everything in-memory.
|
|
8
|
+
*
|
|
9
|
+
* Two caches:
|
|
10
|
+
* - sitemap : 1 hour TTL
|
|
11
|
+
* - pages : 30 min TTL, keyed by URL
|
|
12
|
+
*
|
|
13
|
+
* Search is eager: the first `searchPages` call (per cache window) fetches
|
|
14
|
+
* every page in the sitemap with a concurrency cap. ~77 pages of ~10KB each
|
|
15
|
+
* = well under 1 MB, acceptable for an MCP server session.
|
|
16
|
+
*
|
|
17
|
+
* Injection points for tests: __setSitemapForTest, __setPageForTest.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DOCS_BASE = "https://docs.gxp.dev"
|
|
21
|
+
const SITEMAP_URL = `${DOCS_BASE}/sitemap.xml`
|
|
22
|
+
|
|
23
|
+
const SITEMAP_TTL = 60 * 60 * 1000 // 1 hour
|
|
24
|
+
const PAGE_TTL = 30 * 60 * 1000 // 30 minutes
|
|
25
|
+
const FETCH_CONCURRENCY = 6
|
|
26
|
+
|
|
27
|
+
const sitemapCache = { urls: null, fetchedAt: 0 }
|
|
28
|
+
/** @type {Map<string, { title, headings, body, fetchedAt }>} */
|
|
29
|
+
const pageCache = new Map()
|
|
30
|
+
|
|
31
|
+
/* -------------------------------- fetching ------------------------------- */
|
|
32
|
+
|
|
33
|
+
async function fetchSitemap({ refresh = false } = {}) {
|
|
34
|
+
const now = Date.now()
|
|
35
|
+
if (
|
|
36
|
+
!refresh &&
|
|
37
|
+
sitemapCache.urls &&
|
|
38
|
+
now - sitemapCache.fetchedAt < SITEMAP_TTL
|
|
39
|
+
) {
|
|
40
|
+
return sitemapCache.urls
|
|
41
|
+
}
|
|
42
|
+
const res = await fetch(SITEMAP_URL)
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Failed to fetch sitemap from ${SITEMAP_URL}: HTTP ${res.status}`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const xml = await res.text()
|
|
49
|
+
const urls = parseSitemap(xml)
|
|
50
|
+
sitemapCache.urls = urls
|
|
51
|
+
sitemapCache.fetchedAt = now
|
|
52
|
+
return urls
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseSitemap(xml) {
|
|
56
|
+
const out = []
|
|
57
|
+
const re = /<loc>([^<]+)<\/loc>/g
|
|
58
|
+
let m
|
|
59
|
+
while ((m = re.exec(xml)) !== null) {
|
|
60
|
+
const url = m[1].trim()
|
|
61
|
+
if (url) out.push(url)
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fetchPageText(url, { refresh = false } = {}) {
|
|
67
|
+
const now = Date.now()
|
|
68
|
+
const cached = pageCache.get(url)
|
|
69
|
+
if (!refresh && cached && now - cached.fetchedAt < PAGE_TTL) {
|
|
70
|
+
return cached
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const res = await fetch(url, { redirect: "follow" })
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`)
|
|
76
|
+
}
|
|
77
|
+
const html = await res.text()
|
|
78
|
+
const extracted = extractFromHtml(html)
|
|
79
|
+
const record = { ...extracted, url, fetchedAt: now }
|
|
80
|
+
pageCache.set(url, record)
|
|
81
|
+
return record
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* -------------------------- regex-based extraction ----------------------- */
|
|
85
|
+
|
|
86
|
+
function stripTags(s) {
|
|
87
|
+
return s.replace(/<[^>]+>/g, "")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function decodeEntities(s) {
|
|
91
|
+
return s
|
|
92
|
+
.replace(/&/g, "&")
|
|
93
|
+
.replace(/</g, "<")
|
|
94
|
+
.replace(/>/g, ">")
|
|
95
|
+
.replace(/"/g, '"')
|
|
96
|
+
.replace(/'/g, "'")
|
|
97
|
+
.replace(/ /g, " ")
|
|
98
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
99
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
|
|
100
|
+
String.fromCharCode(parseInt(h, 16)),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clean(text) {
|
|
105
|
+
return decodeEntities(stripTags(text)).replace(/\s+/g, " ").trim()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractFromHtml(html) {
|
|
109
|
+
// Title: prefer the first <h1> inside the article body; fall back to <title>.
|
|
110
|
+
const titleH1 = /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html)
|
|
111
|
+
const titleTag = /<title>([\s\S]*?)<\/title>/i.exec(html)
|
|
112
|
+
const title = clean((titleH1?.[1] ?? titleTag?.[1] ?? "").toString())
|
|
113
|
+
|
|
114
|
+
// Article body; fall back to full doc if Docusaurus structure changes.
|
|
115
|
+
const articleMatch = /<article[^>]*>([\s\S]*?)<\/article>/i.exec(html)
|
|
116
|
+
const bodyHtml = articleMatch ? articleMatch[1] : html
|
|
117
|
+
|
|
118
|
+
const headings = []
|
|
119
|
+
const headingRe = /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi
|
|
120
|
+
let m
|
|
121
|
+
while ((m = headingRe.exec(bodyHtml)) !== null) {
|
|
122
|
+
const text = clean(m[2])
|
|
123
|
+
if (text) headings.push({ level: Number(m[1]), text })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const body = clean(bodyHtml)
|
|
127
|
+
|
|
128
|
+
return { title, headings, body }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* --------------------------------- search -------------------------------- */
|
|
132
|
+
|
|
133
|
+
function scorePage(page, terms) {
|
|
134
|
+
const titleL = page.title.toLowerCase()
|
|
135
|
+
const bodyL = page.body.toLowerCase()
|
|
136
|
+
const headingsText = page.headings.map((h) => h.text.toLowerCase()).join(" ")
|
|
137
|
+
|
|
138
|
+
let score = 0
|
|
139
|
+
for (const t of terms) {
|
|
140
|
+
const titleHits = occurrences(titleL, t)
|
|
141
|
+
const headingHits = occurrences(headingsText, t)
|
|
142
|
+
const bodyHits = occurrences(bodyL, t)
|
|
143
|
+
score += titleHits * 3 + headingHits * 2 + bodyHits
|
|
144
|
+
}
|
|
145
|
+
return score
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function occurrences(haystack, needle) {
|
|
149
|
+
if (!needle) return 0
|
|
150
|
+
let count = 0
|
|
151
|
+
let idx = 0
|
|
152
|
+
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
|
|
153
|
+
count++
|
|
154
|
+
idx += needle.length
|
|
155
|
+
}
|
|
156
|
+
return count
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function snippet(body, terms, chars = 200) {
|
|
160
|
+
const lower = body.toLowerCase()
|
|
161
|
+
let first = -1
|
|
162
|
+
for (const t of terms) {
|
|
163
|
+
const i = lower.indexOf(t)
|
|
164
|
+
if (i !== -1 && (first === -1 || i < first)) first = i
|
|
165
|
+
}
|
|
166
|
+
if (first === -1) return body.slice(0, chars)
|
|
167
|
+
const start = Math.max(0, first - Math.floor(chars / 4))
|
|
168
|
+
const end = Math.min(body.length, start + chars)
|
|
169
|
+
const prefix = start > 0 ? "…" : ""
|
|
170
|
+
const suffix = end < body.length ? "…" : ""
|
|
171
|
+
return prefix + body.slice(start, end) + suffix
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Fetch all sitemap URLs with a concurrency cap and return the pages array.
|
|
176
|
+
* Pages that fail to fetch are silently omitted.
|
|
177
|
+
*/
|
|
178
|
+
async function fetchAllPages({ refresh = false } = {}) {
|
|
179
|
+
const urls = await fetchSitemap({ refresh })
|
|
180
|
+
const queue = [...urls]
|
|
181
|
+
const results = []
|
|
182
|
+
|
|
183
|
+
async function worker() {
|
|
184
|
+
while (queue.length) {
|
|
185
|
+
const url = queue.shift()
|
|
186
|
+
try {
|
|
187
|
+
results.push(await fetchPageText(url, { refresh }))
|
|
188
|
+
} catch {
|
|
189
|
+
// Skip transient fetch failures — one bad page shouldn't sink the whole search.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const workers = Array.from({ length: FETCH_CONCURRENCY }, () => worker())
|
|
195
|
+
await Promise.all(workers)
|
|
196
|
+
return results
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function searchPages(query, { limit = 10, refresh = false } = {}) {
|
|
200
|
+
const terms = String(query || "")
|
|
201
|
+
.toLowerCase()
|
|
202
|
+
.split(/\s+/)
|
|
203
|
+
.filter(Boolean)
|
|
204
|
+
if (!terms.length) return []
|
|
205
|
+
|
|
206
|
+
const pages = await fetchAllPages({ refresh })
|
|
207
|
+
const scored = []
|
|
208
|
+
for (const page of pages) {
|
|
209
|
+
const score = scorePage(page, terms)
|
|
210
|
+
if (score > 0) {
|
|
211
|
+
scored.push({
|
|
212
|
+
url: page.url,
|
|
213
|
+
title: page.title,
|
|
214
|
+
score,
|
|
215
|
+
snippet: snippet(page.body, terms),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
scored.sort((a, b) => b.score - a.score)
|
|
220
|
+
return scored.slice(0, limit)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* --------------------------------- resolve -------------------------------- */
|
|
224
|
+
|
|
225
|
+
function resolvePageUrl(input) {
|
|
226
|
+
if (!input) throw new Error("url_or_slug is required")
|
|
227
|
+
if (/^https?:\/\//i.test(input)) return input
|
|
228
|
+
const slug = input.replace(/^\/+/, "").replace(/\/+$/, "")
|
|
229
|
+
return `${DOCS_BASE}/${slug}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ------------------------------- test seams ------------------------------ */
|
|
233
|
+
|
|
234
|
+
function __setSitemapForTest(urls) {
|
|
235
|
+
sitemapCache.urls = urls
|
|
236
|
+
sitemapCache.fetchedAt = Date.now()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function __setPageForTest(url, { title = "", headings = [], body = "" }) {
|
|
240
|
+
pageCache.set(url, { url, title, headings, body, fetchedAt: Date.now() })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function __resetCacheForTest() {
|
|
244
|
+
sitemapCache.urls = null
|
|
245
|
+
sitemapCache.fetchedAt = 0
|
|
246
|
+
pageCache.clear()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
DOCS_BASE,
|
|
251
|
+
fetchSitemap,
|
|
252
|
+
fetchPageText,
|
|
253
|
+
fetchAllPages,
|
|
254
|
+
searchPages,
|
|
255
|
+
parseSitemap,
|
|
256
|
+
extractFromHtml,
|
|
257
|
+
scorePage,
|
|
258
|
+
snippet,
|
|
259
|
+
resolvePageUrl,
|
|
260
|
+
__setSitemapForTest,
|
|
261
|
+
__setPageForTest,
|
|
262
|
+
__resetCacheForTest,
|
|
263
|
+
}
|
package/mcp/lib/specs.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OpenAPI / AsyncAPI / Webhook spec fetching with an in-memory cache.
|
|
3
|
+
* Used by both the main MCP server and the extended api-tools module.
|
|
4
|
+
*
|
|
5
|
+
* Environment detection: reads VITE_API_ENV/API_ENV from the project's .env
|
|
6
|
+
* file first, then falls back to process.env, then defaults to "develop".
|
|
7
|
+
*
|
|
8
|
+
* Cache: 5-minute TTL, shared across all callers in the same process.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("fs")
|
|
12
|
+
const path = require("path")
|
|
13
|
+
|
|
14
|
+
const ENVIRONMENT_URLS = {
|
|
15
|
+
production: {
|
|
16
|
+
apiBaseUrl: "https://api.gramercy.cloud",
|
|
17
|
+
openApiSpec: "https://api.gramercy.cloud/api-specs/openapi.json",
|
|
18
|
+
asyncApiSpec: "https://api.gramercy.cloud/api-specs/asyncapi.json",
|
|
19
|
+
webhookSpec: "https://api.gramercy.cloud/api-specs/webhooks.json",
|
|
20
|
+
},
|
|
21
|
+
staging: {
|
|
22
|
+
apiBaseUrl: "https://api.efz-staging.env.eventfinity.app",
|
|
23
|
+
openApiSpec:
|
|
24
|
+
"https://api.efz-staging.env.eventfinity.app/api-specs/openapi.json",
|
|
25
|
+
asyncApiSpec:
|
|
26
|
+
"https://api.efz-staging.env.eventfinity.app/api-specs/asyncapi.json",
|
|
27
|
+
webhookSpec:
|
|
28
|
+
"https://api.efz-staging.env.eventfinity.app/api-specs/webhooks.json",
|
|
29
|
+
},
|
|
30
|
+
testing: {
|
|
31
|
+
apiBaseUrl: "https://api.zenith-develop-testing.env.eventfinity.app",
|
|
32
|
+
openApiSpec:
|
|
33
|
+
"https://api.zenith-develop-testing.env.eventfinity.app/api-specs/openapi.json",
|
|
34
|
+
asyncApiSpec:
|
|
35
|
+
"https://api.zenith-develop-testing.env.eventfinity.app/api-specs/asyncapi.json",
|
|
36
|
+
webhookSpec:
|
|
37
|
+
"https://api.zenith-develop-testing.env.eventfinity.app/api-specs/webhooks.json",
|
|
38
|
+
},
|
|
39
|
+
develop: {
|
|
40
|
+
apiBaseUrl: "https://api.zenith-develop.env.eventfinity.app",
|
|
41
|
+
openApiSpec:
|
|
42
|
+
"https://api.zenith-develop.env.eventfinity.app/api-specs/openapi.json",
|
|
43
|
+
asyncApiSpec:
|
|
44
|
+
"https://api.zenith-develop.env.eventfinity.app/api-specs/asyncapi.json",
|
|
45
|
+
webhookSpec:
|
|
46
|
+
"https://api.zenith-develop.env.eventfinity.app/api-specs/webhooks.json",
|
|
47
|
+
},
|
|
48
|
+
local: {
|
|
49
|
+
apiBaseUrl: "https://dashboard.eventfinity.test",
|
|
50
|
+
openApiSpec: "https://api.eventfinity.test/api-specs/openapi.json",
|
|
51
|
+
asyncApiSpec: "https://api.eventfinity.test/api-specs/asyncapi.json",
|
|
52
|
+
webhookSpec: "https://api.eventfinity.test/api-specs/webhooks.json",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const CACHE_TTL = 5 * 60 * 1000
|
|
57
|
+
const specCache = {
|
|
58
|
+
openapi: null,
|
|
59
|
+
asyncapi: null,
|
|
60
|
+
webhooks: null,
|
|
61
|
+
lastFetch: null,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getEnvironment() {
|
|
65
|
+
const envPath = path.join(process.cwd(), ".env")
|
|
66
|
+
if (fs.existsSync(envPath)) {
|
|
67
|
+
const envContent = fs.readFileSync(envPath, "utf-8")
|
|
68
|
+
const match = envContent.match(/VITE_API_ENV=(\w+)/)
|
|
69
|
+
if (match) {
|
|
70
|
+
return match[1]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return process.env.VITE_API_ENV || process.env.API_ENV || "develop"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getEnvUrls() {
|
|
77
|
+
const env = getEnvironment()
|
|
78
|
+
return ENVIRONMENT_URLS[env] || ENVIRONMENT_URLS.develop
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchSpec(specType) {
|
|
82
|
+
const urls = getEnvUrls()
|
|
83
|
+
const urlMap = {
|
|
84
|
+
openapi: urls.openApiSpec,
|
|
85
|
+
asyncapi: urls.asyncApiSpec,
|
|
86
|
+
webhooks: urls.webhookSpec,
|
|
87
|
+
}
|
|
88
|
+
const url = urlMap[specType]
|
|
89
|
+
if (!url) {
|
|
90
|
+
throw new Error(`Unknown spec type: ${specType}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const now = Date.now()
|
|
94
|
+
if (
|
|
95
|
+
specCache[specType] &&
|
|
96
|
+
specCache.lastFetch &&
|
|
97
|
+
now - specCache.lastFetch < CACHE_TTL
|
|
98
|
+
) {
|
|
99
|
+
return specCache[specType]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const res = await fetch(url)
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
throw new Error(`Failed to fetch ${specType} spec: ${res.status}`)
|
|
105
|
+
}
|
|
106
|
+
const spec = await res.json()
|
|
107
|
+
specCache[specType] = spec
|
|
108
|
+
specCache.lastFetch = now
|
|
109
|
+
return spec
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Test seam: allow tests to inject a fixed spec into the cache and freeze it.
|
|
114
|
+
* Returns a restore function.
|
|
115
|
+
*/
|
|
116
|
+
function __setCacheForTest(overrides) {
|
|
117
|
+
const prev = {
|
|
118
|
+
openapi: specCache.openapi,
|
|
119
|
+
asyncapi: specCache.asyncapi,
|
|
120
|
+
webhooks: specCache.webhooks,
|
|
121
|
+
lastFetch: specCache.lastFetch,
|
|
122
|
+
}
|
|
123
|
+
Object.assign(specCache, overrides, {
|
|
124
|
+
lastFetch: Date.now(),
|
|
125
|
+
})
|
|
126
|
+
return () => Object.assign(specCache, prev)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
ENVIRONMENT_URLS,
|
|
131
|
+
getEnvironment,
|
|
132
|
+
getEnvUrls,
|
|
133
|
+
fetchSpec,
|
|
134
|
+
__setCacheForTest,
|
|
135
|
+
}
|